mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
356 lines
9.9 KiB
TypeScript
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>
|
|
);
|
|
}
|