From c526c2f00d985f78b00c4ccb9b86ceaa4b59ae5b Mon Sep 17 00:00:00 2001 From: Dave Kempe Date: Mon, 27 Jul 2026 11:10:52 +1000 Subject: [PATCH] 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. --- src/api.rs | 5 +++++ src/guacd.rs | 5 +++++ src/session.rs | 8 ++++++++ static/client.html | 27 +++++++++++++++++++++++++++ static/guac/Client.js | 38 +++++++++++++++++++++++++++++++++++--- 5 files changed, 80 insertions(+), 3 deletions(-) diff --git a/src/api.rs b/src/api.rs index 0ae2301..0480617 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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 { diff --git a/src/guacd.rs b/src/guacd.rs index 2dd7398..28654bb 100644 --- a/src/guacd.rs +++ b/src/guacd.rs @@ -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() diff --git a/src/session.rs b/src/session.rs index 2fb689a..3356ea5 100644 --- a/src/session.rs +++ b/src/session.rs @@ -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, + /// 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, } /// 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 diff --git a/static/client.html b/static/client.html index 7e8f92c..6ec4a8a 100644 --- a/static/client.html +++ b/static/client.html @@ -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') { diff --git a/static/guac/Client.js b/static/guac/Client.js index 9f2080d..ce7da70 100644 --- a/static/guac/Client.js +++ b/static/guac/Client.js @@ -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); + } };