public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
To: pve-devel@lists.proxmox.com
Subject: [RFC pve-manager 07/13] ui: add rdp console
Date: Tue, 25 Aug 2026 13:34:32 +0200	[thread overview]
Message-ID: <20260825113442.947620-8-alexandre.derumier@groupe-cyllene.com> (raw)
In-Reply-To: <20260825113442.947620-1-alexandre.derumier@groupe-cyllene.com>

Signed-off-by: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
---
 Makefile                             |   2 +-
 PVE/Service/pveproxy.pm              |  14 +
 rdp-web/Makefile                     |  18 +
 rdp-web/index.html.tpl               | 615 +++++++++++++++++++++++++++
 www/manager6/Utils.js                |  31 +-
 www/manager6/button/ConsoleButton.js |  21 +
 www/manager6/qemu/Config.js          |   6 +-
 www/manager6/qemu/DisplayEdit.js     |   4 +-
 8 files changed, 705 insertions(+), 6 deletions(-)
 create mode 100644 rdp-web/Makefile
 create mode 100644 rdp-web/index.html.tpl

diff --git a/Makefile b/Makefile
index b7dfc7f..18de847 100644
--- a/Makefile
+++ b/Makefile
@@ -10,7 +10,7 @@ DSC=$(PACKAGE)_$(DEB_VERSION).dsc
 DEB=$(PACKAGE)_$(DEB_VERSION)_all.deb
 
 DESTDIR=
-SUBDIRS = aplinfo PVE bin www services configs kyber-web network-hooks test templates
+SUBDIRS = aplinfo PVE bin www services configs kyber-web rdp-web network-hooks test templates
 
 all: $(SUBDIRS)
 	set -e && for i in $(SUBDIRS); do $(MAKE) -C $$i; done
diff --git a/PVE/Service/pveproxy.pm b/PVE/Service/pveproxy.pm
index bcc1353..ddc04ff 100755
--- a/PVE/Service/pveproxy.pm
+++ b/PVE/Service/pveproxy.pm
@@ -58,6 +58,7 @@ my $basedirs = {
     i18n => '/usr/share/pve-i18n',
     manager => '/usr/share/pve-manager',
     kyber => '/usr/share/pve-kyber-web',
+    rdp => '/usr/share/pve-rdp-web',
     novnc => '/usr/share/novnc-pve',
     yew_mobile => '/usr/share/pve-yew-mobile-gui',
     i18n_yew => '/usr/share/pve-yew-mobile-i18n',
@@ -68,6 +69,9 @@ my $basedirs = {
 my $kyber_console_prefix = qr!^/api2/json/nodes/([^/]+)/qemu/(\d+)/kyber(/.*)$!;
 my $kyber_proxy_socket = '/run/pvekyberproxy.sock';
 
+my $rdp_console_prefix = qr!^/api2/json/nodes/([^/]+)/qemu/(\d+)/rdp/([^/]+)$!;
+my $rdp_proxy_socket = '/run/pverdpproxy.sock';
+
 my sub check_console_access {
     my ($auth, $console_type, $node, $vmid) = @_;
 
@@ -97,6 +101,12 @@ sub console_proxy {
         return { socket => $kyber_proxy_socket, path => $target, tls => 0 };
     }
 
+    if (my ($node, $vmid, $token) = $path =~ $rdp_console_prefix) {
+        check_console_access($auth, 'RDP', $node, $vmid);
+
+        return { socket => $rdp_proxy_socket, path => "/$vmid/$token", tls => 0 };
+    }
+
     return undef;
 }
 
@@ -118,6 +128,7 @@ sub init {
     my $dirs = {};
 
     add_dirs($dirs, '/kyber/' => "$basedirs->{kyber}/");
+    add_dirs($dirs, '/rdp/' => "$basedirs->{rdp}/");
     add_dirs($dirs, '/novnc/' => "$basedirs->{novnc}/");
     add_dirs($dirs, '/pve-docs/' => "$basedirs->{docs}/");
     add_dirs($dirs, '/pve-docs/api-viewer/extjs/' => "$basedirs->{extjs}/");
@@ -290,6 +301,7 @@ sub get_index {
         || $args->{mobile};
 
     my $kyber = defined($args->{console}) && $args->{kyber};
+    my $rdp = defined($args->{console}) && $args->{rdp};
     my $novnc = defined($args->{console}) && $args->{novnc};
     my $xtermjs = defined($args->{console}) && $args->{xtermjs};
 
@@ -336,6 +348,8 @@ sub get_index {
 
     if ($kyber) {
         $dir = $basedirs->{kyber};
+    } elsif ($rdp) {
+        $dir = $basedirs->{rdp};
     } elsif ($novnc) {
         $dir = $basedirs->{novnc};
     } elsif ($xtermjs) {
diff --git a/rdp-web/Makefile b/rdp-web/Makefile
new file mode 100644
index 0000000..f51cfc2
--- /dev/null
+++ b/rdp-web/Makefile
@@ -0,0 +1,18 @@
+include ../defines.mk
+
+RDPDIR = $(DESTDIR)/usr/share/pve-rdp-web
+
+all:
+
+.PHONY: install
+install: index.html.tpl
+	install -d $(RDPDIR)
+	install -m 0644 index.html.tpl $(RDPDIR)/index.html.tpl
+# The client itself - rdp_client.js and rdp_client_bg.wasm beside it - is
+# IronRDP built for the browser, shipped by pve-rdp-web rather than vendored
+# here: a separate upstream with a wasm toolchain that has no business in this
+# build. The page drives it directly and needs nothing else from it.
+
+.PHONY: clean distclean
+distclean: clean
+clean:
diff --git a/rdp-web/index.html.tpl b/rdp-web/index.html.tpl
new file mode 100644
index 0000000..eda7006
--- /dev/null
+++ b/rdp-web/index.html.tpl
@@ -0,0 +1,615 @@
+<!DOCTYPE HTML>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <title>RDP console</title>
+    <style>
+      html, body {
+          margin: 0;
+          height: 100%;
+          background: #000;
+          color: #ddd;
+          font: 13px/1.5 system-ui, sans-serif;
+          overflow: hidden;
+      }
+      #container {
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          height: 100%;
+      }
+      #canvas {
+          display: block;
+          outline: none;
+      }
+      #status {
+          position: fixed;
+          inset: 0;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          padding: 0 2em;
+          text-align: center;
+          background: #000;
+          white-space: pre-wrap;
+      }
+      #status[hidden] { display: none; }
+      #status.failed { color: #ff8080; }
+      /* The control bar is noVNC's, down to its images: a console should not
+         look like a different product depending on which one it is. Only the
+         rules this page needs are copied - loading noVNC's stylesheet whole
+         would bring its layout with it. */
+      #rdp_control_bar_anchor {
+        position: fixed;
+        top: 0;
+        left: 0;
+        height: 100%;
+        z-index: 10;
+        transition: 0.5s ease-in-out;
+      }
+      #rdp_control_bar {
+        position: relative;
+        left: -100%;
+        height: 100%;
+        padding: 5px;
+        background-color: #1c2331;
+        border-radius: 0 12px 12px 0;
+        transition: 0.5s ease-in-out;
+        display: flex;
+        flex-direction: column;
+        gap: 6px;
+      }
+      #rdp_control_bar.rdp_open { left: 0; }
+      #rdp_control_bar_handle {
+        position: absolute;
+        left: -15px;
+        top: 0;
+        transform: translateY(35px);
+        width: calc(100% + 30px);
+        height: 50px;
+        z-index: -1;
+        cursor: pointer;
+        border-radius: 6px;
+        background-color: #1c2331;
+        background-image: url("/novnc/app/images/handle_bg.svg");
+        background-repeat: no-repeat;
+        background-position: right;
+        box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.5);
+      }
+      #rdp_control_bar_handle:after {
+        content: "";
+        transition: transform 0.1s ease-in-out;
+        background: url("/novnc/app/images/handle.svg");
+        position: absolute;
+        top: 22px;
+        right: 5px;
+        width: 5px;
+        height: 6px;
+      }
+      #rdp_control_bar.rdp_open #rdp_control_bar_handle:after {
+        transform: translateX(1px) rotate(180deg);
+      }
+      .rdp_button {
+        min-width: 36px;
+        padding: 4px;
+        border: 1px solid rgba(255, 255, 255, 0.2);
+        border-radius: 6px;
+        background-color: transparent;
+        color: #fff;
+        cursor: pointer;
+        font-size: 11px;
+        line-height: 1.2;
+      }
+      .rdp_button:hover { background-color: rgba(255, 255, 255, 0.1); }
+      .rdp_button img { width: 24px; height: 24px; display: block; margin: auto; }
+      #rdp_power_menu {
+        display: none;
+        position: absolute;
+        left: 100%;
+        margin-left: 8px;
+        background-color: #1c2331;
+        border-radius: 6px;
+        padding: 5px;
+        box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.5);
+      }
+      #rdp_power_menu.rdp_open { display: flex; flex-direction: column; gap: 4px; }
+      #rdp_power_menu .rdp_button { white-space: nowrap; text-align: left; }
+      #container:fullscreen,
+      :fullscreen #container {
+        width: 100vw;
+        height: 100vh;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        background: #000;
+      }
+    </style>
+  </head>
+  <body>
+    <div id="rdp_control_bar_anchor">
+      <div id="rdp_control_bar">
+        <div id="rdp_control_bar_handle"></div>
+        <button class="rdp_button" id="rdp_ctrl_alt_del" title="Send Ctrl-Alt-Del">
+          <img src="/novnc/app/images/esc.svg" alt=""><span>C-A-D</span>
+        </button>
+        <button class="rdp_button" id="rdp_audio" title="Guest audio">
+          <span id="rdp_audio_label">Audio<br>off</span>
+        </button>
+        <button class="rdp_button" id="rdp_fullscreen" title="Fullscreen">
+          <img src="/novnc/app/images/fullscreen.svg" alt="">
+        </button>
+        <button class="rdp_button" id="rdp_power" title="Power">
+          <img src="/novnc/app/images/power.svg" alt="">
+        </button>
+        <div id="rdp_power_menu">
+          <button class="rdp_button" data-power="start">Start</button>
+          <button class="rdp_button" data-power="shutdown">Shutdown</button>
+          <button class="rdp_button" data-power="reboot">Reboot</button>
+          <button class="rdp_button" data-power="reset">Reset</button>
+          <button class="rdp_button" data-power="stop">Stop</button>
+        </div>
+      </div>
+    </div>
+    <div id="container"><canvas id="canvas" tabindex="0"></canvas></div>
+    <div id="status">Connecting&#8230;</div>
+
+    <!-- Installs globalThis.pveRdpAudio, which the client looks up when the
+         session starts. A classic script rather than a module: those are
+         deferred, and this has to be in place before the module runs. Shipped
+         by pve-rdp-web beside the client itself. -->
+    <script src="/rdp/rdp-audio.js"></script>
+
+    <script type="module">
+      import init, { ClipboardData, DesktopSize, DeviceEvent, InputTransaction, SessionBuilder, setup }
+          from '/rdp/rdp_client.js';
+
+      const params = new URLSearchParams(window.location.search);
+      const node = params.get('node');
+      const vmid = params.get('vmid');
+      const vmname = params.get('vmname');
+
+      document.title = vmname ? `${vmname} (${vmid}) - RDP console`
+                              : `VM ${vmid} - RDP console`;
+
+      const statusEl = document.getElementById('status');
+      const canvas = document.getElementById('canvas');
+
+      const setStatus = (text, failed) => {
+          statusEl.textContent = text;
+          statusEl.classList.toggle('failed', !!failed);
+          statusEl.hidden = false;
+      };
+
+      let session = null;
+
+      // Windows scancodes, which is what RDP carries, keyed by the browser's
+      // KeyboardEvent.code. Taken from IronRDP's own web client; the high byte
+      // is the extended-key prefix.
+      const SCANCODES = {
+          AltLeft: 0x0038, AltRight: 0xE038, ArrowDown: 0xE050, ArrowLeft: 0xE04B,
+          ArrowRight: 0xE04D, ArrowUp: 0xE048, AudioVolumeDown: 0xE02E,
+          AudioVolumeMute: 0xE020, AudioVolumeUp: 0xE030, Backquote: 0x0029, Backslash: 0x002B,
+          Backspace: 0x000E, BracketLeft: 0x001A, BracketRight: 0x001B, BrowserBack: 0xE06A,
+          BrowserFavorites: 0xE066, BrowserForward: 0xE069, BrowserHome: 0xE032,
+          BrowserRefresh: 0xE067, BrowserSearch: 0xE065, BrowserStop: 0xE068, CapsLock: 0x003A,
+          Comma: 0x0033, ContextMenu: 0xE05D, ControlLeft: 0x001D, ControlRight: 0xE01D,
+          Convert: 0x0079, Copy: 0xE018, Cut: 0xE017, Delete: 0xE053, Digit0: 0x000B,
+          Digit1: 0x0002, Digit2: 0x0003, Digit3: 0x0004, Digit4: 0x0005, Digit5: 0x0006,
+          Digit6: 0x0007, Digit7: 0x0008, Digit8: 0x0009, Digit9: 0x000A, Eject: 0xE02C,
+          End: 0xE04F, Enter: 0x001C, Equal: 0x000D, Escape: 0x0001, F1: 0x003B, F10: 0x0044,
+          F11: 0x0057, F12: 0x0058, F13: 0x0064, F14: 0x0065, F15: 0x0066, F16: 0x0067,
+          F17: 0x0068, F18: 0x0069, F19: 0x006A, F2: 0x003C, F20: 0x006B, F21: 0x006C,
+          F22: 0x006D, F23: 0x006E, F24: 0x0076, F3: 0x003D, F4: 0x003E, F5: 0x003F,
+          F6: 0x0040, F7: 0x0041, F8: 0x0042, F9: 0x0043, Help: 0xE03B, Home: 0xE047,
+          Insert: 0xE052, IntlBackslash: 0x0056, IntlRo: 0x0073, IntlYen: 0x007D,
+          KanaMode: 0x0070, KeyA: 0x001E, KeyB: 0x0030, KeyC: 0x002E, KeyD: 0x0020,
+          KeyE: 0x0012, KeyF: 0x0021, KeyG: 0x0022, KeyH: 0x0023, KeyI: 0x0017, KeyJ: 0x0024,
+          KeyK: 0x0025, KeyL: 0x0026, KeyM: 0x0032, KeyN: 0x0031, KeyO: 0x0018, KeyP: 0x0019,
+          KeyQ: 0x0010, KeyR: 0x0013, KeyS: 0x001F, KeyT: 0x0014, KeyU: 0x0016, KeyV: 0x002F,
+          KeyW: 0x0011, KeyX: 0x002D, KeyY: 0x0015, KeyZ: 0x002C, Lang1: 0x0072, Lang2: 0x0071,
+          Lang3: 0x0078, Lang4: 0x0077, LaunchApp1: 0xE06B, LaunchApp2: 0xE021,
+          LaunchMail: 0xE06C, MediaPlayPause: 0xE022, MediaSelect: 0xE06D, MediaStop: 0xE024,
+          MediaTrackNext: 0xE019, MediaTrackPrevious: 0xE010, MetaLeft: 0xE05B,
+          MetaRight: 0xE05C, Minus: 0x000C, NonConvert: 0x007B, NumLock: 0xE045,
+          Numpad0: 0x0052, Numpad1: 0x004F, Numpad2: 0x0050, Numpad3: 0x0051, Numpad4: 0x004B,
+          Numpad5: 0x004C, Numpad6: 0x004D, Numpad7: 0x0047, Numpad8: 0x0048, Numpad9: 0x0049,
+          NumpadAdd: 0x004E, NumpadComma: 0x007E, NumpadDecimal: 0x0053, NumpadDivide: 0xE035,
+          NumpadEnter: 0xE01C, NumpadEqual: 0x0059, NumpadMultiply: 0x0037,
+          NumpadSubtract: 0x004A, OSLeft: 0xE05B, OSRight: 0xE05C, PageDown: 0xE051,
+          PageUp: 0xE049, Paste: 0xE00A, Pause: 0xE046, Period: 0x0034, Power: 0xE05E,
+          PrintScreen: 0xE037, Quote: 0x0028, ScrollLock: 0x0046, Semicolon: 0x0027,
+          ShiftLeft: 0x002A, ShiftRight: 0x0036, Slash: 0x0035, Sleep: 0xE05F, Space: 0x0039,
+          Tab: 0x000F, Undo: 0xE008, VolumeDown: 0xE02E, VolumeMute: 0xE020, VolumeUp: 0xE030,
+          WakeUp: 0xE063
+      };
+
+      // What to ask the server for. It is only a request: qemu-rdp answers with
+      // the guest's own framebuffer size and resizes only if the guest agrees,
+      // so nothing below may assume this is what arrives.
+      const requestedWidth = Math.max(640, Math.floor(window.innerWidth / 4) * 4);
+      const requestedHeight = Math.max(480, Math.floor(window.innerHeight / 4) * 4);
+
+      // Sized from the canvas itself, never from what was requested: the client
+      // sets the backing store to the size the server negotiated, and giving it
+      // a CSS box of a different size is what scales the picture.
+      function fitCanvas() {
+          const width = canvas.width;
+          const height = canvas.height;
+          if (!width || !height) {
+              return;
+          }
+
+          let scale = Math.min(window.innerWidth / width, window.innerHeight / height);
+          // Native size in a window, shrunk only when the guest is bigger than
+          // the window; fullscreen scales up too, which is the point of it.
+          // One factor for both axes, so a 4:3 guest letterboxes rather than
+          // distorts.
+          if (!document.fullscreenElement) {
+              scale = Math.min(scale, 1);
+          }
+
+          canvas.style.width = `${Math.floor(width * scale)}px`;
+          canvas.style.height = `${Math.floor(height * scale)}px`;
+      }
+
+      // A guest that resizes changes the backing store with no event to go
+      // with it - the SDK's canvas_resized callback is a no-op for RDP, which
+      // has no server-side resize. width and height are reflected attributes,
+      // so the change is still observable.
+      new MutationObserver(fitCanvas).observe(canvas, {
+          attributes: true,
+          attributeFilter: ['width', 'height'],
+      });
+
+      // Pointer coordinates are the guest's, not the page's: the canvas is
+      // scaled by CSS whenever the guest does not fit the window as it is.
+      function guestPosition(event) {
+          const rect = canvas.getBoundingClientRect();
+          const x = (event.clientX - rect.left) * (canvas.width / rect.width);
+          const y = (event.clientY - rect.top) * (canvas.height / rect.height);
+          return [
+              Math.max(0, Math.min(canvas.width - 1, Math.round(x))),
+              Math.max(0, Math.min(canvas.height - 1, Math.round(y))),
+          ];
+      }
+
+      function apply(...events) {
+          if (!session) {
+              return;
+          }
+          const transaction = new InputTransaction();
+          for (const event of events) {
+              transaction.addEvent(event);
+          }
+          session.applyInputs(transaction);
+      }
+
+      function bindInput() {
+          canvas.addEventListener('contextmenu', (e) => e.preventDefault());
+
+          canvas.addEventListener('mousemove', (e) => {
+              apply(DeviceEvent.mouseMove(...guestPosition(e)));
+          });
+
+          canvas.addEventListener('mousedown', (e) => {
+              e.preventDefault();
+              canvas.focus();
+              apply(
+                  DeviceEvent.mouseMove(...guestPosition(e)),
+                  DeviceEvent.mouseButtonPressed(e.button),
+              );
+          });
+
+          // On the window, not the canvas: a button released outside it would
+          // otherwise stay down in the guest.
+          window.addEventListener('mouseup', (e) => {
+              apply(DeviceEvent.mouseButtonReleased(e.button));
+          });
+
+          canvas.addEventListener('wheel', (e) => {
+              e.preventDefault();
+              const vertical = e.deltaY !== 0;
+              // Negated: the browser counts down as positive, RDP counts up.
+              // deltaMode's values are RotationUnit's, so it goes as it is.
+              apply(DeviceEvent.wheelRotations(
+                  vertical,
+                  -Math.round(vertical ? e.deltaY : e.deltaX),
+                  e.deltaMode,
+              ));
+          }, { passive: false });
+
+          canvas.addEventListener('keydown', (e) => {
+              e.preventDefault();
+              const scancode = SCANCODES[e.code];
+              if (scancode !== undefined) {
+                  apply(DeviceEvent.keyPressed(scancode));
+              } else if (e.key.length === 1) {
+                  // A layout this table does not cover; the character itself
+                  // still gets through.
+                  apply(DeviceEvent.unicodePressed(e.key));
+              }
+          });
+
+          canvas.addEventListener('keyup', (e) => {
+              e.preventDefault();
+              const scancode = SCANCODES[e.code];
+              if (scancode !== undefined) {
+                  apply(DeviceEvent.keyReleased(scancode));
+              } else if (e.key.length === 1) {
+                  apply(DeviceEvent.unicodeReleased(e.key));
+              }
+          });
+
+          // Every key held when focus leaves would stay held in the guest.
+          canvas.addEventListener('blur', () => session?.releaseAllInputs());
+      }
+
+      // The cursor is drawn by the desktop, not into the framebuffer, so the
+      // guest's shape arrives separately and is put on the canvas as a CSS
+      // cursor.
+      function setCursorStyle(kind, data, hotspotX, hotspotY) {
+          if (kind === 'url') {
+              canvas.style.cursor = `url(${data}) ${hotspotX} ${hotspotY}, default`;
+          } else if (kind === 'none') {
+              canvas.style.cursor = 'none';
+          } else {
+              canvas.style.cursor = 'default';
+          }
+      }
+
+      // Clipboard, both ways, through the browser's own - which hands it over
+      // only to a focused document the user has granted permission to, so a
+      // refusal here is normal and not worth failing the console over.
+      //
+      // The guest's clipboard, on its way here.
+      async function onRemoteClipboardChanged(data) {
+          for (const item of data.items()) {
+              if (item.mimeType() === 'text/plain') {
+                  const text = item.value();
+                  try {
+                      await navigator.clipboard.writeText(text);
+                      // Remembered, or the poll below would read it back and
+                      // announce the guest's own copy straight back at it.
+                      lastLocalText = text;
+                  } catch (err) {
+                      console.warn('could not take the guest clipboard:', err);
+                  }
+                  return;
+              }
+          }
+      }
+
+      // Ours, on its way to the guest. RDP is announce-then-request: until the
+      // client says it holds a format, the guest's paste has nothing to ask
+      // for. Nothing tells a page that another application copied something,
+      // so the only way to notice is to look.
+      let lastLocalText = null;
+
+      async function announceLocalClipboard() {
+          if (!session || !document.hasFocus()) {
+              return;
+          }
+
+          let text;
+          try {
+              text = await navigator.clipboard.readText();
+          } catch (err) {
+              // Refused or unavailable - Firefox gives web pages no unprompted
+              // read at all. Not an error worth repeating every second.
+              return;
+          }
+
+          if (text === lastLocalText) {
+              return;
+          }
+          lastLocalText = text;
+
+          const data = new ClipboardData();
+          if (text.length) {
+              data.addText('text/plain', text);
+          }
+          await session.onClipboardPaste(data);
+      }
+
+      const CLIPBOARD_POLL_MS = 1000;
+      setInterval(() => {
+          announceLocalClipboard().catch((err) => {
+              console.warn('could not announce the clipboard:', err);
+          });
+      }, CLIPBOARD_POLL_MS);
+      window.addEventListener('focus', () => {
+          announceLocalClipboard().catch(() => {});
+      });
+
+      // Asked for when the guest wants what we last announced - a replay, not a
+      // fresh read: this fires while the guest has focus, and a read then is
+      // refused anyway.
+      async function onForceClipboardUpdate() {
+          const data = new ClipboardData();
+          if (lastLocalText) {
+              data.addText('text/plain', lastLocalText);
+          }
+          await session?.onClipboardPaste(data);
+      }
+
+      async function boot() {
+          if (!node || !vmid) {
+              throw new Error('missing node or vmid');
+          }
+
+          await init();
+          setup('info');
+
+          // Proxmox checks its ACL here, starts the VM's RDP server and mints
+          // the credentials. Nothing is in the URL, so a copied link grants
+          // nothing.
+          const base = `/api2/json/nodes/${node}/qemu/${vmid}`;
+          const res = await fetch(`${base}/rdpproxy`, {
+              method: 'POST',
+              credentials: 'same-origin',
+              headers: { CSRFPreventionToken: '[% token %]' },
+          });
+          if (!res.ok) {
+              const detail = await res.text().catch(() => '');
+              throw new Error(`could not start the console (${res.status}) ${detail}`);
+          }
+          const { data } = await res.json();
+
+          // pveproxy forwards this to pve-rdpproxy, which does the RDCleanPath
+          // handshake against the VM's server: the browser cannot drive TLS
+          // over a websocket, so the gateway does it and hands back the chain.
+          const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
+          const proxy = `${scheme}://${window.location.host}${base}/rdp/${data.token}`;
+
+          session = await new SessionBuilder()
+              .username(data.user)
+              .password(data.password)
+              // Both are required and neither is used: the VM is named by the
+              // path, and pve-rdpproxy routes on that alone.
+              .destination(`vm-${vmid}`)
+              .authToken(data.token)
+              .proxyAddress(proxy)
+              .desktopSize(new DesktopSize(requestedWidth, requestedHeight))
+              .renderCanvas(canvas)
+              .setCursorStyleCallback(setCursorStyle)
+              .setCursorStyleCallbackContext(window)
+              .remoteClipboardChangedCallback(onRemoteClipboardChanged)
+              .forceClipboardUpdateCallback(onForceClipboardUpdate)
+              .connect();
+
+          statusEl.hidden = true;
+          fitCanvas();
+          bindInput();
+          canvas.focus();
+
+          // Resolves when the session ends, however it ends.
+          const info = await session.run();
+          session = null;
+          setStatus(`Console stopped: ${info.reason()}`, true);
+      }
+
+      // The bar retracts like noVNC's: the handle is always reachable, the bar
+      // itself only when asked for.
+      const controlBar = document.getElementById('rdp_control_bar');
+      const powerMenu = document.getElementById('rdp_power_menu');
+      document.getElementById('rdp_control_bar_handle').addEventListener('click', () => {
+          controlBar.classList.toggle('rdp_open');
+          if (!controlBar.classList.contains('rdp_open')) {
+              powerMenu.classList.remove('rdp_open');
+          }
+      });
+
+      // Ctrl+Alt+Del cannot be captured by any web page - the OS takes it
+      // before a browser sees it - so it is injected as scancodes instead.
+      document.getElementById('rdp_ctrl_alt_del').addEventListener('click', () => {
+          apply(
+              DeviceEvent.keyPressed(SCANCODES.ControlLeft),
+              DeviceEvent.keyPressed(SCANCODES.AltLeft),
+              DeviceEvent.keyPressed(SCANCODES.Delete),
+              DeviceEvent.keyReleased(SCANCODES.Delete),
+              DeviceEvent.keyReleased(SCANCODES.AltLeft),
+              DeviceEvent.keyReleased(SCANCODES.ControlLeft),
+          );
+          canvas.focus();
+      });
+
+      // Browsers refuse to start an AudioContext without a user gesture, so
+      // audio cannot simply follow the session. Two ways in: the button, and
+      // the first click into the guest - which is what a user does anyway, and
+      // saves the button being the only way to discover the feature exists.
+      const audioLabel = document.getElementById('rdp_audio_label');
+
+      function showAudioState(state) {
+          const on = state === 'running';
+          audioLabel.innerHTML = on ? 'Audio<br>on' : 'Audio<br>off';
+      }
+
+      async function startAudio() {
+          if (!globalThis.pveRdpAudio || globalThis.pveRdpAudio.state === 'running') {
+              return;
+          }
+          try {
+              showAudioState(await globalThis.pveRdpAudio.resume());
+          } catch (err) {
+              console.warn('could not start guest audio:', err);
+          }
+      }
+
+      document.getElementById('rdp_audio').addEventListener('click', async () => {
+          await startAudio();
+          canvas.focus();
+      });
+
+      canvas.addEventListener('mousedown', startAudio, { once: true });
+
+      // Fullscreen takes the keyboard with it. Without the lock, combinations
+      // the browser and desktop claim - Ctrl+W, Alt+Tab, F11 - never reach the
+      // guest. It is granted only to a fullscreen document, and has to be
+      // released again or it outlives the console.
+      async function enterFullscreen() {
+          await document.documentElement.requestFullscreen();
+          try {
+              await navigator.keyboard?.lock?.();
+          } catch (err) {
+              console.warn('keyboard lock refused:', err);
+          }
+          canvas.focus();
+      }
+
+      document.getElementById('rdp_fullscreen').addEventListener('click', async () => {
+          try {
+              if (document.fullscreenElement) {
+                  await document.exitFullscreen();
+              } else {
+                  await enterFullscreen();
+              }
+          } catch (err) {
+              setStatus(`Fullscreen failed: ${err.message ?? err}`, true);
+          }
+      });
+
+      document.addEventListener('fullscreenchange', () => {
+          if (!document.fullscreenElement) {
+              navigator.keyboard?.unlock?.();
+          }
+          fitCanvas();
+      });
+      window.addEventListener('resize', fitCanvas);
+
+      document.getElementById('rdp_power').addEventListener('click', () => {
+          powerMenu.classList.toggle('rdp_open');
+      });
+
+      // Power goes through the API rather than the guest, so it works when the
+      // guest does not answer - which is the case the buttons exist for.
+      for (const button of powerMenu.querySelectorAll('[data-power]')) {
+          button.addEventListener('click', async () => {
+              const action = button.dataset.power;
+              powerMenu.classList.remove('rdp_open');
+              setStatus(`${button.textContent}…`);
+              try {
+                  const res = await fetch(
+                      `/api2/json/nodes/${node}/qemu/${vmid}/status/${action}`,
+                      {
+                          method: 'POST',
+                          credentials: 'same-origin',
+                          headers: { CSRFPreventionToken: '[% token %]' },
+                      },
+                  );
+                  if (!res.ok) {
+                      throw new Error(`${res.status} ${await res.text().catch(() => '')}`);
+                  }
+                  statusEl.hidden = true;
+              } catch (err) {
+                  setStatus(`${button.textContent} failed: ${err.message ?? err}`, true);
+              }
+          });
+      }
+
+      boot().catch((err) => {
+          console.error(err);
+          setStatus(`RDP console failed: ${err.message ?? err}`, true);
+      });
+    </script>
+  </body>
+</html>
diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js
index 44ffdfe..d707ac0 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -657,6 +657,7 @@ Ext.define('PVE.Utils', {
             virtio: 'VirtIO-GPU',
             'virtio-gl': 'VirGL GPU',
             kyber: 'Kyber',
+            rdp: 'RDP',
             none: Proxmox.Utils.noneText,
         },
 
@@ -1455,6 +1456,8 @@ Ext.define('PVE.Utils', {
                 Proxmox.Utils.openXtermJsViewer(consoleType, vmid, nodename, vmname, cmd);
             } else if (viewer === 'kyber') {
                 PVE.Utils.openKyberViewer(consoleType, vmid, nodename, vmname);
+            } else if (viewer === 'rdp') {
+                PVE.Utils.openRdpViewer(consoleType, vmid, nodename, vmname);
             } else if (viewer === 'vv') {
                 let url = '/nodes/' + nodename + '/spiceshell';
                 let params = {
@@ -1478,22 +1481,27 @@ Ext.define('PVE.Utils', {
         },
 
         defaultViewer: function (consoles, type) {
-            var allowSpice, allowXtermjs, allowKyber;
+            var allowSpice, allowXtermjs, allowKyber, allowRdp;
 
             if (consoles === true) {
                 allowSpice = true;
                 allowXtermjs = true;
                 allowKyber = true;
+                allowRdp = true;
             } else if (typeof consoles === 'object') {
                 allowSpice = consoles.spice;
                 allowXtermjs = !!consoles.xtermjs;
                 allowKyber = !!consoles.kyber;
+                allowRdp = !!consoles.rdp;
             }
             let dv = PVE.UIOptions.options.console || (type === 'kvm' ? 'vv' : 'xtermjs');
-            // A Kyber display serves no VNC, so nothing else can show it.
+            // Neither display serves VNC, so nothing else can show them.
             if (allowKyber) {
                 return 'kyber';
             }
+            if (allowRdp) {
+                return 'rdp';
+            }
             if (dv === 'vv' && !allowSpice) {
                 dv = allowXtermjs ? 'xtermjs' : 'html5';
             } else if (dv === 'xtermjs' && !allowXtermjs) {
@@ -1542,6 +1550,24 @@ Ext.define('PVE.Utils', {
             }
         },
 
+        // The RDP console talks to the VM's own RDP server through
+        // pve-rdpproxy rather than through noVNC, so it gets a window of its
+        // own. Its credentials are fetched by that page from rdpproxy; nothing
+        // is passed in the URL, so a copied link grants nothing on its own.
+        openRdpViewer: function (vmtype, vmid, nodename, vmname) {
+            let url = Ext.Object.toQueryString({
+                console: vmtype,
+                rdp: 1,
+                vmid: vmid,
+                vmname: vmname,
+                node: nodename,
+            });
+            let nw = window.open('?' + url, '_blank', 'innerWidth=1280,innerheight=800');
+            if (nw) {
+                nw.focus();
+            }
+        },
+
         openSpiceViewer: function (url, params) {
             var downloadWithName = function (uri, name) {
                 var link = Ext.DomHelper.append(document.body, {
@@ -1618,6 +1644,7 @@ Ext.define('PVE.Utils', {
                             spice: !!conf.spice,
                             xtermjs: !!conf.serial,
                             kyber: !!conf.kyber,
+                            rdp: !!conf.rdp,
                         };
                         PVE.Utils.openDefaultConsoleWindow(consoles, 'kvm', vmid, nodename, vmname);
                     },
diff --git a/www/manager6/button/ConsoleButton.js b/www/manager6/button/ConsoleButton.js
index 63377e7..3c4406b 100644
--- a/www/manager6/button/ConsoleButton.js
+++ b/www/manager6/button/ConsoleButton.js
@@ -14,6 +14,7 @@ Ext.define('PVE.button.ConsoleButton', {
     enableXtermjs: true,
     // Off unless a VM says otherwise, so other guests show it greyed out.
     enableKyber: false,
+    enableRdp: false,
 
     nodename: undefined,
 
@@ -42,6 +43,13 @@ Ext.define('PVE.button.ConsoleButton', {
         me.down('#kybermenu').setDisabled(!enable);
     },
 
+    setEnableRdp: function (enable) {
+        var me = this;
+
+        me.enableRdp = enable;
+        me.down('#rdpmenu').setDisabled(!enable);
+    },
+
     handler: function () {
         // main, general, handler
         let me = this;
@@ -50,6 +58,7 @@ Ext.define('PVE.button.ConsoleButton', {
                 spice: me.enableSpice,
                 xtermjs: me.enableXtermjs,
                 kyber: me.enableKyber,
+                rdp: me.enableRdp,
             },
             me.consoleType,
             me.vmid,
@@ -106,6 +115,18 @@ Ext.define('PVE.button.ConsoleButton', {
                 view.openConsole(button.type);
             },
         },
+        {
+            xtype: 'menuitem',
+            itemId: 'rdpmenu',
+            text: 'RDP',
+            type: 'rdp',
+            iconCls: 'fa fa-fw fa-desktop',
+            disabled: true,
+            handler: function (button) {
+                let view = this.up('button');
+                view.openConsole(button.type);
+            },
+        },
         {
             text: 'xterm.js',
             itemId: 'xtermjs',
diff --git a/www/manager6/qemu/Config.js b/www/manager6/qemu/Config.js
index bcf54d1..79ab012 100644
--- a/www/manager6/qemu/Config.js
+++ b/www/manager6/qemu/Config.js
@@ -232,10 +232,11 @@ Ext.define('PVE.qemu.Config', {
             disabled: !caps.vms['VM.Console'],
             hidden: template,
             consoleType: 'kvm',
-            // disable spice/xterm/kyber for default action until status api call succeeded
+            // disable spice/xterm/kyber/rdp for default action until status api call succeeded
             enableSpice: false,
             enableXtermjs: false,
             enableKyber: false,
+            enableRdp: false,
             consoleName: vm.name,
             nodename: nodename,
             vmid: vmid,
@@ -460,6 +461,7 @@ Ext.define('PVE.qemu.Config', {
             var spice = false;
             var xtermjs = false;
             var kyber = false;
+            var rdp = false;
             var lock;
             var rec;
 
@@ -481,6 +483,7 @@ Ext.define('PVE.qemu.Config', {
                 // 'kyber', which is also the only case with a controller
                 // behind it.
                 kyber = !!s.data.get('kyber');
+                rdp = !!s.data.get('rdp');
             }
 
             rec = s.data.get('tags');
@@ -503,6 +506,7 @@ Ext.define('PVE.qemu.Config', {
             consoleBtn.setEnableSpice(spice);
             consoleBtn.setEnableXtermJS(xtermjs);
             consoleBtn.setEnableKyber(kyber);
+            consoleBtn.setEnableRdp(rdp);
 
             statusTxt.update({ lock: lock });
 
diff --git a/www/manager6/qemu/DisplayEdit.js b/www/manager6/qemu/DisplayEdit.js
index 79e1ea2..decc016 100644
--- a/www/manager6/qemu/DisplayEdit.js
+++ b/www/manager6/qemu/DisplayEdit.js
@@ -26,8 +26,8 @@ Ext.define('PVE.qemu.DisplayInputPanel', {
                     return '4';
                 } else if (val === 'std' || val.match(/^qxl\d?$/) || val === 'vmware') {
                     return '16';
-                } else if (val.match(/^virtio/) || val === 'kyber') {
-                    // kyber is a virtio-vga underneath, so it takes the same
+                } else if (val.match(/^virtio/) || val === 'kyber' || val === 'rdp') {
+                    // Both are a virtio-vga underneath, so they take the same
                     // memory as one.
                     return '256';
                 } else if (get('matchNonGUIOption')) {
-- 
2.55.0




  parent reply	other threads:[~2026-08-25 11:36 UTC|newest]

Thread overview: 11+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-08-25 11:34 [RFC pve-http-server/qemu-server/pve-manager/pve-{qemu-kyber,kyberproxy, kyber-web,qemu-rdp,rdpproxy,rdp-web} 00/13] add rdp && kyber consoles for qemu over D-Bus display Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-http-server 01/13] anyevent : proxy a path prefix to a local http proxy Alexandre Derumier
2026-08-25 11:34 ` [RFC qemu-server 02/13] add D-Bus display support Alexandre Derumier
2026-08-25 11:34 ` [RFC qemu-server 03/13] add kyber display Alexandre Derumier
2026-08-25 11:34 ` [RFC qemu-server 04/13] add rdp display Alexandre Derumier
2026-08-25 11:34 ` [RFC qemu-server 05/13] add experimental kyber-gl display Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-manager 06/13] ui: add kyber console Alexandre Derumier
2026-08-25 11:34 ` Alexandre Derumier [this message]
2026-08-25 11:34 ` [RFC pve-kyber-web 10/13] add pve-kyber-web: console's webassembly client Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-qemu-rdp 11/13] Add pve-qemu-rdp: an RDP server for the console Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-rdpproxy 12/13] Add pve-rdpproxy Alexandre Derumier

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260825113442.947620-8-alexandre.derumier@groupe-cyllene.com \
    --to=alexandre.derumier@groupe-cyllene.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal