mirror of
https://github.com/sol1/rustguac.git
synced 2026-09-10 01:26:06 +00:00
feat(multimon): A3 per-monitor windows + entry storage for monitor count
client.html: per-monitor tick-boxes (contiguity-enforced) that open each secondary monitor in its own window, blitting that monitor's region of the combined framebuffer (rAF) and forwarding mouse (combined coords) + keyboard; untick/close releases the monitor. Requests floor to a sane size if a popup reports zero dimensions. Entry storage: AddressBookEntry/EntryInfo gain max_monitors, ab_connect_entry passes it through, and the Proxmox editor gets a Monitors field. Lets a saved Proxmox/SPICE entry offer multiple monitors (previously only the ad-hoc API path could). KNOWN ISSUE: enabling multi-monitor (secondary-monitors>0) intermittently drops the SPICE connection ~2-6s after connect, independent of the request. Under investigation; single-monitor is unaffected. A3 end-to-end (tick -> activate -> render) still needs a real-browser verification (Playwright can't size popups).
This commit is contained in:
+1
-3
@@ -2513,9 +2513,7 @@ 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,
|
||||
max_monitors: ab_entry.max_monitors,
|
||||
};
|
||||
|
||||
let proxies = trusted.map(|Extension(t)| t.0).unwrap_or_default();
|
||||
|
||||
@@ -137,6 +137,7 @@ pub async fn cmd_import_guacamole(
|
||||
proxmox_token_id: None,
|
||||
proxmox_token_secret: None,
|
||||
proxmox_verify_tls: None,
|
||||
max_monitors: None,
|
||||
};
|
||||
|
||||
// Place the entry into a subfolder matching its parent group path.
|
||||
@@ -742,6 +743,7 @@ mod tests {
|
||||
proxmox_token_id: None,
|
||||
proxmox_token_secret: None,
|
||||
proxmox_verify_tls: None,
|
||||
max_monitors: None,
|
||||
};
|
||||
let mut entries = vec![
|
||||
(("".into(), "web".into()), entry()),
|
||||
|
||||
@@ -257,6 +257,11 @@ pub struct AddressBookEntry {
|
||||
/// ships a self-signed cluster cert).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub proxmox_verify_tls: Option<bool>,
|
||||
/// Total number of monitors to offer for SPICE/Proxmox multi-monitor
|
||||
/// (default 1 = single monitor). guacd is told `secondary-monitors =
|
||||
/// max_monitors - 1`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_monitors: Option<u32>,
|
||||
}
|
||||
|
||||
impl AddressBookEntry {
|
||||
@@ -434,6 +439,9 @@ pub struct EntryInfo {
|
||||
/// Whether a Proxmox token secret is stored (the secret itself is never
|
||||
/// returned; the UI shows "leave blank to keep").
|
||||
pub has_proxmox_token_secret: bool,
|
||||
/// Total monitors offered (SPICE/Proxmox multi-monitor); 1 = single.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_monitors: Option<u32>,
|
||||
}
|
||||
|
||||
impl From<(&str, &AddressBookEntry)> for EntryInfo {
|
||||
@@ -515,6 +523,7 @@ impl From<(&str, &AddressBookEntry)> for EntryInfo {
|
||||
.proxmox_token_secret
|
||||
.as_ref()
|
||||
.is_some_and(|t| !t.is_empty()),
|
||||
max_monitors: e.max_monitors,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+133
-8
@@ -605,6 +605,8 @@
|
||||
// Max monitors the server allows, delivered via a "secondary-monitors"
|
||||
// argv stream. secondary-monitors = N means N+1 monitors total.
|
||||
var maxMonitors = 1;
|
||||
var monitorLayout = {}; // index -> {left, top, width, height} in the combined framebuffer
|
||||
var monitorWindows = {}; // index -> { win, canvas, ctx, mouse, keyboard, running }
|
||||
client.onargv = function(stream, mimetype, name) {
|
||||
var reader = new Guacamole.StringReader(stream);
|
||||
var buf = '';
|
||||
@@ -623,11 +625,16 @@
|
||||
// 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);
|
||||
monitorLayout = JSON.parse(value);
|
||||
} catch (e) {
|
||||
console.warn('[rustguac] multimon-layout parse error:', e, value);
|
||||
return;
|
||||
}
|
||||
console.log('[rustguac] multimon-layout:', monitorLayout);
|
||||
// Reflect the newly-active monitors in the Controls panel. Open
|
||||
// windows read monitorLayout[index] each frame, so no per-window
|
||||
// update is needed here.
|
||||
if (typeof renderMonitorControls === 'function') renderMonitorControls();
|
||||
};
|
||||
|
||||
// ── Receive clipboard from remote ──
|
||||
@@ -786,17 +793,135 @@
|
||||
if (controlsPanelOpen) renderMonitorControls();
|
||||
}
|
||||
|
||||
// Monitors section — reflects the server-advertised count (A1). The
|
||||
// per-monitor tick-boxes + request/render wiring land in the next
|
||||
// increment once the guacd resize semantics are confirmed.
|
||||
// Monitors section — one tick-box per allowed monitor. Monitor 1 is
|
||||
// the primary (always on, in this window). Ticking a secondary opens
|
||||
// it in its own window; monitors must stay contiguous, so monitor N
|
||||
// can only be toggled when monitor N-1 is active.
|
||||
function renderMonitorControls() {
|
||||
if (!controlsPanel) return;
|
||||
var el = document.getElementById('ctrl-monitors');
|
||||
if (!el) return;
|
||||
if (maxMonitors <= 1)
|
||||
if (maxMonitors <= 1) {
|
||||
el.innerHTML = '<div style="color:var(--text-dim);font-size:0.85em;">This connection offers a single monitor.</div>';
|
||||
else
|
||||
el.innerHTML = '<div style="color:var(--text-dim);font-size:0.85em;">' + maxMonitors + ' monitors available. Per-monitor selection is coming in the next update.</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = '';
|
||||
var isActive = function(i) { return i === 0 || !!monitorWindows[i]; };
|
||||
for (var i = 0; i < maxMonitors; i++) {
|
||||
(function(idx) {
|
||||
var row = document.createElement('label');
|
||||
row.style.cssText = 'display:flex;align-items:center;gap:0.5em;padding:0.25em 0;font-size:0.9em;cursor:pointer;';
|
||||
var cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.checked = isActive(idx);
|
||||
cb.disabled = (idx === 0) || !isActive(idx - 1);
|
||||
cb.addEventListener('change', function() { toggleMonitor(idx, cb.checked); });
|
||||
row.appendChild(cb);
|
||||
var span = document.createElement('span');
|
||||
span.textContent = (idx === 0) ? 'Monitor 1 (primary)' : ('Monitor ' + (idx + 1));
|
||||
row.appendChild(span);
|
||||
el.appendChild(row);
|
||||
})(i);
|
||||
}
|
||||
var hint = document.createElement('div');
|
||||
hint.style.cssText = 'color:var(--text-dim);font-size:0.75em;margin-top:0.4em;';
|
||||
hint.textContent = 'Extra monitors open in their own window. Drag each to a physical display and press F11 to fullscreen.';
|
||||
el.appendChild(hint);
|
||||
}
|
||||
|
||||
function toggleMonitor(index, enable) {
|
||||
if (index === 0) return; // primary is always on, in this window
|
||||
if (enable) openMonitorWindow(index);
|
||||
else closeMonitorWindow(index);
|
||||
}
|
||||
|
||||
// Open a secondary monitor in its own browser window: request the
|
||||
// monitor from the server, blit its region of the combined
|
||||
// framebuffer each frame, and forward mouse/keyboard in combined
|
||||
// coordinates.
|
||||
function openMonitorWindow(index) {
|
||||
if (monitorWindows[index]) return;
|
||||
var win = window.open('', 'rustguac-mon-' + index,
|
||||
'width=1280,height=800,menubar=no,toolbar=no,location=no,status=no');
|
||||
if (!win) {
|
||||
alert('Popup blocked. Allow popups for this site to open an extra monitor.');
|
||||
renderMonitorControls();
|
||||
return;
|
||||
}
|
||||
win.document.title = 'Monitor ' + (index + 1);
|
||||
win.document.body.style.cssText = 'margin:0;background:#000;overflow:hidden;cursor:none;';
|
||||
var canvas = win.document.createElement('canvas');
|
||||
canvas.style.cssText = 'display:block;width:100vw;height:100vh;';
|
||||
win.document.body.appendChild(canvas);
|
||||
var ctx = canvas.getContext('2d');
|
||||
var rec = { win: win, canvas: canvas, ctx: ctx, running: true, mouse: null, keyboard: null };
|
||||
monitorWindows[index] = rec;
|
||||
|
||||
// Ask the server for this monitor at the window's inner size
|
||||
// (SPICE wants width as a multiple of 8). Contiguous index. Guard
|
||||
// against a not-yet-laid-out popup reporting 0 (a positive size is
|
||||
// required or the server reads it as a monitor-close).
|
||||
var reqW = ((win.innerWidth || 1280) & ~0x7) || 1280;
|
||||
var reqH = win.innerHeight || 800;
|
||||
if (reqW < 8) reqW = 1280;
|
||||
if (reqH < 8) reqH = 800;
|
||||
client.sendSize(reqW, reqH, index, 0);
|
||||
|
||||
// Per-frame blit of this monitor's region from the combined
|
||||
// framebuffer (the default layer's canvas).
|
||||
var srcLayer = client.getDisplay().getDefaultLayer();
|
||||
(function frame() {
|
||||
if (!rec.running || win.closed) return;
|
||||
win.requestAnimationFrame(frame);
|
||||
var rect = monitorLayout[index];
|
||||
if (!rect) return;
|
||||
var src = srcLayer && srcLayer.getCanvas ? srcLayer.getCanvas() : null;
|
||||
if (!src) return;
|
||||
if (canvas.width !== rect.width || canvas.height !== rect.height) {
|
||||
canvas.width = rect.width;
|
||||
canvas.height = rect.height;
|
||||
}
|
||||
try {
|
||||
ctx.drawImage(src, rect.left, rect.top, rect.width, rect.height,
|
||||
0, 0, rect.width, rect.height);
|
||||
} catch (e) { /* source not ready this frame */ }
|
||||
})();
|
||||
|
||||
// Mouse: translate window coords -> monitor native res -> combined
|
||||
// framebuffer offset, and send without display-scale conversion.
|
||||
var mouse = new Guacamole.Mouse(win.document.body);
|
||||
mouse.onEach(['mousedown', 'mousemove', 'mouseup'], function(e) {
|
||||
var rect = monitorLayout[index];
|
||||
if (!rect) return;
|
||||
var iw = win.innerWidth || rect.width, ih = win.innerHeight || rect.height;
|
||||
e.state.x = rect.left + e.state.x * (rect.width / iw);
|
||||
e.state.y = rect.top + e.state.y * (rect.height / ih);
|
||||
client.sendMouseState(e.state, false);
|
||||
});
|
||||
rec.mouse = mouse;
|
||||
|
||||
// Keyboard: forward to the same client (combined session).
|
||||
var keyboard = new Guacamole.Keyboard(win.document);
|
||||
keyboard.onkeydown = function(sym) { client.sendKeyEvent(1, sym); };
|
||||
keyboard.onkeyup = function(sym) { client.sendKeyEvent(0, sym); };
|
||||
rec.keyboard = keyboard;
|
||||
|
||||
// Closing the window (or the tab) releases the monitor.
|
||||
win.addEventListener('beforeunload', function() {
|
||||
if (monitorWindows[index]) closeMonitorWindow(index);
|
||||
});
|
||||
renderMonitorControls();
|
||||
}
|
||||
|
||||
function closeMonitorWindow(index) {
|
||||
var rec = monitorWindows[index];
|
||||
if (!rec) return;
|
||||
rec.running = false;
|
||||
delete monitorWindows[index];
|
||||
// Tell the server to close this monitor (non-positive size).
|
||||
client.sendSize(0, 0, index, 0);
|
||||
try { if (rec.win && !rec.win.closed) rec.win.close(); } catch (e) {}
|
||||
renderMonitorControls();
|
||||
}
|
||||
|
||||
// ── Auto-hide side tabs (per-entry autohide_side_tabs) ──
|
||||
|
||||
@@ -607,6 +607,9 @@
|
||||
<option value="32">32-bit</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Monitors <span style="color:var(--text-muted);font-size:0.85em">(the guest must support multiple SPICE heads)</span>
|
||||
<input type="number" id="em-proxmox-monitors" min="1" max="4" value="1">
|
||||
</label>
|
||||
<label style="margin-top:0.8em">
|
||||
<input type="checkbox" id="em-proxmox-verify-tls" style="display:inline;width:auto;margin-right:0.4em"> Verify the PVE cluster TLS certificate
|
||||
</label>
|
||||
@@ -3042,6 +3045,7 @@
|
||||
document.getElementById('em-proxmox-token-secret').value = '';
|
||||
document.getElementById('em-proxmox-token-secret').placeholder = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx';
|
||||
document.getElementById('em-proxmox-color-depth').value = '';
|
||||
document.getElementById('em-proxmox-monitors').value = '1';
|
||||
document.getElementById('em-proxmox-verify-tls').checked = false;
|
||||
document.getElementById('em-url').value = '';
|
||||
document.getElementById('em-banner').value = '';
|
||||
@@ -3186,6 +3190,7 @@
|
||||
document.getElementById('em-proxmox-node').value = entryData.proxmox_node || '';
|
||||
document.getElementById('em-proxmox-vmid').value = entryData.proxmox_vmid || '';
|
||||
document.getElementById('em-proxmox-color-depth').value = entryData.color_depth ? String(entryData.color_depth) : '';
|
||||
document.getElementById('em-proxmox-monitors').value = entryData.max_monitors || 1;
|
||||
document.getElementById('em-proxmox-verify-tls').checked = !!entryData.proxmox_verify_tls;
|
||||
document.getElementById('em-proxmox-token-id').value = entryData.proxmox_token_id || '';
|
||||
// Secret is never returned; show a hint if one is stored.
|
||||
@@ -3413,6 +3418,8 @@
|
||||
if (psec) entry.proxmox_token_secret = psec;
|
||||
var pcd = document.getElementById('em-proxmox-color-depth').value;
|
||||
if (pcd) entry.color_depth = parseInt(pcd);
|
||||
var pmon = parseInt(document.getElementById('em-proxmox-monitors').value);
|
||||
if (pmon > 1) entry.max_monitors = pmon;
|
||||
entry.proxmox_verify_tls = document.getElementById('em-proxmox-verify-tls').checked;
|
||||
} else if (type === 'web') {
|
||||
var url = document.getElementById('em-url').value.trim();
|
||||
|
||||
Reference in New Issue
Block a user