Files
Termix/plugins/session-sharing/src/frontend/SharedSessionView.tsx
T

356 lines
9.9 KiB
TypeScript

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<string> {
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 (
<div
className="absolute top-3 left-3 z-20 flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium"
style={{
backgroundColor: "var(--bg-elevated, rgba(0,0,0,0.6))",
color: "var(--foreground)",
border: "1px solid var(--border-base)",
}}
title={names}
>
<Users className="size-3.5" />
{participants.length}
</div>
);
}
function ReadOnlyBadge({ label }: { label: string }) {
return (
<div
className="absolute top-3 right-3 z-20 flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium"
style={{
backgroundColor: "var(--bg-elevated, rgba(0,0,0,0.6))",
color: "var(--foreground)",
border: "1px solid var(--border-base)",
}}
>
<Eye className="size-3.5" />
{label}
</div>
);
}
function CenteredMessage({
icon,
message,
}: {
icon: React.ReactNode;
message: string;
}) {
return (
<div
className="flex flex-col items-center justify-center h-full gap-4 w-full"
style={{ backgroundColor: "var(--bg-base)" }}
>
{icon}
<p
className="text-sm font-semibold text-center max-w-xs"
style={{ color: "var(--foreground)" }}
>
{message}
</p>
</div>
);
}
export function GuestTerminalView({
share,
wsPath,
hideBadges = false,
onParticipantsChange,
}: {
share: Pick<ResolvedShareLink, "permissionLevel">;
/** 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<string | null>(null);
const [participants, setParticipants] = useState<SessionParticipantInfo[]>(
[],
);
const wsRef = useRef<WebSocket | null>(null);
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | 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 (
<div className="relative w-full h-full">
{!hideBadges && (
<>
<ParticipantsBadge
participants={participants}
ownerLabel={t("sessionSharing.guestView.ownerLabel")}
/>
{share.permissionLevel === "read-only" && (
<ReadOnlyBadge
label={t("sessionSharing.guestView.readOnlyBadge")}
/>
)}
</>
)}
{ended && (
<div
className="absolute inset-0 z-30 flex items-center justify-center"
style={{ backgroundColor: "var(--bg-base)" }}
>
<CenteredMessage
icon={
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
}
message={ended}
/>
</div>
)}
<div ref={xtermRef} className="w-full h-full" />
</div>
);
}
function GuestGuacamoleView({ share }: { share: ResolvedShareLink }) {
const { t } = useTranslation();
const [connectionError, setConnectionError] = useState<string | null>(null);
if (!share.connectParams?.token) {
return (
<CenteredMessage
icon={
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
}
message={t("sessionSharing.guestView.linkInvalid")}
/>
);
}
return (
<div className="relative w-full h-full">
{share.permissionLevel === "read-only" && (
<ReadOnlyBadge label={t("sessionSharing.guestView.readOnlyBadge")} />
)}
{connectionError && (
<div
className="absolute inset-0 z-30 flex items-center justify-center"
style={{ backgroundColor: "var(--bg-base)" }}
>
<CenteredMessage
icon={
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
}
message={connectionError}
/>
</div>
)}
<RemoteDisplay
token={share.connectParams.token}
protocol={share.protocol}
isVisible={true}
onError={(err) => setConnectionError(err)}
/>
</div>
);
}
export default function SharedSessionView() {
const { t } = useTranslation();
const [share, setShare] = useState<ResolvedShareLink | null>(null);
const [linkToken, setLinkToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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 (
<div
className="fixed inset-0 flex flex-col"
style={{ backgroundColor: "var(--bg-base)" }}
>
<div className="relative flex-1 min-h-0">
{loading && <Loader message={t("sessionSharing.guestView.loading")} />}
{!loading && error && (
<CenteredMessage
icon={
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
}
message={error}
/>
)}
{!loading &&
!error &&
share &&
linkToken &&
(share.protocol === "ssh" ? (
<GuestTerminalView share={share} wsPath={share.wsPath} />
) : (
<GuestGuacamoleView share={share} />
))}
</div>
</div>
);
}