189 lines
6.5 KiB
TypeScript
189 lines
6.5 KiB
TypeScript
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<number, string> = {
|
|
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);
|
|
},
|
|
};
|
|
}
|