mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: finish the web-endpoint plugin, add ctx.desktop (B8)
This commit is contained in:
1 parent
1682861853
commit
535126fd1b
50 files changed
+2018
-898
No files matched your search
@@ -1,27 +1,33 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Globe } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import {
|
||||
webEndpointRefusalReason,
|
||||
type WebEndpointRefusalReason,
|
||||
} from "./web-endpoint-url";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { Checkbox } from "@/components/checkbox";
|
||||
useTranslation,
|
||||
type HostEditorSectionProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FakeSwitch,
|
||||
Input,
|
||||
SectionCard,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
|
||||
SettingRow,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { resolveConnectionOrigin } from "@/lib/connection-origin";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import {
|
||||
MAX_WEB_ENDPOINTS,
|
||||
MAX_WEB_ENDPOINT_LABEL_LENGTH,
|
||||
type WebEndpoint,
|
||||
type WebUiConfig,
|
||||
} from "@/types/index";
|
||||
} from "../shared/web-endpoint-config";
|
||||
import {
|
||||
webEndpointRefusalReason,
|
||||
type WebEndpointRefusalReason,
|
||||
} from "./web-endpoint-url";
|
||||
import {
|
||||
MAX_WEB_ENDPOINT_PORT,
|
||||
MIN_WEB_ENDPOINT_PORT,
|
||||
@@ -30,11 +36,12 @@ import {
|
||||
webEndpointRowError,
|
||||
} from "./web-endpoint-validation";
|
||||
|
||||
type PluginSettingsForm = Record<string, Record<string, unknown>>;
|
||||
|
||||
/**
|
||||
* crypto.randomUUID is undefined outside a secure context, so a plain-http web
|
||||
* deployment -- a first-class target for the direct+external path -- would
|
||||
* throw on "Add endpoint". Matches the guarded form already used in
|
||||
* MacrosPanel, PanePreview, KeybindingsDialog and AppShell.
|
||||
* throw on "Add endpoint".
|
||||
*/
|
||||
function endpointId(): string {
|
||||
return typeof crypto.randomUUID === "function"
|
||||
@@ -79,22 +86,74 @@ function newEndpoint(label: string): WebEndpoint {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The host editor's Web UI tab. Writes into the form's pluginSettings for
|
||||
* this plugin, which the editor saves through the plugin's host settings
|
||||
* route after the host itself, exactly as the tunnels plugin's own section
|
||||
* does.
|
||||
*
|
||||
* Owns the async connection-origin resolution for the tunnel gate itself
|
||||
* (tunnelAvailable = enableSsh && originIsLocal), deliberately NOT gated on
|
||||
* isElectron(): the forward binds wherever the backend runs, exactly as the
|
||||
* tunnels plugin does, and a web deployment reaches it at the host serving
|
||||
* Termix provided the endpoint opted out of a loopback bind.
|
||||
*
|
||||
* connectionType is passed as "ssh" deliberately: a tunnel endpoint always
|
||||
* rides an SSH connection, and "ssh" keeps resolveConnectionOrigin out of its
|
||||
* RDP/VNC/Telnet guacamole special case, which always resolves to "remote".
|
||||
*
|
||||
* Note the deliberate asymmetry with the sidebar: the sidebar entry appears on
|
||||
* enableWebUi alone, because a direct endpoint needs no SSH -- but Web UI is
|
||||
* an SSH sub-tab, so a host with SSH disabled cannot configure endpoints at
|
||||
* all. That is the accepted behaviour, not an oversight.
|
||||
*/
|
||||
export function HostEditorWebUiSection({
|
||||
enableWebUi,
|
||||
webUiConfig,
|
||||
tunnelAvailable,
|
||||
setField,
|
||||
}: {
|
||||
enableWebUi: boolean;
|
||||
webUiConfig: WebUiConfig;
|
||||
tunnelAvailable: boolean;
|
||||
setField: (field: string, value: unknown) => void;
|
||||
}) {
|
||||
form,
|
||||
updateForm,
|
||||
protocols,
|
||||
}: HostEditorSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const endpoints = webUiConfig?.endpoints ?? [];
|
||||
const [originIsLocal, setOriginIsLocal] = useState(false);
|
||||
|
||||
const connectionOrigin = (form?.connectionOrigin as string | null) ?? null;
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void resolveConnectionOrigin({
|
||||
connectionType: "ssh",
|
||||
connectionOrigin: connectionOrigin as "local" | "remote" | null,
|
||||
}).then((origin) => {
|
||||
if (!cancelled) setOriginIsLocal(origin === "local");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [connectionOrigin]);
|
||||
|
||||
const tunnelAvailable = protocols.enableSsh && originIsLocal;
|
||||
|
||||
const settings = ((form?.pluginSettings as PluginSettingsForm | undefined)?.[
|
||||
"web-endpoint"
|
||||
] ?? {}) as Record<string, unknown>;
|
||||
const enableWebUi = settings.enableWebUi === true;
|
||||
const webUiConfig = (settings.webUiConfig ?? { endpoints: [] }) as {
|
||||
endpoints: WebEndpoint[];
|
||||
};
|
||||
const endpoints = webUiConfig.endpoints ?? [];
|
||||
|
||||
const setSettings = (patch: Record<string, unknown>) =>
|
||||
updateForm((current) => {
|
||||
const all = (current.pluginSettings ?? {}) as PluginSettingsForm;
|
||||
return {
|
||||
...current,
|
||||
pluginSettings: {
|
||||
...all,
|
||||
"web-endpoint": { ...(all["web-endpoint"] ?? {}), ...patch },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const commit = (next: WebEndpoint[]) =>
|
||||
setField("webUiConfig", { endpoints: next });
|
||||
setSettings({ webUiConfig: { endpoints: next } });
|
||||
|
||||
const update = (index: number, patch: Partial<WebEndpoint>) =>
|
||||
commit(
|
||||
@@ -135,7 +194,7 @@ export function HostEditorWebUiSection({
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={enableWebUi}
|
||||
onChange={(v: boolean) => setField("enableWebUi", v)}
|
||||
onChange={(v: boolean) => setSettings({ enableWebUi: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div className="text-xs text-muted-foreground p-3 bg-muted/30 border border-border space-y-1">
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { resolveConnectionOrigin } from "@/lib/connection-origin";
|
||||
import type { HostEditorForm, HostProtocols } from "@/sidebar/HostEditorData";
|
||||
import { HostEditorWebUiSection } from "./HostEditorWebUiSection";
|
||||
|
||||
type SetHostField = <K extends keyof HostEditorForm>(
|
||||
key: K,
|
||||
value: HostEditorForm[K],
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Owns the async connection-origin resolution for the Web UI tab's tunnel
|
||||
* gate, then hands the resolved boolean to the presentational section.
|
||||
*
|
||||
* tunnelAvailable = enableSsh && originIsLocal. Deliberately NOT gated on
|
||||
* isElectron(): the forward binds wherever the backend runs, exactly as the
|
||||
* server tunnels feature does, and a web deployment reaches it at the host
|
||||
* serving Termix provided the endpoint opted out of a loopback bind. Gating
|
||||
* this on the desktop would be stricter than the tunnels feature it mirrors.
|
||||
*
|
||||
* connectionType is passed as "ssh" deliberately: a tunnel endpoint always
|
||||
* rides an SSH connection, and "ssh" keeps resolveConnectionOrigin out of its
|
||||
* RDP/VNC/Telnet guacamole special case, which always resolves to "remote".
|
||||
*
|
||||
* The origin state lives here rather than in HostEditor.tsx, which is already
|
||||
* very long; this follows HostEditorGeneralTab's precedent of taking
|
||||
* `protocols` as a prop and gating its own connection-origin control.
|
||||
*
|
||||
* Note the deliberate asymmetry with the sidebar: the sidebar entry appears on
|
||||
* enableWebUi alone, because a direct endpoint needs no SSH -- but Web UI is
|
||||
* an SSH sub-tab, so a host with SSH disabled cannot configure endpoints at
|
||||
* all. That is the accepted behaviour, not an oversight.
|
||||
*/
|
||||
export function HostWebUiTab({
|
||||
form,
|
||||
setField,
|
||||
protocols,
|
||||
}: {
|
||||
form: HostEditorForm;
|
||||
setField: SetHostField;
|
||||
protocols: HostProtocols;
|
||||
}) {
|
||||
const [originIsLocal, setOriginIsLocal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void resolveConnectionOrigin({
|
||||
connectionType: "ssh",
|
||||
connectionOrigin: form.connectionOrigin,
|
||||
}).then((origin) => {
|
||||
if (!cancelled) setOriginIsLocal(origin === "local");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [form.connectionOrigin]);
|
||||
|
||||
return (
|
||||
<HostEditorWebUiSection
|
||||
enableWebUi={form.enableWebUi}
|
||||
webUiConfig={form.webUiConfig}
|
||||
tunnelAvailable={protocols.enableSsh && originIsLocal}
|
||||
setField={setField as (field: string, value: unknown) => void}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { Button } from "@/components/button";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { WebEndpoint } from "../shared/web-endpoint-config";
|
||||
|
||||
const REFUSAL_MESSAGES: Record<WebEndpointRefusalReason, string> = {
|
||||
"loopback-bind-on-remote-backend": "webEndpoint.tunnelUnreachableFromBrowser",
|
||||
@@ -42,7 +43,10 @@ export function WebEndpointTab({
|
||||
endpointId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const endpoint = (host.webUiConfig?.endpoints ?? []).find(
|
||||
const webUiConfig = (
|
||||
host.pluginSettings as Record<string, Record<string, unknown>> | undefined
|
||||
)?.["web-endpoint"]?.webUiConfig as { endpoints?: WebEndpoint[] } | undefined;
|
||||
const endpoint = (webUiConfig?.endpoints ?? []).find(
|
||||
(candidate) => candidate.id === endpointId,
|
||||
);
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ import type {
|
||||
TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { WebEndpoint } from "@/types";
|
||||
import type { WebEndpoint } from "../shared/web-endpoint-config";
|
||||
import { WebEndpointTab } from "./WebEndpointTab";
|
||||
import { HostWebUiTab } from "./HostWebUiTab";
|
||||
import { HostEditorWebUiSection } from "./HostEditorWebUiSection";
|
||||
import {
|
||||
openWebEndpointExternally,
|
||||
setWebEndpointApi,
|
||||
@@ -19,10 +19,26 @@ import {
|
||||
|
||||
const TAB_TYPE = "web-endpoint";
|
||||
|
||||
function webEndpointSettings(host: PluginHostRecord): {
|
||||
enableWebUi: boolean;
|
||||
webUiConfig: { endpoints: WebEndpoint[] };
|
||||
} {
|
||||
const settings = (
|
||||
host.pluginSettings as Record<string, Record<string, unknown>> | undefined
|
||||
)?.["web-endpoint"];
|
||||
return {
|
||||
enableWebUi: settings?.enableWebUi === true,
|
||||
webUiConfig: (settings?.webUiConfig as
|
||||
{ endpoints: WebEndpoint[] } | undefined) ?? {
|
||||
endpoints: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function endpointsOf(host: PluginHostRecord): WebEndpoint[] {
|
||||
if (!host.enableWebUi) return [];
|
||||
const config = host.webUiConfig as { endpoints?: WebEndpoint[] } | undefined;
|
||||
return config?.endpoints ?? [];
|
||||
const { enableWebUi, webUiConfig } = webEndpointSettings(host);
|
||||
if (!enableWebUi) return [];
|
||||
return webUiConfig.endpoints ?? [];
|
||||
}
|
||||
|
||||
function openEndpoint(
|
||||
@@ -31,8 +47,8 @@ function openEndpoint(
|
||||
shell: ShellApi,
|
||||
): void {
|
||||
if (endpoint.render === "external") {
|
||||
// No tab at all: the real browser opens it. On the desktop the main
|
||||
// process routes it to shell.openExternal.
|
||||
// No tab at all: the real browser opens it. On the desktop the plugin
|
||||
// asks the backend to open it in an isolated window.
|
||||
openWebEndpointExternally(
|
||||
host as unknown as { id: string; ip: string },
|
||||
endpoint,
|
||||
@@ -63,16 +79,8 @@ function EndpointTab({ host, tab }: TabProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function WebUiSection({ form, setField, protocols }: HostEditorSectionProps) {
|
||||
return (
|
||||
<HostWebUiTab
|
||||
form={form}
|
||||
setField={setField as Parameters<typeof HostWebUiTab>[0]["setField"]}
|
||||
protocols={
|
||||
protocols as unknown as Parameters<typeof HostWebUiTab>[0]["protocols"]
|
||||
}
|
||||
/>
|
||||
);
|
||||
function WebUiSection(props: HostEditorSectionProps) {
|
||||
return <HostEditorWebUiSection {...props} />;
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { currentTunnelHost, resolveWebEndpointUrl } from "./web-endpoint-url";
|
||||
import axios from "axios";
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
import { handleApiError } from "@/main-axios";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import type { WebEndpoint } from "@/types/index";
|
||||
import type { WebEndpoint } from "../shared/web-endpoint-config";
|
||||
|
||||
let pluginApi: PluginApiClient | null = null;
|
||||
|
||||
@@ -109,35 +108,43 @@ export function requireNumericHostId(id: string): number {
|
||||
return numericId;
|
||||
}
|
||||
|
||||
/** Desktop windows use a dedicated ephemeral session, including login popups. */
|
||||
/**
|
||||
* Desktop windows use a dedicated ephemeral session, including login popups.
|
||||
*
|
||||
* The backend resolves and validates the target URL (the host's own declared
|
||||
* address for a direct endpoint, or the tunnel port it opens for a tunnel
|
||||
* one) and opens the window itself through ctx.desktop.openIsolatedWindow, so
|
||||
* the capability check and audit line cover the whole decision -- this call
|
||||
* only asks for it and reports whether it worked.
|
||||
*/
|
||||
export async function openWebEndpointExternally(
|
||||
host: { id: string; ip: string },
|
||||
endpoint: WebEndpoint,
|
||||
): Promise<void> {
|
||||
if (!isElectron() || !window.electronAPI?.invoke) {
|
||||
if (!isElectron()) {
|
||||
throw new Error(
|
||||
"Isolated windows require the desktop app. Choose Embedded in the endpoint settings.",
|
||||
);
|
||||
}
|
||||
const localPort =
|
||||
endpoint.access === "tunnel"
|
||||
? await openWebEndpointTunnel(requireNumericHostId(host.id), endpoint.id)
|
||||
: undefined;
|
||||
const url = resolveWebEndpointUrl({
|
||||
hostAddress: host.ip,
|
||||
endpoint,
|
||||
localPort,
|
||||
tunnelHost: currentTunnelHost(true) ?? undefined,
|
||||
});
|
||||
const result = await window.electronAPI.invoke("open-isolated-web-endpoint", {
|
||||
url,
|
||||
ignoreCert: endpoint.ignoreCert === true,
|
||||
});
|
||||
if (
|
||||
!result ||
|
||||
typeof result !== "object" ||
|
||||
!("success" in result) ||
|
||||
result.success !== true
|
||||
)
|
||||
throw new Error("Failed to open isolated web endpoint");
|
||||
if (!pluginApi) throw new Error("The web endpoint plugin is not active");
|
||||
try {
|
||||
await pluginApi.post("/open-window", {
|
||||
hostId: requireNumericHostId(host.id),
|
||||
endpointId: endpoint.id,
|
||||
ignoreCert: endpoint.ignoreCert === true,
|
||||
});
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const data = error.response?.data as
|
||||
{ error?: unknown; message?: unknown } | undefined;
|
||||
const backendMessage = data?.error ?? data?.message;
|
||||
if (typeof backendMessage === "string" && backendMessage) {
|
||||
throw new WebEndpointTunnelError(
|
||||
backendMessage,
|
||||
error.response?.status,
|
||||
);
|
||||
}
|
||||
}
|
||||
return handleApiError(error, "open isolated web endpoint");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WebEndpoint } from "@/types/index";
|
||||
import type { WebEndpoint } from "../shared/web-endpoint-config";
|
||||
|
||||
/**
|
||||
* Builds the URL a web endpoint opens at.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import type { WebEndpoint } from "@/types/index";
|
||||
import type { WebEndpoint } from "../shared/web-endpoint-config";
|
||||
|
||||
/**
|
||||
* Editor-side validation for web endpoint rows.
|
||||
*
|
||||
* The authority on what a stored endpoint may look like is
|
||||
* `normalizeWebEndpoints` in
|
||||
* src/backend/database/routes/host-web-endpoints.ts, and it DROPS any row it
|
||||
* refuses rather than reporting it -- so without a check here the user adds a
|
||||
* row, saves, sees no error, and finds the endpoint gone on reload.
|
||||
* `normalizeWebEndpoints` in ../shared/web-endpoint-config.ts, and it DROPS
|
||||
* any row it refuses rather than reporting it -- so without a check here the
|
||||
* user adds a row, saves, sees no error, and finds the endpoint gone on
|
||||
* reload.
|
||||
*
|
||||
* This deliberately does NOT restate that module's full rule set, and does not
|
||||
* import it: the renderer does not import backend route modules (the same
|
||||
* reason MAX_WEB_ENDPOINTS lives in src/types rather than beside the
|
||||
* validator). It covers only the conditions a user can reach by typing.
|
||||
* src/ui/tests/lib/web-endpoint-validation.test.ts runs both implementations
|
||||
* over the same samples so the two cannot drift apart silently.
|
||||
* This deliberately does NOT restate that module's full rule set: it covers
|
||||
* only the conditions a user can reach by typing, with per-field errors a
|
||||
* drop/keep normalizer cannot give. tests/backend/web-endpoint-config.test.ts
|
||||
* covers the normalizer; this file's own tests cover the same inputs here.
|
||||
*/
|
||||
|
||||
export const MIN_WEB_ENDPOINT_PORT = 1;
|
||||
|
||||
Reference in new issue
Block a user