/* * 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 = `
Verbinde ...
`; 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); } })();