mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: load plugin frontends at runtime and remove plugin ids from the shell
This commit is contained in:
1 parent
49f0286a14
commit
e88d5a52e3
721 files changed
+54265
-44416
No files matched your search
@@ -14,20 +14,15 @@ import {
|
||||
type GuacamoleDisplayHandle,
|
||||
type GuacamoleTouchMode,
|
||||
} from "./GuacamoleDisplay.tsx";
|
||||
import {
|
||||
getGuacamoleTokenFromHost,
|
||||
getGuacdStatus,
|
||||
getSSHHosts,
|
||||
logActivity,
|
||||
isElectron,
|
||||
} from "@/main-axios.ts";
|
||||
import { getSSHHosts, logActivity, isElectron } from "@/main-axios";
|
||||
import { getGuacamoleTokenFromHost, getGuacdStatus } from "./guacamole-api";
|
||||
import { readConfiguredDimension } from "./guacamole-display-size.ts";
|
||||
import { getGuacamoleToken, parseGuacamoleConfig } from "./guacamole-api";
|
||||
import {
|
||||
resolveConnectionOrigin,
|
||||
type ConnectionOrigin,
|
||||
} from "@/lib/connection-origin.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { GuacamoleToolbar } from "./GuacamoleToolbar.tsx";
|
||||
import { GuacamoleFileBrowser } from "./GuacamoleFileBrowser.tsx";
|
||||
import { describeUploadError } from "./guacamole-filesystem.ts";
|
||||
@@ -73,6 +68,8 @@ export interface GuacamoleAppHandle {
|
||||
isConnected: () => boolean;
|
||||
openShareModal: () => void;
|
||||
canShare: () => boolean;
|
||||
/** Reconnects, from the tab bar's refresh. */
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
|
||||
@@ -294,11 +291,15 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
const [promptPassword, setPromptPassword] = useState("");
|
||||
const [promptDomain, setPromptDomain] = useState(hostConfig.domain ?? "");
|
||||
|
||||
// Assigned below, once handleReconnect exists; the shell's tab refresh calls it.
|
||||
const reconnectRef = useRef<() => void>(() => {});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
disconnect: () => displayRef.current?.disconnect(),
|
||||
isConnected: () => displayRef.current?.isConnected() === true,
|
||||
openShareModal: () => setShareModalOpen(true),
|
||||
canShare: () => guacamoleConnectionId !== null,
|
||||
refresh: () => reconnectRef.current(),
|
||||
}));
|
||||
|
||||
const fetchToken = useCallback(async (): Promise<void> => {
|
||||
@@ -454,16 +455,7 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
tokenRetryRef.current.retryNow();
|
||||
}, [needsCredentialPrompt, hostConfig.domain, clearLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tabId) return;
|
||||
const handler = (e: Event) => {
|
||||
const { tabId: eventTabId } = (e as CustomEvent).detail;
|
||||
if (eventTabId === tabId) handleReconnect();
|
||||
};
|
||||
window.addEventListener("termix:refresh-guacamole", handler);
|
||||
return () =>
|
||||
window.removeEventListener("termix:refresh-guacamole", handler);
|
||||
}, [tabId, handleReconnect]);
|
||||
reconnectRef.current = handleReconnect;
|
||||
|
||||
if (promptOpen) {
|
||||
return (
|
||||
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
} from "react";
|
||||
import Guacamole from "guacamole-common-js";
|
||||
import { Upload } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getGuacamoleToken, isElectron } from "@/main-axios.ts";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { isElectron } from "@/main-axios";
|
||||
import { getGuacamoleToken } from "./guacamole-api";
|
||||
import { getBasePath } from "@/lib/base-path.ts";
|
||||
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts";
|
||||
import {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Guacamole from "guacamole-common-js";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
ArrowUp,
|
||||
Download,
|
||||
|
||||
@@ -31,7 +31,7 @@ import type {
|
||||
GuacamoleDisplayHandle,
|
||||
GuacamoleTouchMode,
|
||||
} from "./GuacamoleDisplay.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
detectRuntimeMetaKeyFamily,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* Frontend half of the remote-desktop plugin.
|
||||
*
|
||||
* rdp/vnc/telnet are built-in tabs owned by this plugin (see
|
||||
* BUILT_IN_TABS_BY_PLUGIN in src/ui/shell/pluginLoader.ts), the same
|
||||
* category ssh-terminal's "terminal" tab is in: they render through
|
||||
* tabUtils.tsx's hardcoded switch rather than through registerTabComponent,
|
||||
* because GuacamoleApp needs hostId/tabId/protocol/quickConnectHost/ref --
|
||||
* more props than the generic { tab } plugin surface can carry. There is
|
||||
* nothing to register or unregister here; isTabTypeAvailable("rdp"/"vnc"/
|
||||
* "telnet") is what actually gates them off when this plugin is disabled.
|
||||
*
|
||||
* This file exists only so the plugin has a frontend entry point matching
|
||||
* every other plugin's shape, ready for the day a real frontend loader
|
||||
* calls register()/unregister() directly instead of pluginLoader.ts's
|
||||
* hand-duplicated stopgap wiring.
|
||||
*/
|
||||
|
||||
export const id = "remote-desktop";
|
||||
export const tabIds = ["rdp", "vnc", "telnet"];
|
||||
|
||||
export async function register() {
|
||||
// No-op: see module comment.
|
||||
}
|
||||
|
||||
export async function unregister() {
|
||||
// No-op: see module comment.
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import type { ComponentType, Ref } from "react";
|
||||
import i18next from "i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
MessagesSquare,
|
||||
Monitor,
|
||||
MonitorUp,
|
||||
MousePointerClick,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useTranslation,
|
||||
type HostEditorSectionProps,
|
||||
type StandaloneViewProps,
|
||||
type TabProps,
|
||||
type TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { FakeSwitch, SectionCard, SettingRow } from "@termix/plugin-sdk/ui";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import { isQuickConnectHost } from "@/sidebar/quick-connect-host";
|
||||
import GuacamoleApp, { type GuacamoleAppHandle } from "./GuacamoleApp";
|
||||
import { GuacamoleDisplay } from "./GuacamoleDisplay";
|
||||
import {
|
||||
HostEditorRdpTab,
|
||||
HostEditorTelnetTab,
|
||||
HostEditorVncTab,
|
||||
} from "./HostEditorGuacamoleTabs";
|
||||
import { getGuacamoleTokenFromHost } from "./guacamole-api";
|
||||
import { quickConnectGuacHost } from "./quick-connect-guac-host";
|
||||
|
||||
type Protocol = "rdp" | "vnc" | "telnet";
|
||||
|
||||
const PROTOCOLS: {
|
||||
id: Protocol;
|
||||
titleKey: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
enableKey: "enableRdp" | "enableVnc" | "enableTelnet";
|
||||
priority: number;
|
||||
order: number;
|
||||
}[] = [
|
||||
{
|
||||
id: "rdp",
|
||||
titleKey: "hosts.tabRdp",
|
||||
icon: Monitor,
|
||||
enableKey: "enableRdp",
|
||||
priority: 50,
|
||||
order: 100,
|
||||
},
|
||||
{
|
||||
id: "vnc",
|
||||
titleKey: "hosts.tabVnc",
|
||||
icon: MousePointerClick,
|
||||
enableKey: "enableVnc",
|
||||
priority: 40,
|
||||
order: 110,
|
||||
},
|
||||
{
|
||||
id: "telnet",
|
||||
titleKey: "hosts.tabTelnet",
|
||||
icon: MessagesSquare,
|
||||
enableKey: "enableTelnet",
|
||||
priority: 30,
|
||||
order: 120,
|
||||
},
|
||||
];
|
||||
|
||||
function RemoteDesktopTab({ tab, host, isVisible, handleRef }: TabProps) {
|
||||
const shellHost = host as unknown as Host;
|
||||
return (
|
||||
<GuacamoleApp
|
||||
ref={handleRef as Ref<GuacamoleAppHandle>}
|
||||
hostId={String(host?.id ?? "")}
|
||||
tabId={tab.id}
|
||||
protocol={tab.type as Protocol}
|
||||
isVisible={isVisible}
|
||||
quickConnectHost={
|
||||
isQuickConnectHost(shellHost)
|
||||
? quickConnectGuacHost(shellHost)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoteDesktopStandalone({ hostId, view }: StandaloneViewProps) {
|
||||
return <GuacamoleApp hostId={hostId} protocol={view as Protocol} />;
|
||||
}
|
||||
|
||||
/** What collab rooms and shared-session links draw a stream with. */
|
||||
function RemoteDisplay({
|
||||
token,
|
||||
protocol,
|
||||
isVisible,
|
||||
onConnect,
|
||||
onError,
|
||||
}: {
|
||||
token: string;
|
||||
protocol: Protocol;
|
||||
isVisible: boolean;
|
||||
onConnect?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<GuacamoleDisplay
|
||||
connectionConfig={{ token, protocol, type: protocol }}
|
||||
isVisible={isVisible}
|
||||
onConnect={onConnect}
|
||||
onError={onError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Applies one remote desktop field and stops inheriting user defaults. */
|
||||
function useGuacFieldSetter(
|
||||
updateForm: HostEditorSectionProps["updateForm"],
|
||||
): (key: string, value: unknown) => void {
|
||||
return (key, value) =>
|
||||
updateForm((current) => ({
|
||||
inheritRemoteDesktopDefaults: false,
|
||||
guacamoleConfig: {
|
||||
...((current.guacamoleConfig as Record<string, unknown>) ?? {}),
|
||||
[key]: value,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function RdpSection(props: HostEditorSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const setGuacField = useGuacFieldSetter(props.updateForm);
|
||||
return (
|
||||
<>
|
||||
<SectionCard
|
||||
title={t("hosts.rdpDefaults", { defaultValue: "RDP defaults" })}
|
||||
icon={<Settings className="size-3.5" />}
|
||||
>
|
||||
<SettingRow
|
||||
label={t("hosts.useUserDefaults", {
|
||||
defaultValue: "Use user defaults",
|
||||
})}
|
||||
description={t("hosts.useUserRdpDefaultsDesc", {
|
||||
defaultValue:
|
||||
"Inherit performance, redirection, and clipboard settings from User Profile.",
|
||||
})}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={!!props.form.inheritRemoteDesktopDefaults}
|
||||
onChange={(value) =>
|
||||
props.setField("inheritRemoteDesktopDefaults", value)
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
<HostEditorRdpTab
|
||||
form={props.form}
|
||||
setField={props.setField as never}
|
||||
setGuacField={setGuacField as never}
|
||||
host={props.host as unknown as Host}
|
||||
credentials={props.credentials as never}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function VncSection(props: HostEditorSectionProps) {
|
||||
const setGuacField = useGuacFieldSetter(props.updateForm);
|
||||
return (
|
||||
<HostEditorVncTab
|
||||
form={props.form}
|
||||
setField={props.setField as never}
|
||||
setGuacField={setGuacField as never}
|
||||
host={props.host as unknown as Host}
|
||||
credentials={props.credentials as never}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TelnetSection(props: HostEditorSectionProps) {
|
||||
const setGuacField = useGuacFieldSetter(props.updateForm);
|
||||
return (
|
||||
<HostEditorTelnetTab
|
||||
form={props.form}
|
||||
setField={props.setField as never}
|
||||
setGuacField={setGuacField as never}
|
||||
credentials={props.credentials as never}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const SECTIONS: Record<Protocol, ComponentType<HostEditorSectionProps>> = {
|
||||
rdp: RdpSection,
|
||||
vnc: VncSection,
|
||||
telnet: TelnetSection,
|
||||
};
|
||||
|
||||
async function openNativeRdp(host: Record<string, unknown>): Promise<void> {
|
||||
const t = (key: string) => i18next.t(`remote-desktop:${key}`);
|
||||
try {
|
||||
const result = await window.electronAPI.openNativeRdp({
|
||||
host: String(host.ip),
|
||||
port: (host.rdpPort as number | undefined) ?? 3389,
|
||||
username: host.rdpUser as string | undefined,
|
||||
domain: host.domain as string | undefined,
|
||||
});
|
||||
if (result.success) toast.success(t("hosts.nativeRdpOpened"));
|
||||
else toast.error(result.error || t("hosts.nativeRdpFailed"));
|
||||
} catch {
|
||||
toast.error(t("hosts.nativeRdpFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
// Guest pages only draw shared streams.
|
||||
app.registerSlotContribution("session.remoteDisplay", {
|
||||
actionId: "remote-desktop.display",
|
||||
titleKey: "hosts.tabRdp",
|
||||
kind: "component",
|
||||
component: RemoteDisplay as unknown as ComponentType<
|
||||
Record<string, unknown>
|
||||
>,
|
||||
});
|
||||
if (app.guest) return;
|
||||
|
||||
// Mints a token for presenting a host in a collab room.
|
||||
app.registerAction(
|
||||
"session.remoteDisplay.token",
|
||||
async (hostId: number, origin: unknown, protocol: Protocol) => {
|
||||
const response = await getGuacamoleTokenFromHost(
|
||||
hostId,
|
||||
origin as Parameters<typeof getGuacamoleTokenFromHost>[1],
|
||||
protocol,
|
||||
);
|
||||
return response.guacamoleConnectionId
|
||||
? {
|
||||
token: response.token,
|
||||
connectionId: response.guacamoleConnectionId,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
);
|
||||
|
||||
for (const protocol of PROTOCOLS) {
|
||||
app.registerTab(protocol.id, RemoteDesktopTab, {
|
||||
icon: protocol.icon,
|
||||
titleKey: protocol.titleKey,
|
||||
requiresHost: true,
|
||||
noHostMessageKey: "remoteDesktop.noHostSelected",
|
||||
persistent: true,
|
||||
session: true,
|
||||
restore: (host) => !!host[protocol.enableKey],
|
||||
activityTypes: [protocol.id],
|
||||
standalone: RemoteDesktopStandalone,
|
||||
preload: () => import("./GuacamoleApp"),
|
||||
});
|
||||
|
||||
app.registerHostAction({
|
||||
id: protocol.id,
|
||||
titleKey: protocol.titleKey,
|
||||
icon: protocol.icon,
|
||||
kind: "connect",
|
||||
priority: protocol.priority,
|
||||
order: protocol.order,
|
||||
tabType: protocol.id,
|
||||
copyUrlView: protocol.id,
|
||||
when: (host) => !!host[protocol.enableKey],
|
||||
});
|
||||
|
||||
app.registerHostEditorSection({
|
||||
id: protocol.id,
|
||||
group: "top",
|
||||
titleKey: protocol.titleKey,
|
||||
icon: protocol.icon,
|
||||
order: protocol.order / 5,
|
||||
visible: (protocols) => !!protocols[protocol.enableKey],
|
||||
component: SECTIONS[protocol.id],
|
||||
});
|
||||
}
|
||||
|
||||
// The desktop app on Windows can hand RDP to the system client instead.
|
||||
if (window.electronAPI?.isElectron) {
|
||||
let disposed = false;
|
||||
app.onDispose(() => {
|
||||
disposed = true;
|
||||
});
|
||||
window.electronAPI
|
||||
.getPlatform()
|
||||
.then((platform: string) => {
|
||||
if (disposed || platform !== "win32") return;
|
||||
app.registerHostAction({
|
||||
id: "rdp-native",
|
||||
titleKey: "hosts.openNativeRdp",
|
||||
icon: MonitorUp,
|
||||
kind: "open",
|
||||
order: 105,
|
||||
when: (host) => !!host.enableRdp,
|
||||
run: (host) => void openNativeRdp(host),
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
app.registerSlotContribution("onboarding.features", {
|
||||
actionId: "remote-desktop.feature",
|
||||
titleKey: "onboarding.feature_desktop",
|
||||
descriptionKey: "onboarding.feature_desktop_desc",
|
||||
icon: Monitor,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { GuacamoleQuickHost } from "./GuacamoleApp";
|
||||
|
||||
/** The slice of a quick-connect host that GuacamoleApp mints a token from. */
|
||||
export function quickConnectGuacHost(host: Host): GuacamoleQuickHost {
|
||||
return {
|
||||
name: host.name,
|
||||
ip: host.ip,
|
||||
connectionType: host.enableVnc ? "vnc" : "rdp",
|
||||
domain: host.domain,
|
||||
rdpPort: host.rdpPort,
|
||||
vncPort: host.vncPort,
|
||||
rdpAuthType: host.rdpAuthType,
|
||||
rdpUser: host.rdpUser,
|
||||
rdpPassword: host.rdpPassword,
|
||||
vncUser: host.vncUser,
|
||||
vncPassword: host.vncPassword,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user