From: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
To: pve-devel@lists.proxmox.com
Subject: [RFC pve-manager 06/13] ui: add kyber console
Date: Tue, 25 Aug 2026 13:34:31 +0200 [thread overview]
Message-ID: <20260825113442.947620-7-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/HTTPServer.pm | 4 +-
PVE/Service/pveproxy.pm | 53 +++-
kyber-web/Makefile | 22 ++
kyber-web/index.html.tpl | 438 +++++++++++++++++++++++++++
www/manager6/Utils.js | 30 +-
www/manager6/button/ConsoleButton.js | 22 ++
www/manager6/qemu/AudioEdit.js | 3 +
www/manager6/qemu/Config.js | 9 +-
www/manager6/qemu/DisplayEdit.js | 4 +-
10 files changed, 581 insertions(+), 6 deletions(-)
create mode 100644 kyber-web/Makefile
create mode 100644 kyber-web/index.html.tpl
diff --git a/Makefile b/Makefile
index 6c8b82e..b7dfc7f 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 network-hooks test templates
+SUBDIRS = aplinfo PVE bin www services configs kyber-web network-hooks test templates
all: $(SUBDIRS)
set -e && for i in $(SUBDIRS); do $(MAKE) -C $$i; done
diff --git a/PVE/HTTPServer.pm b/PVE/HTTPServer.pm
index 34403e8..c3fef48 100755
--- a/PVE/HTTPServer.pm
+++ b/PVE/HTTPServer.pm
@@ -120,11 +120,13 @@ sub auth_handler {
$isUpload = 1;
}
+ my $is_kyber_console = $rel_uri =~ m|^/nodes/[^/]+/qemu/\d+/kyber/|;
+
# Skip CSRF check for file upload (difficult to pass CSRF header with native html forms).
# Also skip the check with API tokens, as one of the design goals of API tokens was to
# provide stateless API access without requiring round-trips to get such CSRF tokens.
# CSRF-prevention also does not make much sense outside of the browser context.
- if ($method ne 'GET' && !($api_token || $isUpload)) {
+ if ($method ne 'GET' && !($api_token || $isUpload || $is_kyber_console)) {
my $euid = $>;
PVE::AccessControl::verify_csrf_prevention_token($username, $token) if $euid != 0;
}
diff --git a/PVE/Service/pveproxy.pm b/PVE/Service/pveproxy.pm
index dfdd014..bcc1353 100755
--- a/PVE/Service/pveproxy.pm
+++ b/PVE/Service/pveproxy.pm
@@ -20,6 +20,10 @@ use PVE::Cluster;
use PVE::Daemon;
use PVE::DataCenterConfig;
use PVE::HTTPServer;
+use HTTP::Status qw(HTTP_BAD_REQUEST);
+use PVE::Exception;
+use PVE::INotify;
+use PVE::RPCEnvironment;
use PVE::SafeSyslog;
use PVE::pvecfg;
use PVE::Tools;
@@ -53,6 +57,7 @@ my $basedirs = {
fontlogos => '/usr/share/fonts-font-logos',
i18n => '/usr/share/pve-i18n',
manager => '/usr/share/pve-manager',
+ kyber => '/usr/share/pve-kyber-web',
novnc => '/usr/share/novnc-pve',
yew_mobile => '/usr/share/pve-yew-mobile-gui',
i18n_yew => '/usr/share/pve-yew-mobile-i18n',
@@ -60,6 +65,41 @@ my $basedirs = {
xtermjs => '/usr/share/pve-xtermjs',
};
+my $kyber_console_prefix = qr!^/api2/json/nodes/([^/]+)/qemu/(\d+)/kyber(/.*)$!;
+my $kyber_proxy_socket = '/run/pvekyberproxy.sock';
+
+my sub check_console_access {
+ my ($auth, $console_type, $node, $vmid) = @_;
+
+ PVE::Exception::raise(
+ "the $console_type console for VM $vmid must be opened on node '$node'\n",
+ code => HTTP_BAD_REQUEST,
+ ) if $node ne PVE::INotify::nodename();
+
+ my $rpcenv = PVE::RPCEnvironment::get();
+ $rpcenv->check($auth->{userid}, "/vms/$vmid", ['VM.Console']);
+
+ return;
+}
+
+sub console_proxy {
+ my ($server, $reqstate, $auth, $method, $path) = @_;
+
+ if (my ($node, $vmid, $rest) = $path =~ $kyber_console_prefix) {
+ check_console_access($auth, 'Kyber', $node, $vmid);
+
+ my $target = $path;
+
+ if (my $query = $reqstate->{request}->url->query()) {
+ $target .= "?$query";
+ }
+
+ return { socket => $kyber_proxy_socket, path => $target, tls => 0 };
+ }
+
+ return undef;
+}
+
sub init {
my ($self) = @_;
@@ -77,6 +117,7 @@ sub init {
my $dirs = {};
+ add_dirs($dirs, '/kyber/' => "$basedirs->{kyber}/");
add_dirs($dirs, '/novnc/' => "$basedirs->{novnc}/");
add_dirs($dirs, '/pve-docs/' => "$basedirs->{docs}/");
add_dirs($dirs, '/pve-docs/api-viewer/extjs/' => "$basedirs->{extjs}/");
@@ -134,6 +175,7 @@ sub init {
},
},
dirs => $dirs,
+ local_http_proxy_handler => \&console_proxy,
};
if (defined($proxyconf->{DHPARAMS})) {
@@ -247,6 +289,7 @@ sub get_index {
(is_phone($r->header('User-Agent')) && (!defined($args->{mobile}) || $args->{mobile}))
|| $args->{mobile};
+ my $kyber = defined($args->{console}) && $args->{kyber};
my $novnc = defined($args->{console}) && $args->{novnc};
my $xtermjs = defined($args->{console}) && $args->{xtermjs};
@@ -291,7 +334,9 @@ sub get_index {
# by default, load the normal index
my $dir = $basedirs->{manager};
- if ($novnc) {
+ if ($kyber) {
+ $dir = $basedirs->{kyber};
+ } elsif ($novnc) {
$dir = $basedirs->{novnc};
} elsif ($xtermjs) {
$dir = $basedirs->{xtermjs};
@@ -305,6 +350,12 @@ sub get_index {
$template->process("$dir/index.html.tpl", $vars, \$page) || die $template->error(), "\n";
my $headers = HTTP::Headers->new(Content_Type => "text/html; charset=utf-8");
+
+ if ($kyber) {
+ $headers->header('Cross-Origin-Opener-Policy' => 'same-origin');
+ $headers->header('Cross-Origin-Embedder-Policy' => 'require-corp');
+ }
+
my $resp = HTTP::Response->new(200, "OK", $headers, $page);
return $resp;
diff --git a/kyber-web/Makefile b/kyber-web/Makefile
new file mode 100644
index 0000000..28b07a0
--- /dev/null
+++ b/kyber-web/Makefile
@@ -0,0 +1,22 @@
+include ../defines.mk
+
+KYBERDIR = $(DESTDIR)/usr/share/pve-kyber-web
+
+all:
+
+.PHONY: install
+install: index.html.tpl
+ install -d $(KYBERDIR)
+ install -m 0644 index.html.tpl $(KYBERDIR)/index.html.tpl
+# The client itself - kyclient_wasm.js, kyclient_wasm_bg.wasm and the audio
+# worklet beside them - is built from kyber-web and shipped by its own package
+# rather than vendored here. It is AGPL-3.0-or-later, same as pve-manager, but
+# it is a separate upstream with its own release cadence and a wasm toolchain
+# that has no business in this build.
+#
+# The page drives that SDK directly and needs nothing else from it: no bundle,
+# no stylesheets, and none of the demo client's UI.
+
+.PHONY: clean distclean
+distclean: clean
+clean:
diff --git a/kyber-web/index.html.tpl b/kyber-web/index.html.tpl
new file mode 100644
index 0000000..31f5e76
--- /dev/null
+++ b/kyber-web/index.html.tpl
@@ -0,0 +1,438 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>Kyber console</title>
+ <!--
+ Relative URLs resolve into the client's own directory rather than against
+ this page, which is served from '/'. The SDK loads a few assets by bare
+ name - the audio worklet among them - and without this they would be
+ looked for at the document root and 404 there.
+ -->
+ <base href="/kyber/">
+ <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%;
+ }
+ #container 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. */
+ #kyber_control_bar_anchor {
+ position: fixed;
+ top: 0;
+ left: 0;
+ height: 100%;
+ z-index: 10;
+ transition: 0.5s ease-in-out;
+ }
+ #kyber_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;
+ }
+ #kyber_control_bar.kyber_open { left: 0; }
+ #kyber_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);
+ }
+ #kyber_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;
+ }
+ #kyber_control_bar.kyber_open #kyber_control_bar_handle:after {
+ transform: translateX(1px) rotate(180deg);
+ }
+ .kyber_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;
+ }
+ .kyber_button:hover { background-color: rgba(255, 255, 255, 0.1); }
+ .kyber_button img { width: 24px; height: 24px; display: block; margin: auto; }
+ #kyber_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);
+ }
+ #kyber_power_menu.kyber_open { display: flex; flex-direction: column; gap: 4px; }
+ #kyber_power_menu .kyber_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="kyber_control_bar_anchor">
+ <div id="kyber_control_bar">
+ <div id="kyber_control_bar_handle"></div>
+ <button class="kyber_button" id="kyber_ctrl_alt_del" title="Send Ctrl-Alt-Del">
+ <img src="/novnc/app/images/esc.svg" alt=""><span>C-A-D</span>
+ </button>
+ <button class="kyber_button" id="kyber_fullscreen" title="Fullscreen">
+ <img src="/novnc/app/images/fullscreen.svg" alt="">
+ </button>
+ <button class="kyber_button" id="kyber_power" title="Power">
+ <img src="/novnc/app/images/power.svg" alt="">
+ </button>
+ <div id="kyber_power_menu">
+ <button class="kyber_button" data-power="start">Start</button>
+ <button class="kyber_button" data-power="shutdown">Shutdown</button>
+ <button class="kyber_button" data-power="reboot">Reboot</button>
+ <button class="kyber_button" data-power="reset">Reset</button>
+ <button class="kyber_button" data-power="stop">Stop</button>
+ </div>
+ </div>
+ </div>
+ <div id="container"></div>
+ <div id="status">Connecting…</div>
+
+ <script type="module">
+ import init, { AuthCredentials, Client, StreamingConfigBuilder }
+ from '/kyber/kyclient_wasm.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}) - Kyber console`
+ : `VM ${vmid} - Kyber console`;
+
+ const statusEl = document.getElementById('status');
+ const container = document.getElementById('container');
+
+ const setStatus = (text, failed) => {
+ statusEl.textContent = text;
+ statusEl.classList.toggle('failed', !!failed);
+ statusEl.hidden = false;
+ };
+
+ let client = null;
+ let streamingConfig = null;
+
+ function createCanvas() {
+ const canvas = document.createElement('canvas');
+ canvas.id = 'canvas';
+ canvas.width = 1024;
+ canvas.height = 768;
+ // Without this every keystroke goes to the document, not the guest.
+ canvas.tabIndex = 0;
+ container.appendChild(canvas);
+ canvas.focus();
+ return canvas;
+ }
+
+ function deleteCanvas() {
+ document.getElementById('canvas')?.remove();
+ }
+
+ // The canvas cannot be reused: its control was transferred to an
+ // offscreen canvas in the worker, one way.
+ async function restartStreaming() {
+ deleteCanvas();
+ createCanvas();
+ await client.start_streaming('canvas', streamingConfig.clone());
+ }
+
+ // A guest resize shows up as the host video process stopping; the stream
+ // must be rebuilt, since the decoder cannot change codec configuration
+ // mid-stream. Rate limited against a host stuck in a start/die loop.
+ // The stream's own geometry, which is what the canvas is sized from.
+ let videoSize = null;
+
+ // Native size in a window, scaled up to fill the screen in fullscreen -
+ // aspect ratio kept, so a 4:3 guest letterboxes rather than distorts.
+ function fitCanvas() {
+ const canvas = document.getElementById('canvas');
+ if (!canvas || !videoSize) {
+ return;
+ }
+ let { width, height } = videoSize;
+ if (document.fullscreenElement) {
+ const scale = Math.min(
+ window.innerWidth / width,
+ window.innerHeight / height,
+ );
+ width = Math.floor(width * scale);
+ height = Math.floor(height * scale);
+ }
+ canvas.style.width = `${width}px`;
+ canvas.style.height = `${height}px`;
+ }
+
+ const VIDEO_RESTART_MIN_INTERVAL_MS = 2000;
+ let lastVideoRestart = 0;
+
+ async function onClientEvent(name, event) {
+ switch (name) {
+ case 'streamer_pipeline_event':
+ // Not 'streaming_stopped': the SDK emits that only for a
+ // stop this client asked for. stop_streaming() comes back as
+ // one below, which rebuilds canvas and stream.
+ if (event.type === 'video' && event.stage === 'process'
+ && event.event === 'stopped' && client !== null) {
+ const now = Date.now();
+ if (now - lastVideoRestart < VIDEO_RESTART_MIN_INTERVAL_MS) {
+ console.warn('Host video stopped again too soon; not restarting');
+ break;
+ }
+ lastVideoRestart = now;
+ setStatus('Display changed, restarting…');
+ client.stop_streaming();
+ }
+ break;
+ case 'controlplane_connected':
+ statusEl.hidden = true;
+ await client.start_streaming('canvas', streamingConfig.clone());
+ break;
+ case 'canvas_resized':
+ videoSize = { width: event.width, height: event.height };
+ fitCanvas();
+ break;
+ case 'streaming_stopped':
+ // The session is still up, so pick the new stream up.
+ await restartStreaming();
+ statusEl.hidden = true;
+ break;
+ case 'reconnecting':
+ setStatus(`Reconnecting (${event.attempt}/${event.maxAttempts})…`);
+ break;
+ case 'reconnected':
+ statusEl.hidden = true;
+ if (event.shouldRestartStreaming) {
+ await restartStreaming();
+ }
+ break;
+ case 'reconnection_failed':
+ setStatus('Connection lost. Close this window and open the console again.', true);
+ deleteCanvas();
+ break;
+ case 'stopped':
+ setStatus(`Console stopped: ${event.reason()}`, true);
+ deleteCanvas();
+ break;
+ }
+ }
+
+ async function boot() {
+ if (!node || !vmid) {
+ throw new Error('missing node or vmid');
+ }
+
+ await init();
+
+ // Proxmox checks its ACL here and mints the token. Nothing is in the
+ // URL, so a copied link grants nothing.
+ const base = `/api2/json/nodes/${node}/qemu/${vmid}`;
+ const res = await fetch(`${base}/kyberproxy`, {
+ 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();
+
+ // Control plane only: pveproxy forwards this prefix to
+ // pvekyberproxy and on to the VM's controller. The data plane goes
+ // straight to the UDP port start_mux names - pveproxy speaks no QUIC.
+ const basePath = `${base}/kyber`;
+
+ const builder = new StreamingConfigBuilder();
+ builder.protocol = 'kymux';
+ builder.codec = 'h264';
+ builder.bitrate = 20000000;
+ builder.display_index = 0;
+ builder.inputs = true;
+ builder.clipboard = true;
+ // Carried when the VM has an audio device on a 'dbus' audiodev.
+ // The controller asks the AV service, which says no when there is
+ // none, and the client simply gets no audio stream.
+ builder.audio = true;
+ // Needs nvenc; this encoder is libx264.
+ builder.intra_refresh = false;
+ streamingConfig = builder.build();
+
+ createCanvas();
+
+ client = await Client.new(
+ window.location.hostname,
+ window.location.port || '443',
+ AuthCredentials.jwt(data.ticket),
+ onClientEvent,
+ false, // metrics
+ 'none', // metrics mode
+ null, // live metrics callback
+ true, // reconnect on its own
+ basePath,
+ );
+
+ client.connect();
+ }
+
+ // The bar retracts like noVNC's: the handle is always reachable, the bar
+ // itself only when asked for.
+ const controlBar = document.getElementById('kyber_control_bar');
+ const powerMenu = document.getElementById('kyber_power_menu');
+ document.getElementById('kyber_control_bar_handle').addEventListener('click', () => {
+ controlBar.classList.toggle('kyber_open');
+ if (!controlBar.classList.contains('kyber_open')) {
+ powerMenu.classList.remove('kyber_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('kyber_ctrl_alt_del').addEventListener('click', () => {
+ try {
+ client?.send_ctrl_alt_del();
+ } catch (err) {
+ setStatus(`Could not send Ctrl-Alt-Del: ${err.message ?? err}`, 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() {
+ const el = document.documentElement;
+ await el.requestFullscreen();
+ try {
+ await navigator.keyboard?.lock?.();
+ } catch (err) {
+ console.warn('keyboard lock refused:', err);
+ }
+ document.getElementById('canvas')?.focus();
+ }
+
+ document.getElementById('kyber_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('kyber_power').addEventListener('click', () => {
+ powerMenu.classList.toggle('kyber_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('kyber_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(`Kyber console failed: ${err.message ?? err}`, true);
+ });
+ </script>
+ </body>
+</html>
diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js
index c86a00c..44ffdfe 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -656,6 +656,7 @@ Ext.define('PVE.Utils', {
serial3: gettext('Serial terminal') + ' 3',
virtio: 'VirtIO-GPU',
'virtio-gl': 'VirGL GPU',
+ kyber: 'Kyber',
none: Proxmox.Utils.noneText,
},
@@ -1452,6 +1453,8 @@ Ext.define('PVE.Utils', {
PVE.Utils.openVNCViewer(consoleType, vmid, nodename, vmname, cmd);
} else if (viewer === 'xtermjs') {
Proxmox.Utils.openXtermJsViewer(consoleType, vmid, nodename, vmname, cmd);
+ } else if (viewer === 'kyber') {
+ PVE.Utils.openKyberViewer(consoleType, vmid, nodename, vmname);
} else if (viewer === 'vv') {
let url = '/nodes/' + nodename + '/spiceshell';
let params = {
@@ -1475,16 +1478,22 @@ Ext.define('PVE.Utils', {
},
defaultViewer: function (consoles, type) {
- var allowSpice, allowXtermjs;
+ var allowSpice, allowXtermjs, allowKyber;
if (consoles === true) {
allowSpice = true;
allowXtermjs = true;
+ allowKyber = true;
} else if (typeof consoles === 'object') {
allowSpice = consoles.spice;
allowXtermjs = !!consoles.xtermjs;
+ allowKyber = !!consoles.kyber;
}
let dv = PVE.UIOptions.options.console || (type === 'kvm' ? 'vv' : 'xtermjs');
+ // A Kyber display serves no VNC, so nothing else can show it.
+ if (allowKyber) {
+ return 'kyber';
+ }
if (dv === 'vv' && !allowSpice) {
dv = allowXtermjs ? 'xtermjs' : 'html5';
} else if (dv === 'xtermjs' && !allowXtermjs) {
@@ -1515,6 +1524,24 @@ Ext.define('PVE.Utils', {
}
},
+ // The Kyber console streams the display over its own controller rather
+ // than through noVNC, so it gets a window of its own. Everything it
+ // needs is fetched by that page from kyberproxy; nothing is passed in
+ // the URL, so a copied link grants nothing on its own.
+ openKyberViewer: function (vmtype, vmid, nodename, vmname) {
+ let url = Ext.Object.toQueryString({
+ console: vmtype,
+ kyber: 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, {
@@ -1590,6 +1617,7 @@ Ext.define('PVE.Utils', {
let consoles = {
spice: !!conf.spice,
xtermjs: !!conf.serial,
+ kyber: !!conf.kyber,
};
PVE.Utils.openDefaultConsoleWindow(consoles, 'kvm', vmid, nodename, vmname);
},
diff --git a/www/manager6/button/ConsoleButton.js b/www/manager6/button/ConsoleButton.js
index d64f280..63377e7 100644
--- a/www/manager6/button/ConsoleButton.js
+++ b/www/manager6/button/ConsoleButton.js
@@ -12,6 +12,8 @@ Ext.define('PVE.button.ConsoleButton', {
enableSpice: true,
enableXtermjs: true,
+ // Off unless a VM says otherwise, so other guests show it greyed out.
+ enableKyber: false,
nodename: undefined,
@@ -33,6 +35,13 @@ Ext.define('PVE.button.ConsoleButton', {
me.down('#xtermjs').setDisabled(!enable);
},
+ setEnableKyber: function (enable) {
+ var me = this;
+
+ me.enableKyber = enable;
+ me.down('#kybermenu').setDisabled(!enable);
+ },
+
handler: function () {
// main, general, handler
let me = this;
@@ -40,6 +49,7 @@ Ext.define('PVE.button.ConsoleButton', {
{
spice: me.enableSpice,
xtermjs: me.enableXtermjs,
+ kyber: me.enableKyber,
},
me.consoleType,
me.vmid,
@@ -84,6 +94,18 @@ Ext.define('PVE.button.ConsoleButton', {
view.openConsole(button.type);
},
},
+ {
+ xtype: 'menuitem',
+ itemId: 'kybermenu',
+ text: 'Kyber',
+ type: 'kyber',
+ 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/AudioEdit.js b/www/manager6/qemu/AudioEdit.js
index ba588ed..49f03e1 100644
--- a/www/manager6/qemu/AudioEdit.js
+++ b/www/manager6/qemu/AudioEdit.js
@@ -35,6 +35,9 @@ Ext.define('PVE.qemu.AudioInputPanel', {
fieldLabel: gettext('Backend Driver'),
comboItems: [
['spice', 'SPICE'],
+ // Puts the guest's audio on the same D-Bus as its display,
+ // which is where the Kyber and RDP consoles read it.
+ ['dbus', gettext('D-Bus (Kyber/RDP console)')],
['none', `${Proxmox.Utils.NoneText} (${gettext('Dummy Device')})`],
],
},
diff --git a/www/manager6/qemu/Config.js b/www/manager6/qemu/Config.js
index 842d35d..bcf54d1 100644
--- a/www/manager6/qemu/Config.js
+++ b/www/manager6/qemu/Config.js
@@ -232,9 +232,10 @@ Ext.define('PVE.qemu.Config', {
disabled: !caps.vms['VM.Console'],
hidden: template,
consoleType: 'kvm',
- // disable spice/xterm for default action until status api call succeeded
+ // disable spice/xterm/kyber for default action until status api call succeeded
enableSpice: false,
enableXtermjs: false,
+ enableKyber: false,
consoleName: vm.name,
nodename: nodename,
vmid: vmid,
@@ -458,6 +459,7 @@ Ext.define('PVE.qemu.Config', {
var qmpstatus;
var spice = false;
var xtermjs = false;
+ var kyber = false;
var lock;
var rec;
@@ -475,6 +477,10 @@ Ext.define('PVE.qemu.Config', {
spice = !!s.data.get('spice');
xtermjs = !!s.data.get('serial');
+ // Reported by the status API only for a display set to
+ // 'kyber', which is also the only case with a controller
+ // behind it.
+ kyber = !!s.data.get('kyber');
}
rec = s.data.get('tags');
@@ -496,6 +502,7 @@ Ext.define('PVE.qemu.Config', {
consoleBtn.setEnableSpice(spice);
consoleBtn.setEnableXtermJS(xtermjs);
+ consoleBtn.setEnableKyber(kyber);
statusTxt.update({ lock: lock });
diff --git a/www/manager6/qemu/DisplayEdit.js b/www/manager6/qemu/DisplayEdit.js
index 3f583ad..79e1ea2 100644
--- a/www/manager6/qemu/DisplayEdit.js
+++ b/www/manager6/qemu/DisplayEdit.js
@@ -26,7 +26,9 @@ Ext.define('PVE.qemu.DisplayInputPanel', {
return '4';
} else if (val === 'std' || val.match(/^qxl\d?$/) || val === 'vmware') {
return '16';
- } else if (val.match(/^virtio/)) {
+ } else if (val.match(/^virtio/) || val === 'kyber') {
+ // kyber is a virtio-vga underneath, so it takes the same
+ // memory as one.
return '256';
} else if (get('matchNonGUIOption')) {
return 'N/A';
--
2.55.0
next prev 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 ` Alexandre Derumier [this message]
2026-08-25 11:34 ` [RFC pve-manager 07/13] ui: add rdp console Alexandre Derumier
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-7-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