all lists on 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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal