Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
22f4596
GUACAMOLE-261: Add client-side support for the SPICE protocol
ciroiriarte Jul 2, 2026
ade8f40
GUACAMOLE-261: add disable-audio-opus parameter to the SPICE form
ciroiriarte Jul 2, 2026
0462be7
GUACAMOLE-261: clarify disable-audio-opus field label
ciroiriarte Jul 2, 2026
0e7747a
GUACAMOLE-261: add preferred-video-codec parameter to the SPICE form
ciroiriarte Jul 4, 2026
1462bbc
GUACAMOLE-288: Add SPICE multi-monitor connection parameter
ciroiriarte Jul 4, 2026
c5ad793
GUACAMOLE-288: Add client-side settings for limiting usage of multi-m…
corentin-soriano Oct 29, 2024
8ea1584
GUACAMOLE-288: Add UI button to open new window on RDP connections.
corentin-soriano Nov 16, 2024
3a85617
GUACAMOLE-288: Add service to handle monitor open/close.
corentin-soriano Nov 16, 2024
a3f2d32
GUACAMOLE-288: Forward instructions between guacd and secondary monit…
corentin-soriano Nov 16, 2024
1d3671b
GUACAMOLE-288: Adjust the display according to the number of monitors.
corentin-soriano Nov 16, 2024
5198a0d
GUACAMOLE-288: Propagate full screen mode on additional monitors.
corentin-soriano Nov 16, 2024
890aa8b
GUACAMOLE-288: Offset draw instructions on X depending on monitor pos…
corentin-soriano Nov 16, 2024
61f2f33
GUACAMOLE-288: Dedicated controller for all secondary monitors that c…
corentin-soriano Nov 16, 2024
368e959
GUACAMOLE-288: Add directive that handle client display element.
corentin-soriano Nov 30, 2024
9c8caf6
GUACAMOLE-288: Allow drag and drop from a browser window to another.
corentin-soriano Jan 16, 2025
18e41f7
GUACAMOLE-288: Allow windows to have different dimensions.
corentin-soriano May 23, 2025
5e6272d
GUACAMOLE-288: Add Y offset on monitor position.
corentin-soriano Jun 3, 2025
23212c9
WIP - GUACAMOLE-288: Receive monitor layout via layer set parameter.
corentin-soriano Jun 7, 2025
2943179
GUACAMOLE-288: Force disconnect on error to avoid dead browser windows.
corentin-soriano Nov 3, 2025
1323928
GUACAMOLE-288: Close multi-screen mode on change in the active client…
corentin-soriano Nov 27, 2025
2f4be7c
GUACAMOLE-288: Enable the multi-monitor UI for SPICE connections
ciroiriarte Jul 4, 2026
943a870
GUACAMOLE-288: consume monitor left offset + keep windows on transien…
ciroiriarte Jul 5, 2026
77ee0db
GUACAMOLE-288: debounce resize storm, bound DPR, suppress resize echoes
ciroiriarte Jul 5, 2026
a013c24
spice: add recording-include-clipboard connection parameter
ciroiriarte Jul 5, 2026
079457a
spice: add file-transfer-mode selector for agent vs shared-folder upl…
ciroiriarte Jul 5, 2026
4a929f1
spice: namespace multi-monitor BroadcastChannel per connection (secur…
ciroiriarte Jul 6, 2026
9d4bea7
Crop display immediately on monitor size changes
ciroiriarte Jul 6, 2026
3f991a4
spice: show image clipboard as thumbnails in recording playback
ciroiriarte Jul 8, 2026
80e4788
spice: surface clipboard direction and downscale image thumbnails in …
ciroiriarte Jul 8, 2026
fe31a44
spice: harden multimon-layout parsing and hide sensitive argv params
ciroiriarte Jul 8, 2026
7e1fe18
spice: add Clipboard Activity panel to the recording player (#34)
ciroiriarte Jul 9, 2026
81463cc
spice: clipboard timeline ticks, clustering, and truncation banner (#34)
ciroiriarte Jul 9, 2026
d9726e0
spice: harden and de-duplicate the clipboard cockpit (#34 assessment)
ciroiriarte Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
290 changes: 212 additions & 78 deletions guacamole-common-js/src/main/webapp/modules/Client.js

Large diffs are not rendered by default.

152 changes: 142 additions & 10 deletions guacamole-common-js/src/main/webapp/modules/ClipboardEventInterpreter.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,18 @@ Guacamole.ClipboardEventInterpreter = function ClipboardEventInterpreter(startTi
*/
var activeStreams = {};

/**
* Clipboard transfer directions parsed from server-emitted "log"
* instructions, keyed by stream index, awaiting the clipboard stream they
* annotate. The server writes the direction log immediately before the
* corresponding clipboard stream, so it is buffered here until the
* matching clipboard instruction arrives.
*
* @private
* @type {Object.<string, string>}
*/
var pendingDirections = {};

/**
* The timestamp of the most recent instruction, used for events
* that don't have their own timestamp.
Expand Down Expand Up @@ -83,8 +95,32 @@ Guacamole.ClipboardEventInterpreter = function ClipboardEventInterpreter(startTi
activeStreams[streamIndex] = {
mimetype: mimetype,
data: '',
direction: pendingDirections[streamIndex] || null,
timestamp: lastTimestamp
};

// The buffered direction, if any, has now been consumed
delete pendingDirections[streamIndex];
};

/**
* Handles a log instruction. When the log carries a clipboard direction
* annotation ("clipboard stream=N direction=... mimetype=... [bytes=...]"),
* the direction is buffered against its stream index so it can be attached
* to the clipboard event once that stream arrives. All other log
* instructions are ignored.
*
* @param {!string[]} args
* The arguments: [message]
*/
this.handleLog = function handleLog(args) {
var message = args[0];
if (!message)
return;

var match = /^clipboard stream=(\d+) direction=(\S+)/.exec(message);
if (match)
pendingDirections[match[1]] = match[2];
};

/**
Expand All @@ -103,6 +139,28 @@ Guacamole.ClipboardEventInterpreter = function ClipboardEventInterpreter(startTi
stream.data += base64Data;
};

/**
* Returns the number of decoded bytes represented by the given base64
* string, without actually decoding it. Handles both padded and unpadded
* base64; Guacamole blob payloads are newline-free, so length arithmetic
* is exact.
*
* @private
* @param {string} base64
* The base64-encoded string to measure.
*
* @returns {number}
* The size, in bytes, of the decoded data.
*/
var base64ByteLength = function base64ByteLength(base64) {
if (!base64)
return 0;
var padding = 0;
if (base64.charAt(base64.length - 1) === '=') padding++;
if (base64.charAt(base64.length - 2) === '=') padding++;
return Math.max(0, Math.floor(base64.length * 3 / 4) - padding);
};

/**
* Handles an end instruction, which completes a clipboard stream
* and creates the final clipboard event.
Expand All @@ -115,18 +173,39 @@ Guacamole.ClipboardEventInterpreter = function ClipboardEventInterpreter(startTi

var stream = activeStreams[streamIndex];
if (stream) {
// Decode the base64 data

var isImage = /^image\//i.test(stream.mimetype || '');

var decodedData = '';
try {
decodedData = atob(stream.data);
// Handle UTF-8 decoding
decodedData = decodeURIComponent(escape(decodedData));
} catch (e) {
// If decoding fails, use raw decoded data or mark as binary
var dataURL = null;
var size = 0;

// Image clipboard: keep the base64 payload intact and expose it
// as a data: URL for inline preview. Do NOT run the text/UTF-8
// decode used below - binary image bytes are not valid UTF-8 and
// would otherwise collapse to the literal string '[Binary data]'.
if (isImage) {
// Lowercase the mimetype in the data: scheme - AngularJS's
// img-src sanitizer whitelists "data:image/" case-sensitively,
// so an uppercase mimetype would be rewritten to "unsafe:" and
// silently fail to render.
dataURL = 'data:' + (stream.mimetype || '').toLowerCase()
+ ';base64,' + stream.data;
size = base64ByteLength(stream.data);
}

// Text clipboard: decode the base64 data, then interpret as UTF-8
else {
try {
decodedData = atob(stream.data);
} catch (e2) {
decodedData = '[Binary data]';
decodedData = decodeURIComponent(escape(decodedData));
} catch (e) {
// If decoding fails, use raw decoded data or mark as binary
try {
decodedData = atob(stream.data);
} catch (e2) {
decodedData = '[Binary data]';
}
}
}

Expand All @@ -139,6 +218,10 @@ Guacamole.ClipboardEventInterpreter = function ClipboardEventInterpreter(startTi
parsedEvents.push(new Guacamole.ClipboardEventInterpreter.ClipboardEvent({
mimetype: stream.mimetype,
data: decodedData,
isImage: isImage,
dataURL: dataURL,
size: size,
direction: stream.direction || null,
timestamp: Math.max(0, stream.timestamp - startTimestamp)
}));

Expand All @@ -157,6 +240,20 @@ Guacamole.ClipboardEventInterpreter = function ClipboardEventInterpreter(startTi
return parsedEvents;
};

/**
* Returns the number of clipboard streams that were opened but never
* terminated by an "end" instruction. Such streams indicate clipboard
* transfers that were only partially recorded (e.g. a truncated
* recording), and whose data is therefore missing.
*
* @returns {!number}
* The number of incomplete clipboard streams still open at the end of
* parsing.
*/
this.getIncompleteCount = function getIncompleteCount() {
return Object.keys(activeStreams).length;
};

};

/**
Expand All @@ -178,12 +275,47 @@ Guacamole.ClipboardEventInterpreter.ClipboardEvent = function ClipboardEvent(tem
this.mimetype = template.mimetype || 'text/plain';

/**
* The clipboard content (decoded from base64).
* The clipboard content (decoded from base64). Empty for image clipboard
* events, whose payload is exposed via dataURL instead.
*
* @type {!string}
*/
this.data = template.data || '';

/**
* Whether this clipboard event carries image data (mimetype image/*)
* rather than text.
*
* @type {!boolean}
*/
this.isImage = template.isImage || false;

/**
* For image clipboard events, a data: URL suitable for direct use as an
* <img> source. Null for text clipboard events.
*
* @type {string}
*/
this.dataURL = template.dataURL || null;

/**
* The size, in bytes, of the clipboard payload. Only meaningful for
* image clipboard events; 0 for text.
*
* @type {!number}
*/
this.size = template.size || 0;

/**
* The direction of this clipboard transfer as annotated by the server,
* either "guest-to-client" (data copied out of the guest) or
* "client-to-guest" (data pasted into the guest), or null if the
* recording carries no direction annotation.
*
* @type {string}
*/
this.direction = template.direction || null;

/**
* The timestamp when this clipboard event occurred, relative to
* the start of the recording.
Expand Down
43 changes: 37 additions & 6 deletions guacamole-common-js/src/main/webapp/modules/Display.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,16 @@ Guacamole.Display = function() {
* Reference to this Guacamole.Display.
* @private
*/
var guac_display = this;
const guac_display = this;

var displayWidth = 0;
var displayHeight = 0;
var displayScale = 1;
let displayWidth = 0;
let displayHeight = 0;
let monitorWidth = null;
let monitorHeight = null;
let displayScale = 1;

// Create display
var display = document.createElement("div");
const display = document.createElement("div");
display.style.position = "relative";
display.style.width = displayWidth + "px";
display.style.height = displayHeight + "px";
Expand Down Expand Up @@ -740,7 +742,7 @@ Guacamole.Display = function() {
* @param {!number} y
* The Y coordinate to move the cursor to.
*/
this.moveCursor = function(x, y) {
this.moveCursor = function moveCursor(x, y) {

// Move cursor layer
cursor.translate(x - guac_display.cursorHotspotX,
Expand All @@ -752,6 +754,27 @@ Guacamole.Display = function() {

};

/**
* Set the current monitor size.
*
* @param {!number} width
* The width of the monitor, in pixels.
* @param {!number} height
* The height of the monitor, in pixels.
*/
this.setMonitorSize = function setMonitorSize(width, height) {
monitorWidth = width;
monitorHeight = height;

// A monitor layout update must take effect immediately. The Guacamole
// server may have already resized the default layer to the size of the
// overall desktop, and may not send another resize after the per-window
// monitor rectangle is known. Re-run the resize path to authoritatively
// clamp the visible display to the current monitor bounds.
if (displayWidth !== monitorWidth || displayHeight !== monitorHeight)
guac_display.resize(default_layer, displayWidth, displayHeight);
};

/**
* Changes the size of the given Layer to the given width and height.
* Resizing is only attempted if the new size provided is actually different
Expand All @@ -769,6 +792,14 @@ Guacamole.Display = function() {
this.resize = function(layer, width, height) {
scheduleTask(function __display_resize() {

// Adjust width when using multiple monitors
if (monitorWidth)
width = monitorWidth;

// Adjust height when using multiple monitors
if (monitorHeight)
height = monitorHeight;

layer.resize(width, height);

// Resize display if default layer is resized
Expand Down
11 changes: 11 additions & 0 deletions guacamole-common-js/src/main/webapp/modules/Mouse.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,17 @@ Guacamole.Mouse = function Mouse(element) {
Guacamole.Event.DOMEvent.cancelEvent(e);
}, false);

// Capture mouse events outside the display element when a button is
// pressed to allow drag and drop between multiple windows.
element.addEventListener("pointerdown", function(e) {
element.setPointerCapture(e.pointerId);
}, false);

// Stop capture when mouse button is released
element.addEventListener("pointerup", function(e) {
element.releasePointerCapture(e.pointerId);
}, false);

element.addEventListener("mousemove", function(e) {

// If ignoring events, decrement counter
Expand Down
24 changes: 22 additions & 2 deletions guacamole-common-js/src/main/webapp/modules/SessionRecording.js
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,13 @@ Guacamole.SessionRecording = function SessionRecording(source, refreshInterval)
// Handle stream end (may complete clipboard stream)
else if (opcode === 'end' && clipboardEventInterpreter)
clipboardEventInterpreter.handleEnd(args);

// Handle log instructions, which may carry the clipboard direction
// annotation emitted by the server immediately before each clipboard
// stream (e.g. "clipboard stream=N direction=guest-to-client ...").
// Non-clipboard logs are ignored by the interpreter.
else if (opcode === 'log' && clipboardEventInterpreter)
clipboardEventInterpreter.handleLog(args);
};

/**
Expand All @@ -541,6 +548,15 @@ Guacamole.SessionRecording = function SessionRecording(source, refreshInterval)
};

// Read instructions from provided blob, extracting each frame
//
// NOTE: The onkeyevents and onclipboardevents callbacks are only fired
// from the tunnel branch below (on tunnel CLOSED). When the recording is
// supplied as a Blob, only onload (via notifyLoaded) fires — the extracted
// key-event and clipboard-event logs are NOT delivered. The bundled player
// avoids this by feeding a StaticHTTPTunnel (see settings/.../
// connectionHistoryPlayerController.js), so both logs work today. If a
// future caller loads a recording from a Blob and expects the key/clipboard
// logs, fire those callbacks from the parseBlob completion handler here too.
if (source instanceof Blob) {
recordingBlob = source;
parseBlob(recordingBlob, loadInstruction, notifyLoaded);
Expand Down Expand Up @@ -594,14 +610,18 @@ Guacamole.SessionRecording = function SessionRecording(source, refreshInterval)
}

// Now that the recording is fully processed, and all key events
// have been extracted, call the onkeyevents handler if defined
// have been extracted, call the onkeyevents handler if defined.
// NOTE: This (and the onclipboardevents call below) fire ONLY on
// this tunnel-CLOSED path — a Blob source never reaches here.
// See the Blob branch above before changing how recordings load.
if (recording.onkeyevents && keyEventInterpreter)
recording.onkeyevents(keyEventInterpreter.getEvents());

// call the onclipboardevents handler if defined with extracted
// clipboard events
if (recording.onclipboardevents && clipboardEventInterpreter)
recording.onclipboardevents(clipboardEventInterpreter.getEvents());
recording.onclipboardevents(clipboardEventInterpreter.getEvents(),
{ incomplete: clipboardEventInterpreter.getIncompleteCount() });

// Consider recording loaded if tunnel has closed without errors
if (!errorEncountered)
Expand Down
2 changes: 1 addition & 1 deletion guacamole-common-js/src/main/webapp/modules/Tunnel.js
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ Guacamole.HTTPTunnel = function(tunnelURL, crossDomain, extraTunnelHeaders) {
}


this.sendMessage = function() {
this.sendMessage = function sendMessage() {

// Do not attempt to send messages if not connected
if (!tunnel.isConnected())
Expand Down
Loading