import { desktopSocketUrl, nextDesktopState, type DesktopConnectionState, type DesktopRfbFactory, type DesktopRfbLike, type DesktopStreamCapability, } from "@opengeni/sdk"; import { type RefObject, useCallback, useEffect, useRef, useState, useSyncExternalStore, } from "react"; import { desktopViewerPeers } from "../lib/workbench-peers"; import { type DesktopWebSocketFactory, useRelayFrameStream } from "./use-relay-frame-stream"; export type UseDesktopStreamOptions = { /** The desktop cell of the negotiated capabilities (`capabilities.DesktopStream`). */ capability: DesktopStreamCapability | null; /** The mount target. RFB (or the frame canvas) attaches here on connect. */ containerRef: RefObject; /** Read-only by default (v1 ruling H). interactive only when cap.mode allows. */ interactive?: boolean | undefined; scaleViewport?: boolean | undefined; /** Custom RFB factory. Otherwise call enableDesktopViewer() on the desktop subpath. */ rfbFactory?: DesktopRfbFactory | undefined; /** Additional WebSocket protocols for an authenticated placement proxy. */ webSocketProtocols?: string[] | undefined; /** Custom socket factory for the `relay-frames` transport (tests). Defaults to * `new WebSocket(url)`. Mirrors `rfbFactory` for the frame renderer. */ webSocketFactory?: DesktopWebSocketFactory | undefined; }; export type UseDesktopStreamResult = { state: DesktopConnectionState; error: Error | null; /** Manual reconnect (e.g. after a securityfailure once a fresh URL arrives). */ reconnect: () => void; /** Synchronous input on the already-open RFB authority. */ sendKey?: ((keysym: number, code: string, down?: boolean) => boolean) | undefined; }; /** * Drive the noVNC RFB lifecycle from a `DesktopStreamCapability`, using the * SDK's `desktop.ts` reducer + `desktopSocketUrl`. SSR-safe: the RFB import and * the DOM attach happen inside `useEffect`, so a server render is a no-op and * the component shows its placeholder until hydration. * * Read-only is enforced at three layers: `capability.mode` (server) → * `interactive` prop → `RFB.viewOnly`. v1 always resolves to read-only. On a * capability `url` change (a rotation), the old RFB disconnects and a fresh one * connects to the new URL — a brief "desktop blink", acceptable on rollover. * * Transport dispatch: a Modal box negotiates `transport: "vnc-ws"` and drives the * noVNC RFB below. A SELF-HOSTED machine negotiates `transport: "relay-frames"` * (PNG-per-frame over the relay, view-only) — that path is delegated to * `useRelayFrameStream`, which paints a ``. Both hooks are ALWAYS called * (rules of hooks); each is dormant for the other's transport, and we return the * result of whichever owns the surface. The public interface is identical either * way, so `DesktopViewer` is transport-agnostic. */ export function useDesktopStream(options: UseDesktopStreamOptions): UseDesktopStreamResult { const { capability, containerRef, interactive, scaleViewport, rfbFactory, webSocketProtocols, webSocketFactory, } = options; const peerRevision = useSyncExternalStore( desktopViewerPeers.subscribe, desktopViewerPeers.revision, desktopViewerPeers.revision, ); const factoryRevision = rfbFactory ? 0 : peerRevision; // The self-hosted PNG-frame path. Always invoked (rules of hooks); dormant // unless `transport === "relay-frames"`, in which case it owns the surface. const frameStream = useRelayFrameStream({ capability, containerRef, ...(webSocketFactory ? { webSocketFactory } : {}), }); const [state, setState] = useState("idle"); const [error, setError] = useState(null); const [nonce, setNonce] = useState(0); const stateRef = useRef("idle"); const setBoth = (next: DesktopConnectionState) => { stateRef.current = next; setState(next); }; const reconnect = () => setNonce((n) => n + 1); const sendKey = useCallback((keysym: number, code: string, down?: boolean): boolean => { const rfb = rfbRef.current; if (!rfb?.sendKey || rfb.viewOnly || stateRef.current !== "connected") return false; rfb.sendKey(keysym, code, down); return true; }, []); const url = capability?.url ?? null; // The credential is keyed into the connect effect so a token rotation (new // password) reconnects, but NOT folded into the whole-`capability` identity — // a benign re-negotiation that re-mints the same cell must not churn the socket. const token = capability?.token ?? null; const transport = capability?.transport ?? null; const mode = capability?.mode ?? "read-only"; // For direct sandbox RFB, authorization is carried in WebSocket // subprotocols rather than `capability.token`. The view grant rotates before // expiry while the URL stays stable, so the protocol credential is a real // connection boundary: keeping the old socket leaves the viewer black once // browserd expires it. Key the effect on the primitive value (not the caller's // array identity) so credential rotation reconnects exactly once without // churning on ordinary renders. const webSocketProtocolsKey = webSocketProtocols?.join("\u0000") ?? ""; // The live RFB handle. Holding it in a ref lets us flip view-only (take // control / return control) and re-scale on the OPEN connection instead of // tearing the socket down — the old behaviour reconnected on every // `interactive` change, which read as a constant "refresh" and (combined with // the canvas churn) bounced control back to watch. const rfbRef = useRef(null); // Latest input/scale/factory the connect effect reads via refs so they are NOT // connect-effect dependencies: changing them must update the live RFB in place, // never reconnect it. const interactiveRef = useRef(interactive); const scaleViewportRef = useRef(scaleViewport); const modeRef = useRef(mode); const rfbFactoryRef = useRef(rfbFactory); const webSocketProtocolsRef = useRef(webSocketProtocols); interactiveRef.current = interactive; scaleViewportRef.current = scaleViewport; modeRef.current = mode; rfbFactoryRef.current = rfbFactory; webSocketProtocolsRef.current = webSocketProtocols; // read-only is forced when the server says so OR the caller didn't opt in. const viewOnlyFor = (m: string, want: boolean | undefined) => m === "read-only" || !want; useEffect(() => { // SSR / no DOM / no usable transport: stay idle and show the placeholder. if (typeof window === "undefined") return; if (transport !== "vnc-ws" || !url) { setBoth("idle"); return; } const container = containerRef.current; if (!container) { // The mount target isn't in the DOM yet (e.g. the tab is still hidden). // Stay idle (not a stale negotiating/connecting) so a re-attach nudge — // fired by the viewer on becoming visible / on mount — can re-run this // effect once the container exists. Without this the surface can stick // forever on the idle scrim and never open a socket. setBoth("idle"); return; } let rfb: DesktopRfbLike | null = null; let disposed = false; setError(null); setBoth("negotiating"); const onConnect = () => { if (!disposed) setBoth(nextDesktopState(stateRef.current, { type: "connected" })); }; const onDisconnect = () => { if (!disposed) setBoth(nextDesktopState(stateRef.current, { type: "disconnected" })); }; const onSecurityFailure = () => { if (disposed) return; setError(new Error("desktop authentication failed (token expired or revoked)")); setBoth(nextDesktopState(stateRef.current, { type: "fail" })); }; void (async () => { try { const factory = rfbFactoryRef.current ?? (await desktopViewerPeers.load()); if (disposed) return; const socketUrl = desktopSocketUrl({ url }); setBoth(nextDesktopState(stateRef.current, { type: "negotiated" })); rfb = factory(container, socketUrl, { credentials: token ? { password: token } : undefined, ...(webSocketProtocolsRef.current ? { wsProtocols: webSocketProtocolsRef.current } : {}), }); rfb.viewOnly = viewOnlyFor(modeRef.current, interactiveRef.current); // Fit-to-panel: SCALE the 1280x800 framebuffer down to the container // (aspect-preserved) and never 1:1-clip. `clipViewport=false` is pinned // explicitly — noVNC forces it off while scaling, but a fresh RFB on a // url rotation could otherwise start from a stale clip and read as // "zoomed in". Order matters: clip off, then scale on. rfb.clipViewport = false; rfb.scaleViewport = scaleViewportRef.current ?? true; rfb.addEventListener("connect", onConnect); rfb.addEventListener("disconnect", onDisconnect); rfb.addEventListener("securityfailure", onSecurityFailure); rfbRef.current = rfb; } catch (cause) { if (!disposed) { setError(cause instanceof Error ? cause : new Error(String(cause))); setBoth("error"); } } })(); return () => { disposed = true; if (rfbRef.current === rfb) rfbRef.current = null; if (rfb) { rfb.removeEventListener?.("connect", onConnect); rfb.removeEventListener?.("disconnect", onDisconnect); rfb.removeEventListener?.("securityfailure", onSecurityFailure); try { rfb.disconnect(); } catch { // ignore teardown errors } } }; // ONLY a real transport change reconnects: a fresh url (rotation), a new // credential (RFB password OR bearer subprotocol), the transport flipping, // a newly registered default peer loader, or an explicit manual reconnect // (`nonce`). `interactive`/`scaleViewport`/`mode`/`rfbFactory` are read via // refs and applied live below — they must never re-open the socket. // eslint-disable-next-line react-hooks/exhaustive-deps }, [url, token, transport, webSocketProtocolsKey, nonce, factoryRevision]); // Apply take-control / return-control to the OPEN connection in place. noVNC // honours `viewOnly` live, so flipping it neither blinks the surface nor drops // the framebuffer — the cure for the take-control "refresh + auto-release" loop. useEffect(() => { const rfb = rfbRef.current; if (rfb) rfb.viewOnly = viewOnlyFor(mode, interactive); // viewOnlyFor is pure; re-run only when the inputs change. // eslint-disable-next-line react-hooks/exhaustive-deps }, [interactive, mode, state]); // Re-scale the live RFB in place (no reconnect) when the scale preference flips // OR the connection state advances (e.g. the first framebuffer paints after a // take-control flip / a re-attach). Re-asserting clip-off + scale-on here is the // belt-and-suspenders against a "zoomed" surface: it re-fits the canvas to the // panel on every meaningful transition, so a transient mis-measure at connect // time can never stick. useEffect(() => { const rfb = rfbRef.current; if (rfb) { rfb.clipViewport = false; rfb.scaleViewport = scaleViewport ?? true; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [scaleViewport, state]); // Delegate to whichever transport owns the surface. For `relay-frames` the // noVNC effect above is dormant (it bailed to idle) and the frame renderer // drives; for `vnc-ws` (and everything else) the noVNC path drives while the // frame renderer stays idle. Same public shape either way. if (transport === "relay-frames") return frameStream; return { state, error, reconnect, sendKey }; }