import React, { useEffect, useRef, useState } from "react"; import { useTranslation } from "@termix/plugin-sdk/frontend"; import { useXTerm } from "react-xtermjs"; import { FitAddon } from "@xterm/addon-fit"; import { AlertCircle, Eye, Users } from "lucide-react"; import { resolveShareLink, type ResolvedShareLink, type ShareLinkErrorKind, } from "./api"; import { Loader, RemoteDisplay, wsUrlForPath } from "./shared"; const PING_INTERVAL_MS = 30000; interface TerminalWsMessage { type: string; data?: string; [key: string]: unknown; } // A shared session link always resolves against the desktop app's embedded // local backend: joining a session on someone else's remote server is not // supported from the desktop app. async function resolveTerminalWsUrl(wsPath: string): Promise { const url = await wsUrlForPath(wsPath); if (!url) throw new Error("No terminal endpoint is available"); return url; } export interface SessionParticipantInfo { isOwner: boolean; permissionLevel: "read-write" | "read-only"; label: string | null; } function ParticipantsBadge({ participants, ownerLabel, }: { participants: SessionParticipantInfo[]; ownerLabel: string; }) { if (participants.length < 2) return null; const names = participants .map((participant) => participant.isOwner ? ownerLabel : (participant.label ?? "?"), ) .join(", "); return (
{participants.length}
); } function ReadOnlyBadge({ label }: { label: string }) { return (
{label}
); } function CenteredMessage({ icon, message, }: { icon: React.ReactNode; message: string; }) { return (
{icon}

{message}

); } export function GuestTerminalView({ share, wsPath, hideBadges = false, onParticipantsChange, }: { share: Pick; /** The socket path the backend handed out, query string included. */ wsPath: string; /** Suppress the built-in overlay badges when the host page renders its own. */ hideBadges?: boolean; onParticipantsChange?: (participants: SessionParticipantInfo[]) => void; }) { const { t } = useTranslation(); const { instance: terminal, ref: xtermRef } = useXTerm(); const [ended, setEnded] = useState(null); const [participants, setParticipants] = useState( [], ); const wsRef = useRef(null); const pingIntervalRef = useRef | null>(null); const onParticipantsChangeRef = useRef(onParticipantsChange); onParticipantsChangeRef.current = onParticipantsChange; 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; resolveTerminalWsUrl(wsPath).then((url) => { if (cancelled) return; ws = new WebSocket(url); 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 "participants": if (Array.isArray(msg.participants)) { const next = msg.participants as SessionParticipantInfo[]; setParticipants(next); onParticipantsChangeRef.current?.(next); } 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, wsPath]); return (
{!hideBadges && ( <> {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" ? ( ) : ( ))}
); }