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 = { "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(null); const [error, setError] = useState(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 (

{t("webEndpoint.notFound")}

); } if (error) { return (

{error}

); } return (
{url ?? ""} {isElectron() && ( )}

{t("webEndpoint.isolationNotice")}

{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.