372 lines
17 KiB
JavaScript
372 lines
17 KiB
JavaScript
/*
|
|
* RDP-Sitzungs-Baustein: guacamole-common-js <-> /ws/rdp/{host_id}.
|
|
*
|
|
* F1 (Umsetzungsauftrag_Sonnet5.md Teil F.3.1): vorher ein Dokument-Singleton
|
|
* (ein Skript pro Seite, feste Element-IDs). Jetzt eine Klasse
|
|
* (RdpSession) mit genau einer Instanz pro Sitzung: eigenes, dynamisch
|
|
* gebautes DOM-Fragment (this.element), connect(), dispose(), setActive(bool)
|
|
* und ein Statusrueckruf (options.onStatusChange). Die eigenstaendige Seite
|
|
* /rdp/{host_id} (templates/rdp.html) ist ab jetzt nur noch ein duenner
|
|
* Rahmen, der GENAU EINE Instanz erzeugt (F.2, letzter Absatz).
|
|
*
|
|
* ACHTUNG Tastatur/Zwischenablage (F.3.2, noch NICHT geloest -- folgt in
|
|
* Stufe F4): guacamole-common-js' Guacamole.Keyboard haengt sich immer an
|
|
* ein von aussen uebergebenes Element -- bisher `document`, und das bleibt
|
|
* in dieser Stufe zunaechst so, weil die Bibliothek keine saubere
|
|
* Abmeldung/Umschaltung bietet. Bei mehreren GLEICHZEITIGEN RdpSession-
|
|
* Instanzen wuerden ohne Gegenmassnahme alle Tastatureingaben an ALLE
|
|
* Instanzen gehen. Deshalb: jede Instanz filtert selbst ueber setActive() --
|
|
* Tastatur-/Paste-Ereignisse werden nur weitergeleitet, wenn genau diese
|
|
* Instanz aktiv ist (this._active). Das ist die Grundlage fuer F.3.2, aber
|
|
* noch NICHT die vollstaendige Loesung (fehlende Modifiertasten-Ruecksetzung
|
|
* beim Umschalten, siehe F.3.2 Satz 2) -- die kommt mit der eigentlichen
|
|
* Mehrfach-Sitzungs-Umschaltung in Stufe F4, wenn es ueberhaupt zwei
|
|
* gleichzeitig existierende Instanzen geben kann. Bei genau einer Instanz
|
|
* (heutiger Stand: Stufe F1/F2/F3, dort niemals zwei RdpSession gleichzeitig
|
|
* auf derselben Seite) ist das Verhalten exakt wie vorher.
|
|
*
|
|
* Vollbild: native Browser-Fullscreen-API (Konzept 4.3).
|
|
* Copy & Paste: bidirektionale Synchronisation ueber Guacamole.Client
|
|
* onclipboard-Event (Ziel -> Browser) und den `paste`-Browser-Event
|
|
* (Browser -> Ziel, sendet eine "clipboard"-Instruktion). Wird serverseitig
|
|
* zusaetzlich blockiert, wenn fuer den Host clipboard_enabled=false ist
|
|
* (siehe app/rdp_proxy/ws_tunnel.py) -- das UI blendet den Hinweis dann ein.
|
|
*/
|
|
(() => {
|
|
"use strict";
|
|
|
|
let _nextInstanceId = 1;
|
|
|
|
class RdpSession {
|
|
/**
|
|
* @param {number|string} hostId
|
|
* @param {object} [options]
|
|
* @param {(text: string, isError: boolean) => void} [options.onStatusChange]
|
|
* @param {() => void} [options.onExitRequested]
|
|
* Wird aufgerufen, NACHDEM der interne Exit-Knopf dispose()
|
|
* bereits ausgeloest hat.
|
|
*/
|
|
constructor(hostId, options = {}) {
|
|
this.hostId = hostId;
|
|
this.options = options;
|
|
this._uid = _nextInstanceId++;
|
|
this._active = false;
|
|
this._disposed = false;
|
|
this._intentionalDisconnect = false;
|
|
this._resizeDebounceHandle = null;
|
|
|
|
this.element = this._buildElement();
|
|
this._cacheRefs();
|
|
this._wireToolbar();
|
|
}
|
|
|
|
// --- Aufbau -------------------------------------------------------
|
|
|
|
_buildElement() {
|
|
const wrapper = document.createElement("div");
|
|
wrapper.className = "session-instance";
|
|
wrapper.innerHTML = `
|
|
<div class="session-shell">
|
|
<div class="session-toolbar">
|
|
<button type="button" class="fullscreen-btn">Vollbild</button>
|
|
<button type="button" class="ctrlaltdel-btn">Strg+Alt+Entf</button>
|
|
<span class="spacer"></span>
|
|
<span class="status">Verbinde ...</span>
|
|
<button type="button" class="exit-btn btn-danger btn-small" title="Sitzung beenden">Exit</button>
|
|
</div>
|
|
<div class="session-rdp-display">
|
|
<!-- B.1 (Umsetzungsauftrag Teil B): Overlay-Panel fuer
|
|
Verbindungsfehler, siehe rdp-error-overlay unten. -->
|
|
<div class="rdp-error-overlay" hidden>
|
|
<div class="rdp-error-panel">
|
|
<h2 class="rdp-error-title">Verbindungsfehler</h2>
|
|
<p class="rdp-error-message"></p>
|
|
<dl class="rdp-error-meta">
|
|
<dt>Sitzungs-ID</dt><dd class="rdp-error-session">-</dd>
|
|
<dt>Zeitpunkt</dt><dd class="rdp-error-time">-</dd>
|
|
<dt>Ziel-Host</dt><dd class="rdp-error-host">-</dd>
|
|
</dl>
|
|
<button type="button" class="rdp-error-retry-btn">Wiederholen</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
return wrapper;
|
|
}
|
|
|
|
_cacheRefs() {
|
|
const el = this.element;
|
|
this.shellEl = el.querySelector(".session-shell");
|
|
this.statusEl = el.querySelector(".status");
|
|
this.displayDiv = el.querySelector(".session-rdp-display");
|
|
this.fullscreenBtn = el.querySelector(".fullscreen-btn");
|
|
this.ctrlAltDelBtn = el.querySelector(".ctrlaltdel-btn");
|
|
this.exitBtn = el.querySelector(".exit-btn");
|
|
this.errorOverlay = el.querySelector(".rdp-error-overlay");
|
|
this.errorTitleEl = el.querySelector(".rdp-error-title");
|
|
this.errorMessageEl = el.querySelector(".rdp-error-message");
|
|
this.errorSessionEl = el.querySelector(".rdp-error-session");
|
|
this.errorTimeEl = el.querySelector(".rdp-error-time");
|
|
this.errorHostEl = el.querySelector(".rdp-error-host");
|
|
this.errorRetryBtn = el.querySelector(".rdp-error-retry-btn");
|
|
}
|
|
|
|
_setStatus(text, isError) {
|
|
this.statusEl.textContent = text;
|
|
this.statusEl.classList.toggle("error", !!isError);
|
|
if (this.options.onStatusChange) {
|
|
try {
|
|
this.options.onStatusChange(text, !!isError);
|
|
} catch (_) { /* Aufrufer-Fehler duerfen die Sitzung nicht stoeren */ }
|
|
}
|
|
}
|
|
|
|
// --- Verbindungsaufbau ------------------------------------------------
|
|
|
|
connect() {
|
|
if (this.client) return; // bereits verbunden/am Verbinden
|
|
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
const width = Math.round(window.innerWidth);
|
|
const height = Math.round(window.innerHeight - 40);
|
|
const dpi = Math.round(window.devicePixelRatio * 96) || 96;
|
|
|
|
// WICHTIG: die Verbindungsparameter gehoeren NICHT in die Tunnel-URL.
|
|
// Guacamole.WebSocketTunnel.connect(data) baut die Socket-URL selbst als
|
|
// `tunnelURL + "?" + data` zusammen. Standen die Parameter schon in der
|
|
// URL, entstand daraus `...?width=1280&height=800&dpi=96?undefined` --
|
|
// der letzte Query-Parameter war damit kein gueltiger Integer mehr,
|
|
// FastAPI wies den WebSocket noch vor dem Routenhandler ab (HTTP 422).
|
|
const tunnelUrl = `${proto}//${window.location.host}/ws/rdp/${this.hostId}`;
|
|
const connectParams = `width=${width}&height=${height}&dpi=${dpi}`;
|
|
this.tunnel = new Guacamole.WebSocketTunnel(tunnelUrl);
|
|
this.client = new Guacamole.Client(this.tunnel);
|
|
|
|
this.displayDiv.appendChild(this.client.getDisplay().getElement());
|
|
|
|
this.client.onstatechange = (state) => {
|
|
// 0=idle,1=connecting,2=waiting,3=connected,4=disconnecting,5=disconnected
|
|
const labels = ["Idle", "Verbinde ...", "Warte auf Server ...", "Verbunden", "Trenne ...", "Getrennt"];
|
|
this._setStatus(labels[state] || `Status ${state}`, false);
|
|
};
|
|
|
|
const showError = (err) => {
|
|
// Ein vom Benutzer ueber den Exit-Knopf ausgeloestes client.disconnect()
|
|
// schliesst den Tunnel sauber (Guacamole.Status.Code.SUCCESS) und loest
|
|
// damit laut guacamole-common-js KEIN onerror aus -- dieser Schutz greift
|
|
// trotzdem zusaetzlich, falls sich das je aendert.
|
|
if (this._intentionalDisconnect) return;
|
|
|
|
const message = (err && err.message) || "Unbekannter Fehler (keine Meldung vom Server empfangen)";
|
|
this._setStatus("Fehler: " + message, true);
|
|
|
|
this.errorTitleEl.textContent = "Verbindungsfehler";
|
|
this.errorMessageEl.textContent = message;
|
|
this.errorSessionEl.textContent = this.tunnel.uuid || "unbekannt";
|
|
this.errorTimeEl.textContent = new Date().toLocaleString("de-AT");
|
|
this.errorHostEl.textContent = `Host-ID ${this.hostId}`;
|
|
this.errorOverlay.hidden = false;
|
|
|
|
console.error("RDP-Sitzung beendet/Fehler:", err);
|
|
};
|
|
this.client.onerror = showError;
|
|
this.tunnel.onerror = showError;
|
|
|
|
this.errorRetryBtn.addEventListener("click", () => {
|
|
// Einfachste zuverlaessige Wiederholung: die Sitzung (Tunnel, Client,
|
|
// Maus-/Tastatur-Listener) neu aufzubauen wuerde denselben Zustand wie
|
|
// ein frischer Seitenaufruf erfordern -- ein Reload erreicht das direkt
|
|
// und erzeugt serverseitig eine neue Korrelations-ID/Sitzung. Betrifft
|
|
// bei /workspace (Stufe F3/F4) nur diese eine Kachel -- ein voller
|
|
// Seiten-Reload dort wuerde ALLE offenen Sitzungen der Arbeitsflaeche
|
|
// beenden (siehe F.3.7: kein Wiederanschluss nach Reload).
|
|
window.location.reload();
|
|
});
|
|
|
|
this.client.onclipboard = (stream, mimetype) => {
|
|
if (!mimetype.startsWith("text/")) return;
|
|
const reader = new Guacamole.StringReader(stream);
|
|
let data = "";
|
|
reader.ontext = (text) => { data += text; };
|
|
reader.onend = () => {
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(data).catch(() => {});
|
|
}
|
|
};
|
|
};
|
|
|
|
this.client.connect(connectParams);
|
|
|
|
this._onBeforeUnload = () => { try { this.client.disconnect(); } catch (_) { /* ignorieren */ } };
|
|
window.addEventListener("beforeunload", this._onBeforeUnload);
|
|
|
|
// D4 (Umsetzungsauftrag Teil A): der Verbindungsaufbau setzt bereits
|
|
// "resize-method: display-update" -- das RDP-Ziel unterstuetzt damit
|
|
// dynamische Aufloesungsaenderungen waehrend der laufenden Sitzung.
|
|
// Debounce: "resize" kann waehrend eines Fenster-Drags sehr haeufig
|
|
// feuern -- ohne Verzoegerung wuerde jede Zwischengroesse eine eigene
|
|
// "size"-Instruktion ueber den Tunnel schicken.
|
|
// F4 (Umsetzungsauftrag_Sonnet5.md Teil F.3.3): mehrere Instanzen
|
|
// koennen jetzt gleichzeitig existieren (Arbeitsflaeche /workspace) --
|
|
// eine ausgeblendete Instanz hat Groesse 0 (display:none). Ein Refit
|
|
// waehrend ausgeblendet waere sinnlos und wuerde bei Sichtbarwerden
|
|
// ohnehin durch setActive(true) -> _sendCurrentSize() nachgeholt --
|
|
// daher nur reagieren, wenn diese Instanz gerade sichtbar ist.
|
|
this._onWindowResize = () => { if (this._active) this._scheduleResize(); };
|
|
window.addEventListener("resize", this._onWindowResize);
|
|
document.addEventListener("fullscreenchange", this._onWindowResize);
|
|
|
|
this.mouse = new Guacamole.Mouse(this.client.getDisplay().getElement());
|
|
this.mouse.onmousedown = this.mouse.onmouseup = this.mouse.onmousemove = (mouseState) => {
|
|
this.client.sendMouseState(mouseState);
|
|
};
|
|
|
|
// Siehe Moduldoc oben: dokumentweit angeheftet (Bibliotheksgrenze),
|
|
// Weiterleitung aber ueber this._active gefiltert.
|
|
this.keyboard = new Guacamole.Keyboard(document);
|
|
this.keyboard.onkeydown = (keysym) => { if (this._active) this.client.sendKeyEvent(1, keysym); };
|
|
this.keyboard.onkeyup = (keysym) => { if (this._active) this.client.sendKeyEvent(0, keysym); };
|
|
|
|
this._onPaste = (ev) => {
|
|
if (!this._active) return;
|
|
const text = (ev.clipboardData || window.clipboardData).getData("text");
|
|
if (!text) return;
|
|
const stream = this.client.createClipboardStream("text/plain");
|
|
const writer = new Guacamole.StringWriter(stream);
|
|
writer.sendText(text);
|
|
writer.sendEnd();
|
|
};
|
|
document.addEventListener("paste", this._onPaste);
|
|
}
|
|
|
|
_sendCurrentSize() {
|
|
const newWidth = Math.round(this.displayDiv.clientWidth || window.innerWidth);
|
|
const newHeight = Math.round(this.displayDiv.clientHeight || (window.innerHeight - 40));
|
|
if (newWidth > 0 && newHeight > 0) {
|
|
this.client.sendSize(newWidth, newHeight);
|
|
}
|
|
}
|
|
|
|
_scheduleResize() {
|
|
if (this._resizeDebounceHandle !== null) {
|
|
window.clearTimeout(this._resizeDebounceHandle);
|
|
}
|
|
this._resizeDebounceHandle = window.setTimeout(() => {
|
|
this._resizeDebounceHandle = null;
|
|
this._sendCurrentSize();
|
|
}, 250);
|
|
}
|
|
|
|
/** Wird von einer kuenftigen Seitenleiste (Teil F.3/F.4) beim Umschalten
|
|
* aufgerufen. Steuert, ob diese Instanz Tastatur-/Paste-Ereignisse
|
|
* entgegennimmt (siehe Moduldoc) -- bei genau einer Instanz (heutiger
|
|
* Stand) ohne sichtbaren Effekt ausser dem anfaenglichen Aktivieren. */
|
|
setActive(active) {
|
|
const wasActive = this._active;
|
|
this._active = active;
|
|
// F4 (F.3.2, Satz 2): beim Umschalten muessen gedrueckte Modifiertasten
|
|
// zurueckgesetzt werden -- sonst bleibt z.B. Strg in der verlassenen
|
|
// Sitzung haengen (dort kommt ja kein keyup mehr an, sobald diese
|
|
// Instanz keine Ereignisse mehr weiterleitet). Keyboard.reset() ist
|
|
// Teil der guacamole-common-js-API (dieselbe Methode, die der
|
|
// offizielle Client bei Fokusverlust des Fensters aufruft) -- per
|
|
// Feature-Check abgesichert, falls eine kuenftige Bibliotheksversion
|
|
// sie umbenennt.
|
|
if (!active && wasActive && this.keyboard && typeof this.keyboard.reset === "function") {
|
|
this.keyboard.reset();
|
|
}
|
|
if (active && this.client) {
|
|
this._sendCurrentSize();
|
|
if (this.keyboard && typeof this.keyboard.reset === "function") {
|
|
this.keyboard.reset();
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Toolbar --------------------------------------------------------
|
|
|
|
_wireToolbar() {
|
|
this.fullscreenBtn.addEventListener("click", () => {
|
|
if (!document.fullscreenElement) {
|
|
this.shellEl.requestFullscreen().catch(() => {});
|
|
} else {
|
|
document.exitFullscreen();
|
|
}
|
|
});
|
|
|
|
// Strg+Alt+Entf: der Browser faengt diese Kombination selbst ab (Windows
|
|
// reserviert sie systemweit), sie kommt also nie als normales Tastatur-
|
|
// Event beim RDP-Ziel an. Abhilfe wie im offiziellen Guacamole-Client:
|
|
// die drei Tasten einzeln als synthetische Key-Events senden (X11-
|
|
// Keysyms: Ctrl=0xFFE3, Alt=0xFFE9, Delete=0xFFFF), erst alle drei
|
|
// herunter- dann in umgekehrter Reihenfolge wieder hochdruecken.
|
|
this.ctrlAltDelBtn.addEventListener("click", () => {
|
|
if (!this.client) return;
|
|
const keys = [0xffe3, 0xffe9, 0xffff];
|
|
for (const keysym of keys) this.client.sendKeyEvent(1, keysym);
|
|
for (const keysym of keys.slice().reverse()) this.client.sendKeyEvent(0, keysym);
|
|
});
|
|
|
|
this.exitBtn.addEventListener("click", () => {
|
|
this._intentionalDisconnect = true;
|
|
this._setStatus("Sitzung wird beendet ...", false);
|
|
this.dispose();
|
|
if (this.options.onExitRequested) this.options.onExitRequested();
|
|
});
|
|
}
|
|
|
|
// --- Abbau ------------------------------------------------------------
|
|
|
|
/** Idempotent. Siehe terminal.js::SshTerminalSession.dispose() fuer die
|
|
* gleiche Begruendung. Einschraenkung: guacamole-common-js bietet keine
|
|
* saubere Abmeldung fuer Guacamole.Keyboard(document) -- der zugrunde-
|
|
* liegende DOM-Listener bleibt technisch bestehen, sendet aber nach
|
|
* dispose() nichts mehr (this._active bleibt false). Das ist bei genau
|
|
* einer Instanz (heutiger Stand) folgenlos; bei mehreren gleichzeitigen
|
|
* Instanzen (Stufe F4) muss dieser Punkt beim Kachel-Schliessen erneut
|
|
* betrachtet werden. */
|
|
dispose() {
|
|
if (this._disposed) return;
|
|
this._disposed = true;
|
|
try {
|
|
if (this.client) this.client.disconnect();
|
|
} catch (_) { /* bereits getrennt */ }
|
|
if (this._onWindowResize) {
|
|
window.removeEventListener("resize", this._onWindowResize);
|
|
document.removeEventListener("fullscreenchange", this._onWindowResize);
|
|
}
|
|
if (this._onBeforeUnload) window.removeEventListener("beforeunload", this._onBeforeUnload);
|
|
if (this._onPaste) document.removeEventListener("paste", this._onPaste);
|
|
if (this._resizeDebounceHandle !== null) {
|
|
window.clearTimeout(this._resizeDebounceHandle);
|
|
this._resizeDebounceHandle = null;
|
|
}
|
|
if (this.element.parentNode) this.element.parentNode.removeChild(this.element);
|
|
}
|
|
}
|
|
|
|
window.RdpSession = RdpSession;
|
|
|
|
// --- Bootstrap fuer die eigenstaendige Seite /rdp/{host_id} --------------
|
|
//
|
|
// F1: templates/rdp.html liefert nur noch einen leeren Container mit
|
|
// data-host-id -- diese Seite bleibt als duenner Rahmen um denselben
|
|
// Baustein bestehen (F.2), keine zweite Implementierung.
|
|
const container = document.getElementById("session-container");
|
|
if (container) {
|
|
const hostId = container.dataset.hostId;
|
|
const session = new RdpSession(hostId, {
|
|
onExitRequested: () => {
|
|
window.close();
|
|
// Siehe terminal.js fuer die ausfuehrliche Begruendung: window.close()
|
|
// schliesst den Tab nur unter bestimmten Voraussetzungen lautlos --
|
|
// deshalb zusaetzlich immer ein Fallback zum Dashboard.
|
|
window.setTimeout(() => { window.location.href = "/dashboard"; }, 300);
|
|
},
|
|
});
|
|
container.appendChild(session.element);
|
|
session.connect();
|
|
session.setActive(true);
|
|
}
|
|
})();
|