mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat: make each plugin an npm workspace built by a termix-plugin CLI
This commit is contained in:
1 parent
4758016f41
commit
5796186a1c
448 files changed
+3087
-2003
No files matched your search
@@ -0,0 +1,227 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Globe, RotateCw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
currentTunnelHost,
|
||||
resolveWebEndpointUrl,
|
||||
webEndpointRefusalReason,
|
||||
type WebEndpointRefusalReason,
|
||||
} from "./web-endpoint-url";
|
||||
import {
|
||||
allowInvalidCertificateForOrigin,
|
||||
openWebEndpointExternally,
|
||||
openWebEndpointTunnel,
|
||||
requireNumericHostId,
|
||||
} from "./web-endpoint-api";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { Button } from "@/components/button";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
const REFUSAL_MESSAGES: Record<WebEndpointRefusalReason, string> = {
|
||||
"loopback-bind-on-remote-backend": "webEndpoint.tunnelUnreachableFromBrowser",
|
||||
"shares-session-cookie-with-termix": "webEndpoint.tunnelSharesSessionCookie",
|
||||
"direct-shares-session-cookie": "webEndpoint.directSharesSessionCookie",
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders one web endpoint in an iframe.
|
||||
*
|
||||
* Takes the whole host plus the endpoint's ID -- not the endpoint object --
|
||||
* so a tab whose endpoint was deleted while it was open (or that never matched
|
||||
* one, if the host's webUiConfig raced the tab's mount) can be detected here
|
||||
* and shown a plain "no longer exists" message instead of throwing on
|
||||
* endpoint.scheme.
|
||||
*/
|
||||
export function WebEndpointTab({
|
||||
host,
|
||||
endpointId,
|
||||
}: {
|
||||
host: Host;
|
||||
endpointId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const endpoint = (host.webUiConfig?.endpoints ?? []).find(
|
||||
(candidate) => candidate.id === endpointId,
|
||||
);
|
||||
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/**
|
||||
* Bumped on every resolution that lands, and folded into the iframe's key
|
||||
* alongside the URL. A direct endpoint's URL never changes, and a live
|
||||
* tunnel returns the SAME port on every open -- so the URL alone is not
|
||||
* enough to key on: React bails out of a same-value setState, the key would
|
||||
* not change, and Reload would silently do nothing in the two most common
|
||||
* cases.
|
||||
*/
|
||||
const [generation, setGeneration] = useState(0);
|
||||
/**
|
||||
* Guards two in-flight resolutions completing out of order -- a
|
||||
* double-clicked Reload, or a manual reload racing the mount effect. Only
|
||||
* the most recently started resolution may apply its result.
|
||||
*/
|
||||
const resolveGenerationRef = useRef(0);
|
||||
|
||||
const resolve = useCallback(async () => {
|
||||
if (!endpoint) return;
|
||||
const mine = ++resolveGenerationRef.current;
|
||||
setError(null);
|
||||
try {
|
||||
// Checked BEFORE the open call, not after. A tunnel must not be opened
|
||||
// from a browser when a loopback bind on a remote backend would leave
|
||||
// nothing to connect to, or when the tunnel URL would land on the page's
|
||||
// own host string. A DIRECT endpoint on the same host that serves Termix
|
||||
// leaks the session cookie exactly the same way -- cookies ignore the
|
||||
// port -- so it is refused here too, before any navigation, rather than
|
||||
// framing the URL and leaking on the first request.
|
||||
const refusal = webEndpointRefusalReason(endpoint, isElectron(), host.ip);
|
||||
if (refusal) {
|
||||
throw new Error(t(REFUSAL_MESSAGES[refusal]));
|
||||
}
|
||||
|
||||
if (!("credentialless" in HTMLIFrameElement.prototype)) {
|
||||
throw new Error(t("webEndpoint.isolationUnavailable"));
|
||||
}
|
||||
|
||||
// Always re-resolved rather than reloading the frame: the backend closes
|
||||
// an idle tunnel after ten minutes and re-binds a fresh kernel-assigned
|
||||
// port, so a cached port may be gone.
|
||||
const localPort =
|
||||
endpoint.access === "tunnel"
|
||||
? await openWebEndpointTunnel(
|
||||
requireNumericHostId(host.id),
|
||||
endpoint.id,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const resolved = resolveWebEndpointUrl({
|
||||
hostAddress: host.ip,
|
||||
endpoint,
|
||||
localPort,
|
||||
// Non-null for a tunnel by the time we get here: a null separated host
|
||||
// is exactly what the refusal above catches.
|
||||
tunnelHost: currentTunnelHost(isElectron()) ?? undefined,
|
||||
});
|
||||
|
||||
if (endpoint.access === "direct" && endpoint.ignoreCert) {
|
||||
await allowInvalidCertificateForOrigin(new URL(resolved).origin);
|
||||
}
|
||||
|
||||
if (mine !== resolveGenerationRef.current) return;
|
||||
setUrl(resolved);
|
||||
setGeneration((g) => g + 1);
|
||||
} catch (caught) {
|
||||
if (mine !== resolveGenerationRef.current) return;
|
||||
setError(caught instanceof Error ? caught.message : String(caught));
|
||||
}
|
||||
// Depends on the endpoint's FIELDS, not the object: host.webUiConfig is
|
||||
// re-derived on every render of the host list, so a dependency on
|
||||
// `endpoint` itself would reopen the tunnel on every parent render.
|
||||
// bindHost and localPort are included because the refusal guard reads them.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
host.id,
|
||||
host.ip,
|
||||
endpoint?.id,
|
||||
endpoint?.access,
|
||||
endpoint?.scheme,
|
||||
endpoint?.port,
|
||||
endpoint?.path,
|
||||
endpoint?.ignoreCert,
|
||||
endpoint?.bindHost,
|
||||
endpoint?.localPort,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
void resolve();
|
||||
}, [resolve]);
|
||||
|
||||
if (!endpoint) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-xs text-muted-foreground">
|
||||
<Globe className="size-5 opacity-60" />
|
||||
<p>{t("webEndpoint.notFound")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-xs">
|
||||
<Globe className="size-5 opacity-60" />
|
||||
<p className="text-center">{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => void resolve()}>
|
||||
{t("webEndpoint.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2 border-b border-border px-2 py-1 text-xs">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => void resolve()}
|
||||
title={t("webEndpoint.reload")}
|
||||
>
|
||||
<RotateCw />
|
||||
</Button>
|
||||
<span className="truncate text-muted-foreground">{url ?? ""}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="ml-auto"
|
||||
onClick={() => {
|
||||
if (!url) return;
|
||||
void copyToClipboard(url).then((ok) => {
|
||||
if (ok) toast.success(t("webEndpoint.urlCopied"));
|
||||
else toast.error(t("webEndpoint.copyFailed"));
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("webEndpoint.copyUrl")}
|
||||
</Button>
|
||||
{isElectron() && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
void openWebEndpointExternally(host, endpoint).catch((error) =>
|
||||
toast.error(error.message),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("webEndpoint.openIsolatedWindow")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="px-2 py-1 text-xs text-muted-foreground">
|
||||
{t("webEndpoint.isolationNotice")}
|
||||
</p>
|
||||
{url && (
|
||||
// Keyed on generation as well as url: a re-resolved tunnel usually
|
||||
// returns the SAME port, so url alone would not change and React would
|
||||
// never remount the frame.
|
||||
<iframe
|
||||
key={`${generation}:${url}`}
|
||||
title={endpoint.label}
|
||||
ref={(frame) => {
|
||||
if (!frame) return;
|
||||
// Set isolation before the first navigation, including redirects.
|
||||
(
|
||||
frame as HTMLIFrameElement & { credentialless: boolean }
|
||||
).credentialless = true;
|
||||
frame.src = url;
|
||||
}}
|
||||
sandbox="allow-scripts allow-forms"
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-full w-full flex-1 border-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user