feat: load plugin frontends at runtime and remove plugin ids from the shell

This commit is contained in:
LukeGus committed 2026-09-22 20:15:52 -05:00
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,
};
}