declare const Guacamole: any; /** * The lifecycle of a console session as the UI needs to talk about it. * * Deliberately not Guacamole's own state enum: "waiting" and "connecting" are * one thing to an operator, and "disconnected because you clicked Disconnect" * and "disconnected because the far end vanished" are two things that Guacamole * reports identically. */ export type ConsoleState = | "connecting" | "connected" | "disconnected" | "error"; export type ConsoleFailure = { /** Guacamole status code, when the failure came with one. */ code?: number; /** Operator-facing sentence. Always set. */ message: string; /** Where the failure was reported: the tunnel or the client session. */ source: "tunnel" | "client"; }; /** * Guacamole status codes, as operator-facing sentences. * * These are the only diagnosis anyone gets from a failed console: the relay, * guacd and the target daemon are all invisible from the browser, and guacd * deliberately reports upstream failures as a bare number. Leaving them * unmapped is what makes a broken console indistinguishable from a slow one. * * Source: Guacamole protocol status codes (guacamole-common-js). */ const STATUS_TEXT: Record = { 256: "The server does not support this operation.", 512: "The remote desktop server encountered an error.", 513: "The remote desktop server is busy.", 514: "The remote host did not respond in time.", 515: "The remote host encountered an error.", 516: "The requested resource was not found.", 517: "The requested resource is already in use.", 518: "The remote connection was closed.", 519: "The remote host could not be reached.", 520: "The remote host is not currently available.", 521: "The session conflicts with another session.", 522: "The session timed out.", 523: "The session was closed.", 768: "The server rejected the connection request.", 769: "Authentication failed — check the credentials or SSH key.", 771: "Access to this connection was refused.", 776: "The session was closed after a period of inactivity.", 781: "The connection was closed because the client fell behind.", 782: "The server sent data the client could not understand.", 783: "Too many concurrent connections.", }; /** * describeStatus turns a Guacamole status into a sentence, always keeping the * numeric code available separately. The code is what makes a support ticket * actionable, so it is never discarded in favour of the prose. */ export function describeStatus(code: number | undefined, fallback: string): string { if (code === undefined) return fallback; return STATUS_TEXT[code] ?? fallback; } export type ConsoleHandlers = { onState?: (state: ConsoleState) => void; onFailure?: (failure: ConsoleFailure) => void; }; export function openConsole( container: HTMLElement, wsUrl: string, connectData = "", handlers: ConsoleHandlers = {} ): { disconnect: () => void; setScale: (scale: number) => void; resize: (width: number, height: number) => void; focus: () => void; } { const tunnel = new Guacamole.WebSocketTunnel(wsUrl); const client = new Guacamole.Client(tunnel); // Set once the caller tears the session down deliberately. Guacamole reports // a user-initiated disconnect through exactly the same callbacks as a far-end // failure, so without this flag closing the tab raises an error banner. let closing = false; const fail = (source: "tunnel" | "client", status: any, fallback: string) => { if (closing) return; const code: number | undefined = typeof status?.code === "number" ? status.code : undefined; const message = describeStatus( code, typeof status?.message === "string" && status.message ? status.message : fallback ); handlers.onFailure?.({ code, message, source }); handlers.onState?.("error"); }; tunnel.onerror = (status: any) => fail("tunnel", status, "The connection to the control plane was lost."); client.onerror = (status: any) => fail("client", status, "The remote session ended unexpectedly."); // Guacamole.Client.State: 0 IDLE, 1 CONNECTING, 2 WAITING, 3 CONNECTED, // 4 DISCONNECTING, 5 DISCONNECTED. CONNECTING and WAITING are one state to an // operator — both mean "not usable yet". client.onstatechange = (state: number) => { if (closing) return; if (state === 1 || state === 2) handlers.onState?.("connecting"); else if (state === 3) handlers.onState?.("connected"); else if (state === 5) handlers.onState?.("disconnected"); }; container.innerHTML = ""; container.appendChild(client.getDisplay().getElement()); container.tabIndex = 0; container.style.outline = "none"; handlers.onState?.("connecting"); client.connect(connectData); const display = client.getDisplay(); let scale = 1; const mouse = new Guacamole.Mouse(display.getElement()); mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = (state: any) => { const s = new Guacamole.Mouse.State( state.x / scale, state.y / scale, state.left, state.middle, state.right, state.up, state.down ); client.sendMouseState(s); }; const keyboard = new Guacamole.Keyboard(container); keyboard.onkeydown = (k: number) => client.sendKeyEvent(1, k); keyboard.onkeyup = (k: number) => client.sendKeyEvent(0, k); const refocus = () => { if (document.activeElement !== container) container.focus({ preventScroll: true }); }; container.addEventListener("pointerdown", refocus, true); container.addEventListener("mousedown", refocus, true); container.addEventListener("touchstart", refocus, true); const onBlur = () => { if (typeof keyboard.reset === "function") keyboard.reset(); }; container.addEventListener("blur", onBlur); window.addEventListener("blur", onBlur); container.focus({ preventScroll: true }); return { disconnect() { closing = true; container.removeEventListener("pointerdown", refocus, true); container.removeEventListener("mousedown", refocus, true); container.removeEventListener("touchstart", refocus, true); container.removeEventListener("blur", onBlur); window.removeEventListener("blur", onBlur); keyboard.onkeydown = null; keyboard.onkeyup = null; if (typeof keyboard.reset === "function") keyboard.reset(); client.disconnect(); }, focus: refocus, setScale(s: number) { scale = s; display.scale(s); }, resize(width: number, height: number) { client.sendSize(width, height); }, }; }