import React, { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useXTerm } from "react-xtermjs"; import { FitAddon } from "@xterm/addon-fit"; import { AlertCircle, Eye } from "lucide-react"; import { resolveShareLink, type ResolvedShareLink, type ShareLinkErrorKind, } from "@/api/session-sharing-api"; import { SimpleLoader } from "@/lib/SimpleLoader.tsx"; import { getBasePath } from "@/lib/base-path"; import { isElectron } from "@/lib/electron"; import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx"; const PING_INTERVAL_MS = 30000; interface TerminalWsMessage { type: string; data?: string; [key: string]: unknown; } // Mirrors Terminal.tsx's baseWsUrl construction (dev/electron/embedded/prod). // Duplicated rather than extracted from that file to avoid touching it here. // A shared session link is always resolved against the desktop app's // embedded local backend -- joining a session hosted on someone else's // remote server isn't supported from the desktop app today. async function resolveTerminalWsBaseUrl(): Promise { const isDev = !isElectron() && process.env.NODE_ENV === "development" && (window.location.port === "3000" || window.location.port === "5173" || window.location.port === ""); if (isDev) { return `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`; } if (isElectron()) { return "ws://127.0.0.1:30002"; } const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws"; return `${wsProtocol}://${window.location.host}${getBasePath()}/ssh/websocket/`; } function ReadOnlyBadge({ label }: { label: string }) { return (
{label}
); } function CenteredMessage({ icon, message, }: { icon: React.ReactNode; message: string; }) { return (
{icon}

{message}

); } function GuestTerminalView({ share, linkToken, }: { share: ResolvedShareLink; linkToken: string; }) { const { t } = useTranslation(); const { instance: terminal, ref: xtermRef } = useXTerm(); const [ended, setEnded] = useState(null); const wsRef = useRef(null); const pingIntervalRef = useRef | null>(null); useEffect(() => { if (!terminal || !xtermRef.current) return; terminal.options.theme = { background: "#0c0d0b" }; const fitAddon = new FitAddon(); terminal.loadAddon(fitAddon); terminal.open(xtermRef.current); fitAddon.fit(); const resizeObserver = new ResizeObserver(() => fitAddon.fit()); resizeObserver.observe(xtermRef.current); let cancelled = false; let ws: WebSocket | null = null; resolveTerminalWsBaseUrl().then((baseWsUrl) => { if (cancelled) return; const separator = baseWsUrl.includes("?") ? "&" : "?"; ws = new WebSocket( `${baseWsUrl}${separator}shareToken=${encodeURIComponent(linkToken)}`, ); wsRef.current = ws; ws.onopen = () => { pingIntervalRef.current = setInterval(() => { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "ping" })); } }, PING_INTERVAL_MS); }; ws.onmessage = (event) => { let msg: TerminalWsMessage; try { msg = JSON.parse(event.data); } catch { return; } switch (msg.type) { case "data": if (typeof msg.data === "string") terminal.write(msg.data); break; case "sessionExpired": case "sessionTerminatedByOwner": case "session_ended": setEnded(t("sessionSharing.guestView.sessionEnded")); break; default: break; } }; ws.onclose = () => { setEnded((prev) => prev ?? t("sessionSharing.guestView.sessionEnded")); }; if (share.permissionLevel === "read-write") { terminal.onData((data) => { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "input", data })); } }); } }); return () => { cancelled = true; resizeObserver.disconnect(); if (pingIntervalRef.current) clearInterval(pingIntervalRef.current); ws?.close(); wsRef.current = null; }; // Deliberately runs once terminal mounts - share/token/permission are stable for the view's lifetime. // eslint-disable-next-line react-hooks/exhaustive-deps }, [terminal, linkToken]); return (
{share.permissionLevel === "read-only" && ( )} {ended && (
} message={ended} />
)}
); } function GuestGuacamoleView({ share }: { share: ResolvedShareLink }) { const { t } = useTranslation(); const [connectionError, setConnectionError] = useState(null); if (!share.connectParams?.token) { return ( } message={t("sessionSharing.guestView.linkInvalid")} /> ); } return (
{share.permissionLevel === "read-only" && ( )} {connectionError && (
} message={connectionError} />
)} setConnectionError(err)} />
); } export default function SharedSessionView() { const { t } = useTranslation(); const [share, setShare] = useState(null); const [linkToken, setLinkToken] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { const params = new URLSearchParams(window.location.search); const token = params.get("token"); if (!token) { setError(t("sessionSharing.guestView.linkInvalid")); setLoading(false); return; } setLinkToken(token); resolveShareLink(token) .then((resolved) => setShare(resolved)) .catch((err) => { const kind = (err as { kind?: ShareLinkErrorKind })?.kind; if (kind === "rate-limited") { setError(t("sessionSharing.guestView.rateLimited")); } else { setError(t("sessionSharing.guestView.linkInvalid")); } }) .finally(() => setLoading(false)); }, [t]); return (
{loading && ( )} {!loading && error && ( } message={error} /> )} {!loading && !error && share && linkToken && (share.protocol === "ssh" ? ( ) : ( ))}
); }