diff --git a/guacamole-common-js/src/main/webapp/modules/CameraRecorder.js b/guacamole-common-js/src/main/webapp/modules/CameraRecorder.js new file mode 100644 index 0000000000..71e20a85c7 --- /dev/null +++ b/guacamole-common-js/src/main/webapp/modules/CameraRecorder.js @@ -0,0 +1,851 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +var Guacamole = Guacamole || {}; + +/** + * Abstract camera recorder which streams H.264 video data to an underlying + * Guacamole.OutputStream. It is up to implementations of this class to provide + * some means of handling this Guacamole.OutputStream. Data produced by the + * recorder is to be sent along the provided stream immediately. + * + * @constructor + */ +Guacamole.CameraRecorder = function CameraRecorder() { + + /** + * Callback which is invoked when the camera recording process has stopped + * and the underlying Guacamole stream has been closed normally. Camera will + * only resume recording if a new Guacamole.CameraRecorder is started. This + * Guacamole.CameraRecorder instance MAY NOT be reused. + * + * @event + */ + this.onclose = null; + + /** + * Callback which is invoked when the camera recording process cannot + * continue due to an error, if it has started at all. The underlying + * Guacamole stream is automatically closed. Future attempts to record + * camera should not be made, and this Guacamole.CameraRecorder instance + * MAY NOT be reused. + * + * @event + */ + this.onerror = null; + + /** + * Callback invoked when the recorder has determined the set of formats + * supported by the underlying camera (resolution and frame rate pairs). + * + * @event + * @param {!Array.} formats + * Array describing the supported formats. Each element contains + * at least {width, height, fpsNumerator, fpsDenominator}. + * @param {string} deviceName + * The label/name of the camera device, if available. + */ + this.oncapabilities = null; + +}; + +/** + * Determines whether the given mimetype is supported by any built-in + * implementation of Guacamole.CameraRecorder, and thus will be properly handled + * by Guacamole.CameraRecorder.getInstance(). + * + * @param {!string} mimetype + * The mimetype to check. + * + * @returns {!boolean} + * true if the given mimetype is supported by any built-in + * Guacamole.CameraRecorder, false otherwise. + */ +Guacamole.CameraRecorder.isSupportedType = function isSupportedType(mimetype) { + + return Guacamole.H264CameraRecorder.isSupportedType(mimetype); + +}; + +/** + * Returns a list of all mimetypes supported by any built-in + * Guacamole.CameraRecorder, in rough order of priority. Beware that only the + * core mimetypes themselves will be listed. Any mimetype parameters, even + * required ones, will not be included in the list. + * + * @returns {!string[]} + * A list of all mimetypes supported by any built-in + * Guacamole.CameraRecorder, excluding any parameters. + */ +Guacamole.CameraRecorder.getSupportedTypes = function getSupportedTypes() { + + return Guacamole.H264CameraRecorder.getSupportedTypes(); + +}; + +/** + * Returns an instance of Guacamole.CameraRecorder providing support for the + * given video format. If support for the given video format is not available, + * null is returned. + * + * @param {!Guacamole.OutputStream} stream + * The Guacamole.OutputStream to send video data through. + * + * @param {!string} mimetype + * The mimetype of the video data to be sent along the provided stream. + * + * @return {Guacamole.CameraRecorder} + * A Guacamole.CameraRecorder instance supporting the given mimetype and + * writing to the given stream, or null if support for the given mimetype + * is absent. + */ +Guacamole.CameraRecorder.getInstance = function getInstance(stream, mimetype) { + + // Use H.264 camera recorder if possible + if (Guacamole.H264CameraRecorder.isSupportedType(mimetype)) + return new Guacamole.H264CameraRecorder(stream, mimetype); + + // No support for given mimetype + return null; + +}; + +/** + * Implementation of Guacamole.CameraRecorder providing support for H.264 + * format video. This recorder relies on the WebCodecs API and requires + * browser-level support for H.264 encoding. + * + * @constructor + * @augments Guacamole.CameraRecorder + * @param {!Guacamole.OutputStream} stream + * The Guacamole.OutputStream to write video data to. + * + * @param {!string} mimetype + * The mimetype of the video data to send along the provided stream, which + * must be "application/rdpecam+h264". + */ +Guacamole.H264CameraRecorder = function H264CameraRecorder(stream, mimetype) { + + /** + * Reference to this H264CameraRecorder. + * + * @private + * @type {!Guacamole.H264CameraRecorder} + */ + var recorder = this; + + /** The format of video this recorder will encode. */ + var format = { + width: 640, + height: 480, + frameRate: 30, + /** Optional browser device ID to target a specific camera */ + deviceId: undefined + }; + + /** + * Monotonic PTS last emitted downstream (milliseconds). + * + * @private + * @type {?number} + */ + var lastOutputPtsMs = null; + + /** + * Tracks whether capability information has already been reported + * upstream for this recorder. + * + * @private + * @type {boolean} + */ + var capabilitiesReported = false; + + /** + * Candidate formats which will be offered if supported by the camera. + * + * @private + * @type {!Array.<{width:number,height:number,fps:!Array.}>} + */ + var CAPABILITY_CANDIDATES = [ + { width: 640, height: 480, fps: [30, 15] }, + { width: 320, height: 240, fps: [30, 15] }, + { width: 1280, height: 720, fps: [30] }, + { width: 1920, height: 1080, fps: [30] } + ]; + + /** + * Returns whether the provided dimension capability includes the given + * value. Capabilities may be expressed as ranges, arrays, or omitted. + * + * @private + */ + var capabilitySupportsValue = function capabilitySupportsValue(capability, value) { + if (!capability) + return true; + + if (Array.isArray(capability)) + return capability.indexOf(value) !== -1; + + if (typeof capability === 'object') { + if (typeof capability.max === 'number' && value > capability.max) + return false; + if (typeof capability.min === 'number' && value < capability.min) + return false; + if (typeof capability.step === 'number' && typeof capability.min === 'number') { + var step = capability.step; + if (step > 0 && ((value - capability.min) % step) !== 0) + return false; + } + } + + return true; + }; + + /** + * Builds a list of supported formats from the provided + * MediaTrackCapabilities. The resulting list is guaranteed to contain at + * least the currently requested format. + * + * @private + */ + var buildSupportedFormats = function buildSupportedFormats(capabilities) { + var formats = []; + var seen = {}; + + var pushFormat = function pushFormat(width, height, fpsNum, fpsDen) { + var key = width + 'x' + height + '@' + fpsNum + '/' + fpsDen; + if (seen[key]) + return; + + seen[key] = true; + formats.push({ + width: width, + height: height, + fpsNumerator: fpsNum, + fpsDenominator: fpsDen + }); + }; + + CAPABILITY_CANDIDATES.forEach(function(candidate) { + if (!capabilitySupportsValue(capabilities.width, candidate.width)) + return; + if (!capabilitySupportsValue(capabilities.height, candidate.height)) + return; + + candidate.fps.forEach(function(fps) { + if (!capabilitySupportsValue(capabilities.frameRate, fps)) + return; + pushFormat(candidate.width, candidate.height, fps, 1); + }); + }); + + if (!formats.length) { + pushFormat(format.width, format.height, + typeof format.frameRate === 'number' && format.frameRate > 0 ? format.frameRate : 30, 1); + } + + return formats; + }; + + /** + * Reports the supported formats upstream exactly once if the recorder + * consumer has provided an oncapabilities handler. + * + * @private + */ + var reportCapabilities = function reportCapabilities(track) { + if (capabilitiesReported || !track || typeof track.getCapabilities !== 'function') + return; + + try { + var caps = track.getCapabilities(); + var formats = buildSupportedFormats(caps || {}); + if (recorder.oncapabilities && formats.length) { + // Extract device name from track label, if available + var deviceName = (track && track.label) ? track.label : ''; + recorder.oncapabilities(formats, deviceName); + } + capabilitiesReported = true; + } + catch (e) {} + }; + + /** + * The video stream provided by the browser, if allowed. If no stream has + * yet been received, this will be null. + * + * @private + * @type {MediaStream} + */ + var mediaStream = null; + + /** + * The video encoder instance. + * + * @private + * @type {VideoEncoder} + */ + var encoder = null; + + /** + * The media stream track processor. + * + * @private + * @type {MediaStreamTrackProcessor} + */ + var processor = null; + + /** + * The readable stream reader. + * + * @private + * @type {ReadableStreamDefaultReader} + */ + var reader = null; + + /** + * Parsed AVCC decoder configuration containing length size and parameter sets. + * + * @private + * @type {{ lengthSize: number, sps: Uint8Array[], pps: Uint8Array[] }|null} + */ + var decoderConfig = null; + + /** + * Parses an AVCC decoder configuration record to extract lengthSize, SPS, and PPS. + * + * @private + * @param {ArrayBuffer} avcc + * The AVCC decoder configuration (decoderConfig.description). + * + * @returns {{ lengthSize: number, sps: Uint8Array[], pps: Uint8Array[] }} + * Parsed configuration for conversion to Annex B. + */ + var parseAvccDecoderConfig = function parseAvccDecoderConfig(avcc) { + var view = new DataView(avcc); + var offset = 0; + + /* configurationVersion, AVCProfileIndication, profile_compatibility, AVCLevelIndication */ + offset += 4; + + /* lengthSizeMinusOne (lower 2 bits) */ + var lengthSizeMinusOne = view.getUint8(offset) & 0x03; + offset += 1; + var lengthSize = (lengthSizeMinusOne & 0x03) + 1; + + /* numOfSequenceParameterSets (lower 5 bits) */ + var numSps = view.getUint8(offset) & 0x1F; + offset += 1; + + var spsList = []; + for (var i = 0; i < numSps; i++) { + if (offset + 2 > view.byteLength) break; + var spsLen = view.getUint16(offset); + offset += 2; + if (offset + spsLen > view.byteLength) break; + spsList.push(new Uint8Array(avcc, offset, spsLen)); + offset += spsLen; + } + + /* numOfPictureParameterSets */ + var ppsList = []; + if (offset < view.byteLength) { + var numPps = view.getUint8(offset); + offset += 1; + for (var j = 0; j < numPps; j++) { + if (offset + 2 > view.byteLength) break; + var ppsLen = view.getUint16(offset); + offset += 2; + if (offset + ppsLen > view.byteLength) break; + ppsList.push(new Uint8Array(avcc, offset, ppsLen)); + offset += ppsLen; + } + } + + var config = { lengthSize: lengthSize, sps: [], pps: [] }; + for (var s = 0; s < spsList.length; s++) { + var spsCopy = new Uint8Array(spsList[s].length); + spsCopy.set(spsList[s]); + config.sps.push(spsCopy); + } + for (var p = 0; p < ppsList.length; p++) { + var ppsCopy = new Uint8Array(ppsList[p].length); + ppsCopy.set(ppsList[p]); + config.pps.push(ppsCopy); + } + + return config; + }; + + + /** + * Whether to force the next frame to be a keyframe. + * + * @private + * @type {boolean} + */ + var needKeyframe = true; + + /** + * Interval in milliseconds to request periodic IDR frames. + * + * @private + * @type {number} + */ + var forceIdrIntervalMs = (typeof window !== 'undefined' && window.GUAC_RDPECAM_FORCE_IDR_MS) ? + (parseInt(window.GUAC_RDPECAM_FORCE_IDR_MS, 10) || 2000) : 2000; + + /** + * Wall-clock timestamp (ms) of last observed keyframe. + * + * Initialize to current time instead of 0 to prevent race condition. + * If initialized to 0 (epoch), encoding loop may process frame 2 before frame 1's + * output callback updates lastKeyframeWallMs, causing frame 2 to see stale value + * and incorrectly request keyframe when checking (Date.now() - 0) >= 2000ms. + * This prevents consecutive I-frames that Windows Media Foundation decoder rejects. + * + * @private + * @type {number} + */ + var lastKeyframeWallMs = Date.now(); + + /** + * Marks that the next encoded frame should request a keyframe. + * + * @private + */ + var requireKeyframe = function requireKeyframe() { + needKeyframe = true; + }; + + /** + * Marks that a keyframe has just been produced, clearing any outstanding + * request and updating the periodic IDR timer baseline. + * + * @private + */ + var markKeyframeObserved = function markKeyframeObserved() { + needKeyframe = false; + lastKeyframeWallMs = Date.now(); + }; + + /** + * Baseline PTS (in microseconds) for normalizing chunk timestamps to start at 0. + * Set to the timestamp of the first chunk received after encoding starts. + * + * @private + * @type {number|null} + */ + var baselinePtsUs = null; + + /** + * Guacamole.ArrayBufferWriter wrapped around the video output stream + * provided when this Guacamole.H264CameraRecorder was created. + * + * @private + * @type {!Guacamole.ArrayBufferWriter} + */ + var writer = new Guacamole.ArrayBufferWriter(stream); + + /** + * Builds the RDPECAM frame header. + * + * @private + * @param {Object} params + * Parameters for the frame header. + * + * @param {boolean} params.keyframe + * Whether this is a keyframe. + * + * @param {number} params.ptsMs + * Presentation timestamp in milliseconds. + * + * @param {number} params.payloadLen + * Length of the payload in bytes. + * + * @returns {ArrayBuffer} + * The frame header as ArrayBuffer. + */ + var buildFrameHeader = function buildFrameHeader(params) { + var header = new ArrayBuffer(12); + var view = new DataView(header); + + view.setUint8(0, 1); // version + view.setUint8(1, params.keyframe ? 1 : 0); // flags (bit0: keyframe) + view.setUint16(2, 0, true); // reserved (little-endian) + view.setUint32(4, params.ptsMs, true); // pts_ms (little-endian) + view.setUint32(8, params.payloadLen, true); // payload_len (little-endian) + + return header; + }; + + /** + * Concatenates multiple ArrayBuffers. + * + * @private + * @param {...ArrayBuffer} buffers + * The buffers to concatenate. + * + * @returns {ArrayBuffer} + * The concatenated buffer. + */ + var concatBuffers = function concatBuffers() { + var totalLength = 0; + for (var i = 0; i < arguments.length; i++) { + totalLength += arguments[i].byteLength; + } + + var result = new Uint8Array(totalLength); + var offset = 0; + + for (var i = 0; i < arguments.length; i++) { + result.set(new Uint8Array(arguments[i]), offset); + offset += arguments[i].byteLength; + } + + return result.buffer; + }; + + /** + * getUserMedia() callback which handles successful retrieval of a + * video stream (successful start of recording). + * + * @private + * @param {!MediaStream} stream + * A MediaStream which provides access to video data read from the + * user's local camera device. + */ + var streamReceived = function streamReceived(stream) { + + // Create video encoder + encoder = new VideoEncoder({ + output: function(chunk, meta) { + if (meta && meta.decoderConfig && meta.decoderConfig.description) + decoderConfig = parseAvccDecoderConfig(meta.decoderConfig.description); + + if (chunk.type === 'key') + markKeyframeObserved(); + + // Extract AVCC encoded data from browser + var chunkData = new Uint8Array(chunk.byteLength); + chunk.copyTo(chunkData); + + // Convert AVCC to Annex B (prepend SPS/PPS on keyframes) + var payload = Guacamole.H264AnnexBUtil.avccToAnnexB( + chunkData, + chunk.type === 'key', + decoderConfig + ); + + if (!payload || payload.length === 0) + return; + + var payloadSize = payload.length; + + // Ignore empty frames - nothing to send downstream + if (!payloadSize) + return; + + if (baselinePtsUs === null) + baselinePtsUs = chunk.timestamp; + + var relativePtsUs = chunk.timestamp - baselinePtsUs; + var relativePtsMs = Math.max(0, Math.round(relativePtsUs / 1000)); + + if (lastOutputPtsMs !== null && relativePtsMs < lastOutputPtsMs) + relativePtsMs = lastOutputPtsMs; + + lastOutputPtsMs = relativePtsMs; + + var header = buildFrameHeader({ + keyframe: chunk.type === 'key', + ptsMs: relativePtsMs, + payloadLen: payloadSize + }); + + var payloadBuffer = payload.buffer.slice(payload.byteOffset, payload.byteOffset + payloadSize); + var frameData = concatBuffers(header, payloadBuffer); + + writer.sendData(frameData); + }, + error: function(e) { + if (recorder.onerror) + recorder.onerror(); + } + }); + + // Select appropriate AVC level based on resolution + var selectLevelIdcHex = function(width, height) { + var mbW = Math.ceil((width || 0) / 16); + var mbH = Math.ceil((height || 0) / 16); + var mbPerFrame = mbW * mbH; + if (mbPerFrame <= 1620) return '1E'; // Level 3.0 + if (mbPerFrame <= 3600) return '1F'; // Level 3.1 + if (mbPerFrame <= 8192) return '28'; // Level 4.0 + return '29'; // Level 4.1 fallback + }; + + var codecString = 'avc1.6400' + selectLevelIdcHex(format.width, format.height); + + // Calculate optimal bitrate based on resolution height + // Matches FreeRDP's bitrate recommendations for RDPECAM + // Source: https://livekit.io/webrtc/bitrate-guide (webcam streaming) + var defaultBitrate; + if (format.height >= 1080) { + defaultBitrate = 2700000; // 2.7 Mbps for 1080p + } else if (format.height >= 720) { + defaultBitrate = 1250000; // 1.25 Mbps for 720p + } else if (format.height >= 480) { + defaultBitrate = 700000; // 700 kbps for 480p + } else if (format.height >= 360) { + defaultBitrate = 400000; // 400 kbps for 360p + } else if (format.height >= 240) { + defaultBitrate = 170000; // 170 kbps for 240p + } else { + defaultBitrate = 100000; // 100 kbps for lower resolutions + } + + // Configure encoder + var encoderConfig = { + codec: codecString, + width: format.width, + height: format.height, + framerate: format.frameRate, + hardwareAcceleration: 'prefer-hardware', + latencyMode: 'quality', + bitrate: defaultBitrate, + bitrateMode: 'variable' + }; + + var effectiveEncoderConfig = encoderConfig; + try { + encoder.configure(encoderConfig); + } + catch (configureError) { + if (encoderConfig.colorSpace) { + effectiveEncoderConfig = Object.assign({}, encoderConfig); + delete effectiveEncoderConfig.colorSpace; + encoder.configure(effectiveEncoderConfig); + } + else + throw configureError; + } + + // Create track processor + var track = stream.getVideoTracks()[0]; + reportCapabilities(track); + processor = new MediaStreamTrackProcessor({ track: track }); + reader = processor.readable.getReader(); + + // Start encoding loop + (async function() { + while (true) { + var result = await reader.read(); + if (result.done) break; + var wantPeriodicIdr = (Date.now() - lastKeyframeWallMs) >= forceIdrIntervalMs; + var requestKey = needKeyframe || wantPeriodicIdr; + encoder.encode(result.value, { keyFrame: !!requestKey }); + result.value.close(); + } + })(); + + // Save stream for later cleanup + mediaStream = stream; + + }; + + /** + * getUserMedia() callback which handles camera recording denial. The + * underlying Guacamole output stream is closed, and the failure to + * record is noted using onerror. + * + * @private + */ + var streamDenied = function streamDenied() { + + // Simply end stream if camera access is not allowed + writer.sendEnd(); + + // Notify of closure + if (recorder.onerror) + recorder.onerror(); + + }; + + /** + * Requests access to the user's camera and begins capturing video. All + * received video data is encoded as H.264 and forwarded to the + * Guacamole stream underlying this Guacamole.H264CameraRecorder. This + * function must be invoked ONLY ONCE per instance of + * Guacamole.H264CameraRecorder. + * + * @private + */ + var beginVideoCapture = function beginVideoCapture() { + + // Attempt to retrieve a video input stream from the browser + var videoConstraints = { + width: format.width, + height: format.height, + frameRate: format.frameRate + }; + if (format.deviceId) { + try { + videoConstraints.deviceId = { exact: format.deviceId }; + } catch (e) { + // Fallback: string assignment if object form not supported + videoConstraints.deviceId = format.deviceId; + } + } + var promise = navigator.mediaDevices.getUserMedia({ 'video': videoConstraints }); + + // Handle stream creation/rejection via Promise + if (promise && promise.then) + promise.then(streamReceived, streamDenied); + + }; + + /** + * Stops capturing video, if the capture has started, freeing all associated + * resources. If the capture has not started, this function simply ends the + * underlying Guacamole stream. + * + * @private + */ + var stopVideoCapture = function stopVideoCapture() { + + // Attempt graceful shutdown in order: reader, encoder, tracks + try { if (reader && reader.cancel) reader.cancel(); } catch (e) {} + try { if (reader && reader.releaseLock) reader.releaseLock(); } catch (e) {} + try { if (encoder && encoder.flush) encoder.flush(); } catch (e) {} + try { if (encoder && encoder.close) encoder.close(); } catch (e) {} + + // Reset PTS baseline and frame tracking so next encoding session starts fresh + baselinePtsUs = null; + + lastOutputPtsMs = null; + requireKeyframe(); + lastKeyframeWallMs = Date.now(); + + // Stop capture + if (mediaStream) { + try { + var tracks = mediaStream.getTracks(); + for (var i = 0; i < tracks.length; i++) + tracks[i].stop(); + } catch (e) {} + } + + // Remove references to now-unneeded components + processor = null; + reader = null; + encoder = null; + mediaStream = null; + + // End stream + writer.sendEnd(); + + }; + + + /** + * Resets timing state so the next encoded frame becomes the new baseline. + */ + this.resetTimeline = function resetTimeline() { + baselinePtsUs = null; + lastOutputPtsMs = null; + + requireKeyframe(); + }; + + /** + * Updates desired capture format (width/height/frameRate). + * + * @param {Object} c + * Optional constraints to override the current format. + * @param {number} [c.width] + * Override width in pixels. + * @param {number} [c.height] + * Override height in pixels. + * @param {number} [c.frameRate] + * Override frame rate (frames per second). + */ + this.setFormat = function setFormat(c) { + if (!c) return; + if (typeof c.width === 'number') format.width = c.width; + if (typeof c.height === 'number') format.height = c.height; + if (typeof c.frameRate === 'number') format.frameRate = c.frameRate; + if (c.deviceId) format.deviceId = c.deviceId; + }; + /** + * Starts the camera recording process. + */ + this.start = function start() { + if (!mediaStream) { + beginVideoCapture(); + } + }; + + /** + * Stops the camera recording process. + */ + this.stop = function stop() { + stopVideoCapture(); + }; + +}; + +Guacamole.H264CameraRecorder.prototype = new Guacamole.CameraRecorder(); + +/** + * Determines whether the given mimetype is supported by + * Guacamole.H264CameraRecorder. + * + * @param {!string} mimetype + * The mimetype to check. + * + * @returns {!boolean} + * true if the given mimetype is supported by Guacamole.H264CameraRecorder, + * false otherwise. + */ +Guacamole.H264CameraRecorder.isSupportedType = function isSupportedType(mimetype) { + + // Check for WebCodecs support + if (!window.VideoEncoder || !window.MediaStreamTrackProcessor) + return false; + + return mimetype === 'application/rdpecam+h264'; + +}; + +/** + * Returns a list of all mimetypes supported by Guacamole.H264CameraRecorder. + * + * @returns {!string[]} + * A list of all mimetypes supported by Guacamole.H264CameraRecorder. + */ +Guacamole.H264CameraRecorder.getSupportedTypes = function getSupportedTypes() { + + // Check for WebCodecs support + if (!window.VideoEncoder || !window.MediaStreamTrackProcessor) + return []; + + return ['application/rdpecam+h264']; + +}; diff --git a/guacamole-common-js/src/main/webapp/modules/Client.js b/guacamole-common-js/src/main/webapp/modules/Client.js index 03dee3e1b9..099eb0753d 100644 --- a/guacamole-common-js/src/main/webapp/modules/Client.js +++ b/guacamole-common-js/src/main/webapp/modules/Client.js @@ -477,6 +477,25 @@ Guacamole.Client = function(tunnel) { }; + /** + * Opens a new video stream for writing, having the given mimetype. The + * instruction necessary to create this stream will automatically be sent. + * + * @param {!string} mimetype + * The mimetype of the video data being sent. + * + * @return {!Guacamole.OutputStream} + * The created video stream. + */ + this.createVideoStream = function(mimetype) { + + // Allocate and associate stream with video metadata + var stream = guac_client.createOutputStream(); + tunnel.sendMessage("video", stream.index, mimetype); + return stream; + + }; + /** * Opens a new file for writing, having the given index, mimetype and * filename. The instruction necessary to create this stream will diff --git a/guacamole-common-js/src/main/webapp/modules/H264AnnexBUtil.js b/guacamole-common-js/src/main/webapp/modules/H264AnnexBUtil.js new file mode 100644 index 0000000000..f2728253c9 --- /dev/null +++ b/guacamole-common-js/src/main/webapp/modules/H264AnnexBUtil.js @@ -0,0 +1,100 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +var Guacamole = Guacamole || {}; + +/** + * Utilities for converting H.264 AVCC format to Annex-B. + */ +Guacamole.H264AnnexBUtil = (function() { + + /** + * Converts an AVCC payload into Annex-B format. If isKey is true and the + * provided config contains SPS/PPS, those will be prepended using start + * codes. The config.lengthSize determines the size (in bytes) of the + * length prefixes for NAL units within the AVCC payload. + * + * @param {!Uint8Array} payload + * The AVCC payload containing one or more NAL units with length + * prefixes of size config.lengthSize. + * + * @param {!boolean} isKey + * Whether this payload corresponds to a keyframe (IDR). + * + * @param {{ lengthSize: number, sps: Uint8Array[], pps: Uint8Array[] }|null} config + * Decoder configuration including SPS/PPS and the AVCC lengthSize. If + * null, SPS/PPS will not be included and a default lengthSize of 4 is + * assumed. + * + * @returns {!Uint8Array} + * The Annex-B formatted byte sequence. + */ + function avccToAnnexB(payload, isKey, config) { + var startCode = new Uint8Array([0x00, 0x00, 0x00, 0x01]); + var outParts = []; + + var haveConfig = !!config; + var lenSize = (haveConfig && config.lengthSize) ? config.lengthSize : 4; + + if (isKey && haveConfig) { + for (var i = 0; i < (config.sps ? config.sps.length : 0); i++) { + outParts.push(startCode); + outParts.push(config.sps[i]); + } + for (var j = 0; j < (config.pps ? config.pps.length : 0); j++) { + outParts.push(startCode); + outParts.push(config.pps[j]); + } + } + + var dv = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); + var off = 0; + while (off + lenSize <= dv.byteLength) { + var nalLen = 0; + for (var k = 0; k < lenSize; k++) + nalLen = (nalLen << 8) | dv.getUint8(off + k); + off += lenSize; + if (nalLen <= 0) + continue; + if (off + nalLen > dv.byteLength) + break; + var nal = new Uint8Array(payload.buffer, payload.byteOffset + off, nalLen); + outParts.push(startCode); + outParts.push(nal); + off += nalLen; + } + + var total = 0; + for (var p = 0; p < outParts.length; p++) total += outParts[p].length; + var out = new Uint8Array(total); + var pos = 0; + for (var q = 0; q < outParts.length; q++) { + out.set(outParts[q], pos); + pos += outParts[q].length; + } + + return out; + } + + return { + avccToAnnexB: avccToAnnexB + }; + +})(); + diff --git a/guacamole-common-js/src/main/webapp/tests/H264AnnexBUtilSpec.js b/guacamole-common-js/src/main/webapp/tests/H264AnnexBUtilSpec.js new file mode 100644 index 0000000000..1804addc69 --- /dev/null +++ b/guacamole-common-js/src/main/webapp/tests/H264AnnexBUtilSpec.js @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +describe('Guacamole.H264AnnexBUtil', function() { + + it('converts AVCC with lengthSize=1 to Annex-B', function() { + var payload = new Uint8Array([ 0x00, 0x01, 0x65 ]); // len=1, NAL=0x65 + var config = { lengthSize: 1, sps: [], pps: [] }; + var out = Guacamole.H264AnnexBUtil.avccToAnnexB(payload, false, config); + // 4-byte start code + 1-byte nal + expect(out.length).toBe(5); + expect(Array.prototype.slice.call(out)).toEqual([0,0,0,1,0x65]); + }); + + it('prepends SPS/PPS on keyframe', function() { + var payload = new Uint8Array([ 0x00, 0x01, 0x65 ]); + var sps = new Uint8Array([0x67, 0x64]); + var pps = new Uint8Array([0x68, 0xEE]); + var config = { lengthSize: 1, sps: [sps], pps: [pps] }; + var out = Guacamole.H264AnnexBUtil.avccToAnnexB(payload, true, config); + // start+SPS + start+PPS + start+IDR + expect(Array.prototype.slice.call(out)).toEqual([ + 0,0,0,1,0x67,0x64, + 0,0,0,1,0x68,0xEE, + 0,0,0,1,0x65 + ]); + }); + + it('supports lengthSize=2 and 4', function() { + // lengthSize=2, nal len=2 -> [00 02 AA BB] + var payload2 = new Uint8Array([ 0x00,0x02, 0xAA,0xBB ]); + var out2 = Guacamole.H264AnnexBUtil.avccToAnnexB(payload2, false, { lengthSize: 2, sps: [], pps: [] }); + expect(Array.prototype.slice.call(out2)).toEqual([0,0,0,1,0xAA,0xBB]); + + // lengthSize=4, nal len=1 -> [00 00 00 01 CC] + var payload4 = new Uint8Array([ 0x00,0x00,0x00,0x01, 0xCC ]); + var out4 = Guacamole.H264AnnexBUtil.avccToAnnexB(payload4, false, { lengthSize: 4, sps: [], pps: [] }); + expect(Array.prototype.slice.call(out4)).toEqual([0,0,0,1,0xCC]); + }); +}); + + diff --git a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/rdp.json b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/rdp.json index b62aaaad83..cf99993ca0 100644 --- a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/rdp.json +++ b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/rdp.json @@ -228,6 +228,11 @@ "type" : "BOOLEAN", "options" : [ "true" ] }, + { + "name" : "enable-rdpecam", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, { "name" : "enable-printing", "type" : "BOOLEAN", diff --git a/guacamole/src/main/frontend/src/app/client/controllers/clientController.js b/guacamole/src/main/frontend/src/app/client/controllers/clientController.js index 6a486fe9a2..f228406c5c 100644 --- a/guacamole/src/main/frontend/src/app/client/controllers/clientController.js +++ b/guacamole/src/main/frontend/src/app/client/controllers/clientController.js @@ -40,6 +40,7 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams const dataSourceService = $injector.get('dataSourceService'); const guacClientManager = $injector.get('guacClientManager'); const guacFullscreen = $injector.get('guacFullscreen'); + const guacRDPECAM = $injector.get('guacRDPECAM'); const iconService = $injector.get('iconService'); const preferenceService = $injector.get('preferenceService'); const requestService = $injector.get('requestService'); @@ -168,6 +169,74 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams */ $scope.focusedClient = null; + /** + * Camera redirection state + */ + $scope.cameraActive = false; + $scope.cameraSupported = guacRDPECAM.isSupported(); + + /** + * Video delay setting (0-1000ms) bound to slider in UI. + * @type {number} + */ + $scope.videoDelay = guacRDPECAM.getVideoDelay(); + + /** + * Video delay notification state + */ + $scope.videoDelayNotification = { + message: null, + timeoutHandle: null + }; + + /** + * Shows a video delay notification message for 3 seconds + * @param {string} message - The message to display + */ + $scope.showVideoDelayNotification = function(message) { + // Clear existing timeout if any + if ($scope.videoDelayNotification.timeoutHandle) { + clearTimeout($scope.videoDelayNotification.timeoutHandle); + } + + // Show new message + $scope.videoDelayNotification.message = message; + + // Auto-hide after 3 seconds + $scope.videoDelayNotification.timeoutHandle = setTimeout(function() { + $scope.$apply(function() { + $scope.videoDelayNotification.message = null; + $scope.videoDelayNotification.timeoutHandle = null; + }); + }, 3000); + }; + + /** + * Array of available camera devices with their enabled/active state. + * @type {Array.} + */ + $scope.cameraDevices = []; + + /** + * Registers callback to update camera device list from guacRDPECAM service. + */ + guacRDPECAM.registerCameraListCallback(function(cameras) { + // Use $evalAsync to avoid digest cycle errors + // This safely updates the scope whether we're in a digest or not + $scope.$evalAsync(function() { + $scope.cameraDevices = cameras; + }); + }); + + /** + * Handles camera checkbox toggle from UI. + * @param {string} deviceId - The camera device ID + * @param {boolean} enabled - Whether camera should be enabled + */ + $scope.onCameraToggle = function(deviceId, enabled) { + guacRDPECAM.toggleCamera(deviceId, enabled); + }; + /** * The set of clients that should be attached to the client UI. This will * be immediately initialized by a call to updateAttachedClients() below. @@ -489,6 +558,11 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams const oldFocusedClient = $scope.focusedClient; $scope.focusedClient = newFocusedClient; + // Stop any active camera stream associated with the previously focused client + if (oldFocusedClient && oldFocusedClient !== newFocusedClient && $scope.cameraActive) { + $scope.stopCamera(); + } + // Apply any parameter changes when focus is changing if (oldFocusedClient) $scope.applyParameterChanges(oldFocusedClient); @@ -498,6 +572,32 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams $scope.menu.connectionParameters = newFocusedClient ? ManagedClient.getArgumentModel(newFocusedClient) : {}; + // Register camera state callback for the new focused client + if (newFocusedClient && newFocusedClient.client) { + // Register callback to update $scope.cameraActive when server sends start/stop signals + guacRDPECAM.registerStateCallback(newFocusedClient.client, function(state) { + $scope.$apply(function() { + var wasInactive = !$scope.cameraActive; + $scope.cameraActive = state.active; + + // Sync slider value with stored delay when camera starts + if (state.active) { + $scope.videoDelay = guacRDPECAM.getVideoDelay(); + + // Show reminder notification when camera starts with delay configured + if (wasInactive && $scope.videoDelay > 0) { + $scope.showVideoDelayNotification('Video delay active: +' + $scope.videoDelay + 'ms'); + } + } + }); + }); + } + + // Always prepare camera capabilities if supported + if (newFocusedClient && newFocusedClient.client && guacRDPECAM.isSupported()) { + $scope.prepareCameraCapabilities(); + } + }); // Automatically update connection parameters that have been modified @@ -679,6 +779,69 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams }; + /** + * Prepares camera capabilities for the focused client. + * + * This prefetches the browser's camera capabilities and advertises them + * to the server. The actual camera start is protocol-driven - when Windows + * sends Start Streams Request, the server sends argv instructions that + * trigger camera start automatically. + */ + $scope.prepareCameraCapabilities = function prepareCameraCapabilities() { + if (!$scope.focusedClient || !guacRDPECAM.isSupported()) { + return; + } + + guacRDPECAM.prefetchCapabilities($scope.focusedClient.client); + }; + + /** + * Stops camera redirection for the focused client. + */ + $scope.stopCamera = function stopCamera() { + if ($scope.focusedClient) { + guacRDPECAM.stopCamera($scope.focusedClient.client); + } + $scope.cameraActive = false; + }; + + /** + * Gets the current video delay setting in milliseconds. + * + * @returns {number} + * The current video delay in milliseconds (0-1000). + */ + $scope.getVideoDelay = function getVideoDelay() { + return $scope.videoDelay; + }; + + /** + * Updates the video delay when slider is changed. + * Saves to localStorage and shows notification. + */ + $scope.updateVideoDelay = function updateVideoDelay() { + // Read directly from slider element to avoid child scope issues (ng-if creates child scope) + var sliderElement = document.getElementById('video-delay-range'); + if (!sliderElement) { + return; + } + + var delay = parseInt(sliderElement.value, 10); + + if (isNaN(delay)) delay = 0; + if (delay < 0) delay = 0; + if (delay > 1000) delay = 1000; + + // Update the service (saves to localStorage) + guacRDPECAM.setVideoDelay(delay); + + // Update parent scope value (for display) + $scope.videoDelay = delay; + + // Show notification + $scope.showVideoDelayNotification('Video delay: +' + delay + 'ms'); + }; + /** * Disconnects the given ManagedClient, removing it from the current * view. @@ -872,6 +1035,8 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams $scope.$on('$destroy', function clientViewDestroyed() { setAttachedGroup(null); + guacRDPECAM.resetDeviceChangeHandler(); + // always unset fullscreen mode to not confuse user guacFullscreen.setFullscreenMode(false); }); diff --git a/guacamole/src/main/frontend/src/app/client/services/guacRDPECAM.js b/guacamole/src/main/frontend/src/app/client/services/guacRDPECAM.js new file mode 100644 index 0000000000..150e81c2ef --- /dev/null +++ b/guacamole/src/main/frontend/src/app/client/services/guacRDPECAM.js @@ -0,0 +1,1464 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/* global Guacamole */ + +/** + * RDPECAM (Remote Desktop Protocol Enhanced Camera) service for Guacamole. + * This service provides camera redirection functionality for RDP connections. + */ +angular.module('client').factory('guacRDPECAM', ['$injector', function guacRDPECAM($injector) { + + // Required services + const preferenceService = $injector.get('preferenceService'); + + /** + * The mimetype of video data to be sent along the Guacamole connection if + * camera redirection is supported. + * + * @constant + * @type String + */ + const RDPECAM_MIMETYPE = 'application/rdpecam+h264'; + + /** + * Default camera constraints for RDPECAM. + * Using 640x480@15fps for Windows compatibility with H.264 Level 3.0 + * + * @constant + * @type Object + */ + const DEFAULT_CONSTRAINTS = { width: 640, height: 480, frameRate: 15 }; + + const CAPABILITY_CANDIDATES = [ + { width: 640, height: 480, fps: [30, 15] }, + { width: 320, height: 240, fps: [30, 15] }, + { width: 1280, height: 720, fps: [30] }, + { width: 1920, height: 1080, fps: [30] } + ]; + + const probedClients = new WeakSet(); + + /** + * Camera registry tracking all available cameras and their state. + * Map of deviceId -> camera object with: + * - deviceId: string (browser device ID) + * - label: string (friendly name) + * - enabled: boolean (user preference - checkbox state) + * - isActive: boolean (currently streaming to Windows) + * - formats: array (supported video formats) + * + * @type {Object.} + * @private + */ + var cameraRegistry = {}; + + /** + * Reference to the current Guacamole client for capability updates. + * + * @type {Guacamole.Client} + * @private + */ + var currentClient = null; + + /** + * Tracks the currently registered devicechange handler and which client it + * is associated with. Only one handler is active at a time to mirror the + * legacy behaviour that targeted the most recently focused client. + */ + var deviceChangeHandler = null; + var deviceChangeListenerClientId = null; + var previousOnDeviceChangeHandler = null; + var deviceChangeHandlerUsesAddEventListener = false; + + /** + * Callbacks to notify when camera registry changes (for UI updates). + * + * @type {Array.} + * @private + */ + var registryChangeCallbacks = []; + /** + * Tracks any in-flight permission request so we don't spam + * getUserMedia() when device IDs are unavailable. + * + * @type {Promise|Null} + */ + var pendingPermissionRequest = null; + + // Always use a local, stable ID for each Guacamole.Client + const clientIds = new WeakMap(); + /** + * Gets or creates a stable local ID for a Guacamole client. + * + * @param {Guacamole.Client} client + * The Guacamole client to get an ID for. + * + * @returns {string|null} + * A unique local ID for the client, or null if client is null. + * + * @private + */ + function getLocalClientId(client) { + if (!client) + return null; + var id = clientIds.get(client); + if (id) + return id; + id = 'local-' + Date.now().toString(36) + '-' + Math.floor(Math.random() * 1e6).toString(36); + clientIds.set(client, id); + return id; + } + + /** + * Removes the currently registered media device change handler if it is + * associated with the given client. If no client is provided, any registered + * handler is removed unconditionally. + * + * @param {Guacamole.Client} [client] + * The Guacamole client whose handler should be removed, or undefined to + * force removal of the active handler. + */ + function unregisterDeviceChangeHandler(client) { + if (!deviceChangeHandler) + return; + + var shouldRemove = true; + if (client) { + var clientId = getLocalClientId(client); + shouldRemove = (clientId && clientId === deviceChangeListenerClientId); + } + + if (!shouldRemove) + return; + + if (typeof navigator !== 'undefined' && navigator.mediaDevices) { + if (deviceChangeHandlerUsesAddEventListener && + typeof navigator.mediaDevices.removeEventListener === 'function') { + navigator.mediaDevices.removeEventListener('devicechange', deviceChangeHandler); + } + else if (!deviceChangeHandlerUsesAddEventListener && + Object.prototype.hasOwnProperty.call(navigator.mediaDevices, 'ondevicechange')) { + navigator.mediaDevices.ondevicechange = previousOnDeviceChangeHandler || null; + } + } + + deviceChangeHandler = null; + deviceChangeListenerClientId = null; + previousOnDeviceChangeHandler = null; + deviceChangeHandlerUsesAddEventListener = false; + } + + /** + * Ensures a media device change handler is registered for the given client, + * replacing any previously registered handler. + * + * @param {Guacamole.Client} client + * The Guacamole client that should receive device change updates. + */ + function registerDeviceChangeHandler(client) { + if (typeof navigator === 'undefined' || !navigator.mediaDevices) + return; + + var clientId = getLocalClientId(client); + if (!clientId) + return; + + if (deviceChangeListenerClientId === clientId && deviceChangeHandler) + return; + + /* Remove any previously registered handler (different client). */ + unregisterDeviceChangeHandler(); + + if (typeof navigator.mediaDevices.addEventListener === 'function') { + deviceChangeHandler = function() { + if (currentClient) + enumerateAndUpdateCameras(currentClient); + }; + navigator.mediaDevices.addEventListener('devicechange', deviceChangeHandler); + deviceChangeHandlerUsesAddEventListener = true; + deviceChangeListenerClientId = clientId; + } + else if (Object.prototype.hasOwnProperty.call(navigator.mediaDevices, 'ondevicechange')) { + previousOnDeviceChangeHandler = navigator.mediaDevices.ondevicechange; + deviceChangeHandler = function(event) { + if (typeof previousOnDeviceChangeHandler === 'function') + previousOnDeviceChangeHandler.call(this, event); + if (currentClient) + enumerateAndUpdateCameras(currentClient); + }; + navigator.mediaDevices.ondevicechange = deviceChangeHandler; + deviceChangeHandlerUsesAddEventListener = false; + deviceChangeListenerClientId = clientId; + } + } + + /** + * Clears any active device change handler. + */ + function resetDeviceChangeHandler() { + unregisterDeviceChangeHandler(); + } + + /** + * Checks if a capability supports a given value. + * + * @param {Object|Array|undefined} capability + * The capability object, array, or undefined. + * + * @param {*} value + * The value to check against the capability. + * + * @returns {boolean} + * True if the capability supports the value, false otherwise. + * + * @private + */ + function capabilitySupportsValue(capability, value) { + if (!capability) + return true; + + if (Array.isArray(capability)) + return capability.indexOf(value) !== -1; + + if (typeof capability === 'object') { + if (typeof capability.max === 'number' && value > capability.max) + return false; + if (typeof capability.min === 'number' && value < capability.min) + return false; + if (typeof capability.step === 'number' && typeof capability.min === 'number') { + const step = capability.step; + if (step > 0 && ((value - capability.min) % step) !== 0) + return false; + } + } + + return true; + } + + /** + * Notifies all registered callbacks that the camera registry has changed. + * + * @private + */ + function notifyRegistryChange() { + registryChangeCallbacks.forEach(function(callback) { + try { + callback(getCameraList()); + } catch (e) { + // Ignore callback errors + } + }); + } + + /** + * Gets an array of all cameras in the registry. + * + * @returns {Array.} + * Array of camera objects from registry. + */ + function getCameraList() { + return Object.keys(cameraRegistry).map(function(deviceId) { + return cameraRegistry[deviceId]; + }); + } + + /** + * Saves currently enabled camera device IDs to preferences. + * + * @private + */ + function saveEnabledDevicesToPreferences() { + var enabledDevices = Object.keys(cameraRegistry) + .filter(function(deviceId) { + return cameraRegistry[deviceId].enabled; + }); + preferenceService.preferences.rdpecamEnabledDevices = enabledDevices; + preferenceService.save(); + } + + /** + * Gets array of enabled camera objects from registry. + * + * @returns {Array.} + * Array of enabled camera objects with deviceId, deviceName, and formats. + * + * @private + */ + function getEnabledCameras() { + return Object.keys(cameraRegistry) + .filter(function(deviceId) { + return cameraRegistry[deviceId].enabled; + }) + .map(function(deviceId) { + var camera = cameraRegistry[deviceId]; + return { + deviceId: camera.deviceId, + deviceName: camera.label, + formats: camera.formats + }; + }); + } + + /** + * Sends updated capabilities to the server via rdpecam-capabilities-update argv stream. + * Only includes enabled cameras. + * + * @param {Guacamole.Client} client + * The Guacamole client. + * + * @private + */ + function updateCapabilities(client) { + if (!client) + return; + + var enabledCameras = getEnabledCameras(); + + // If no cameras are enabled, send empty string + if (enabledCameras.length === 0) { + try { + var stream = client.createArgumentValueStream('text/plain', 'rdpecam-capabilities-update'); + var writer = new Guacamole.StringWriter(stream); + writer.sendText(''); + writer.sendEnd(); + } + catch (e) { + // Unable to send capability update - ignore + } + return; + } + + // Build and send capability string (same format as initial capabilities) + try { + var deviceEntries = enabledCameras.map(function(device) { + if (!device || !device.formats || !device.formats.length) + return null; + + var deviceId = (device.deviceId && device.deviceId.trim()) ? device.deviceId.trim() : ''; + if (!deviceId) { + return null; + } + + var entries = device.formats.map(function(format) { + var width = Math.round(format.width); + var height = Math.round(format.height); + var fpsNum = Math.round(format.fpsNumerator || format.frameRate || 0); + var fpsDen = Math.round(format.fpsDenominator || 1); + + if (fpsNum <= 0) + fpsNum = 1; + if (fpsDen <= 0) + fpsDen = 1; + + return width + 'x' + height + '@' + fpsNum + '/' + fpsDen; + }).join(','); + + var name = (device.deviceName && device.deviceName.trim()) ? device.deviceName.trim() : ''; + return deviceId + ':' + name + '|' + entries; + }).filter(function(entry) { return entry !== null; }); + + if (deviceEntries.length === 0) { + return; + } + + var payload = deviceEntries.join(';'); + + var stream = client.createArgumentValueStream('text/plain', 'rdpecam-capabilities-update'); + var writer = new Guacamole.StringWriter(stream); + writer.sendText(payload); + writer.sendEnd(); + } + catch (e) { + // Unable to send capability update - ignore + } + } + + /** + * Derives video format list from MediaTrackCapabilities object. + * + * @param {MediaTrackCapabilities} capabilities + * The MediaTrackCapabilities object from getCapabilities(). + * + * @returns {Array.} + * Array of format objects with width, height, fpsNumerator, fpsDenominator. + * + * @private + */ + function deriveFormatsFromCapabilities(capabilities) { + const formats = []; + const seen = {}; + + const pushFormat = function(width, height, fpsNum, fpsDen) { + const key = width + 'x' + height + '@' + fpsNum + '/' + fpsDen; + if (seen[key]) + return; + seen[key] = true; + formats.push({ + width: width, + height: height, + fpsNumerator: fpsNum, + fpsDenominator: fpsDen + }); + }; + + CAPABILITY_CANDIDATES.forEach(function(candidate) { + if (!capabilitySupportsValue(capabilities.width, candidate.width)) + return; + if (!capabilitySupportsValue(capabilities.height, candidate.height)) + return; + + candidate.fps.forEach(function(fps) { + if (!capabilitySupportsValue(capabilities.frameRate, fps)) + return; + pushFormat(candidate.width, candidate.height, fps, 1); + }); + }); + + if (!formats.length) { + pushFormat(DEFAULT_CONSTRAINTS.width, DEFAULT_CONSTRAINTS.height, + DEFAULT_CONSTRAINTS.frameRate || 15, 1); + } + + return formats; + } + + /** + * Sends a capability descriptor payload upstream to guacd via an "argv" + * stream. Always uses multi-device format: "DEVICE_ID:DEVICE_NAME|FORMATS;..." + * + * @param {!Guacamole.Client} client + * The client instance handling the RDPECAM stream. + * + * @param {!Array.<{deviceId:string,deviceName:string,formats:Array}>} devices + * Array of device objects with deviceId, deviceName, and formats. + * Each device must have a non-empty deviceId. + */ + function sendCapabilities(client, devices) { + + if (!client || !devices || !Array.isArray(devices) || devices.length === 0) + return; + + try { + var deviceEntries = devices.map(function(device) { + if (!device || !device.formats || !device.formats.length) + return null; + + // Require device ID - skip devices without it + var deviceId = (device.deviceId && device.deviceId.trim()) ? device.deviceId.trim() : ''; + if (!deviceId) { + return null; + } + + var entries = device.formats.map(function(format) { + var width = Math.round(format.width); + var height = Math.round(format.height); + var fpsNum = Math.round(format.fpsNumerator || format.frameRate || 0); + var fpsDen = Math.round(format.fpsDenominator || 1); + + if (fpsNum <= 0) + fpsNum = 1; + if (fpsDen <= 0) + fpsDen = 1; + + return width + 'x' + height + '@' + fpsNum + '/' + fpsDen; + }).join(','); + + // Format: "DEVICE_ID:DEVICE_NAME|FORMATS" + var name = (device.deviceName && device.deviceName.trim()) ? device.deviceName.trim() : ''; + return deviceId + ':' + name + '|' + entries; + }).filter(function(entry) { return entry !== null; }); + + if (deviceEntries.length === 0) { + return; + } + + var payload = deviceEntries.join(';'); + + var stream = client.createArgumentValueStream('text/plain', 'rdpecam-capabilities'); + var writer = new Guacamole.StringWriter(stream); + writer.sendText(payload); + writer.sendEnd(); + } + catch (e) { + // Unable to advertise camera capabilities - ignore + } + + } + + function prefetchCapabilities(client) { + if (typeof navigator === 'undefined' || !navigator.mediaDevices) + return; + + if (!client || probedClients.has(client)) + return; + + probedClients.add(client); + + // Store client reference for capability updates + currentClient = client; + + // Set up hot-plug detection (only once per active client) + registerDeviceChangeHandler(client); + + // Initial enumeration + enumerateAndUpdateCameras(client); + } + + /** + * Enumerates cameras, updates registry, and sends capabilities. + * + * @param {Guacamole.Client} client + * The Guacamole client. + * + * @private + */ + function enumerateAndUpdateCameras(client) { + if (typeof navigator.mediaDevices.enumerateDevices !== 'function') + return; + + navigator.mediaDevices.enumerateDevices() + .then(function(devices) { + var videoDevices = devices.filter(function(device) { + return device.kind === 'videoinput'; + }); + + if (videoDevices.length === 0) { + // No cameras detected, clear registry + cameraRegistry = {}; + notifyRegistryChange(); + return; + } + + var hasUsableDeviceIds = videoDevices.some(function(device) { + return device.deviceId && device.deviceId.trim().length > 0; + }); + + // If browsers redact device IDs prior to permission being granted, + // request access once and retry enumeration so that prompting occurs. + if (!hasUsableDeviceIds) { + requestCameraPermission().then(function() { + enumerateAndUpdateCameras(client); + }).catch(function(error) { + console.error('Camera permission request failed:', error); + cameraRegistry = {}; + notifyRegistryChange(); + }); + return; + } + + // Probe capabilities for each device + var devicePromises = videoDevices.map(function(deviceInfo) { + return probeDeviceCapabilities(deviceInfo.deviceId, deviceInfo.label || ''); + }); + + Promise.all(devicePromises).then(function(deviceCapabilities) { + // Filter out devices with no capabilities or no device ID + var validDevices = deviceCapabilities.filter(function(dev) { + return dev && dev.deviceId && dev.deviceId.trim() && dev.formats && dev.formats.length > 0; + }); + + if (validDevices.length === 0) { + cameraRegistry = {}; + notifyRegistryChange(); + return; + } + + // Load saved preferences + var savedEnabledDevices = preferenceService.preferences.rdpecamEnabledDevices; + var isFirstTime = (typeof savedEnabledDevices === 'undefined' || savedEnabledDevices === null); + + // If not first time, ensure it's an array + if (!isFirstTime && !Array.isArray(savedEnabledDevices)) { + savedEnabledDevices = []; + } + + // Build new registry + var newRegistry = {}; + var deviceIdsChanged = false; + + validDevices.forEach(function(device) { + var wasInRegistry = cameraRegistry.hasOwnProperty(device.deviceId); + var wasEnabled = wasInRegistry ? cameraRegistry[device.deviceId].enabled : false; + + // Determine enabled state + var enabled; + if (isFirstTime) { + // First time (preference never set): enable all cameras by default + enabled = true; + } else if (wasInRegistry) { + // Keep existing enabled state from current session + enabled = wasEnabled; + } else if (savedEnabledDevices.indexOf(device.deviceId) !== -1) { + // New camera that user previously enabled + enabled = true; + } else { + // New camera or user disabled it: default to disabled + enabled = false; + } + + newRegistry[device.deviceId] = { + deviceId: device.deviceId, + label: device.deviceName, + enabled: enabled, + isActive: wasInRegistry ? cameraRegistry[device.deviceId].isActive : false, + formats: device.formats + }; + + if (!wasInRegistry || wasEnabled !== enabled) { + deviceIdsChanged = true; + } + }); + + // Check for removed devices + Object.keys(cameraRegistry).forEach(function(deviceId) { + if (!newRegistry.hasOwnProperty(deviceId)) { + deviceIdsChanged = true; + } + }); + + cameraRegistry = newRegistry; + + // Save enabled devices if first time or if devices changed + if (isFirstTime || deviceIdsChanged) { + saveEnabledDevicesToPreferences(); + } + + // Send capabilities for enabled cameras only + var enabledCameras = getEnabledCameras(); + if (enabledCameras.length > 0) { + sendCapabilities(client, enabledCameras); + } + + // Notify UI + notifyRegistryChange(); + + }).catch(function(error) { + // Error probing device capabilities - log error and clear registry + console.error('Error probing camera capabilities:', error); + cameraRegistry = {}; + notifyRegistryChange(); + }); + }).catch(function(error) { + // Error enumerating devices - log error and clear registry + console.error('Error enumerating camera devices:', error); + cameraRegistry = {}; + notifyRegistryChange(); + }); + } + + /** + * Requests generic camera access to trigger the browser permission prompt. + * Subsequent enumerateDevices() calls will include device IDs. + * + * @returns {Promise} + * Resolves once permission request completes (granted or denied). + * + * @private + */ + function requestCameraPermission() { + if (pendingPermissionRequest) + return pendingPermissionRequest; + + if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== 'function') + return Promise.reject(new Error('Camera APIs unavailable')); + + pendingPermissionRequest = navigator.mediaDevices.getUserMedia({ video: true }) + .then(function(stream) { + if (stream) { + try { + stream.getTracks().forEach(function(track) { track.stop(); }); + } + catch (ignore) {} + } + }) + .catch(function(error) { + console.error('Camera permission request failed:', error); + return Promise.reject(error); + }) + .finally(function() { + pendingPermissionRequest = null; + }); + + return pendingPermissionRequest; + } + + /** + * Probes capabilities for a single device by device ID. + * + * @param {string} deviceId + * The device ID to probe. + * + * @param {string} deviceName + * The device label/name. + * + * @returns {Promise.<{deviceId:string,deviceName:string,formats:Array}>} + * Promise resolving to device capabilities object. + */ + function probeDeviceCapabilities(deviceId, deviceName) { + return new Promise(function(resolve, reject) { + var constraints = { video: { deviceId: { exact: deviceId } } }; + if (!deviceId || !deviceId.trim()) { + constraints = { video: true }; + } + + navigator.mediaDevices.getUserMedia(constraints).then(function(stream) { + try { + var track = stream.getVideoTracks()[0]; + var formats = []; + + if (track && typeof track.getCapabilities === 'function') { + var caps = track.getCapabilities(); + formats = deriveFormatsFromCapabilities(caps || {}); + } else { + formats = deriveFormatsFromCapabilities({}); + } + + // Get actual device name from track if available + var actualName = (track && track.label) ? track.label : deviceName; + + resolve({ + deviceId: deviceId, + deviceName: actualName, + formats: formats + }); + } + catch (e) { + reject(e); + } + finally { + if (stream) { + try { + stream.getTracks().forEach(function(track) { track.stop(); }); + } + catch (ignore) {} + } + } + }).catch(function(error) { + reject(error); + }); + }); + } + + + /** + * Starts camera redirection for the given client. + * + * @param {Guacamole.Client} client + * The Guacamole client for which camera redirection should be started. + * + * @param {Object} [constraints] + * Optional camera constraints. If not provided, default constraints + * will be used. + * + * @param {Function} [onState] + * Optional callback function to be invoked when the camera state changes. + * The callback will receive an object with the current state. + * + * @returns {Promise} + * A promise that resolves with an object containing a stop() method + * to stop camera redirection. + */ + function startCamera(client, constraints, onState) { + + // Use default constraints if none provided + constraints = constraints || DEFAULT_CONSTRAINTS; + + return new Promise((resolve, reject) => { + + try { + // Ensure single recorder per client: stop any existing one first + var existingClientId = getLocalClientId(client); + if (existingClientId && (cameraControllers[existingClientId] || cameraRecorders[existingClientId])) { + try { + stopCamera(client); + } catch (ignore) {} + } + + // Get client ID for tracking + var clientId = getLocalClientId(client); + + // Create video stream for camera data + const realStream = client.createVideoStream(RDPECAM_MIMETYPE); + + // Wrap stream with delay queue (handles both delayed and immediate modes) + const stream = createDelayedStream(realStream, clientId); + + // Create camera recorder (will use delayed stream) + const recorder = Guacamole.CameraRecorder.getInstance(stream, RDPECAM_MIMETYPE); + + if (!recorder) { + stream.sendEnd(); + reject(new Error('Camera recording not supported')); + return; + } + + // Capabilities are sent via prefetchCapabilities, which enumerates all devices. + // This callback may still fire, but we rely on prefetchCapabilities for the + // multi-device format. + if (recorder) { + recorder.oncapabilities = function(formats, deviceName) { + // Capabilities should have been sent via prefetchCapabilities already. + // If this fires, it means a camera track was detected, but we rely + // on the enumeration-based approach for multi-device support. + }; + } + + // Set format constraints before starting (deviceId-aware) + if (recorder && typeof recorder.setFormat === 'function') { + recorder.setFormat(constraints); + } + + // Explicitly start camera capture now that recorder is configured. + if (recorder && typeof recorder.start === 'function') { + recorder.start(); + if (typeof recorder.resetTimeline === 'function') + recorder.resetTimeline(); + } + + // No pause/resume handling; throttling is credit-based on server. + + // Set up recorder event handlers + recorder.onclose = function() { + // Update state to inactive + var clientId = getLocalClientId(client); + if (clientId) { + var deviceId = cameraStates[clientId] ? cameraStates[clientId].deviceId : null; + updateCameraState(clientId, false, deviceId); + } + + if (onState) { + onState({ running: false }); + } + }; + + recorder.onerror = function() { + // Update state to inactive on error + var clientId = getLocalClientId(client); + if (clientId) { + var deviceId = cameraStates[clientId] ? cameraStates[clientId].deviceId : null; + updateCameraState(clientId, false, deviceId); + } + + if (onState) { + onState({ running: false, error: true }); + } + }; + + // Do not override stream.onack — the underlying writer uses + // its own ACK handler to control start/back-pressure. + + // Create stop function + var stopFunction = function() { + recorder.stop(); + stream.sendEnd(); + + // Clean up recorder reference (state update handled by caller) + if (clientId && cameraRecorders[clientId]) { + delete cameraRecorders[clientId]; + } + + if (onState) { + onState({ running: false }); + } + }; + + // Update state to active and register controller IMMEDIATELY + var clientId = getLocalClientId(client); + if (clientId) { + cameraRecorders[clientId] = recorder; + cameraControllers[clientId] = stopFunction; // Register BEFORE resolving promise + + // Store deviceId in cameraStates for later use when stopping + if (!cameraStates[clientId]) { + cameraStates[clientId] = {}; + } + cameraStates[clientId].deviceId = constraints.deviceId; + + updateCameraState(clientId, true, constraints.deviceId); + } + + // Notify state change + if (onState) { + onState({ + running: true, + width: constraints.width, + height: constraints.height + }); + } + + // Return control object + resolve({ + stop: stopFunction + }); + + } catch (error) { + reject(error); + } + + }); + + } + + /** + * Starts camera redirection with parameters received from server. + * + * When Windows sends Start Streams Request, server sends argv instructions + * with camera configuration parameters. This function is called when all + * parameters have been received and starts camera capture at the correct + * protocol time. + * + * @param {Guacamole.Client} client + * The Guacamole client for which camera redirection should be started. + * + * @param {Object} params + * Camera parameters received from server via argv instructions. + * Expected properties: width, height, fpsNum, fpsDenom, streamIndex + * + * @param {Function} [onState] + * Optional callback function to be invoked when the camera state changes. + * + * @returns {Promise} + * A promise that resolves with an object containing a stop() method. + */ + function startCameraWithParams(client, params, onState) { + + // Convert server parameters to camera constraints format + const constraints = { + width: params.width, + height: params.height, + frameRate: params.fpsNum / params.fpsDenom, // Calculate actual FPS (e.g., 15/1 = 15) + deviceId: params.deviceId || undefined + }; + + // Use the existing startCamera function with server-provided constraints + // Controller is registered immediately inside startCamera, no need to do it here + return startCamera(client, constraints, onState); + } + + /** + * Checks if camera redirection is supported by the current browser. + * + * @returns {boolean} + * true if camera redirection is supported, false otherwise. + */ + function isSupported() { + return Guacamole.CameraRecorder.isSupportedType(RDPECAM_MIMETYPE); + } + + /** + * Map of client IDs to their camera controllers/stop functions. + * Allows stopping camera for specific client instances. + * + * @type {Object.} + * @private + */ + var cameraControllers = {}; + + /** + * Map of client IDs to their active camera recorder instances. + * Used for cleanup and state management. + * + * @type {Object.} + * @private + */ + var cameraRecorders = {}; + + /** + * Map of client IDs to their camera state objects. + * Tracks whether camera is active for each client. + * + * @type {Object.} + * @private + */ + var cameraStates = {}; + + /** + * Map of client IDs to their UI state update callbacks. + * Called when camera active state changes. + * + * @type {Object.} + * @private + */ + var stateUpdateCallbacks = {}; + + /** + * Maximum queue size in bytes before overflow protection triggers. + * Set to 500KB (4x normal max at 1000ms delay). + * + * @constant + * @type {number} + * @private + */ + var DELAY_QUEUE_OVERFLOW_THRESHOLD = 500 * 1024; + + /** + * Timer interval in milliseconds for processing the delay queue. + * 20ms provides max 20ms jitter while being CPU-efficient. + * + * @constant + * @type {number} + * @private + */ + var DELAY_TIMER_INTERVAL_MS = 20; + + /** + * Current video delay in milliseconds (0-1000ms). + * Loaded from preferenceService on first access. + * + * @type {number} + * @private + */ + var videoDelayMs = 0; + + /** + * Map of client IDs to their delay queues. + * Each queue is an array of {data: Blob, timestamp: number, size: number} objects. + * + * @type {Object.} + * @private + */ + var delayQueues = {}; + + /** + * Map of client IDs to their queue processing timer intervals. + * + * @type {Object.} + * @private + */ + var delayTimerIntervals = {}; + + /** + * Gets the current video delay setting in milliseconds from preferenceService. + * + * @returns {number} + * The current delay in milliseconds (0-1000). + */ + function getVideoDelay() { + var delay = preferenceService.preferences.rdpecamVideoDelay; + // Ensure it's a valid number in range + if (typeof delay !== 'number' || isNaN(delay) || delay < 0 || delay > 1000) { + delay = 0; + } + videoDelayMs = delay; + return videoDelayMs; + } + + /** + * Sets the video delay to an absolute value and saves to preferenceService. + * + * @param {number} delayMs + * The new delay value in milliseconds (0-1000). + * + * @returns {number} + * The new delay value after clamping. + */ + function setVideoDelay(delayMs) { + videoDelayMs = delayMs; + + // Clamp to [0, 1000] + if (videoDelayMs < 0) + videoDelayMs = 0; + else if (videoDelayMs > 1000) + videoDelayMs = 1000; + + // Save to preferenceService + preferenceService.preferences.rdpecamVideoDelay = videoDelayMs; + preferenceService.save(); + + return videoDelayMs; + } + + /** + * Creates a delayed stream wrapper that queues video frames and sends them + * after the configured delay. This wrapper proxies the Guacamole.OutputStream + * interface. + * + * @param {Guacamole.OutputStream} realStream + * The actual stream to send delayed data to. + * + * @param {string} clientId + * The client ID for tracking this stream's queue. + * + * @returns {Object} + * A proxy object implementing the OutputStream interface with delay. + * + * @private + */ + function createDelayedStream(realStream, clientId) { + + // Initialize queue for this client + delayQueues[clientId] = []; + + var delayedStream = { + /** + * The stream index (proxied from real stream). + * @type {number} + */ + index: realStream.index, + + /** + * Acknowledgement handler (proxied to real stream). + * Initially null, set by the writer. + * @type {Function|null} + */ + onack: null, + + /** + * Sends a blob with optional delay. + * If delay is 0, sends immediately. Otherwise, queues for later. + * + * @param {string} data + * Base64-encoded blob data to send. + */ + sendBlob: function(data) { + // Load current delay from preferenceService + getVideoDelay(); + + // If no delay, send immediately (zero overhead path) + if (videoDelayMs === 0) { + realStream.sendBlob(data); + return; + } + + // Estimate blob size (base64 is ~4/3 of binary size) + var estimatedSize = Math.ceil(data.length * 3 / 4); + + // Queue the blob with current timestamp + delayQueues[clientId].push({ + data: data, + timestamp: Date.now(), + size: estimatedSize + }); + + // Check for queue overflow + checkQueueOverflow(clientId); + + // Ensure timer is running + startQueueTimer(clientId, realStream); + }, + + /** + * Ends the stream (proxied to real stream). + * Also clears the delay queue and stops the timer. + */ + sendEnd: function() { + stopQueueTimer(clientId); + clearDelayQueue(clientId); + realStream.sendEnd(); + } + }; + + // Forward ACKs from realStream to delayedStream + // The server sends ACKs to realStream, but ArrayBufferWriter expects them on delayedStream + realStream.onack = function(status) { + if (delayedStream.onack) { + delayedStream.onack(status); + } + }; + + return delayedStream; + } + + /** + * Starts the queue processing timer for a client if not already running. + * + * @param {string} clientId + * The client ID. + * + * @param {Guacamole.OutputStream} realStream + * The real stream to send delayed frames to. + * + * @private + */ + function startQueueTimer(clientId, realStream) { + // Don't start if already running + if (delayTimerIntervals[clientId]) + return; + + delayTimerIntervals[clientId] = setInterval(function() { + processDelayQueue(clientId, realStream); + }, DELAY_TIMER_INTERVAL_MS); + } + + /** + * Stops the queue processing timer for a client. + * + * @param {string} clientId + * The client ID. + * + * @private + */ + function stopQueueTimer(clientId) { + if (delayTimerIntervals[clientId]) { + clearInterval(delayTimerIntervals[clientId]); + delete delayTimerIntervals[clientId]; + } + } + + /** + * Processes the delay queue, sending frames that have aged past the delay threshold. + * + * @param {string} clientId + * The client ID. + * + * @param {Guacamole.OutputStream} realStream + * The real stream to send frames to. + * + * @private + */ + function processDelayQueue(clientId, realStream) { + var queue = delayQueues[clientId]; + if (!queue) + return; + + getVideoDelay(); + var now = Date.now(); + + // Process frames in FIFO order until we hit one that's not ready + while (queue.length > 0) { + var item = queue[0]; + var age = now - item.timestamp; + + if (age >= videoDelayMs) { + // Frame has aged enough, send it + queue.shift(); + realStream.sendBlob(item.data); + } else { + // This frame (and all after it) aren't ready yet + break; + } + } + } + + /** + * Clears the delay queue for a client, discarding all buffered frames. + * + * @param {string} clientId + * The client ID. + * + * @private + */ + function clearDelayQueue(clientId) { + if (delayQueues[clientId]) { + delayQueues[clientId] = []; + } + } + + /** + * Checks if the delay queue has exceeded the overflow threshold and drops + * oldest frames if necessary. + * + * @param {string} clientId + * The client ID. + * + * @private + */ + function checkQueueOverflow(clientId) { + var queue = delayQueues[clientId]; + if (!queue) + return; + + // Calculate total queue size + var totalSize = 0; + for (var i = 0; i < queue.length; i++) { + totalSize += queue[i].size; + } + + // Drop oldest frames if over threshold + if (totalSize > DELAY_QUEUE_OVERFLOW_THRESHOLD) { + // Drop oldest frames until under threshold + while (totalSize > DELAY_QUEUE_OVERFLOW_THRESHOLD && queue.length > 0) { + var dropped = queue.shift(); + totalSize -= dropped.size; + } + } + } + + /** + * Stops camera redirection for the given client. + * This is called when server sends Stop Streams Request via argv camera-stop signal. + * + * @param {Guacamole.Client} client + * The Guacamole client for which camera redirection should be stopped. + */ + function stopCamera(client) { + if (!client) { + return; + } + + var clientId = getLocalClientId(client); + if (!clientId) { + return; + } + + // Check if camera is already stopped + if (!cameraControllers[clientId]) { + return; + } + + // Stop the camera for this client + try { + cameraControllers[clientId](); + delete cameraControllers[clientId]; + if (cameraRecorders[clientId]) + delete cameraRecorders[clientId]; + + // Clean up delay queue and timer + stopQueueTimer(clientId); + clearDelayQueue(clientId); + + // Get deviceId from cameraStates before updating state + var deviceId = cameraStates[clientId] ? cameraStates[clientId].deviceId : null; + updateCameraState(clientId, false, deviceId); + + // Clear deviceId from cameraStates + if (cameraStates[clientId]) { + delete cameraStates[clientId].deviceId; + } + } catch (error) { + // Error stopping camera - ignore + } + + } + + /** + * Updates the camera active state and notifies any registered callbacks. + * + * @param {string} clientId + * The client ID (tunnel UUID). + * + * @param {boolean} active + * Whether the camera is active. + * + * @param {string} [deviceId] + * Optional device ID to mark as active/inactive in registry. + * + * @private + */ + function updateCameraState(clientId, active, deviceId) { + if (!cameraStates[clientId]) { + cameraStates[clientId] = {}; + } + cameraStates[clientId].active = active; + + // Update registry if deviceId provided + if (deviceId && cameraRegistry[deviceId]) { + cameraRegistry[deviceId].isActive = active; + notifyRegistryChange(); + } + + // Call any registered state update callbacks + if (stateUpdateCallbacks[clientId]) { + try { + stateUpdateCallbacks[clientId]({ active: active }); + } catch (error) { + // Error in state update callback - ignore + } + } + } + + /** + * Registers a callback to be invoked when camera state changes. + * + * @param {Guacamole.Client} client + * The Guacamole client. + * + * @param {Function} callback + * Function to call when camera state changes. Receives object with active property. + */ + function registerStateCallback(client, callback) { + if (!client) { + return; + } + var clientId = getLocalClientId(client); + stateUpdateCallbacks[clientId] = callback; + + // Immediately call with current state + if (cameraStates[clientId]) { + callback({ active: cameraStates[clientId].active }); + } + } + + /** + * Toggles camera enabled state and sends capability update to server. + * + * @param {string} deviceId + * The camera device ID. + * + * @param {boolean} enabled + * Whether to enable or disable the camera. + */ + function toggleCamera(deviceId, enabled) { + if (!cameraRegistry[deviceId]) + return; + + // If disabling an active camera, stop it first + if (!enabled && cameraRegistry[deviceId].isActive) { + // Find and stop the camera + // Note: We don't have a per-device stop mechanism yet, but the server + // will handle removing the device when it receives the capability update + cameraRegistry[deviceId].isActive = false; + } + + // Update enabled state + cameraRegistry[deviceId].enabled = enabled; + + // Save to preferences + saveEnabledDevicesToPreferences(); + + // Send capability update to server + if (currentClient) { + updateCapabilities(currentClient); + } + + // Notify UI + notifyRegistryChange(); + } + + /** + * Registers a callback to be invoked when the camera list changes. + * + * @param {Function} callback + * Function to call with updated camera list. + */ + function registerCameraListCallback(callback) { + if (typeof callback === 'function') { + registryChangeCallbacks.push(callback); + // Immediately call with current list + callback(getCameraList()); + } + } + + // Public API + return { + startCameraWithParams: startCameraWithParams, + prefetchCapabilities: prefetchCapabilities, + isSupported: isSupported, + stopCamera: stopCamera, + registerStateCallback: registerStateCallback, + getVideoDelay: getVideoDelay, + setVideoDelay: setVideoDelay, + toggleCamera: toggleCamera, + getCameraList: getCameraList, + registerCameraListCallback: registerCameraListCallback, + resetDeviceChangeHandler: resetDeviceChangeHandler + }; + +}]); diff --git a/guacamole/src/main/frontend/src/app/client/styles/menu.css b/guacamole/src/main/frontend/src/app/client/styles/menu.css index b5742ab5d0..bedc99d3c5 100644 --- a/guacamole/src/main/frontend/src/app/client/styles/menu.css +++ b/guacamole/src/main/frontend/src/app/client/styles/menu.css @@ -144,3 +144,10 @@ left: 0px; opacity: 1; } + +/* Camera active indicator */ +.active-indicator { + color: #28a745; + font-weight: 500; + margin-left: 0.5em; +} diff --git a/guacamole/src/main/frontend/src/app/client/styles/video-delay-notification.css b/guacamole/src/main/frontend/src/app/client/styles/video-delay-notification.css new file mode 100644 index 0000000000..e6a95c71fa --- /dev/null +++ b/guacamole/src/main/frontend/src/app/client/styles/video-delay-notification.css @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#video-delay-notification { + + position: absolute; + right: 0.25em; + top: 0.25em; + z-index: 20; + + width: 3in; + max-width: 100%; + min-height: 1em; + + border-left: 0.5em solid #4A90E2; + box-shadow: 1px 1px 2px rgba(0,0,0,0.25); + background: #E8F4FF; + padding: 0.5em 0.75em; + font-size: .8em; + +} + +.video-delay-slider input[type="range"] { + width: 100%; + margin: 0.5em 0; +} + +.video-delay-slider .delay-labels { + display: flex; + justify-content: space-between; + font-size: 0.85em; + color: #666; + margin-top: 0.25em; +} diff --git a/guacamole/src/main/frontend/src/app/client/templates/client.html b/guacamole/src/main/frontend/src/app/client/templates/client.html index 66dca3a770..00c7d04354 100644 --- a/guacamole/src/main/frontend/src/app/client/templates/client.html +++ b/guacamole/src/main/frontend/src/app/client/templates/client.html @@ -44,7 +44,12 @@
{{'CLIENT.TEXT_CLIENT_STATUS_UNSTABLE' | translate}}
- + + +
+ {{ videoDelayNotification.message }} +
+ + + + - diff --git a/guacamole/src/main/frontend/src/app/client/types/ManagedClient.js b/guacamole/src/main/frontend/src/app/client/types/ManagedClient.js index b7e83a3a05..8b895fc18b 100644 --- a/guacamole/src/main/frontend/src/app/client/types/ManagedClient.js +++ b/guacamole/src/main/frontend/src/app/client/types/ManagedClient.js @@ -53,6 +53,7 @@ angular.module('client').factory('ManagedClient', ['$rootScope', '$injector', const guacHistory = $injector.get('guacHistory'); const guacImage = $injector.get('guacImage'); const guacVideo = $injector.get('guacVideo'); + const guacRDPECAM = $injector.get('guacRDPECAM'); /** * The minimum amount of time to wait between updates to the client @@ -539,6 +540,11 @@ angular.module('client').factory('ManagedClient', ['$rootScope', '$injector', case Guacamole.Client.State.DISCONNECTING: case Guacamole.Client.State.DISCONNECTED: ManagedClient.updateThumbnail(managedClient); + + // Clean up camera redirection on disconnect + if (clientState === Guacamole.Client.State.DISCONNECTED) { + guacRDPECAM.stopCamera(client); + } break; } @@ -625,10 +631,20 @@ angular.module('client').factory('ManagedClient', ['$rootScope', '$injector', } }; + /** + * Stops camera recording when server sends camera-stop signal. + * This is called when Windows sends Stop Streams Request. + */ + function stopCamera() { + // Stop camera via guacRDPECAM service + guacRDPECAM.stopCamera(client); + } + // Test for argument mutability whenever an argument value is // received client.onargv = function clientArgumentValueReceived(stream, mimetype, name) { + // Ignore arguments which do not use a mimetype currently supported // by the web application if (mimetype !== 'text/plain') @@ -642,13 +658,52 @@ angular.module('client').factory('ManagedClient', ['$rootScope', '$injector', value += text; }; - // Test mutability once stream is finished, storing the current - // value for the argument only if it is mutable - reader.onend = function textComplete() { - ManagedArgument.getInstance(managedClient, name, value).then(function argumentIsMutable(argument) { - managedClient.arguments[name] = argument; - }, function ignoreImmutableArguments() {}); - }; + // Handle camera parameter reception (protocol-driven camera start) + // Prefer the single JSON argv path and ignore legacy per-parameter values. + if (name.indexOf('camera-') === 0) { + reader.onend = function cameraParameterReceived() { + // Handle camera-stop signal (no parameters) + if (name === 'camera-stop') { + stopCamera(); + return; + } + + // Alternative concise form: "WIDTHxHEIGHT@FPS_NUM/FPS_DEN#STREAM_INDEX" + if (name === 'camera-start') { + try { + var m = /^\s*(\d+)x(\d+)@(\d+)\/(\d+)(?:#(\d+)(?:#([^\s]*))?)?\s*$/.exec(value || ''); + if (!m) { + return; + } + + guacRDPECAM.startCameraWithParams(client, { + width: parseInt(m[1]), + height: parseInt(m[2]), + fpsNum: parseInt(m[3]), + fpsDenom: parseInt(m[4]) || 1, + streamIndex: m[5] ? parseInt(m[5]) : 0, + deviceId: m[6] ? m[6].toString() : undefined + }).catch(function(error) { + console.error('Failed to start camera with params:', error); + }); + } catch (e) { + // Failed to parse camera-start string - ignore + } + return; + } + return; + }; + } + // Handle normal arguments (non-camera parameters) + else { + // Test mutability once stream is finished, storing the current + // value for the argument only if it is mutable + reader.onend = function textComplete() { + ManagedArgument.getInstance(managedClient, name, value).then(function argumentIsMutable(argument) { + managedClient.arguments[name] = argument; + }, function ignoreImmutableArguments() {}); + }; + } }; diff --git a/guacamole/src/main/frontend/src/app/settings/services/preferenceService.js b/guacamole/src/main/frontend/src/app/settings/services/preferenceService.js index f029790f84..dbcaebd78d 100644 --- a/guacamole/src/main/frontend/src/app/settings/services/preferenceService.js +++ b/guacamole/src/main/frontend/src/app/settings/services/preferenceService.js @@ -159,10 +159,26 @@ angular.module('settings').provider('preferenceService', ['$injector', /** * The timezone set by the user, in IANA zone key format (Olson time * zone database). - * + * * @type String */ - timezone : getDetectedTimezone() + timezone : getDetectedTimezone(), + + /** + * Array of enabled camera device IDs for RDP camera redirection. + * Empty array means no cameras are enabled by default (user must opt-in). + * + * @type {Array.} + */ + rdpecamEnabledDevices : [], + + /** + * Video delay in milliseconds for RDP camera redirection (0-1000). + * Used to synchronize video and audio during calls. + * + * @type {Number} + */ + rdpecamVideoDelay : 0 };