feat(multimon): A1 receive plumbing for multi-monitor (SPICE)

Client library (Client.js): new onmultimonlayout callback + a multimon-layout
layerPropertyHandler (mirrors multi-touch), and sendSize extended to carry the
optional per-monitor x_position/top_offset.

rustguac: SpiceParams.secondary_monitors + a secondary-monitors connect arg so
guacd advertises the allowed monitor count; CreateSessionRequest.max_monitors
(secondary = max-1) wired through both SPICE branches.

client.html: onargv reads the secondary-monitors count and onmultimonlayout
parses the layout JSON (logging for now; per-monitor windows are A2).

Verified on the canary vs VMID 300: client logs 'server allows 2 monitors' and
receives a multimon-layout. The layout is request-driven (guacd activates a
second guest head only when the client sends a size for monitor 1), so the
second monitor appears in A2.
This commit is contained in:
Dave Kempe
2026-07-27 11:10:52 +10:00
parent 3fb250dc99
commit c526c2f00d
5 changed files with 80 additions and 3 deletions
+5
View File
@@ -2513,6 +2513,9 @@ pub async fn ab_connect_entry(
proxmox_token_id: ab_entry.proxmox_token_id,
proxmox_token_secret: ab_entry.proxmox_token_secret,
proxmox_verify_tls: ab_entry.proxmox_verify_tls,
// Entry-stored monitor count comes with the connections UI in a later
// phase; ad-hoc/API sessions set max_monitors directly on the request.
max_monitors: None,
};
let proxies = trusted.map(|Extension(t)| t.0).unwrap_or_default();
@@ -4185,6 +4188,7 @@ pub async fn quick_connect(
proxmox_token_id: None,
proxmox_token_secret: None,
proxmox_verify_tls: None,
max_monitors: None,
};
tracing::info!(
@@ -4302,6 +4306,7 @@ pub async fn quick_connect(
proxmox_token_id: None,
proxmox_token_secret: None,
proxmox_verify_tls: None,
max_monitors: None,
};
match manager.create_session(create_req, admin_name).await {
+5
View File
@@ -103,6 +103,10 @@ pub struct SpiceParams {
pub disable_copy: bool,
pub disable_paste: bool,
pub enable_audio: bool,
/// Number of secondary monitors to allow (beyond the primary). guacd
/// advertises this to the client as `secondary-monitors` so a multi-monitor
/// client can offer the right number of monitor windows. 0 = single monitor.
pub secondary_monitors: u32,
}
/// RDP connection parameters to pass to guacd.
@@ -389,6 +393,7 @@ pub async fn connect_and_handshake(
"enable-audio" => if p.enable_audio { "true" } else { "false" }.into(),
"disable-copy" => if p.disable_copy { "true" } else { "false" }.into(),
"disable-paste" => if p.disable_paste { "true" } else { "false" }.into(),
"secondary-monitors" => p.secondary_monitors.to_string(),
_ => {
tracing::debug!("Unknown guacd SPICE parameter '{}', sending empty", name);
String::new()
+8
View File
@@ -176,6 +176,10 @@ pub struct CreateSessionRequest {
/// Verify the PVE API server's TLS certificate (default false; PVE ships a
/// self-signed cluster cert). Also controls SPICE-proxy cert verification.
pub proxmox_verify_tls: Option<bool>,
/// Total number of monitors to offer (SPICE/Proxmox multi-monitor). guacd
/// is told `secondary-monitors = max_monitors - 1`, which it advertises to
/// the client. Default 1 (single monitor).
pub max_monitors: Option<u32>,
}
/// Session status in the lifecycle.
@@ -984,6 +988,8 @@ impl SessionManager {
disable_copy: req.disable_copy.unwrap_or(false),
disable_paste: req.disable_paste.unwrap_or(false),
enable_audio: false,
// Secondary monitors = total requested minus the primary.
secondary_monitors: req.max_monitors.unwrap_or(1).saturating_sub(1),
};
tracing::info!(
session_id = %session_id,
@@ -1132,6 +1138,8 @@ impl SessionManager {
disable_copy: req.disable_copy.unwrap_or(false),
disable_paste: req.disable_paste.unwrap_or(false),
enable_audio: false,
// Secondary monitors = total requested minus the primary.
secondary_monitors: req.max_monitors.unwrap_or(1).saturating_sub(1),
};
// `cfg.host` is an opaque PVE routing token, used as the display
+27
View File
@@ -601,6 +601,33 @@
toggleTab.addEventListener('click', function() { toggleClipboardPanel(); });
document.body.appendChild(toggleTab);
// ── Multi-monitor (A1: receive + log; per-monitor windows are A2) ──
// Max monitors the server allows, delivered via a "secondary-monitors"
// argv stream. secondary-monitors = N means N+1 monitors total.
var maxMonitors = 1;
client.onargv = function(stream, mimetype, name) {
var reader = new Guacamole.StringReader(stream);
var buf = '';
reader.ontext = function(t) { buf += t; };
reader.onend = function() {
if (name !== 'secondary-monitors') return;
var secondary = parseInt(buf, 10);
if (!isNaN(secondary)) maxMonitors = secondary + 1;
console.log('[rustguac] multimon: server allows ' + maxMonitors
+ ' monitor(s) (secondary-monitors=' + buf.trim() + ')');
};
};
// Current monitor layout (each monitor's rect within the combined
// framebuffer), JSON via a "multimon-layout" set param.
client.onmultimonlayout = function(layer, value) {
try {
var layout = JSON.parse(value);
console.log('[rustguac] multimon-layout:', layout);
} catch (e) {
console.warn('[rustguac] multimon-layout parse error:', e, value);
}
};
// ── Receive clipboard from remote ──
client.onclipboard = function(stream, mimetype) {
if (mimetype !== 'text/plain') {
+35 -3
View File
@@ -343,13 +343,20 @@ Guacamole.Client = function(tunnel) {
* @param {!number} height
* The height of the screen.
*/
this.sendSize = function(width, height) {
this.sendSize = function(width, height, x_position, top_offset) {
// Do not send requests if not connected
if (!isConnected())
return;
tunnel.sendMessage("size", width, height);
// Include the optional per-monitor position (x_position, top_offset)
// when provided, so multi-monitor clients can declare where each
// monitor sits within the combined framebuffer. Single-monitor
// callers omit them and the server treats the monitor as primary.
if (x_position !== undefined && top_offset !== undefined)
tunnel.sendMessage("size", width, height, x_position, top_offset);
else
tunnel.sendMessage("size", width, height);
};
@@ -860,9 +867,26 @@ Guacamole.Client = function(tunnel) {
*/
this.onargv = null;
/**
* Fired when the server sends an updated multi-monitor layout via the
* "multimon-layout" parameter on a visible layer. The value is a JSON
* string mapping monitor index to its rectangle within the combined
* framebuffer, e.g. {"0":{"left":0,"top":0,"width":1920,"height":1080}}.
* Used by multi-monitor clients to split the framebuffer into per-monitor
* windows.
*
* @event
* @param {!Guacamole.Display.VisibleLayer} layer
* The layer the layout applies to (typically the default layer).
*
* @param {!string} value
* The multimon-layout JSON string.
*/
this.onmultimonlayout = null;
/**
* Fired when the clipboard of the remote client is changing.
*
*
* @event
* @param {!Guacamole.InputStream} stream
* The stream that will receive clipboard data from the server.
@@ -1045,6 +1069,14 @@ Guacamole.Client = function(tunnel) {
if (guac_client.onmultitouch && layer instanceof Guacamole.Display.VisibleLayer)
guac_client.onmultitouch(layer, parseInt(value));
},
"multimon-layout" : function layerMultimonLayout(layer, value) {
// Expose the multi-monitor layout only for true visible layers
if (guac_client.onmultimonlayout && layer instanceof Guacamole.Display.VisibleLayer)
guac_client.onmultimonlayout(layer, value);
}
};