mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
452 lines
14 KiB
TypeScript
452 lines
14 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { enabledHostProtocols } from "@/sidebar/host-protocols";
|
|
import { useHostActions } from "@/sidebar/host-contributions";
|
|
import { tabTypeForActivity, useTabTypes } from "@/shell/tab-registry";
|
|
import { getExtension, useExtensions } from "./extension-registry";
|
|
import { useTranslation as useI18nTranslation } from "react-i18next";
|
|
import { toast } from "sonner";
|
|
import {
|
|
__setPluginHost,
|
|
type ExtensionContribution,
|
|
type HostActionContribution,
|
|
type PluginHostBridge,
|
|
type PluginHostRecord,
|
|
type SettingsScope,
|
|
type SettingsState,
|
|
} from "@termix/plugin-sdk/frontend";
|
|
import { usePermissions } from "@/hooks/use-permissions";
|
|
import { useTheme } from "@/components/theme-provider";
|
|
import { createPluginApi, pluginApiFor } from "@/lib/plugin-transport";
|
|
import {
|
|
getPluginAdminSettings,
|
|
getPluginHostSettings,
|
|
getPluginUserSettings,
|
|
updatePluginAdminSettings,
|
|
updatePluginHostSettings,
|
|
updatePluginUserSettings,
|
|
} from "@/api/plugins-api";
|
|
import { getCookie, getUserInfo, setCookie } from "@/main-axios";
|
|
import { logActivity } from "@/api/dashboard-api";
|
|
import { getCredentials, getHostPassword } from "@/api/credentials-api";
|
|
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
|
import {
|
|
getUserPreferences,
|
|
parseCustomKeybindings,
|
|
patchOpenTab,
|
|
} from "@/api/open-tabs-api";
|
|
import { runKeybindingAction } from "@/shell/keybinding-registry";
|
|
import { usePluginScope } from "./scope";
|
|
import { knownPluginIds, usePluginStore } from "./plugin-store";
|
|
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
|
import type { UiPluginPresets } from "@/types/ui-preferences";
|
|
import { tabsApi, useShellHosts } from "./shell-bridge";
|
|
import { invokeAction } from "@/shell/action-registry";
|
|
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
|
|
import { useActionSlot } from "@/hooks/use-action-slot";
|
|
import { usePluginComponent } from "./component-registry";
|
|
import { useOptionalHostStatusEntry } from "@/lib/ServerStatusContext";
|
|
|
|
/**
|
|
* Core permission groups, mirroring RESERVED_PERMISSION_PREFIXES in the SDK
|
|
* manifest module, which is not imported here because it pulls semver into
|
|
* the browser bundle. A plugin's own namespace is covered by knownPluginIds()
|
|
* below.
|
|
*/
|
|
const CORE_PERMISSION_GROUPS = ["hosts", "credentials", "admin"];
|
|
|
|
/**
|
|
* Resolves a permission the way the backend's ctx.rbac does: a short name
|
|
* belongs to the plugin, while a full id in a core group or another plugin's
|
|
* namespace is taken as given.
|
|
*/
|
|
export function resolvePluginPermission(
|
|
pluginId: string,
|
|
permission: string,
|
|
): string {
|
|
if (permission.startsWith(`${pluginId}.`)) return permission;
|
|
const head = permission.split(".")[0];
|
|
if (
|
|
permission.includes(".") &&
|
|
(CORE_PERMISSION_GROUPS.includes(head) ||
|
|
(head !== pluginId && knownPluginIds().includes(head)))
|
|
) {
|
|
return permission;
|
|
}
|
|
return `${pluginId}.${permission}`;
|
|
}
|
|
|
|
const apiClients = new Map<string, ReturnType<typeof createPluginApi>>();
|
|
|
|
/** Test seam: renderWithApp's `api` option stands in for the real client. */
|
|
export function setPluginApiForTesting(
|
|
pluginId: string,
|
|
client: ReturnType<typeof createPluginApi> | null,
|
|
): void {
|
|
if (client) apiClients.set(pluginId, client);
|
|
else apiClients.delete(pluginId);
|
|
}
|
|
|
|
function getApi(pluginId: string) {
|
|
let client = apiClients.get(pluginId);
|
|
if (!client) {
|
|
client = createPluginApi(pluginId);
|
|
apiClients.set(pluginId, client);
|
|
}
|
|
return client;
|
|
}
|
|
|
|
let currentUser: Promise<{
|
|
userId: string;
|
|
username: string;
|
|
isAdmin: boolean;
|
|
} | null> | null = null;
|
|
|
|
function loadCurrentUser() {
|
|
if (!currentUser) {
|
|
currentUser = getUserInfo()
|
|
.then((info) => ({
|
|
userId: info.userId,
|
|
username: info.username,
|
|
isAdmin: !!info.is_admin,
|
|
}))
|
|
.catch(() => {
|
|
currentUser = null;
|
|
return null;
|
|
});
|
|
}
|
|
return currentUser;
|
|
}
|
|
|
|
const SETTINGS_READERS: Record<
|
|
SettingsScope,
|
|
(pluginId: string, hostId?: number) => Promise<Record<string, unknown>>
|
|
> = {
|
|
admin: (pluginId) => getPluginAdminSettings(pluginId),
|
|
user: (pluginId) => getPluginUserSettings(pluginId),
|
|
host: (pluginId, hostId) => getPluginHostSettings(pluginId, hostId!),
|
|
};
|
|
|
|
const SETTINGS_WRITERS: Record<
|
|
SettingsScope,
|
|
(
|
|
pluginId: string,
|
|
values: Record<string, unknown>,
|
|
hostId?: number,
|
|
) => Promise<Record<string, unknown>>
|
|
> = {
|
|
admin: (pluginId, values) => updatePluginAdminSettings(pluginId, values),
|
|
user: (pluginId, values) => updatePluginUserSettings(pluginId, values),
|
|
host: (pluginId, values, hostId) =>
|
|
updatePluginHostSettings(pluginId, hostId!, values),
|
|
};
|
|
|
|
/** Both host shapes the shell holds: its own list and the API's. */
|
|
type ShellHost = Omit<Partial<PluginHostRecord>, "id" | "parentHostId"> & {
|
|
id: string | number;
|
|
parentHostId?: string | number | null;
|
|
};
|
|
|
|
/**
|
|
* The shell's host as the SDK types it. Copied field by field, so a core
|
|
* field reaches plugins only once it is part of PluginHostRecord.
|
|
*/
|
|
export function toHostRecord(host: ShellHost): PluginHostRecord {
|
|
return {
|
|
id: String(host.id),
|
|
name: host.name ?? "",
|
|
ip: host.ip ?? "",
|
|
port: host.port,
|
|
username: host.username,
|
|
folder: host.folder,
|
|
tags: host.tags,
|
|
pin: host.pin,
|
|
notes: host.notes,
|
|
syncId: host.syncId,
|
|
parentHostId: host.parentHostId == null ? null : String(host.parentHostId),
|
|
authType: host.authType,
|
|
credentialId: host.credentialId,
|
|
overrideCredentialUsername: host.overrideCredentialUsername,
|
|
connectionType: host.connectionType,
|
|
connectionOrigin: host.connectionOrigin,
|
|
enableSsh: host.enableSsh,
|
|
sshPort: host.sshPort,
|
|
jumpHosts: host.jumpHosts?.map((jump) => ({ hostId: jump.hostId })),
|
|
statusCheckEnabled: host.statusCheckEnabled,
|
|
statusCheckInterval: host.statusCheckInterval,
|
|
sshOptions: host.sshOptions,
|
|
pluginSettings: host.pluginSettings,
|
|
protocolAuth: host.protocolAuth,
|
|
quickConnectLogin: host.quickConnectLogin,
|
|
quickConnectSavable: host.quickConnectSavable,
|
|
instanceId: host.instanceId,
|
|
status: host.status,
|
|
online: host.online,
|
|
isShared: host.isShared,
|
|
permissionLevel: host.permissionLevel,
|
|
sharedExpiresAt: host.sharedExpiresAt,
|
|
ownerUsername: host.ownerUsername,
|
|
authOverrides: host.authOverrides,
|
|
sharedCopy: host.sharedCopy,
|
|
localOnly: host.localOnly,
|
|
};
|
|
}
|
|
|
|
/** 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();
|
|
if (!pluginId) {
|
|
throw new Error(
|
|
"SDK hooks can only be used inside a component a plugin registered",
|
|
);
|
|
}
|
|
return pluginId;
|
|
},
|
|
|
|
useTranslation(pluginId) {
|
|
const { t, i18n } = useI18nTranslation(pluginId);
|
|
return {
|
|
t: t as unknown as (
|
|
key: string,
|
|
options?: Record<string, unknown> | string,
|
|
) => string,
|
|
language: i18n.language,
|
|
};
|
|
},
|
|
|
|
usePermission(pluginId, permission) {
|
|
const { has, loaded } = usePermissions();
|
|
return loaded && has(resolvePluginPermission(pluginId, permission));
|
|
},
|
|
|
|
useSettings(pluginId, scope, hostId) {
|
|
const numericHostId =
|
|
hostId === undefined ? undefined : Number.parseInt(String(hostId), 10);
|
|
const [state, setState] = useState<{
|
|
values: Record<string, unknown>;
|
|
loaded: boolean;
|
|
}>({ values: {}, loaded: false });
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
if (scope === "host" && !Number.isFinite(numericHostId)) return;
|
|
SETTINGS_READERS[scope](pluginId, numericHostId)
|
|
.then((values) => {
|
|
if (!cancelled) setState({ values, loaded: true });
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setState((prev) => ({ ...prev, loaded: true }));
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [pluginId, scope, numericHostId]);
|
|
|
|
const save = useCallback(
|
|
async (values: Record<string, unknown>) => {
|
|
const saved = await SETTINGS_WRITERS[scope](
|
|
pluginId,
|
|
values,
|
|
numericHostId,
|
|
);
|
|
setState({ values: saved, loaded: true });
|
|
},
|
|
[pluginId, scope, numericHostId],
|
|
);
|
|
|
|
return useMemo<SettingsState>(() => ({ ...state, save }), [state, save]);
|
|
},
|
|
|
|
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 ? toPluginHostRecord(found, pluginId) : null;
|
|
}, [hosts, hostId, pluginId]);
|
|
},
|
|
|
|
useHosts() {
|
|
const pluginId = usePluginScope();
|
|
const { hosts, loaded } = useShellHosts();
|
|
return useMemo(
|
|
() => ({
|
|
hosts: hosts.map((host) => toPluginHostRecord(host, pluginId)),
|
|
loaded,
|
|
}),
|
|
[hosts, loaded, pluginId],
|
|
);
|
|
},
|
|
|
|
useCurrentUser() {
|
|
const [user, setUser] = useState<{
|
|
userId: string;
|
|
username: string;
|
|
isAdmin: boolean;
|
|
} | null>(null);
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
loadCurrentUser().then((next) => {
|
|
if (!cancelled) setUser(next);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
return user;
|
|
},
|
|
|
|
useTheme() {
|
|
const { theme } = useTheme();
|
|
if (theme === "light") return { theme: "light" };
|
|
if ((theme as string) === "system" && typeof window !== "undefined") {
|
|
return {
|
|
theme: window.matchMedia?.("(prefers-color-scheme: light)").matches
|
|
? "light"
|
|
: "dark",
|
|
};
|
|
}
|
|
return { theme: "dark" };
|
|
},
|
|
|
|
toast: {
|
|
success: (message) => toast.success(message),
|
|
error: (message) => toast.error(message),
|
|
info: (message) => toast.info(message),
|
|
warning: (message) => toast.warning(message),
|
|
},
|
|
|
|
getApi: (pluginId) => getApi(pluginId),
|
|
getApiFor: (pluginId, origin) =>
|
|
pluginApiFor(
|
|
pluginId,
|
|
origin as "local" | "remote" | undefined,
|
|
getApi(pluginId) as never,
|
|
) as never,
|
|
|
|
useTabs: () => tabsApi,
|
|
|
|
invokeAction: (id, ...args) => invokeAction(id, ...args),
|
|
|
|
useSshAuthTypes: () => {
|
|
const { providers, loaded } = useSshAuthProviders();
|
|
return {
|
|
loaded,
|
|
types: providers
|
|
.filter((option) => option.available)
|
|
.map((option) => ({
|
|
type: option.type,
|
|
labelKey: option.editorTitleKey ?? option.labelKey,
|
|
pluginId: option.pluginId,
|
|
credentialType: option.credentialType,
|
|
supportsBackground: option.supportsBackground,
|
|
quickConnect: option.quickConnect === true,
|
|
})),
|
|
};
|
|
},
|
|
|
|
useSlotContributions: (slotId, context) => useActionSlot(slotId, context),
|
|
|
|
hostProtocols: (record) => [
|
|
...(record.enableSsh !== false ? ["ssh"] : []),
|
|
...enabledHostProtocols(
|
|
record as { pluginSettings?: Record<string, Record<string, unknown>> },
|
|
).map((protocol) => protocol.id),
|
|
],
|
|
|
|
usePluginComponent: (id) => usePluginComponent(id),
|
|
|
|
useHostStatus: (hostId) => {
|
|
const entry = useOptionalHostStatusEntry(hostId);
|
|
if (!entry) return null;
|
|
return {
|
|
status: entry.status === "degraded" ? "unknown" : entry.status,
|
|
...(entry.reason ? { reason: entry.reason } : {}),
|
|
};
|
|
},
|
|
|
|
useHostActions: () => useHostActions() as unknown as HostActionContribution[],
|
|
|
|
useActivityTypes: () => {
|
|
const defs = useTabTypes();
|
|
const types = new Set<string>();
|
|
for (const def of defs) {
|
|
for (const type of def.activityTypes ?? []) types.add(type);
|
|
}
|
|
return [...types];
|
|
},
|
|
|
|
activityTarget: (type) => {
|
|
const def = tabTypeForActivity(type);
|
|
if (!def) return undefined;
|
|
return { icon: def.icon, tab: def.id, titleKey: def.titleKey };
|
|
},
|
|
|
|
useExtensions: (pointId) => useExtensions(pointId) as ExtensionContribution[],
|
|
getExtension: (pointId, id) =>
|
|
getExtension(pointId, id) as ExtensionContribution | undefined,
|
|
|
|
usePluginUiPreferences: (pluginId) => {
|
|
const ctx = useUiPreferencesContext();
|
|
const store = usePluginStore();
|
|
const presets = store.records.get(pluginId)?.summary.contributes
|
|
?.uiPresets as UiPluginPresets | undefined;
|
|
const values = ctx
|
|
? ctx.resolvePlugin(pluginId, presets)
|
|
: { ...(presets?.balanced ?? {}) };
|
|
return {
|
|
values,
|
|
set: (key, value) => ctx?.setPluginOverride(pluginId, key, value),
|
|
};
|
|
},
|
|
|
|
core: {
|
|
logActivity: async (type, hostId, hostName) => {
|
|
await logActivity(type, hostId, hostName);
|
|
},
|
|
getHostPassword: async (hostId, field) =>
|
|
(await getHostPassword(hostId, field)) ?? null,
|
|
patchOpenTab: (instanceId, updates) => patchOpenTab(instanceId, updates),
|
|
getCustomKeybindings: async () =>
|
|
parseCustomKeybindings((await getUserPreferences()).customKeybindings),
|
|
runKeybindingAction: (action, context) =>
|
|
runKeybindingAction(action, context),
|
|
getClientPreference: (name) => getCookie(name),
|
|
setClientPreference: (name, value) => {
|
|
void setCookie(name, value, 365);
|
|
},
|
|
listHosts: async (pluginId) =>
|
|
(await getSSHHosts({ includeStatus: false })).map((host) =>
|
|
toPluginHostRecord(host, pluginId ?? null),
|
|
),
|
|
listCredentials: async () => {
|
|
const raw = await getCredentials();
|
|
const list = Array.isArray(raw)
|
|
? raw
|
|
: ((raw as { credentials?: unknown[] }).credentials ?? []);
|
|
return (list as Record<string, unknown>[]).map((c) => ({
|
|
id: Number(c.id),
|
|
name: String(c.name ?? ""),
|
|
username: typeof c.username === "string" ? c.username : undefined,
|
|
authType: typeof c.authType === "string" ? c.authType : undefined,
|
|
}));
|
|
},
|
|
},
|
|
};
|
|
|
|
/** Installs the bridge. Idempotent. */
|
|
export function installPluginHostBridge(): void {
|
|
__setPluginHost(pluginHostBridge);
|
|
}
|