mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
feat: ship the ssh terminal as a first-party in-process plugin
Adds a hardcoded process:transport-owner tier so the terminal can own its WebSocket server and ssh2 clients, which cannot cross the worker boundary.
This commit is contained in:
1 parent
e040b12045
commit
c94a453f1e
43 files changed
+2426
-38
No files matched your search
@@ -217,6 +217,7 @@ import {
|
||||
serializeSplitTabs,
|
||||
type PersistedSplitTab,
|
||||
} from "@/shell/splitTabUtils";
|
||||
import { isTabTypeAvailable, refreshPluginState } from "@/shell/pluginLoader";
|
||||
|
||||
export { buildHostTree } from "@/sidebar/build-host-tree";
|
||||
export { tabIcon, renderTabContent } from "@/shell/tabUtils";
|
||||
@@ -458,6 +459,12 @@ export function AppShell({
|
||||
.catch(() => setIsAdmin(false));
|
||||
}, []);
|
||||
|
||||
// Plugin state gates which tab types can be opened, so it has to land before
|
||||
// a saved session is restored. A failure leaves everything enabled.
|
||||
useEffect(() => {
|
||||
refreshPluginState().catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleDismissDonationModal = useCallback(() => {
|
||||
setShowDonationModal(false);
|
||||
dismissDonationModal().catch(() => {});
|
||||
@@ -1657,6 +1664,11 @@ export function AppShell({
|
||||
},
|
||||
options?: { endpointId?: string; label?: string; forceNewTab?: boolean },
|
||||
) {
|
||||
// A tab type owned by a disabled plugin cannot be opened, including from a
|
||||
// restored session. Guarded here rather than at each call site because
|
||||
// every path into a tab goes through this one.
|
||||
if (!isTabTypeAvailable(type)) return null;
|
||||
|
||||
if (!restore && !options?.forceNewTab) {
|
||||
const existing = tabsRef.current.find(
|
||||
(t) =>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { rbacApi } from "@/main-axios";
|
||||
|
||||
export interface PluginTabContribution {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
icon: string;
|
||||
openFrom: string[];
|
||||
}
|
||||
|
||||
export interface PluginContributions {
|
||||
tabs?: PluginTabContribution[];
|
||||
}
|
||||
|
||||
export interface PluginSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
tier: string;
|
||||
source: string;
|
||||
enabled: boolean;
|
||||
runtimeState: string;
|
||||
lastError: string | null;
|
||||
contributes: PluginContributions | null;
|
||||
}
|
||||
|
||||
export async function getPlugins(): Promise<PluginSummary[]> {
|
||||
const response = await rbacApi.get("/plugins");
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
}
|
||||
|
||||
export async function setPluginEnabled(
|
||||
pluginId: string,
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
await rbacApi.patch(`/plugins/${encodeURIComponent(pluginId)}/state`, {
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
@@ -3977,7 +3977,14 @@
|
||||
"notificationPrivateEndpointsDesc": "Exact private hosts that notification channels may contact. Separate them with commas.",
|
||||
"updateNotificationEndpointsFailed": "Failed to update notification endpoint allowlist",
|
||||
"updateAiEnabledFailed": "Could not update the AI setting",
|
||||
"updateAiEndpointsFailed": "Could not update the allowed hosts"
|
||||
"updateAiEndpointsFailed": "Could not update the allowed hosts",
|
||||
"plugins": "Plugins",
|
||||
"pluginsNone": "No plugins installed.",
|
||||
"pluginsLoadFailed": "Failed to load plugins",
|
||||
"pluginToggleFailed": "Failed to change the plugin",
|
||||
"pluginBuiltIn": "BUILT IN",
|
||||
"pluginEnabled": "{{name}} enabled",
|
||||
"pluginDisabled": "{{name}} disabled"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -46,6 +46,7 @@ import { canEditHost } from "@/sidebar/host-permissions";
|
||||
import { RAIL_ITEMS, RAIL_UTILITY_ITEMS } from "@/sidebar/rail-items";
|
||||
import { useAiAvailability } from "@/hooks/use-ai-availability";
|
||||
import { useSnippetRunner } from "@/hooks/use-snippet-runner.tsx";
|
||||
import { isTabTypeAvailable } from "@/shell/pluginLoader";
|
||||
|
||||
interface CommandPaletteProps {
|
||||
isOpen: boolean;
|
||||
@@ -87,11 +88,12 @@ function getSshActions(host: Host): {
|
||||
}[] {
|
||||
const metricsEnabled = host.statsConfig?.metricsEnabled !== false;
|
||||
return [
|
||||
host.enableTerminal !== false && {
|
||||
type: "terminal",
|
||||
icon: Terminal,
|
||||
label: "Terminal",
|
||||
},
|
||||
host.enableTerminal !== false &&
|
||||
isTabTypeAvailable("terminal") && {
|
||||
type: "terminal",
|
||||
icon: Terminal,
|
||||
label: "Terminal",
|
||||
},
|
||||
host.enableFileManager && {
|
||||
type: "files",
|
||||
icon: FolderSearch,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Applies backend plugin state to the shell.
|
||||
*
|
||||
* Two kinds of contributed surface exist, and they are removed differently:
|
||||
*
|
||||
* - A tab that only exists because a plugin registered it goes away with
|
||||
* unregisterTabComponent / unregisterRailItem.
|
||||
* - A tab that ships as a built-in code path but is OWNED by a plugin (the
|
||||
* terminal) cannot, because it was never registered. Callers ask
|
||||
* isTabTypeAvailable() before offering it instead.
|
||||
*
|
||||
* The terminal is in the second group because its tab takes around a dozen
|
||||
* props (host, split-pane state, file-manager and editor callbacks) that the
|
||||
* generic { tab } plugin surface has no way to supply. Rewiring that was a
|
||||
* bigger regression risk to a working terminal than gating the entry points.
|
||||
*/
|
||||
|
||||
import { unregisterRailItem } from "@/sidebar/rail-items";
|
||||
import { unregisterTabComponent } from "@/shell/tabUtils";
|
||||
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
|
||||
|
||||
/**
|
||||
* Built-in tab types owned by a plugin, keyed by plugin id. The terminal has
|
||||
* no rail item: it opens from a host, the command palette or quick connect.
|
||||
*/
|
||||
const BUILT_IN_TABS_BY_PLUGIN: Record<string, string[]> = {
|
||||
"ssh-terminal": ["terminal"],
|
||||
};
|
||||
|
||||
let pluginEnabled = new Map<string, boolean>();
|
||||
let disabledTabTypes = new Set<string>();
|
||||
|
||||
export function applyPluginState(plugins: PluginSummary[]): void {
|
||||
const nextEnabled = new Map<string, boolean>();
|
||||
const nextDisabledTabs = new Set<string>();
|
||||
|
||||
for (const plugin of plugins) {
|
||||
nextEnabled.set(plugin.id, plugin.enabled);
|
||||
|
||||
const builtInTabs = BUILT_IN_TABS_BY_PLUGIN[plugin.id];
|
||||
if (builtInTabs) {
|
||||
if (!plugin.enabled) {
|
||||
for (const tabId of builtInTabs) nextDisabledTabs.add(tabId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Plugin-registered surfaces: only removal happens here. Registration is
|
||||
// the plugin's own frontend entry being imported.
|
||||
if (!plugin.enabled) {
|
||||
for (const tab of plugin.contributes?.tabs ?? []) {
|
||||
unregisterRailItem(tab.id);
|
||||
unregisterTabComponent(tab.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pluginEnabled = nextEnabled;
|
||||
disabledTabTypes = nextDisabledTabs;
|
||||
}
|
||||
|
||||
export async function refreshPluginState(): Promise<PluginSummary[]> {
|
||||
const plugins = await getPlugins();
|
||||
applyPluginState(plugins);
|
||||
return plugins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a built-in tab type may still be opened. Call sites that offer a
|
||||
* connection action (host item, command palette, quick connect) check this so
|
||||
* a disabled plugin stops offering the tab it owns.
|
||||
*/
|
||||
export function isTabTypeAvailable(tabType: string): boolean {
|
||||
return !disabledTabTypes.has(tabType);
|
||||
}
|
||||
|
||||
export function isPluginEnabled(pluginId: string): boolean {
|
||||
// Unknown means not yet loaded; assume enabled so the UI does not blank out
|
||||
// a working feature while the first request is in flight.
|
||||
return pluginEnabled.get(pluginId) ?? true;
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function resetPluginState(): void {
|
||||
pluginEnabled = new Map();
|
||||
disabledTabTypes = new Set();
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Puzzle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { SettingRow } from "@/components/section-card";
|
||||
import {
|
||||
getPlugins,
|
||||
setPluginEnabled,
|
||||
type PluginSummary,
|
||||
} from "@/api/plugins-api";
|
||||
import { refreshPluginState } from "@/shell/pluginLoader";
|
||||
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
|
||||
|
||||
export function AdminPluginsSection({
|
||||
open,
|
||||
onToggle,
|
||||
}: {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setPlugins(await getPlugins());
|
||||
} catch {
|
||||
toast.error(t("admin.pluginsLoadFailed"));
|
||||
} finally {
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !loaded) void load();
|
||||
}, [open, loaded, load]);
|
||||
|
||||
const toggle = async (plugin: PluginSummary) => {
|
||||
setBusy(plugin.id);
|
||||
try {
|
||||
await setPluginEnabled(plugin.id, !plugin.enabled);
|
||||
await load();
|
||||
// Re-read state so the shell picks the change up without a reload.
|
||||
await refreshPluginState();
|
||||
toast.success(
|
||||
plugin.enabled
|
||||
? t("admin.pluginDisabled", { name: plugin.name })
|
||||
: t("admin.pluginEnabled", { name: plugin.name }),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("admin.pluginToggleFailed"));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AccordionSection
|
||||
label={t("admin.plugins")}
|
||||
icon={<Puzzle className="size-3.5" />}
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
{plugins.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-3">
|
||||
{loaded ? t("admin.pluginsNone") : t("common.loading")}
|
||||
</p>
|
||||
) : (
|
||||
plugins.map((plugin) => (
|
||||
<SettingRow
|
||||
key={plugin.id}
|
||||
label={plugin.name}
|
||||
badge={
|
||||
plugin.tier === "first-party"
|
||||
? t("admin.pluginBuiltIn")
|
||||
: undefined
|
||||
}
|
||||
description={
|
||||
plugin.lastError
|
||||
? plugin.lastError
|
||||
: `v${plugin.version} · ${plugin.runtimeState}`
|
||||
}
|
||||
>
|
||||
<AdminToggle
|
||||
on={plugin.enabled}
|
||||
onToggle={() => void toggle(plugin)}
|
||||
disabled={busy === plugin.id}
|
||||
/>
|
||||
</SettingRow>
|
||||
))
|
||||
)}
|
||||
</AccordionSection>
|
||||
);
|
||||
}
|
||||
@@ -94,6 +94,7 @@ import {
|
||||
type UserRole,
|
||||
} from "@/main-axios";
|
||||
import { type AdminSection, type Host } from "@/types/ui-types";
|
||||
import { AdminPluginsSection } from "./AdminPluginsSection";
|
||||
import {
|
||||
AdminRolesSection,
|
||||
AdminSessionsSection,
|
||||
@@ -1496,6 +1497,11 @@ export function AdminSettingsPanel({
|
||||
users={users}
|
||||
/>
|
||||
|
||||
<AdminPluginsSection
|
||||
open={openSections.has("plugins")}
|
||||
onToggle={() => toggle("plugins")}
|
||||
/>
|
||||
|
||||
<AdminTouchInputSection
|
||||
open={openSections.has("touch-input")}
|
||||
onToggle={() => toggle("touch-input")}
|
||||
|
||||
@@ -88,6 +88,7 @@ import {
|
||||
} from "@/lib/local-adaptive-preferences";
|
||||
import type { WebEndpoint } from "@/types/index";
|
||||
import { openWebEndpointExternally } from "@/api/web-endpoint-api";
|
||||
import { isTabTypeAvailable } from "@/shell/pluginLoader";
|
||||
|
||||
export function statusCheckEnabled(host: Host): boolean {
|
||||
return host.statsConfig?.statusCheckEnabled !== false;
|
||||
@@ -149,7 +150,8 @@ export function getSshActions(host: Host): {
|
||||
|
||||
const connectionActions = [
|
||||
host.enableSsh &&
|
||||
host.enableTerminal && {
|
||||
host.enableTerminal &&
|
||||
isTabTypeAvailable("terminal") && {
|
||||
type: "terminal" as TabType,
|
||||
icon: Terminal,
|
||||
label: "Terminal",
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Disabling a plugin has to remove the surfaces it owns, and disabling one
|
||||
* plugin must not touch another's.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Puzzle } from "lucide-react";
|
||||
import {
|
||||
applyPluginState,
|
||||
isPluginEnabled,
|
||||
isTabTypeAvailable,
|
||||
resetPluginState,
|
||||
} from "@/shell/pluginLoader";
|
||||
import {
|
||||
registerRailItem,
|
||||
unregisterRailItem,
|
||||
visibleRailItems,
|
||||
} from "@/sidebar/rail-items";
|
||||
import type { PluginSummary } from "@/api/plugins-api";
|
||||
|
||||
vi.mock("@/api/plugins-api", () => ({
|
||||
getPlugins: vi.fn().mockResolvedValue([]),
|
||||
setPluginEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
|
||||
return {
|
||||
id: "ssh-terminal",
|
||||
name: "SSH Terminal",
|
||||
version: "1.0.0",
|
||||
tier: "first-party",
|
||||
source: "bundled",
|
||||
enabled: true,
|
||||
runtimeState: "active",
|
||||
lastError: null,
|
||||
contributes: {
|
||||
tabs: [
|
||||
{
|
||||
id: "terminal",
|
||||
titleKey: "nav.terminal",
|
||||
icon: "SquareTerminal",
|
||||
openFrom: ["host-context-menu", "palette"],
|
||||
},
|
||||
],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("plugin state applied to the shell", () => {
|
||||
afterEach(() => {
|
||||
resetPluginState();
|
||||
unregisterRailItem("other-plugin-tab");
|
||||
});
|
||||
|
||||
it("allows terminal tabs while the plugin is enabled", () => {
|
||||
applyPluginState([plugin({ enabled: true })]);
|
||||
expect(isTabTypeAvailable("terminal")).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks terminal tabs when the plugin is disabled", () => {
|
||||
applyPluginState([plugin({ enabled: false })]);
|
||||
expect(isTabTypeAvailable("terminal")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows them again when the plugin is re-enabled", () => {
|
||||
applyPluginState([plugin({ enabled: false })]);
|
||||
expect(isTabTypeAvailable("terminal")).toBe(false);
|
||||
|
||||
applyPluginState([plugin({ enabled: true })]);
|
||||
expect(isTabTypeAvailable("terminal")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves unrelated tab types alone", () => {
|
||||
applyPluginState([plugin({ enabled: false })]);
|
||||
|
||||
// Disabling the terminal must not take the rest of the app with it.
|
||||
for (const type of ["files", "docker", "tunnel", "rdp", "host-metrics"]) {
|
||||
expect(isTabTypeAvailable(type)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves another plugin's rail item alone when the terminal is disabled", () => {
|
||||
registerRailItem({
|
||||
id: "other-plugin-tab",
|
||||
icon: Puzzle,
|
||||
labelKey: "nav.otherPlugin",
|
||||
kind: "tab",
|
||||
});
|
||||
|
||||
applyPluginState([
|
||||
plugin({ enabled: false }),
|
||||
plugin({
|
||||
id: "other-plugin",
|
||||
name: "Other",
|
||||
tier: "community",
|
||||
source: "registry",
|
||||
enabled: true,
|
||||
contributes: null,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(isTabTypeAvailable("terminal")).toBe(false);
|
||||
// The core ask: disabling one plugin must not disturb another.
|
||||
expect(visibleRailItems().map((i) => i.id)).toContain("other-plugin-tab");
|
||||
});
|
||||
|
||||
it("unregisters a plugin-registered tab when that plugin is disabled", () => {
|
||||
registerRailItem({
|
||||
id: "other-plugin-tab",
|
||||
icon: Puzzle,
|
||||
labelKey: "nav.otherPlugin",
|
||||
kind: "tab",
|
||||
});
|
||||
expect(visibleRailItems().map((i) => i.id)).toContain("other-plugin-tab");
|
||||
|
||||
applyPluginState([
|
||||
plugin({
|
||||
id: "other-plugin",
|
||||
name: "Other",
|
||||
enabled: false,
|
||||
contributes: {
|
||||
tabs: [
|
||||
{
|
||||
id: "other-plugin-tab",
|
||||
titleKey: "nav.otherPlugin",
|
||||
icon: "Puzzle",
|
||||
openFrom: ["rail"],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(visibleRailItems().map((i) => i.id)).not.toContain(
|
||||
"other-plugin-tab",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports enablement, defaulting to true before the first load", () => {
|
||||
expect(isPluginEnabled("ssh-terminal")).toBe(true);
|
||||
|
||||
applyPluginState([plugin({ enabled: false })]);
|
||||
expect(isPluginEnabled("ssh-terminal")).toBe(false);
|
||||
expect(isPluginEnabled("never-heard-of-it")).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user