From 5881e9eed3219d5a804d38ebdabd98fe7c3ae8f8 Mon Sep 17 00:00:00 2001 From: Loic Date: Tue, 4 Nov 2025 12:38:03 -0500 Subject: [PATCH 01/12] GUACAMOLE-1415: Add video handler infrastructure Add foundation layer for handling video streams from Guacamole clients. This infrastructure enables protocols to receive continuous video data from web clients, similar to the existing audio handler mechanism. Changes: - Add video_handler field to guac_user structure - Implement video instruction handler in user-handlers - Add video handler declaration and handshake support This is generic infrastructure not specific to RDPECAM and can be used by other protocols that need client-to-server video streaming. --- src/libguac/guacamole/user.h | 22 ++++++++++++++++++++++ src/libguac/user-handlers.c | 24 ++++++++++++++++++++++++ src/libguac/user-handlers.h | 7 +++++++ 3 files changed, 53 insertions(+) diff --git a/src/libguac/guacamole/user.h b/src/libguac/guacamole/user.h index 4531e75a3f..ef337cc734 100644 --- a/src/libguac/guacamole/user.h +++ b/src/libguac/guacamole/user.h @@ -496,6 +496,28 @@ struct guac_user { */ guac_user_audio_handler* audio_handler; + /** + * Handler for video events sent by the Guacamole web-client. This handler + * will be called whenever the web-client wishes to send a continuous + * stream of video data from some arbitrary source (a camera, for + * example). + * + * The handler takes a guac_stream, which contains the stream index and + * will persist through the duration of the transfer, and the mimetype + * of the data being transferred. + * + * Example: + * @code + * int video_handler(guac_user* user, guac_stream* stream, + * char* mimetype); + * + * int guac_user_init(guac_user* user, int argc, char** argv) { + * user->video_handler = video_handler; + * } + * @endcode + */ + guac_user_audio_handler* video_handler; + /** * Handler for argv events (updates to the connection parameters of an * in-progress connection) sent by the Guacamole web-client. diff --git a/src/libguac/user-handlers.c b/src/libguac/user-handlers.c index b9013bc7c2..c714594769 100644 --- a/src/libguac/user-handlers.c +++ b/src/libguac/user-handlers.c @@ -53,6 +53,7 @@ __guac_instruction_handler_mapping __guac_instruction_handler_map[] = { {"get", __guac_handle_get}, {"put", __guac_handle_put}, {"audio", __guac_handle_audio}, + {"video", __guac_handle_video}, {"argv", __guac_handle_argv}, {"nop", __guac_handle_nop}, {NULL, NULL} @@ -344,6 +345,29 @@ int __guac_handle_audio(guac_user* user, int argc, char** argv) { } +int __guac_handle_video(guac_user* user, int argc, char** argv) { + + /* Pull corresponding stream */ + int stream_index = atoi(argv[0]); + guac_stream* stream = __init_input_stream(user, stream_index); + if (stream == NULL) + return 0; + + /* If supported, call handler */ + if (user->video_handler) + return user->video_handler( + user, + stream, + argv[1] /* mimetype */ + ); + + /* Otherwise, abort */ + guac_protocol_send_ack(user->socket, stream, + "Video input unsupported", GUAC_PROTOCOL_STATUS_UNSUPPORTED); + return 0; + +} + int __guac_handle_clipboard(guac_user* user, int argc, char** argv) { /* Pull corresponding stream */ diff --git a/src/libguac/user-handlers.h b/src/libguac/user-handlers.h index 2f3ecbe9b3..7784779b3d 100644 --- a/src/libguac/user-handlers.h +++ b/src/libguac/user-handlers.h @@ -105,6 +105,13 @@ __guac_instruction_handler __guac_handle_key; */ __guac_instruction_handler __guac_handle_audio; +/** + * Internal initial handler for the video instruction. When a video + * instruction is received, this handler will be called. The client's video + * handler will be invoked if defined. + */ +__guac_instruction_handler __guac_handle_video; + /** * Internal initial handler for the clipboard instruction. When a clipboard * instruction is received, this handler will be called. The client's clipboard From 4b59c528bce18bd16f43ad6c73fd221d24ff00ba Mon Sep 17 00:00:00 2001 From: Loic Date: Tue, 4 Nov 2025 12:38:11 -0500 Subject: [PATCH 02/12] GUACAMOLE-1415: Implement RDPECAM channel core Add core RDPECAM (RDP Enhanced Camera) channel implementation following the MS-RDPECAM specification. This provides the protocol layer for camera device enumeration, capability negotiation, and video streaming to remote Windows sessions. Components: - rdpecam.c/h: Main channel implementation and device management - rdpecam_caps.c/h: Capability negotiation and format handling - rdpecam_sink.c/h: Video sink for receiving and forwarding H.264 streams This channel implementation handles the low-level RDP protocol communication required for camera redirection. --- src/protocols/rdp/channels/rdpecam/rdpecam.c | 251 ++++++++++++++++ src/protocols/rdp/channels/rdpecam/rdpecam.h | 52 ++++ .../rdp/channels/rdpecam/rdpecam_caps.c | 283 ++++++++++++++++++ .../rdp/channels/rdpecam/rdpecam_caps.h | 140 +++++++++ .../rdp/channels/rdpecam/rdpecam_sink.c | 283 ++++++++++++++++++ .../rdp/channels/rdpecam/rdpecam_sink.h | 250 ++++++++++++++++ 6 files changed, 1259 insertions(+) create mode 100644 src/protocols/rdp/channels/rdpecam/rdpecam.c create mode 100644 src/protocols/rdp/channels/rdpecam/rdpecam.h create mode 100644 src/protocols/rdp/channels/rdpecam/rdpecam_caps.c create mode 100644 src/protocols/rdp/channels/rdpecam/rdpecam_caps.h create mode 100644 src/protocols/rdp/channels/rdpecam/rdpecam_sink.c create mode 100644 src/protocols/rdp/channels/rdpecam/rdpecam_sink.h diff --git a/src/protocols/rdp/channels/rdpecam/rdpecam.c b/src/protocols/rdp/channels/rdpecam/rdpecam.c new file mode 100644 index 0000000000..b35775e901 --- /dev/null +++ b/src/protocols/rdp/channels/rdpecam/rdpecam.c @@ -0,0 +1,251 @@ +/* + * 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. + */ + +#include "rdpecam.h" +#include "channels/rdpecam/rdpecam_sink.h" +#include "plugins/channels.h" +#include "plugins/ptr-string.h" +#include "rdp.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +/** + * Per-stream reassembly state for RDPECAM frames. Handles fragmentation + * across arbitrary Guacamole blob boundaries. One instance is attached to + * the Guacamole stream via stream->data. + */ +typedef struct guac_rdp_rdpecam_stream_state { + + /* Header assembly */ + uint8_t header_buf[sizeof(guac_rdpecam_frame_header)]; + size_t header_received; + + /* Full-frame assembly (header + payload) */ + uint8_t* frame_buf; + size_t frame_expected; /* total bytes expected (header+payload) */ + size_t frame_received; /* total bytes currently accumulated */ + +} guac_rdp_rdpecam_stream_state; + +/** + * Parses the given RDPECAM mimetype, validating that it's the expected format. + * + * @param mimetype + * The RDPECAM mimetype to parse. + * + * @return + * Zero if the given mimetype is valid, non-zero otherwise. + */ +static int guac_rdp_rdpecam_parse_mimetype(const char* mimetype) { + + /* Validate RDPECAM H.264 mimetype */ + if (strcmp(mimetype, "application/rdpecam+h264") == 0) + return 0; + + return 1; + +} + +int guac_rdp_rdpecam_video_handler(guac_user* user, guac_stream* stream, + char* mimetype) { + + guac_client* client = user->client; + guac_rdp_client* rdp_client = (guac_rdp_client*) client->data; + + /* Parse mimetype, abort on parse error */ + if (guac_rdp_rdpecam_parse_mimetype(mimetype)) { + guac_user_log(user, GUAC_LOG_WARNING, "Denying user camera stream with " + "unsupported mimetype: \"%s\"", mimetype); + guac_protocol_send_ack(user->socket, stream, "Unsupported camera " + "mimetype", GUAC_PROTOCOL_STATUS_CLIENT_BAD_TYPE); + return 0; + } + + /* Init stream data */ + stream->blob_handler = guac_rdp_rdpecam_blob_handler; + stream->end_handler = guac_rdp_rdpecam_end_handler; + + /* Initialize per-stream reassembly state */ + if (!stream->data) { + guac_rdp_rdpecam_stream_state* st = guac_mem_zalloc(sizeof(guac_rdp_rdpecam_stream_state)); + stream->data = st; + } + + /* Associate stream with RDPECAM sink */ + if (rdp_client->rdpecam_sink) { + guac_user_log(user, GUAC_LOG_DEBUG, "User is requesting to provide camera " + "input as H.264 video stream."); + + /* Send acknowledgment */ + guac_protocol_send_ack(user->socket, stream, "OK", GUAC_PROTOCOL_STATUS_SUCCESS); + guac_socket_flush(user->socket); + } else { + guac_user_log(user, GUAC_LOG_WARNING, "RDPECAM sink not available"); + guac_protocol_send_ack(user->socket, stream, "RDPECAM not available", + GUAC_PROTOCOL_STATUS_SERVER_ERROR); + } + + return 0; + +} + +int guac_rdp_rdpecam_blob_handler(guac_user* user, guac_stream* stream, + void* data, int length) { + + guac_client* client = user->client; + guac_rdp_client* rdp_client = (guac_rdp_client*) client->data; + + /* If sink not available, fail */ + if (!rdp_client->rdpecam_sink) { + guac_protocol_send_ack(user->socket, stream, "RDPECAM not available", + GUAC_PROTOCOL_STATUS_SERVER_ERROR); + guac_socket_flush(user->socket); + return 0; + } + + /* Retrieve or initialize reassembly state */ + guac_rdp_rdpecam_stream_state* st = (guac_rdp_rdpecam_stream_state*) stream->data; + if (!st) { + st = guac_mem_zalloc(sizeof(guac_rdp_rdpecam_stream_state)); + stream->data = st; + } + + const uint8_t* in = (const uint8_t*) data; + size_t remaining = (length < 0) ? 0 : (size_t) length; + + + /* Consume input, assembling one or more complete frames if present */ + while (remaining > 0) { + + /* Step 1: ensure header is complete */ + if (st->frame_expected == 0) { + size_t need = sizeof(guac_rdpecam_frame_header) - st->header_received; + size_t take = (remaining < need) ? remaining : need; + if (take > 0) { + memcpy(st->header_buf + st->header_received, in, take); + st->header_received += take; + in += take; + remaining -= take; + } + + if (st->header_received < sizeof(guac_rdpecam_frame_header)) { + /* Need more data to finish header */ + break; + } + + /* Header complete: validate and start frame assembly */ + const guac_rdpecam_frame_header* hdr = (const guac_rdpecam_frame_header*) st->header_buf; + + /* Basic sanity checks to avoid pathological allocations */ + if (hdr->version != 1 || hdr->payload_len > GUAC_RDPECAM_MAX_FRAME_SIZE) { + guac_user_log(user, GUAC_LOG_WARNING, + "RDPECAM invalid header (version=%u, payload_len=%u) - discarding corrupted data (likely camera switch in progress)", + (unsigned) hdr->version, (unsigned) hdr->payload_len); + /* Fast recovery: discard all accumulated data and wait for next clean frame. + * This typically happens when switching cameras - old camera's partial data + * arrives mixed with new camera's data. Discarding everything is faster + * than byte-by-byte scanning and reduces warning spam. */ + st->header_received = 0; + remaining = 0; /* Discard rest of this blob */ + break; + } + + st->frame_expected = sizeof(guac_rdpecam_frame_header) + (size_t) hdr->payload_len; + st->frame_buf = guac_mem_alloc(st->frame_expected); + if (!st->frame_buf) { + guac_user_log(user, GUAC_LOG_ERROR, "RDPECAM failed to allocate reassembly buffer: %zu bytes", st->frame_expected); + /* Reset state and drop current partial data */ + st->header_received = 0; + st->frame_expected = 0; + st->frame_received = 0; + break; + } + memcpy(st->frame_buf, st->header_buf, sizeof(guac_rdpecam_frame_header)); + st->frame_received = sizeof(guac_rdpecam_frame_header); + /* Header buffer consumed for this frame */ + st->header_received = 0; + } + + /* Step 2: append payload (and possibly following headers/payloads) */ + size_t to_copy = st->frame_expected - st->frame_received; + size_t take = (remaining < to_copy) ? remaining : to_copy; + if (take > 0) { + memcpy(st->frame_buf + st->frame_received, in, take); + st->frame_received += take; + in += take; + remaining -= take; + } + + if (st->frame_received == st->frame_expected) { + /* We have a full frame: push to sink (failures are tracked in periodic stats) */ + guac_rdpecam_push(rdp_client->rdpecam_sink, st->frame_buf, st->frame_expected); + guac_mem_free(st->frame_buf); + st->frame_buf = NULL; + st->frame_expected = 0; + st->frame_received = 0; + /* Loop continues to see if additional frame data is present in this blob */ + } else { + /* Partial frame */ + guac_user_log(user, GUAC_LOG_TRACE, + "RDPECAM partial frame: %zu/%zu bytes", st->frame_received, st->frame_expected); + } + } + + /* Always ACK success for accepted blob bytes to keep the stream flowing. */ + guac_protocol_send_ack(user->socket, stream, "OK", GUAC_PROTOCOL_STATUS_SUCCESS); + guac_socket_flush(user->socket); + + return 0; + +} + +int guac_rdp_rdpecam_end_handler(guac_user* user, guac_stream* stream) { + + /* Free any reassembly state */ + guac_rdp_rdpecam_stream_state* st = (guac_rdp_rdpecam_stream_state*) stream->data; + if (st) { + if (st->frame_buf) guac_mem_free(st->frame_buf); + guac_mem_free(st); + stream->data = NULL; + } + + return 0; + +} + +void guac_rdp_rdpecam_load_plugin(rdpContext* context) { + + guac_client* client = ((rdp_freerdp_context*) context)->client; + char client_ref[GUAC_RDP_PTR_STRING_LENGTH]; + + /* Add "guacrdpecam" plugin (loads libguacrdpecam-client.so) */ + guac_rdp_ptr_to_string(client, client_ref); + guac_freerdp_dynamic_channel_collection_add(context->settings, "guacrdpecam", client_ref, NULL); + +} diff --git a/src/protocols/rdp/channels/rdpecam/rdpecam.h b/src/protocols/rdp/channels/rdpecam/rdpecam.h new file mode 100644 index 0000000000..e0c76703f2 --- /dev/null +++ b/src/protocols/rdp/channels/rdpecam/rdpecam.h @@ -0,0 +1,52 @@ +/* + * 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. + */ + +#ifndef GUAC_RDP_CHANNELS_RDPECAM_H +#define GUAC_RDP_CHANNELS_RDPECAM_H + +#include +#include + +/** + * Handler for inbound camera data (RDPECAM) via video streams. + */ +guac_user_audio_handler guac_rdp_rdpecam_video_handler; + +/** + * Handler for stream data related to camera input. + */ +guac_user_blob_handler guac_rdp_rdpecam_blob_handler; + +/** + * Handler for end-of-stream related to camera input. + */ +guac_user_end_handler guac_rdp_rdpecam_end_handler; + +/** + * Adds Guacamole's "guacrdpecam" plugin to the list of dynamic virtual channel + * plugins to be loaded by FreeRDP's "drdynvc" plugin. The plugin will only + * be loaded once the "drdynvc" plugin is loaded. The "guacrdpecam" plugin + * ultimately adds support for the "RDPECAM" dynamic virtual channel. + * + * @param context + * The rdpContext associated with the active RDP session. + */ +void guac_rdp_rdpecam_load_plugin(rdpContext* context); + +#endif diff --git a/src/protocols/rdp/channels/rdpecam/rdpecam_caps.c b/src/protocols/rdp/channels/rdpecam/rdpecam_caps.c new file mode 100644 index 0000000000..d8148766d5 --- /dev/null +++ b/src/protocols/rdp/channels/rdpecam/rdpecam_caps.c @@ -0,0 +1,283 @@ +/* + * 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. + */ + +#include "channels/rdpecam/rdpecam_caps.h" +#include "rdp.h" + +#include +#include +#include +#include + +#include +#include +#include + +size_t guac_rdp_rdpecam_sanitize_device_name(const char* name, char* sanitized, size_t len) { + + if (!name || !sanitized || len == 0) + return 0; + + size_t pos = 0; + const char* src = name; + + /* Windows invalid characters: / \ : * ? " < > | */ + while (*src && pos < len - 1) { + char c = *src++; + + /* Replace invalid characters with underscore */ + if (c == '/' || c == '\\' || c == ':' || c == '*' || c == '?' || + c == '"' || c == '<' || c == '>' || c == '|') { + if (pos < len - 1) + sanitized[pos++] = '_'; + } + /* Skip control characters */ + else if ((unsigned char)c < 32) { + continue; + } + else { + sanitized[pos++] = c; + } + } + + sanitized[pos] = '\0'; + + /* Trim to 255 characters (Windows device name limit) */ + if (pos > 255) { + sanitized[255] = '\0'; + pos = 255; + } + + return pos; +} + +int guac_rdp_rdpecam_capabilities_callback(guac_user* user, + const char* mimetype, const char* name, const char* value, void* data) { + + guac_client* client = user ? user->client : NULL; + guac_rdp_client* rdp_client = client ? (guac_rdp_client*) client->data : NULL; + + if (!client || !rdp_client || !value) + return 0; + + size_t len = strlen(value); + char* copy = guac_mem_alloc(len + 1); + if (!copy) + return 0; + memcpy(copy, value, len + 1); + + guac_rwlock_acquire_write_lock(&(rdp_client->lock)); + + /* Free old device capabilities */ + for (unsigned int i = 0; i < rdp_client->rdpecam_device_caps_count; i++) { + guac_rdp_rdpecam_device_caps* caps = &rdp_client->rdpecam_device_caps[i]; + if (caps->device_id) + guac_mem_free(caps->device_id); + if (caps->device_name) + guac_mem_free(caps->device_name); + caps->device_id = NULL; + caps->device_name = NULL; + caps->format_count = 0; + } + rdp_client->rdpecam_device_caps_count = 0; + + /* Parse multi-device capabilities format (required): + * "DEVICE_ID:DEVICE_NAME|640x480@30/1,...;DEVICE_ID:DEVICE_NAME|320x240@30/1,..." + * Format requires semicolon-separated device list, each entry must include device ID. + */ + + unsigned int device_count = 0; + char* device_saveptr = NULL; + char* device_entry = strtok_r(copy, ";", &device_saveptr); + + /* Require semicolon-separated format (multi-device) */ + if (!device_entry) { + guac_client_log(client, GUAC_LOG_WARNING, + "RDPECAM received capabilities in invalid format (expected semicolon-separated device list)"); + guac_mem_free(copy); + return 0; + } + + while (device_entry && device_count < GUAC_RDP_RDPECAM_MAX_DEVICES) { + guac_rdp_rdpecam_device_caps* caps = &rdp_client->rdpecam_device_caps[device_count]; + + /* Find pipe separator (between device info and formats) */ + char* formats_str = device_entry; + char* pipe_pos = strchr(device_entry, '|'); + char* device_info = NULL; + + if (!pipe_pos) { + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM skipping device entry without pipe separator: '%s'", device_entry); + device_entry = strtok_r(NULL, ";", &device_saveptr); + continue; + } + + *pipe_pos = '\0'; + device_info = device_entry; + formats_str = pipe_pos + 1; + + /* Require device ID in format "DEVICE_ID:DEVICE_NAME" */ + char* device_id_parsed = NULL; + char* device_name_parsed = NULL; + + if (!device_info || !*device_info) { + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM skipping device entry without device info"); + device_entry = strtok_r(NULL, ";", &device_saveptr); + continue; + } + + char* colon_pos = strchr(device_info, ':'); + if (!colon_pos) { + guac_client_log(client, GUAC_LOG_WARNING, + "RDPECAM skipping device entry without device ID (format: DEVICE_ID:DEVICE_NAME required): '%s'", + device_info); + device_entry = strtok_r(NULL, ";", &device_saveptr); + continue; + } + + /* Parse device ID and name (format: "DEVICE_ID:DEVICE_NAME") */ + *colon_pos = '\0'; + device_id_parsed = device_info; + device_name_parsed = colon_pos + 1; + + /* Require non-empty device ID */ + if (!device_id_parsed || !*device_id_parsed) { + guac_client_log(client, GUAC_LOG_WARNING, + "RDPECAM skipping device entry with empty device ID"); + device_entry = strtok_r(NULL, ";", &device_saveptr); + continue; + } + + /* Store device ID (required) */ + size_t id_len = strlen(device_id_parsed); + caps->device_id = guac_mem_alloc(id_len + 1); + if (!caps->device_id) { + guac_client_log(client, GUAC_LOG_ERROR, + "RDPECAM failed to allocate device ID string"); + device_entry = strtok_r(NULL, ";", &device_saveptr); + continue; + } + memcpy(caps->device_id, device_id_parsed, id_len + 1); + + /* Sanitize and store device name */ + if (device_name_parsed && *device_name_parsed) { + char sanitized[256]; + size_t sanitized_len = guac_rdp_rdpecam_sanitize_device_name( + device_name_parsed, sanitized, sizeof(sanitized)); + + if (sanitized_len > 0) { + caps->device_name = guac_mem_alloc(sanitized_len + 1); + if (caps->device_name) { + memcpy(caps->device_name, sanitized, sanitized_len + 1); + } + } + } + + /* Parse formats for this device */ + unsigned int format_count = 0; + char* format_saveptr = NULL; + char* format_token = strtok_r(formats_str, ",", &format_saveptr); + + while (format_token && format_count < GUAC_RDP_RDPECAM_MAX_FORMATS) { + /* Trim whitespace */ + while (isspace((unsigned char) *format_token)) + format_token++; + + char* end = format_token + strlen(format_token); + while (end > format_token && isspace((unsigned char) *(end - 1))) + *(--end) = '\0'; + + unsigned int width = 0; + unsigned int height = 0; + unsigned int fps_num = 0; + unsigned int fps_den = 1; + + int parsed = sscanf(format_token, "%ux%u@%u/%u", &width, &height, &fps_num, &fps_den); + if (parsed < 4) { + fps_den = 1; + parsed = sscanf(format_token, "%ux%u@%u", &width, &height, &fps_num); + } + + if (parsed >= 3 && width && height && fps_num) { + guac_rdp_rdpecam_format* fmt = &caps->formats[format_count++]; + fmt->width = width; + fmt->height = height; + fmt->fps_num = fps_num; + fmt->fps_den = fps_den ? fps_den : 1; + } + else { + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM ignored unparseable format entry: '%s'", format_token); + } + + format_token = strtok_r(NULL, ",", &format_saveptr); + } + + caps->format_count = format_count; + + /* Only add device if it has valid formats (device ID is already stored above) */ + if (format_count > 0) { + device_count++; + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM device %u: id='%s', name='%s', formats=%u", + device_count - 1, + caps->device_id, + caps->device_name ? caps->device_name : "(none)", + format_count); + } else { + /* No formats for this device - free device ID and skip */ + guac_client_log(client, GUAC_LOG_WARNING, + "RDPECAM skipping device '%s' (id='%s') with no valid formats", + caps->device_name ? caps->device_name : "(unnamed)", + caps->device_id); + if (caps->device_id) { + guac_mem_free(caps->device_id); + caps->device_id = NULL; + } + if (caps->device_name) { + guac_mem_free(caps->device_name); + caps->device_name = NULL; + } + } + + /* Get next device entry */ + device_entry = strtok_r(NULL, ";", &device_saveptr); + } + + rdp_client->rdpecam_device_caps_count = device_count; + + /* Set flag to notify plugin that capabilities have been updated. */ + rdp_client->rdpecam_caps_updated = 1; + + /* If plugin registered a notification callback, invoke it now to allow + * immediate processing (e.g., sending DeviceAddedNotification). */ + if (rdp_client->rdpecam_caps_notify) + rdp_client->rdpecam_caps_notify(client); + + guac_rwlock_release_lock(&(rdp_client->lock)); + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM capabilities updated (%u devices), notifying plugin", device_count); + + guac_mem_free(copy); + return 0; +} + diff --git a/src/protocols/rdp/channels/rdpecam/rdpecam_caps.h b/src/protocols/rdp/channels/rdpecam/rdpecam_caps.h new file mode 100644 index 0000000000..4471a017f5 --- /dev/null +++ b/src/protocols/rdp/channels/rdpecam/rdpecam_caps.h @@ -0,0 +1,140 @@ +/* + * 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. + */ + +#ifndef GUAC_RDP_CHANNELS_RDPECAM_CAPS_H +#define GUAC_RDP_CHANNELS_RDPECAM_CAPS_H + +#include +#include + +/** + * The name of the guacamole protocol argument for camera capabilities. + */ +#define GUAC_RDPECAM_ARG_CAPABILITIES "rdpecam-capabilities" + +/** + * Maximum number of RDPECAM formats remembered from the browser. + */ +#define GUAC_RDP_RDPECAM_MAX_FORMATS 16 + +/** + * Maximum number of camera devices that can be redirected simultaneously. + */ +#define GUAC_RDP_RDPECAM_MAX_DEVICES 8 + +/** + * Describes a single camera format (resolution + frame rate) reported by the + * browser. + */ +typedef struct guac_rdp_rdpecam_format { + /** + * Width of the video format in pixels. + */ + unsigned int width; + + /** + * Height of the video format in pixels. + */ + unsigned int height; + + /** + * Frame rate numerator (frames per second). + */ + unsigned int fps_num; + + /** + * Frame rate denominator (for fractional frame rates). + */ + unsigned int fps_den; +} guac_rdp_rdpecam_format; + +/** + * Per-device camera capabilities reported by the browser. + */ +typedef struct guac_rdp_rdpecam_device_caps { + /** + * Browser device ID (unique identifier from navigator.mediaDevices). + * Used to map between browser devices and Windows channel names. + */ + char* device_id; + + /** + * Sanitized device name from track.label, suitable for Windows. + * If NULL or empty, a default name will be used based on device index. + */ + char* device_name; + + /** + * Supported formats for this device. + */ + guac_rdp_rdpecam_format formats[GUAC_RDP_RDPECAM_MAX_FORMATS]; + + /** + * Number of valid entries within formats array. + */ + unsigned int format_count; +} guac_rdp_rdpecam_device_caps; + +/** + * Sanitizes a camera device name for Windows compatibility. + * Removes or replaces characters that are invalid in Windows device names. + * + * @param name + * The device name to sanitize. + * + * @param sanitized + * Buffer to store the sanitized name (must be at least 256 bytes). + * + * @param len + * Size of the sanitized buffer. + * + * @return + * Number of characters written (excluding null terminator), or 0 on error. + */ +size_t guac_rdp_rdpecam_sanitize_device_name(const char* name, char* sanitized, size_t len); + +/** + * Callback invoked when camera capabilities are received from the browser. + * This function parses the multi-device capability string and updates the + * RDP client's device capability storage. + * + * @param user + * The user who sent the capabilities. + * + * @param mimetype + * The mimetype of the data (unused). + * + * @param name + * The name of the argument (should be "rdpecam-capabilities"). + * + * @param value + * The capability string in format: + * "DEVICE_ID:DEVICE_NAME|WIDTHxHEIGHT@FPS_NUM/FPS_DEN,...;..." + * + * @param data + * User-defined data (unused). + * + * @return + * Always returns 0. + */ +int guac_rdp_rdpecam_capabilities_callback(guac_user* user, + const char* mimetype, const char* name, const char* value, void* data); + +#endif + diff --git a/src/protocols/rdp/channels/rdpecam/rdpecam_sink.c b/src/protocols/rdp/channels/rdpecam/rdpecam_sink.c new file mode 100644 index 0000000000..fab72e5f2d --- /dev/null +++ b/src/protocols/rdp/channels/rdpecam/rdpecam_sink.c @@ -0,0 +1,283 @@ +/* + * 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. + */ + +#include "rdpecam_sink.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +guac_rdpecam_sink* guac_rdpecam_create(guac_client* client) { + + guac_rdpecam_sink* sink = guac_mem_zalloc(sizeof(guac_rdpecam_sink)); + + if (!sink) { + guac_client_log(client, GUAC_LOG_ERROR, "Failed to allocate RDPECAM sink"); + return NULL; + } + + if (pthread_mutex_init(&sink->lock, NULL) != 0) { + guac_client_log(client, GUAC_LOG_ERROR, "Failed to initialize RDPECAM sink mutex"); + guac_mem_free(sink); + return NULL; + } + + if (pthread_cond_init(&sink->frame_available, NULL) != 0) { + guac_client_log(client, GUAC_LOG_ERROR, "Failed to initialize RDPECAM sink condition variable"); + pthread_mutex_destroy(&sink->lock); + guac_mem_free(sink); + return NULL; + } + + sink->client = client; + sink->queue_head = NULL; + sink->queue_tail = NULL; + sink->queue_size = 0; + sink->stopping = false; + sink->streaming = false; + sink->credits = 0; + sink->stream_index = 0; + sink->has_active_sender = false; + sink->active_sender_channel = NULL; + + guac_client_log(client, GUAC_LOG_DEBUG, "RDPECAM sink created"); + + return sink; + +} + +void guac_rdpecam_destroy(guac_rdpecam_sink* sink) { + + if (!sink) + return; + pthread_mutex_lock(&sink->lock); + + sink->stopping = true; + + /* Drain any queued frames before releasing the sink. */ + guac_rdpecam_frame* frame = sink->queue_head; + while (frame) { + guac_rdpecam_frame* next = frame->next; + guac_mem_free(frame->data); + guac_mem_free(frame); + frame = next; + } + + sink->queue_head = NULL; + sink->queue_tail = NULL; + sink->queue_size = 0; + + pthread_cond_broadcast(&sink->frame_available); + + pthread_mutex_unlock(&sink->lock); + + pthread_cond_destroy(&sink->frame_available); + pthread_mutex_destroy(&sink->lock); + + guac_mem_free(sink); + +} + + +void guac_rdpecam_signal_stop(guac_rdpecam_sink* sink) { + + if (!sink) + return; + + pthread_mutex_lock(&sink->lock); + if (!sink->stopping) + sink->stopping = true; + + pthread_cond_broadcast(&sink->frame_available); + pthread_mutex_unlock(&sink->lock); +} + + +bool guac_rdpecam_push(guac_rdpecam_sink* sink, const void* data, size_t len) { + + guac_client* client = sink ? sink->client : NULL; + + if (!sink || !data || len == 0) { + if (client) + guac_client_log(client, GUAC_LOG_WARNING, "RDPECAM push called with invalid parameters: sink=%p, data=%p, len=%zu", sink, data, len); + return false; + } + + pthread_mutex_lock(&sink->lock); + + /* Reject new frames once destruction has begun. */ + if (sink->stopping) { + guac_client_log(sink->client, GUAC_LOG_DEBUG, "RDPECAM sink is stopping, rejecting frame"); + pthread_mutex_unlock(&sink->lock); + return false; + } + + /* Prevent unbounded growth when the consumer is back-pressured. */ + if (sink->queue_size >= GUAC_RDPECAM_MAX_FRAMES) { + pthread_mutex_unlock(&sink->lock); + return false; + } + + if (len < sizeof(guac_rdpecam_frame_header)) { + guac_client_log(sink->client, GUAC_LOG_WARNING, "RDPECAM frame too small: %zu bytes (expected at least %zu)", + len, sizeof(guac_rdpecam_frame_header)); + pthread_mutex_unlock(&sink->lock); + return false; + } + + const guac_rdpecam_frame_header* header = (const guac_rdpecam_frame_header*) data; + + if (header->version != 1) { + guac_client_log(sink->client, GUAC_LOG_WARNING, "RDPECAM frame has invalid version: %d", header->version); + pthread_mutex_unlock(&sink->lock); + return false; + } + + if (header->payload_len > GUAC_RDPECAM_MAX_FRAME_SIZE) { + guac_client_log(sink->client, GUAC_LOG_WARNING, "RDPECAM frame payload too large: %u bytes", header->payload_len); + pthread_mutex_unlock(&sink->lock); + return false; + } + + size_t expected_total_len = sizeof(guac_rdpecam_frame_header) + header->payload_len; + if (len != expected_total_len) { + guac_client_log(sink->client, GUAC_LOG_WARNING, "RDPECAM frame length mismatch: got %zu bytes, expected %zu (header: %zu + payload: %u)", + len, expected_total_len, sizeof(guac_rdpecam_frame_header), header->payload_len); + pthread_mutex_unlock(&sink->lock); + return false; + } + + guac_rdpecam_frame* frame = guac_mem_zalloc(sizeof(guac_rdpecam_frame)); + if (!frame) { + guac_client_log(sink->client, GUAC_LOG_ERROR, "Failed to allocate RDPECAM frame"); + pthread_mutex_unlock(&sink->lock); + return false; + } + + frame->data = guac_mem_alloc(header->payload_len); + if (!frame->data) { + guac_client_log(sink->client, GUAC_LOG_ERROR, "Failed to allocate RDPECAM frame data"); + guac_mem_free(frame); + pthread_mutex_unlock(&sink->lock); + return false; + } + + const uint8_t* payload_start = (const uint8_t*) data + sizeof(guac_rdpecam_frame_header); + memcpy(frame->data, payload_start, header->payload_len); + frame->length = header->payload_len; + frame->pts_ms = header->pts_ms; + frame->keyframe = (header->flags & 0x01) != 0; + frame->next = NULL; + + if (sink->queue_tail) { + sink->queue_tail->next = frame; + sink->queue_tail = frame; + } else { + sink->queue_head = frame; + sink->queue_tail = frame; + } + sink->queue_size++; + + guac_client_log(sink->client, GUAC_LOG_TRACE, "RDPECAM frame queued: %zu bytes, keyframe=%s, pts=%u ms, queue_size=%d/%d", + frame->length, frame->keyframe ? "yes" : "no", frame->pts_ms, sink->queue_size, GUAC_RDPECAM_MAX_FRAMES); + + int utilization = (sink->queue_size * 100) / GUAC_RDPECAM_MAX_FRAMES; + if (utilization >= 80) { + guac_client_log(sink->client, GUAC_LOG_DEBUG, "RDPECAM queue utilization: %d%% (%d/%d frames)", + utilization, sink->queue_size, GUAC_RDPECAM_MAX_FRAMES); + } + + pthread_cond_signal(&sink->frame_available); + + pthread_mutex_unlock(&sink->lock); + + return true; + +} + +bool guac_rdpecam_pop(guac_rdpecam_sink* sink, uint8_t** out_buf, size_t* out_len, + bool* out_keyframe, uint32_t* out_pts_ms) { + + if (!sink || !out_buf || !out_len || !out_keyframe || !out_pts_ms) + return false; + + pthread_mutex_lock(&sink->lock); + + /* Sleep until a frame arrives or destruction is requested. */ + while (sink->queue_size == 0 && !sink->stopping) { + pthread_cond_wait(&sink->frame_available, &sink->lock); + } + + if (sink->stopping || sink->queue_size == 0) { + pthread_mutex_unlock(&sink->lock); + return false; + } + + guac_rdpecam_frame* frame = sink->queue_head; + sink->queue_head = frame->next; + if (!sink->queue_head) { + sink->queue_tail = NULL; + } + sink->queue_size--; + + *out_buf = frame->data; + *out_len = frame->length; + *out_keyframe = frame->keyframe; + *out_pts_ms = frame->pts_ms; + + guac_client_log(sink->client, GUAC_LOG_TRACE, "RDPECAM frame popped: %zu bytes, keyframe=%s, pts=%u ms, queue_size=%d/%d", + frame->length, frame->keyframe ? "yes" : "no", frame->pts_ms, sink->queue_size, GUAC_RDPECAM_MAX_FRAMES); + + if (sink->queue_size == 0) { + guac_client_log(sink->client, GUAC_LOG_DEBUG, "RDPECAM queue is now empty"); + } else if (sink->queue_size <= 3) { + guac_client_log(sink->client, GUAC_LOG_DEBUG, "RDPECAM queue is low: %d/%d frames remaining", sink->queue_size, GUAC_RDPECAM_MAX_FRAMES); + } + + guac_mem_free(frame); + + pthread_mutex_unlock(&sink->lock); + + return true; + +} + +int guac_rdpecam_get_queue_size(guac_rdpecam_sink* sink) { + + if (!sink) + return 0; + + pthread_mutex_lock(&sink->lock); + int size = sink->queue_size; + pthread_mutex_unlock(&sink->lock); + + return size; + +} diff --git a/src/protocols/rdp/channels/rdpecam/rdpecam_sink.h b/src/protocols/rdp/channels/rdpecam/rdpecam_sink.h new file mode 100644 index 0000000000..b3294263b6 --- /dev/null +++ b/src/protocols/rdp/channels/rdpecam/rdpecam_sink.h @@ -0,0 +1,250 @@ +/* + * 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. + */ + +#ifndef GUAC_RDP_CHANNELS_RDPECAM_SINK_H +#define GUAC_RDP_CHANNELS_RDPECAM_SINK_H + +#include +#include +#include +#include + +/** + * The maximum number of video frames to buffer in the RDPECAM sink. + */ +#define GUAC_RDPECAM_MAX_FRAMES 15 + +/** + * The maximum size of a single video frame in bytes. + */ +#define GUAC_RDPECAM_MAX_FRAME_SIZE (1024 * 1024) // 1MB + +/** + * RDPECAM frame header structure (little-endian). + */ +typedef struct guac_rdpecam_frame_header { + + /** + * Version number (must be 1). + */ + uint8_t version; + + /** + * Flags (bit 0: keyframe). + */ + uint8_t flags; + + /** + * Reserved field (must be 0). + */ + uint16_t reserved; + + /** + * Presentation timestamp in milliseconds. + */ + uint32_t pts_ms; + + /** + * Length of the following H.264 payload in bytes. + */ + uint32_t payload_len; + +} __attribute__((packed)) guac_rdpecam_frame_header; + +/** + * A single video frame in the RDPECAM queue. + */ +typedef struct guac_rdpecam_frame { + + /** + * Presentation timestamp in milliseconds. + */ + uint32_t pts_ms; + + /** + * Whether this is a keyframe. + */ + bool keyframe; + + /** + * The frame data (H.264 Annex-B format). + */ + uint8_t* data; + + /** + * The length of the frame data in bytes. + */ + size_t length; + + /** + * Pointer to the next frame in the queue. + */ + struct guac_rdpecam_frame* next; + +} guac_rdpecam_frame; + +/** + * RDPECAM sink for buffering and managing video frames from the client. + */ +typedef struct guac_rdpecam_sink { + + /** + * Lock for thread-safe access to the sink. + */ + pthread_mutex_t lock; + + /** + * Condition variable for signaling frame availability. + */ + pthread_cond_t frame_available; + + /** + * The guac_client instance handling the relevant RDP connection. + */ + guac_client* client; + + /** + * Head of the frame queue. + */ + guac_rdpecam_frame* queue_head; + + /** + * Tail of the frame queue. + */ + guac_rdpecam_frame* queue_tail; + + /** + * Current number of frames in the queue. + */ + int queue_size; + + + /** + * Whether the sink is being destroyed. + */ + bool stopping; + + /** + * Whether streaming has been started (shared across all device channels). + */ + bool streaming; + + /** + * Number of available credits for sending frames (shared across all channels). + * Protected by the sink lock. + */ + uint32_t credits; + + /** + * Stream index for the active stream (shared across all channels). + * This is the stream index from StartStreamsRequest (typically 0). + * Protected by the sink lock. + */ + uint8_t stream_index; + + /** + * Whether a device channel has claimed the sender role. Only one + * channel should actively dequeue and transmit frames at a time. + */ + bool has_active_sender; + + /** + * Opaque pointer to the channel currently authorized to transmit samples. + * If NULL, no sender is active. Protected by the sink lock. + */ + void* active_sender_channel; + +} guac_rdpecam_sink; + +/** + * Creates a new RDPECAM sink for the given client. + * + * @param client + * The guac_client instance handling the relevant RDP connection. + * + * @return + * A newly-allocated RDPECAM sink, or NULL if allocation fails. + */ +guac_rdpecam_sink* guac_rdpecam_create(guac_client* client); + +/** + * Destroys the given RDPECAM sink, freeing all associated resources. + * + * @param sink + * The RDPECAM sink to destroy. + */ +void guac_rdpecam_destroy(guac_rdpecam_sink* sink); + +/** + * Signals any threads waiting on the sink that shutdown is in progress, waking + * them so they can terminate gracefully. The sink itself is not freed. + * + * @param sink + * The RDPECAM sink to signal. + */ +void guac_rdpecam_signal_stop(guac_rdpecam_sink* sink); + +/** + * Queues a fully-assembled RDPECAM frame within the sink. The frame data is + * copied into an internal buffer, and the call fails if the sink is stopping, + * the queue is full, or validation of the header/payload fails. + * + * @param sink + * The sink receiving the frame. + * + * @param data + * Pointer to the frame header followed by the H.264 payload. + * + * @param len + * Total number of bytes available at @p data. + * + * @return + * Non-zero if the frame was queued successfully, zero otherwise. + */ +bool guac_rdpecam_push(guac_rdpecam_sink* sink, const void* data, size_t len); + +/** + * Retrieves the next queued frame from the sink. Ownership of the returned + * payload buffer is transferred to the caller. + * + * @param sink + * The sink to dequeue from. + * + * @param out_buf + * Receives a pointer to the payload buffer (must be freed by the caller). + * + * @param out_len + * Receives the payload length in bytes. + * + * @param out_keyframe + * Receives whether the frame represents a keyframe. + * + * @param out_pts_ms + * Receives the presentation timestamp, in milliseconds. + * + * @return + * Non-zero if a frame was returned, zero if the sink is stopping or empty. + */ +bool guac_rdpecam_pop(guac_rdpecam_sink* sink, uint8_t** out_buf, size_t* out_len, + bool* out_keyframe, uint32_t* out_pts_ms); + +int guac_rdpecam_get_queue_size(guac_rdpecam_sink* sink); + + +#endif From 4ce6bf0bd6aab09ce82036e7130090027585014b Mon Sep 17 00:00:00 2001 From: Loic Date: Tue, 4 Nov 2025 12:38:19 -0500 Subject: [PATCH 03/12] GUACAMOLE-1415: Add RDPECAM plugin Add guacrdpecam plugin that bridges Guacamole with the RDPECAM channel. This plugin handles the integration between Guacamole's video stream handling and FreeRDP's RDPECAM dynamic virtual channel. Components: - guacrdpecam.c/h: Main plugin implementation and lifecycle management - rdpecam_proto.c/h: Protocol message handling and serialization The plugin registers as a FreeRDP plugin and manages the connection between client video streams and the RDPECAM channel implementation. --- .../rdp/plugins/guacrdpecam/guacrdpecam.c | 2297 +++++++++++++++++ .../rdp/plugins/guacrdpecam/guacrdpecam.h | 333 +++ .../rdp/plugins/guacrdpecam/rdpecam_proto.c | 325 +++ .../rdp/plugins/guacrdpecam/rdpecam_proto.h | 456 ++++ 4 files changed, 3411 insertions(+) create mode 100644 src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.c create mode 100644 src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.h create mode 100644 src/protocols/rdp/plugins/guacrdpecam/rdpecam_proto.c create mode 100644 src/protocols/rdp/plugins/guacrdpecam/rdpecam_proto.h diff --git a/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.c b/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.c new file mode 100644 index 0000000000..577d718019 --- /dev/null +++ b/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.c @@ -0,0 +1,2297 @@ +/* + * 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. + */ + +#include "channels/rdpecam/rdpecam_sink.h" +#include "plugins/guacrdpecam/guacrdpecam.h" +#include "plugins/guacrdpecam/rdpecam_proto.h" +#include "plugins/ptr-string.h" +#include "rdp.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * Credits per SampleRequest. Set to 1 to enforce strict request-response + * behavior. Each SampleRequest from Windows grants exactly 1 credit, and each + * SampleResponse consumes 1 credit, ensuring 1:1 frame delivery. + */ +#define GUAC_RDPECAM_SAMPLE_CREDITS 1u + +#define GUAC_RDPECAM_DEFAULT_WIDTH 640u +#define GUAC_RDPECAM_DEFAULT_HEIGHT 480u +#define GUAC_RDPECAM_DEFAULT_FPS_NUM 30u +#define GUAC_RDPECAM_DEFAULT_FPS_DEN 1u + +/** + * Returns true if RDPECAM hexdump logging is enabled. RDPECAM traffic is always + * dumped to the log (subject to the overall guacd log level filtering). + * + * @return + * true if hexdump logging should be emitted, false otherwise. + */ +static bool guac_rdp_rdpecam_should_hexdump(void) { + static bool initialized = false; + static bool enabled = false; + if (!initialized) { + const char* env = getenv("GUAC_RDPECAM_HEXDUMP"); + if (env && *env) { + if (!strcasecmp(env, "1") || !strcasecmp(env, "true") || + !strcasecmp(env, "yes") || !strcasecmp(env, "on")) + enabled = true; + } + initialized = true; + } + return enabled; +} + + +/** + * Logs a hexadecimal dump of the provided buffer if hexdump logging is + * enabled. Output roughly matches the format used by winpr_HexDump to aid in + * side-by-side comparison against FreeRDP traces. + * + * @param client + * The client whose log handler should receive the output. + * @param direction + * Direction label ("TX" or "RX") to prefix each line, or NULL for none. + * @param channel_name + * The RDPECAM channel name associated with the payload. + * @param channel_id + * The FreeRDP channel identifier. + * @param data + * Pointer to the binary data to dump. + * @param length + * Length of the binary data in bytes. + */ +static void guac_rdp_rdpecam_log_hexdump(guac_client* client, const char* direction, + const char* channel_name, UINT32 channel_id, const BYTE* data, size_t length) { + + if (!client || !data || length == 0) + return; + + if (!guac_rdp_rdpecam_should_hexdump()) + return; + + const size_t max_dump = 256; + const size_t dump_len = (length > max_dump) ? max_dump : length; + + if (length > max_dump) { + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM %s %s[id=%" PRIu32 "] hexdump length=%zu truncated to %zu bytes", + direction ? direction : "", + channel_name ? channel_name : "rdpecam", + channel_id, + length, + dump_len); + } + + char ascii[17]; + ascii[16] = '\0'; + + for (size_t offset = 0; offset < dump_len; offset += 16) { + const size_t chunk = (dump_len - offset < 16) ? (dump_len - offset) : 16; + char hexbuf[(16 * 3) + 1]; + size_t pos = 0; + + for (size_t i = 0; i < chunk && pos < sizeof(hexbuf); i++) + pos += (size_t)snprintf(&hexbuf[pos], sizeof(hexbuf) - pos, "%02" PRIx8 " ", + data[offset + i]); + + for (size_t i = chunk; i < 16 && pos < sizeof(hexbuf); i++) + pos += (size_t)snprintf(&hexbuf[pos], sizeof(hexbuf) - pos, " "); + + hexbuf[sizeof(hexbuf) - 1] = '\0'; + + for (size_t i = 0; i < chunk; i++) + ascii[i] = isprint(data[offset + i]) ? (char)data[offset + i] : '.'; + + for (size_t i = chunk; i < 16; i++) + ascii[i] = ' '; + + ascii[16] = '\0'; + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM %s %s[id=%" PRIu32 "] %04" PRIx64 " %-48s %s", + direction ? direction : "", + channel_name ? channel_name : "rdpecam", + channel_id, + (uint64_t)offset, + hexbuf, + ascii); + } +} + +static void guac_rdp_rdpecam_log_message(guac_client* client, const char* prefix, + const char* channel_name, UINT32 channel_id, UINT8 cam_msg, size_t payload_len, + const BYTE* payload) { + + if (!client) + return; + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM %s %s[id=%" PRIu32 "] msg=0x%02X payload_len=%zu", + prefix ? prefix : "", channel_name ? channel_name : "rdpecam", + channel_id, cam_msg, payload_len); + + if (guac_rdp_rdpecam_should_hexdump()) + guac_rdp_rdpecam_log_hexdump(client, prefix, channel_name, channel_id, payload, payload_len); +} + +static void guac_rdp_rdpecam_log_stream(guac_client* client, const char* prefix, + const char* channel_name, UINT32 channel_id, wStream* stream) { + + if (!stream) + return; + + const size_t length = Stream_Length(stream); + const BYTE* buffer = Stream_Buffer(stream); + UINT8 cam_msg = (length >= 2) ? buffer[1] : 0; + const BYTE* payload = (length >= 2) ? buffer + 2 : NULL; + const size_t payload_len = (length >= 2) ? (length - 2) : 0; + + guac_rdp_rdpecam_log_message(client, prefix, channel_name, channel_id, + cam_msg, payload_len, payload); +} + +/* Forward declarations for device helper functions (definitions at end of file) */ +static guac_rdpecam_device* guac_rdpecam_device_create( + guac_rdp_rdpecam_plugin* plugin, const char* device_name); +static void guac_rdpecam_device_destroy(guac_rdpecam_device* device); +static guac_rdpecam_device* guac_rdpecam_device_lookup( + guac_rdp_rdpecam_plugin* plugin, const char* device_name); +static guac_rdp_rdpecam_device_caps* guac_rdp_rdpecam_get_device_caps( + guac_rdp_client* rdp_client, const char* channel_name); +static UINT guac_rdp_rdpecam_new_connection( + IWTSListenerCallback* listener_callback, IWTSVirtualChannel* channel, + BYTE* data, int* accept, + IWTSVirtualChannelCallback** channel_callback); + +/** + * Invoked when RDPECAM capabilities have been updated on the core side. + * If the plugin is ready (version negotiated) and the enumerator channel is + * known, immediately sends DeviceAddedNotification for all devices. + */ +void guac_rdp_rdpecam_caps_notify(guac_client* client) { + if (!client) + return; + guac_rdp_client* rdp_client = (guac_rdp_client*) client->data; + if (!rdp_client) + return; + guac_rdp_rdpecam_plugin* plugin = rdp_client->rdpecam_plugin; + if (!plugin || !plugin->version_negotiated || !plugin->enumerator_channel) + return; + guac_rwlock_acquire_write_lock(&(rdp_client->lock)); + if (rdp_client->rdpecam_caps_updated && rdp_client->rdpecam_device_caps_count > 0) { + guac_rdp_rdpecam_send_device_notifications(plugin, client, rdp_client, plugin->enumerator_channel); + rdp_client->rdpecam_caps_updated = 0; + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM sent device notifications via immediate caps notify"); + } + guac_rwlock_release_lock(&(rdp_client->lock)); +} + +/** + * Parameters describing the camera stream announced to the browser owner via + * argv instructions. + */ +typedef struct guac_rdp_camera_start_params { + uint32_t width; + uint32_t height; + uint32_t fps_numerator; + uint32_t fps_denominator; + uint8_t stream_index; + /** Optional browser device ID to target a specific camera */ + const char* device_id; +} guac_rdp_camera_start_params; + +/** + * Invoked for the owner user when Windows requests streaming. Informs the + * browser which resolution, frame rate, and stream index to use. + * + * @param user + * The user receiving the camera start signal. + * + * @param data + * Pointer to a guac_rdp_camera_start_params structure containing the + * stream parameters. + * + * @return + * Always NULL. + */ +static void* guac_rdp_rdpecam_send_camera_start_signal_callback(guac_user* user, void* data) { + + if (user == NULL) + return NULL; + + guac_rdp_camera_start_params* params = (guac_rdp_camera_start_params*) data; + guac_socket* socket = user->socket; + + /* Send concise string form always including deviceId (may be empty): + * WIDTHxHEIGHT@FPS_NUM/FPS_DEN#STREAM_INDEX#DEVICE_ID + */ + char concise[512]; + const char* device_id_str = (params->device_id) ? params->device_id : ""; + int concise_written = snprintf(concise, sizeof(concise), "%ux%u@%u/%u#%u#%s", + params->width, + params->height, + params->fps_numerator, + params->fps_denominator, + params->stream_index, + device_id_str); + if (concise_written > 0 && (size_t) concise_written < sizeof(concise)) { + guac_user_stream_argv(user, socket, "text/plain", "camera-start", concise); + guac_socket_flush(socket); /* Reduce latency delivering the start signal. */ + } + + return NULL; +} + +/** + * Invoked for the owner user when Windows stops streaming. Signals the + * browser to release its capture pipeline. + * + * @param user + * The user receiving the camera stop signal. + * + * @param data + * Ignored. + * + * @return + * Always NULL. + */ +static void* guac_rdp_rdpecam_send_camera_stop_signal_callback(guac_user* user, void* data) { + + if (user == NULL) + return NULL; + + guac_socket* socket = user->socket; + guac_user_stream_argv(user, socket, "text/plain", "camera-stop", ""); + guac_socket_flush(socket); + + return NULL; +} + +/** + * Dequeue thread entry point. Continuously pops frames from the rdpecam sink + * and sends them to the RDP client via the RDPECAM protocol. + * + * @param arg + * Pointer to the guac_rdpecam_device structure for per-device frame processing. + * + * @return + * NULL on success. + */ +static void* guac_rdp_rdpecam_dequeue_thread(void* arg) { + + guac_rdpecam_device* device = (guac_rdpecam_device*) arg; + if (!device || !device->sink) { + return NULL; + } + + guac_client* client = NULL; + guac_rdpecam_sink* sink = device->sink; + + /* For logging, we need the client - get it from sink if available */ + if (sink && sink->client) { + client = sink->client; + } + + if (!client) { + return NULL; + } + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM dequeue thread started for device: %s", device->device_name); + + uint32_t frames_processed = 0; + uint32_t frames_dropped = 0; + uint32_t last_stats_time = 0; + + while (true) { + + /* Snapshot current state; the loop will sleep with the lock released. */ + pthread_mutex_lock(&device->lock); + bool should_stop = device->stopping; + bool is_streaming = device->streaming; + bool is_active_sender = device->is_active_sender; + uint32_t available_credits = device->credits; + IWTSVirtualChannel* current_channel = device->stream_channel; + pthread_mutex_unlock(&device->lock); + + if (should_stop) + break; + + if (!current_channel) { + usleep(10000); + continue; + } + + /* Streaming proceeds only after the host sends StartStreams. */ + if (!is_streaming) { + usleep(50000); + continue; + } + + if (!is_active_sender) { + usleep(10000); + continue; + } + + if (available_credits == 0) { + usleep(10000); + continue; + } + + /* We have credits; attempt to pull a frame from the shared sink. */ + uint8_t* frame_data = NULL; + size_t frame_length = 0; + bool keyframe = false; + uint32_t pts_ms = 0; + if (!guac_rdpecam_pop(sink, &frame_data, &frame_length, &keyframe, &pts_ms)) { + /* No frame available or stopping */ + if (device->stopping) { + break; + } + continue; + } + + /* Validate frame data */ + if (!frame_data || frame_length == 0) { + guac_client_log(client, GUAC_LOG_WARNING, "RDPECAM received invalid frame data"); + if (frame_data) { + guac_mem_free(frame_data); + } + continue; + } + + bool stop_requested; + bool stream_active; + bool channel_available; + bool waiting_for_keyframe; + pthread_mutex_lock(&device->lock); + stop_requested = device->stopping; + stream_active = device->streaming; + channel_available = (device->stream_channel != NULL); + waiting_for_keyframe = device->need_keyframe; + bool allow_send = !stop_requested && stream_active && channel_available && + (!waiting_for_keyframe || keyframe); + uint32_t stream_idx = device->stream_index; + uint32_t sample_seq = device->sample_sequence; + IWTSVirtualChannel* active_channel = device->stream_channel; + if (allow_send) { + device->sample_sequence++; + } + pthread_mutex_unlock(&device->lock); + + if (!allow_send) { + if (!stream_active) { + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM dropping frame - streaming not active"); + } + else if (!channel_available) { + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM dropping frame - channel unavailable"); + } + else if (waiting_for_keyframe && !keyframe) { + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM dropping P-frame - waiting for keyframe to start stream"); + } + else if (stop_requested) { + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM dropping frame - device stopping"); + } + guac_mem_free(frame_data); + frames_dropped++; + continue; + } + + /* Build RDPECAM sample (placeholder header + payload) */ + + wStream* s = Stream_New(NULL, frame_length + 64); + if (s && rdpecam_write_sample_response_header(s, + /*streamId*/ stream_idx, + /*sampleSequence*/ sample_seq, + (uint32_t) frame_length, + /*pts in HNS*/ ((uint64_t) pts_ms) * 10000ULL)) { + /* Log pts conversion for early frames */ + if (frames_processed < 8) { + uint64_t pts_hns = ((uint64_t) pts_ms) * 10000ULL; + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM TX frame: pts_ms=%u -> pts_hns=%" PRIu64, + pts_ms, pts_hns); + } + Stream_Write(s, frame_data, frame_length); + const size_t sample_len = Stream_GetPosition(s); + uint32_t log_channel_id = 0; + pthread_mutex_lock(&device->lock); + log_channel_id = device->stream_channel_id; + pthread_mutex_unlock(&device->lock); + guac_rdp_rdpecam_log_stream(client, "TX", + device->device_name, log_channel_id, s); + + /* Use message_lock to prevent blocking the RDP event loop */ + guac_rdp_client* rdp_client = (guac_rdp_client*) client->data; + pthread_mutex_lock(&(rdp_client->message_lock)); + UINT result = active_channel->Write(active_channel, (UINT32) sample_len, Stream_Buffer(s), NULL); + pthread_mutex_unlock(&(rdp_client->message_lock)); + + if (result == CHANNEL_RC_OK) { + /* Decrement credits atomically and log transition (per-device) */ + pthread_mutex_lock(&device->lock); + uint32_t before = device->credits; + if (device->credits > 0) device->credits--; + uint32_t remaining = device->credits; + if (keyframe && device->need_keyframe) + device->need_keyframe = false; + pthread_mutex_unlock(&device->lock); + + frames_processed++; + + guac_client_log(client, GUAC_LOG_DEBUG, "RDPECAM frame sent: %zu bytes, keyframe=%s, pts=%u ms, credits %u->%u", + frame_length, keyframe ? "yes" : "no", pts_ms, before, remaining); + + } else { + /* DVC Write failed - log detailed information */ + guac_client_log(client, GUAC_LOG_WARNING, + "RDPECAM DVC Write FAILED: size=%zu, result=0x%08X, frame=%zu, keyframe=%s", + sample_len, result, frames_processed + 1, + keyframe ? "yes" : "no"); + + frames_dropped++; + + /* If channel write fails, we might need to stop streaming */ + if (result != CHANNEL_RC_OK) { + guac_client_log(client, GUAC_LOG_ERROR, + "RDPECAM channel write failed (code=0x%08X), stopping streaming", result); + pthread_mutex_lock(&device->lock); + device->streaming = false; + device->is_active_sender = false; + pthread_mutex_unlock(&device->lock); + pthread_mutex_lock(&sink->lock); + sink->streaming = false; + sink->has_active_sender = false; + sink->active_sender_channel = NULL; + pthread_mutex_unlock(&sink->lock); + } + } + Stream_Free(s, TRUE); + } + else { + if (s) Stream_Free(s, TRUE); + guac_client_log(client, GUAC_LOG_ERROR, "RDPECAM failed to build sample header"); + } + + /* Free frame data */ + guac_mem_free(frame_data); + + /* Log performance statistics every 100 frames or 30 seconds */ + uint32_t current_time = (uint32_t) time(NULL); + if (frames_processed % 100 == 0 || (current_time - last_stats_time) >= 30) { + uint32_t total_frames = frames_processed + frames_dropped; + float drop_rate = total_frames > 0 ? (float)frames_dropped * 100.0f / total_frames : 0.0f; + + /* Log device credits (per-device) */ + pthread_mutex_lock(&device->lock); + uint32_t device_credits_log = device->credits; + pthread_mutex_unlock(&device->lock); + guac_client_log(client, GUAC_LOG_INFO, "RDPECAM performance stats: device=%s, processed=%u, dropped=%u, drop_rate=%.1f%%, credits=%u, queue=%d/%d", + device->device_name, frames_processed, frames_dropped, drop_rate, device_credits_log, + guac_rdpecam_get_queue_size(sink), GUAC_RDPECAM_MAX_FRAMES); + + last_stats_time = current_time; + } + } + + /* Log final statistics */ + uint32_t total_frames = frames_processed + frames_dropped; + float drop_rate = total_frames > 0 ? (float)frames_dropped * 100.0f / total_frames : 0.0f; + guac_client_log(client, GUAC_LOG_INFO, "RDPECAM final stats for device=%s: processed=%u, dropped=%u, drop_rate=%.1f%%", + device->device_name, frames_processed, frames_dropped, drop_rate); + + guac_client_log(client, GUAC_LOG_DEBUG, "RDPECAM dequeue thread stopped for device: %s", device->device_name); + + /* Device cleanup is handled by device_destroy via hash table destructor. + * Thread is detached and will exit naturally when device->stopping is set. */ + + return NULL; + +} + +/** + * Processes a single RDPECAM protocol message delivered by FreeRDP. The + * provided stream is positioned at the start of the message payload (after + * the message header) and must be fully consumed by the handler. + * + * @param client + * The guac_client associated with the RDP session. + * + * @param channel + * The FreeRDP dynamic virtual channel on which the message was received. + * + * @param stream + * The WinPR stream containing the message payload. + * + * @param rdpecam_channel_callback + * Callback context describing the channel and associated device state. + * + * @return + * CHANNEL_RC_OK on success, or a FreeRDP CHANNEL_RC_* error code. + */ +static UINT guac_rdp_rdpecam_handle_data(guac_client* client, IWTSVirtualChannel* channel, + wStream* stream, guac_rdp_rdpecam_channel_callback* rdpecam_channel_callback) { + + guac_rdp_client* rdp_client = (guac_rdp_client*) client->data; + + /* Resolve per-channel context supplied by FreeRDP. */ + guac_rdpecam_device* device = rdpecam_channel_callback ? rdpecam_channel_callback->device : NULL; + guac_rdp_rdpecam_plugin* plugin = rdpecam_channel_callback ? rdpecam_channel_callback->plugin : NULL; + const char* ch_name = rdpecam_channel_callback ? rdpecam_channel_callback->channel_name : "unknown"; + UINT32 channel_id = rdpecam_channel_callback ? rdpecam_channel_callback->channel_id : 0; + + /* Get remaining data from current stream position (FreeRDP has already consumed any framing) */ + size_t data_length = Stream_GetRemainingLength(stream); + + if (data_length < 2) { + guac_client_log(client, GUAC_LOG_WARNING, "RDPECAM message too short: %zu bytes (expected at least 2 for header)", data_length); + return CHANNEL_RC_OK; + } + + /* Log full payload prior to parsing */ + const BYTE* raw = Stream_Pointer(stream); + + /* Read MS-RDPECAM protocol header: [Version:1][MessageId:1] */ + BYTE version, cam_msg; + Stream_Read_UINT8(stream, version); + Stream_Read_UINT8(stream, cam_msg); + + size_t payload_len = (data_length >= 2) ? (data_length - 2) : 0; + const BYTE* payload_ptr = Stream_Pointer(stream); + guac_rdp_rdpecam_log_message(client, "RX", ch_name, channel_id, cam_msg, + payload_len, payload_ptr); + + guac_client_log(client, GUAC_LOG_DEBUG, "RDPECAM RX message on %s[id=%" PRIu32 "]: version=%d, cam_msg=0x%02x, payload_len=%zu", + ch_name, channel_id, version, cam_msg, data_length - 2); + + /* Verify protocol version */ + if (version != RDPECAM_PROTO_VERSION) { + guac_client_log(client, GUAC_LOG_WARNING, + "RDPECAM received message with unexpected version: expected 0x%02x, got 0x%02x", + RDPECAM_PROTO_VERSION, version); + return CHANNEL_RC_OK; + } + + /* Process message based on message ID */ + { + /* Stream is now positioned at the start of the payload (after version and messageId) */ + UINT result = CHANNEL_RC_OK; + wStream* rs = NULL; + + switch (cam_msg) { + case RDPECAM_MSG_SELECT_VERSION_RESPONSE: { + /* Server accepted our version request */ + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM RX ChannelId=%" PRIu32 " MessageId=0x04 SelectVersionResponse (version=%d)", + channel_id, RDPECAM_PROTO_VERSION); + + /* Mark version negotiation as complete */ + if (plugin) { + plugin->version_negotiated = true; + } + + /* Store enumerator channel reference for later use */ + if (plugin && !strcasecmp(ch_name, GUAC_RDPECAM_CHANNEL_NAME)) { + plugin->enumerator_channel = channel; + } + + /* If devices are already available, send notifications now */ + guac_rwlock_acquire_read_lock(&(rdp_client->lock)); + unsigned int device_count = rdp_client->rdpecam_device_caps_count; + guac_rwlock_release_lock(&(rdp_client->lock)); + + if (device_count > 0 && plugin && plugin->enumerator_channel) { + guac_rwlock_acquire_write_lock(&(rdp_client->lock)); + guac_rdp_rdpecam_send_device_notifications(plugin, client, rdp_client, plugin->enumerator_channel); + rdp_client->rdpecam_caps_updated = 0; /* Clear the flag */ + guac_rwlock_release_lock(&(rdp_client->lock)); + } else { + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM version negotiated, waiting for device capabilities"); + } + break; + } + + case RDPECAM_MSG_ACTIVATE_DEVICE_REQUEST: { + guac_client_log(client, GUAC_LOG_DEBUG, "RDPECAM received ActivateDeviceRequest on %s[id=%" PRIu32 "]", ch_name, channel_id); + + if (!strcasecmp(ch_name, GUAC_RDPECAM_CHANNEL_NAME)) { + rs = Stream_New(NULL, 8); + if (rs && rdpecam_build_success_response(rs)) { + Stream_SealLength(rs); + const size_t out_len = Stream_Length(rs); + guac_rdp_rdpecam_log_stream(client, "TX", ch_name, channel_id, rs); + pthread_mutex_lock(&(rdp_client->message_lock)); + result = channel->Write(channel, (UINT32) out_len, Stream_Buffer(rs), NULL); + pthread_mutex_unlock(&(rdp_client->message_lock)); + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM TX ChannelId=%" PRIu32 " MessageId=0x01 SuccessResponse (ActivateDevice control)", + channel_id); + } + } else { + if (!device) { + guac_client_log(client, GUAC_LOG_WARNING, + "RDPECAM ActivateDevice on device channel but no device available (ChannelId=%" PRIu32 ")", + channel_id); + return CHANNEL_RC_OK; + } + + rs = Stream_New(NULL, 8); + if (rs && rdpecam_build_success_response(rs)) { + Stream_SealLength(rs); + const size_t out_len = Stream_Length(rs); + guac_rdp_rdpecam_log_stream(client, "TX", ch_name, channel_id, rs); + pthread_mutex_lock(&(rdp_client->message_lock)); + result = channel->Write(channel, (UINT32) out_len, Stream_Buffer(rs), NULL); + pthread_mutex_unlock(&(rdp_client->message_lock)); + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM TX ChannelId=%" PRIu32 " MessageId=0x01 SuccessResponse (ActivateDevice device=%s)", + channel_id, device->device_name); + } + } + break; + } + + case RDPECAM_MSG_DEACTIVATE_DEVICE_REQUEST: { + guac_client_log(client, GUAC_LOG_DEBUG, "RDPECAM received DeactivateDeviceRequest on %s[id=%" PRIu32 "]", ch_name, channel_id); + + if (!strcasecmp(ch_name, GUAC_RDPECAM_CHANNEL_NAME)) { + rs = Stream_New(NULL, 8); + if (rs && rdpecam_build_success_response(rs)) { + Stream_SealLength(rs); + const size_t out_len = Stream_Length(rs); + guac_rdp_rdpecam_log_stream(client, "TX", ch_name, channel_id, rs); + pthread_mutex_lock(&(rdp_client->message_lock)); + result = channel->Write(channel, (UINT32) out_len, Stream_Buffer(rs), NULL); + pthread_mutex_unlock(&(rdp_client->message_lock)); + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM TX ChannelId=%" PRIu32 " MessageId=0x01 SuccessResponse (DeactivateDevice control)", + channel_id); + } + } else { + if (!device || !device->sink) { + guac_client_log(client, GUAC_LOG_WARNING, + "RDPECAM DeactivateDevice on device channel but no device/sink available (ChannelId=%" PRIu32 ")", + channel_id); + return CHANNEL_RC_OK; + } + + bool same_stream_channel = (device->stream_channel == channel) || rdpecam_channel_callback->is_stream_channel; + if (!same_stream_channel) { + rs = Stream_New(NULL, 8); + if (rs && rdpecam_build_success_response(rs)) { + Stream_SealLength(rs); + const size_t out_len = Stream_Length(rs); + guac_rdp_rdpecam_log_stream(client, "TX", ch_name, channel_id, rs); + pthread_mutex_lock(&(rdp_client->message_lock)); + result = channel->Write(channel, (UINT32) out_len, Stream_Buffer(rs), NULL); + pthread_mutex_unlock(&(rdp_client->message_lock)); + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM TX ChannelId=%" PRIu32 " MessageId=0x01 SuccessResponse (DeactivateDevice property device=%s)", + channel_id, device->device_name); + } + break; + } + + rdpecam_channel_callback->is_stream_channel = true; + + /* Stop streaming if active (per-device) */ + uint32_t outstanding = 0; + uint32_t stream_index = 0; + bool was_active_sender = false; + pthread_mutex_lock(&device->lock); + outstanding = device->credits; + stream_index = device->stream_index; + was_active_sender = device->is_active_sender; + device->credits = 0; + device->streaming = false; + device->is_active_sender = false; + device->need_keyframe = true; + pthread_mutex_unlock(&device->lock); + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM DeactivateDevice device=%s was_active_sender=%s", + device->device_name, was_active_sender ? "true" : "false"); + + guac_rdpecam_sink* sink = device->sink; + + /* Only clear this device's sink state if it was the active sender. + * If this device was never streaming, its sink state is already clean. + * Only the active sender has sink state that needs to be cleared. */ + if (was_active_sender) { + pthread_mutex_lock(&sink->lock); + sink->streaming = false; + sink->credits = 0; + sink->has_active_sender = false; + sink->active_sender_channel = NULL; + pthread_mutex_unlock(&sink->lock); + + /* Clear the browser's frame push target to prevent race condition where + * in-flight frames arrive after deactivation but before channel close. + * The blob handler will safely drop frames when rdpecam_sink is NULL. */ + if (rdp_client->rdpecam_sink == sink) + rdp_client->rdpecam_sink = NULL; + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM cleared shared sink state for device=%s", + device->device_name); + } else { + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM NOT clearing shared sink state (device=%s was not active sender)", + device->device_name); + } + + pthread_mutex_lock(&device->lock); + device->stream_channel = NULL; + pthread_mutex_unlock(&device->lock); + + /* Send error responses for outstanding credits */ + for (uint32_t i = 0; i < outstanding; i++) { + wStream* es = Stream_New(NULL, 8); + if (es && rdpecam_build_sample_error_response(es, stream_index)) { + Stream_SealLength(es); + const size_t err_len = Stream_Length(es); + guac_rdp_rdpecam_log_stream(client, "TX", ch_name, channel_id, es); + pthread_mutex_lock(&(rdp_client->message_lock)); + channel->Write(channel, (UINT32) err_len, Stream_Buffer(es), NULL); + pthread_mutex_unlock(&(rdp_client->message_lock)); + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM TX ChannelId=%" PRIu32 " MessageId=0x13 SampleErrorResponse (stream=%u)", + channel_id, stream_index); + } + if (es) Stream_Free(es, TRUE); + } + + rs = Stream_New(NULL, 8); + if (rs && rdpecam_build_success_response(rs)) { + Stream_SealLength(rs); + const size_t out_len = Stream_Length(rs); + guac_rdp_rdpecam_log_stream(client, "TX", ch_name, channel_id, rs); + pthread_mutex_lock(&(rdp_client->message_lock)); + result = channel->Write(channel, (UINT32) out_len, Stream_Buffer(rs), NULL); + pthread_mutex_unlock(&(rdp_client->message_lock)); + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM TX ChannelId=%" PRIu32 " MessageId=0x01 SuccessResponse (DeactivateDevice device=%s)", + channel_id, device->device_name); + + /* Only inform browser to stop camera if this device was the active sender. + * This prevents stopping the wrong camera when multiple devices exist but + * only one is actively streaming. */ + if (was_active_sender) { + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM sending camera-stop to browser (device %s was active sender)", + device->device_name); + guac_client_for_owner(client, + guac_rdp_rdpecam_send_camera_stop_signal_callback, + NULL); + } else { + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM NOT sending camera-stop to browser (device %s was not active sender)", + device->device_name); + } + } + } + break; + } + + case RDPECAM_MSG_STREAM_LIST_REQUEST: { + if (!device) { + guac_client_log(client, GUAC_LOG_WARNING, + "RDPECAM StreamListRequest received but no device available on ChannelId=%" PRIu32, + channel_id); + return CHANNEL_RC_OK; + } + + /* StreamListRequest has no payload - just respond with our stream list */ + guac_client_log(client, GUAC_LOG_DEBUG, "RDPECAM received StreamListRequest"); + + rdpecam_stream_desc stream = { + .FrameSourceType = CAM_STREAM_FRAME_SOURCE_TYPE_Color, + .Category = CAM_STREAM_CATEGORY_Capture, + .Selected = 1, + .CanBeShared = 0 + }; + rs = Stream_New(NULL, 16); + if (rs && rdpecam_build_stream_list(rs, &stream, 1)) { + Stream_SealLength(rs); + const size_t out_len = Stream_Length(rs); + guac_rdp_rdpecam_log_stream(client, "TX", ch_name, channel_id, rs); + pthread_mutex_lock(&(rdp_client->message_lock)); + result = channel->Write(channel, (UINT32) out_len, Stream_Buffer(rs), NULL); + pthread_mutex_unlock(&(rdp_client->message_lock)); + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM TX ChannelId=%" PRIu32 " MessageId=0x0A StreamListResponse (streams=%u)", + channel_id, 1U); + } + break; + } + + case RDPECAM_MSG_MEDIA_TYPE_LIST_REQUEST: { + if (!device) { + guac_client_log(client, GUAC_LOG_WARNING, + "RDPECAM MediaTypeListRequest received but no device available on ChannelId=%" PRIu32, + channel_id); + return CHANNEL_RC_OK; + } + + /* Read stream index */ + if (payload_len < 1) { + guac_client_log(client, GUAC_LOG_WARNING, + "RDPECAM MediaTypeListRequest missing stream index (payload_len=%zu) ChannelId=%" PRIu32, + payload_len, channel_id); + return CHANNEL_RC_OK; + } + uint8_t stream_idx; + Stream_Read_UINT8(stream, stream_idx); + + guac_client_log(client, GUAC_LOG_DEBUG, "RDPECAM received MediaTypeListRequest for stream %d", stream_idx); + + rdpecam_media_type_desc media_types[GUAC_RDP_RDPECAM_MAX_FORMATS]; + size_t media_type_count = 0; + + /* Get formats for this specific device */ + guac_rwlock_acquire_read_lock(&(rdp_client->lock)); + guac_rdp_rdpecam_device_caps* caps = NULL; + if (ch_name) { + caps = guac_rdp_rdpecam_get_device_caps(rdp_client, ch_name); + } + + if (caps && caps->format_count > 0) { + /* Use formats from this device's capabilities */ + for (unsigned int i = 0; i < caps->format_count + && media_type_count < GUAC_RDP_RDPECAM_MAX_FORMATS; i++) { + guac_rdp_rdpecam_format* fmt = &caps->formats[i]; + if (!fmt->width || !fmt->height || !fmt->fps_num) + continue; + media_types[media_type_count++] = (rdpecam_media_type_desc) { + .Format = CAM_MEDIA_FORMAT_H264, + .Width = fmt->width, + .Height = fmt->height, + .FrameRateNumerator = fmt->fps_num, + .FrameRateDenominator = fmt->fps_den ? fmt->fps_den : 1, + .PixelAspectRatioNumerator = 1, + .PixelAspectRatioDenominator = 1, + .Flags = CAM_MEDIA_TYPE_DESCRIPTION_FLAG_DecodingRequired + }; + } + } + guac_rwlock_release_lock(&(rdp_client->lock)); + + if (media_type_count == 0) { + media_types[media_type_count++] = (rdpecam_media_type_desc) { + .Format = CAM_MEDIA_FORMAT_H264, + .Width = GUAC_RDPECAM_DEFAULT_WIDTH, + .Height = GUAC_RDPECAM_DEFAULT_HEIGHT, + .FrameRateNumerator = GUAC_RDPECAM_DEFAULT_FPS_NUM, + .FrameRateDenominator = GUAC_RDPECAM_DEFAULT_FPS_DEN, + .PixelAspectRatioNumerator = 1, + .PixelAspectRatioDenominator = 1, + .Flags = CAM_MEDIA_TYPE_DESCRIPTION_FLAG_DecodingRequired + }; + media_types[media_type_count++] = (rdpecam_media_type_desc) { + .Format = CAM_MEDIA_FORMAT_H264, + .Width = 320, + .Height = 240, + .FrameRateNumerator = GUAC_RDPECAM_DEFAULT_FPS_NUM, + .FrameRateDenominator = GUAC_RDPECAM_DEFAULT_FPS_DEN, + .PixelAspectRatioNumerator = 1, + .PixelAspectRatioDenominator = 1, + .Flags = CAM_MEDIA_TYPE_DESCRIPTION_FLAG_DecodingRequired + }; + } + + rs = Stream_New(NULL, 128); + if (rs && rdpecam_build_media_type_list(rs, media_types, media_type_count)) { + Stream_SealLength(rs); + const size_t out_len = Stream_Length(rs); + guac_rdp_rdpecam_log_stream(client, "TX", ch_name, channel_id, rs); + pthread_mutex_lock(&(rdp_client->message_lock)); + result = channel->Write(channel, (UINT32) out_len, Stream_Buffer(rs), NULL); + pthread_mutex_unlock(&(rdp_client->message_lock)); + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM TX ChannelId=%" PRIu32 " MessageId=0x0C MediaTypeListResponse (count=%zu)", + channel_id, media_type_count); + } + break; + } + + case RDPECAM_MSG_CURRENT_MEDIA_TYPE_REQUEST: { + if (!device) { + guac_client_log(client, GUAC_LOG_WARNING, + "RDPECAM CurrentMediaTypeRequest received but no device available on ChannelId=%" PRIu32, + channel_id); + return CHANNEL_RC_OK; + } + + /* Read stream index */ + if (payload_len < 1) { + guac_client_log(client, GUAC_LOG_WARNING, + "RDPECAM CurrentMediaTypeRequest missing stream index (ChannelId=%" PRIu32 ")", + channel_id); + return CHANNEL_RC_OK; + } + uint8_t stream_idx; + Stream_Read_UINT8(stream, stream_idx); + + if (stream_idx == 0) { + /* If no media type set yet, use the default (first advertised type) */ + rdpecam_media_type_desc media_type = device->media_type; + if (media_type.Format == 0) { + /* Get formats for this specific device */ + guac_rwlock_acquire_read_lock(&(rdp_client->lock)); + guac_rdp_rdpecam_device_caps* caps = NULL; + if (ch_name) { + caps = guac_rdp_rdpecam_get_device_caps(rdp_client, ch_name); + } + + if (caps && caps->format_count > 0) { + /* Use first format from this device's capabilities */ + guac_rdp_rdpecam_format* preferred = &caps->formats[0]; + media_type = (rdpecam_media_type_desc){ + .Format = CAM_MEDIA_FORMAT_H264, + .Width = preferred->width, + .Height = preferred->height, + .FrameRateNumerator = preferred->fps_num, + .FrameRateDenominator = preferred->fps_den ? preferred->fps_den : 1, + .PixelAspectRatioNumerator = 1, + .PixelAspectRatioDenominator = 1, + .Flags = CAM_MEDIA_TYPE_DESCRIPTION_FLAG_DecodingRequired + }; + } else { + media_type = (rdpecam_media_type_desc){ + .Format = CAM_MEDIA_FORMAT_H264, + .Width = GUAC_RDPECAM_DEFAULT_WIDTH, + .Height = GUAC_RDPECAM_DEFAULT_HEIGHT, + .FrameRateNumerator = GUAC_RDPECAM_DEFAULT_FPS_NUM, + .FrameRateDenominator = GUAC_RDPECAM_DEFAULT_FPS_DEN, + .PixelAspectRatioNumerator = 1, + .PixelAspectRatioDenominator = 1, + .Flags = CAM_MEDIA_TYPE_DESCRIPTION_FLAG_DecodingRequired + }; + } + guac_rwlock_release_lock(&(rdp_client->lock)); + } + + rs = Stream_New(NULL, 64); + if (rs && rdpecam_build_current_media_type(rs, &media_type)) { + Stream_SealLength(rs); + const size_t out_len = Stream_Length(rs); + guac_rdp_rdpecam_log_stream(client, "TX", ch_name, channel_id, rs); + pthread_mutex_lock(&(rdp_client->message_lock)); + result = channel->Write(channel, (UINT32) out_len, Stream_Buffer(rs), NULL); + pthread_mutex_unlock(&(rdp_client->message_lock)); + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM TX ChannelId=%" PRIu32 " MessageId=0x0E CurrentMediaTypeResponse (format=%u, %ux%u@%u/%u)", + channel_id, media_type.Format, media_type.Width, media_type.Height, + media_type.FrameRateNumerator, media_type.FrameRateDenominator); + } + } + break; + } + + case RDPECAM_MSG_START_STREAMS_REQUEST: { + if (!device || !device->sink) { + guac_client_log(client, GUAC_LOG_WARNING, + "RDPECAM StartStreamsRequest received but no device/sink available (ChannelId=%" PRIu32 ")", + channel_id); + return CHANNEL_RC_OK; + } + + /* Parse StartStreamsRequest from stream */ + if (payload_len < 1 + 26) { /* stream_idx + media_type_desc */ + guac_client_log(client, GUAC_LOG_WARNING, "RDPECAM StartStreamsRequest too short"); + return CHANNEL_RC_OK; + } + + uint8_t stream_idx; + Stream_Read_UINT8(stream, stream_idx); + + /* Read media type description */ + rdpecam_media_type_desc media_type; + Stream_Read_UINT8(stream, media_type.Format); + Stream_Read_UINT32(stream, media_type.Width); + Stream_Read_UINT32(stream, media_type.Height); + Stream_Read_UINT32(stream, media_type.FrameRateNumerator); + Stream_Read_UINT32(stream, media_type.FrameRateDenominator); + Stream_Read_UINT32(stream, media_type.PixelAspectRatioNumerator); + Stream_Read_UINT32(stream, media_type.PixelAspectRatioDenominator); + Stream_Read_UINT8(stream, media_type.Flags); + + /* Handle camera switching: if another device is currently streaming, + * stop it before starting this device (single-camera model). + * Windows doesn't explicitly stop the old camera before starting the new one, + * so we must handle the switch automatically. */ + if (rdp_client->rdpecam_sink != NULL && rdp_client->rdpecam_sink != device->sink) { + guac_rdpecam_sink* old_sink = rdp_client->rdpecam_sink; + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM switching cameras: stopping previous device to start %s", + device->device_name); + + /* Find and stop the old device that owns old_sink */ + if (plugin && plugin->devices) { + ULONG_PTR* keys = NULL; + int count = HashTable_GetKeys(plugin->devices, &keys); + for (int i = 0; i < count; i++) { + guac_rdpecam_device* old_device = + (guac_rdpecam_device*) HashTable_GetItemValue(plugin->devices, (void*) keys[i]); + if (old_device && old_device->sink == old_sink) { + /* Stop the old device */ + pthread_mutex_lock(&old_device->lock); + old_device->streaming = false; + old_device->is_active_sender = false; + old_device->credits = 0; + old_device->stream_channel = NULL; + old_device->stream_channel_id = 0; + pthread_mutex_unlock(&old_device->lock); + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM stopped streaming on device %s for camera switch", + old_device->device_name); + break; + } + } + free(keys); + } + + /* Clear the old sink's streaming state */ + pthread_mutex_lock(&old_sink->lock); + old_sink->streaming = false; + old_sink->credits = 0; + old_sink->has_active_sender = false; + old_sink->active_sender_channel = NULL; + pthread_mutex_unlock(&old_sink->lock); + + /* Clear browser's frame push target temporarily. + * The new camera-start signal (sent below) will inform the browser + * to switch cameras. We don't send camera-stop because: + * 1. It has no device ID, so browser can't distinguish which camera to stop + * 2. The browser's camera-start handler should gracefully handle switching + * 3. Sending stop+start creates a temporary "no camera" state that can confuse browser */ + rdp_client->rdpecam_sink = NULL; + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM stopped streaming on old device, proceeding with %s (browser will be notified via camera-start)", + device->device_name); + } + + if (stream_idx == 0) { + /* Persist media type for later requests */ + pthread_mutex_lock(&device->lock); + device->media_type = media_type; + device->stream_index = stream_idx; + device->sample_sequence = 0; + device->credits = 0; + device->streaming = true; + device->need_keyframe = true; + device->is_active_sender = true; + device->stopping = false; + device->stream_channel = channel; + device->stream_channel_id = channel_id; + pthread_mutex_unlock(&device->lock); + + rdpecam_channel_callback->is_stream_channel = true; + + guac_rdpecam_sink* sink = device->sink; + pthread_mutex_lock(&sink->lock); + + /* Flush any stale frames queued before Start Streams */ + int flushed = 0; + while (sink->queue_head) { + guac_rdpecam_frame* stale = sink->queue_head; + sink->queue_head = stale->next; + guac_mem_free(stale->data); + guac_mem_free(stale); + flushed++; + } + sink->queue_tail = NULL; + sink->queue_size = 0; + + if (flushed > 0) { + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM flushed %d stale frames before streaming", flushed); + } + + sink->stopping = false; + sink->streaming = true; + sink->credits = 0; + sink->stream_index = stream_idx; + if (!sink->has_active_sender) { + sink->has_active_sender = true; + sink->active_sender_channel = (void*) channel; + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM active sender claimed by device channel"); + } + pthread_mutex_unlock(&sink->lock); + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM streaming started ChannelId=%" PRIu32 " format=%u %ux%u@%u/%u", + channel_id, media_type.Format, media_type.Width, media_type.Height, + media_type.FrameRateNumerator, media_type.FrameRateDenominator); + + /* Browser pushes frames into rdp_client->rdpecam_sink. Point it at this device's sink. */ + rdp_client->rdpecam_sink = sink; + + rs = Stream_New(NULL, 8); + if (rs && rdpecam_build_start_streams_response(rs, 0)) { + Stream_SealLength(rs); + const size_t out_len = Stream_Length(rs); + guac_rdp_rdpecam_log_stream(client, "TX", ch_name, channel_id, rs); + pthread_mutex_lock(&(rdp_client->message_lock)); + result = channel->Write(channel, (UINT32) out_len, Stream_Buffer(rs), NULL); + pthread_mutex_unlock(&(rdp_client->message_lock)); + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM TX ChannelId=%" PRIu32 " MessageId=0x01 SuccessResponse (StartStreams)", + channel_id); + + if (result == CHANNEL_RC_OK) { + guac_rdp_camera_start_params camera_params = { + .width = media_type.Width, + .height = media_type.Height, + .fps_numerator = media_type.FrameRateNumerator, + .fps_denominator = media_type.FrameRateDenominator, + .stream_index = stream_idx, + .device_id = device ? device->browser_device_id : NULL + }; + + guac_client_for_owner(client, + guac_rdp_rdpecam_send_camera_start_signal_callback, + &camera_params); + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM sent camera-start signal to JavaScript: " + "width=%u, height=%u, fps=%u/%u, stream_index=%u", + media_type.Width, media_type.Height, + media_type.FrameRateNumerator, media_type.FrameRateDenominator, + stream_idx); + } + } + } + break; + } + + case RDPECAM_MSG_STOP_STREAMS_REQUEST: { + if (!device || !device->sink) { + guac_client_log(client, GUAC_LOG_WARNING, + "RDPECAM StopStreamsRequest received but no device/sink available (ChannelId=%" PRIu32 ")", + channel_id); + return CHANNEL_RC_OK; + } + + uint32_t outstanding = 0; + uint32_t stream_index = 0; + + pthread_mutex_lock(&device->lock); + if (!device->stream_channel) + device->stream_channel = channel; + rdpecam_channel_callback->is_stream_channel = true; + outstanding = device->credits; + stream_index = device->stream_index; + device->credits = 0; + device->streaming = false; + device->is_active_sender = false; + device->need_keyframe = true; + pthread_mutex_unlock(&device->lock); + + guac_rdpecam_sink* sink = device->sink; + pthread_mutex_lock(&sink->lock); + sink->streaming = false; + sink->credits = 0; + sink->has_active_sender = false; + sink->active_sender_channel = NULL; + pthread_mutex_unlock(&sink->lock); + + if (rdp_client->rdpecam_sink == sink) + rdp_client->rdpecam_sink = NULL; + + for (uint32_t i = 0; i < outstanding; i++) { + wStream* es = Stream_New(NULL, 8); + if (es && rdpecam_build_sample_error_response(es, stream_index)) { + Stream_SealLength(es); + const size_t err_len = Stream_Length(es); + guac_rdp_rdpecam_log_stream(client, "TX", ch_name, channel_id, es); + pthread_mutex_lock(&(rdp_client->message_lock)); + channel->Write(channel, (UINT32) err_len, Stream_Buffer(es), NULL); + pthread_mutex_unlock(&(rdp_client->message_lock)); + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM TX ChannelId=%" PRIu32 " MessageId=0x13 SampleErrorResponse (stream=%u)", + channel_id, stream_index); + } + if (es) Stream_Free(es, TRUE); + } + rs = Stream_New(NULL, 8); + if (rs && rdpecam_build_stop_streams_response(rs, /*status*/ 0)) { + Stream_SealLength(rs); + const size_t out_len = Stream_Length(rs); + guac_rdp_rdpecam_log_stream(client, "TX", ch_name, channel_id, rs); + pthread_mutex_lock(&(rdp_client->message_lock)); + result = channel->Write(channel, (UINT32) out_len, Stream_Buffer(rs), NULL); + pthread_mutex_unlock(&(rdp_client->message_lock)); + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM TX ChannelId=%" PRIu32 " MessageId=0x01 SuccessResponse (StopStreams)", + channel_id); + + /* PROTOCOL-DRIVEN CAMERA STOP: Signal JavaScript client to stop + * camera capture NOW that Windows has requested stream stop. + * This coordinates with SERVER_PROTO_001 fix and ensures browser + * stops capturing at correct protocol time. + * + * Timing: After Stop Streams Response sent to Windows. + * Effect: Browser receives argv camera-stop instruction and stops + * getUserMedia() and encoder, cleaning up resources. */ + if (result == CHANNEL_RC_OK) { + /* Send camera-stop signal to owner user via argv instruction */ + guac_client_for_owner(client, + guac_rdp_rdpecam_send_camera_stop_signal_callback, + NULL); + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM sent camera-stop signal to JavaScript"); + } + } + break; + } + + case RDPECAM_MSG_PROPERTY_LIST_REQUEST: { + if (!device) { + guac_client_log(client, GUAC_LOG_WARNING, + "RDPECAM PropertyListRequest received but no device available (ChannelId=%" PRIu32 ")", + channel_id); + return CHANNEL_RC_OK; + } + + /* PropertyListRequest has no payload - respond with empty property list */ + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM received PropertyListRequest ChannelId=%" PRIu32, channel_id); + + rs = Stream_New(NULL, 8); + if (rs) { + Stream_Write_UINT8(rs, RDPECAM_PROTO_VERSION); + Stream_Write_UINT8(rs, RDPECAM_MSG_PROPERTY_LIST_RESPONSE); + Stream_SealLength(rs); + const size_t out_len = Stream_Length(rs); + guac_rdp_rdpecam_log_stream(client, "TX", ch_name, channel_id, rs); + pthread_mutex_lock(&(rdp_client->message_lock)); + result = channel->Write(channel, (UINT32) out_len, Stream_Buffer(rs), NULL); + pthread_mutex_unlock(&(rdp_client->message_lock)); + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM TX ChannelId=%" PRIu32 " MessageId=0x15 PropertyListResponse (empty)", + channel_id); + } + break; + } + + case RDPECAM_MSG_SAMPLE_REQUEST: { + if (!device) { + guac_client_log(client, GUAC_LOG_WARNING, "RDPECAM SampleRequest received but no device available on %s[id=%" PRIu32 "]", ch_name, channel_id); + return CHANNEL_RC_OK; + } + + /* Read stream index */ + if (payload_len < 1) { + guac_client_log(client, GUAC_LOG_WARNING, "RDPECAM SampleRequest missing stream index"); + return CHANNEL_RC_OK; + } + uint8_t stream_idx; + Stream_Read_UINT8(stream, stream_idx); + + if (stream_idx == 0 || stream_idx == device->stream_index) { + pthread_mutex_lock(&device->lock); + /* SampleRequests grant credits on the channel they arrive; bind responses there. */ + if (device->stream_channel != channel) { + device->stream_channel = channel; + device->stream_channel_id = channel_id; + } + rdpecam_channel_callback->is_stream_channel = true; + uint32_t before = device->credits; + device->credits = GUAC_RDPECAM_SAMPLE_CREDITS; + uint32_t remaining = device->credits; + /* Wake dequeue thread waiting on this device */ + pthread_cond_broadcast(&device->credits_signal); + pthread_mutex_unlock(&device->lock); + + /* Ensure browser has a sink to push into if streaming is active */ + if (device->streaming) { + guac_rdp_client* rdp_client_ctx = (guac_rdp_client*) client->data; + if (rdp_client_ctx && !rdp_client_ctx->rdpecam_sink) { + rdp_client_ctx->rdpecam_sink = device->sink; + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM bound session sink to active device due to SampleRequest (channel=%" PRIu32 ")", + channel_id); + } + } + int queue_size = guac_rdpecam_get_queue_size(device->sink); + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM SampleRequest ChannelId=%" PRIu32 " device=%s credits %u->%u queue=%d/%d", + channel_id, device->device_name, before, remaining, queue_size, + GUAC_RDPECAM_MAX_FRAMES); + } + break; + } + + default: + /* Unknown/unsupported CAM msg; ignore so custom shim can handle */ + break; + } + + if (rs) Stream_Free(rs, TRUE); + } + + /* Check if capabilities were updated while we were processing messages. + * This handles the case where capabilities arrive after version negotiation. */ + if (plugin && plugin->version_negotiated && plugin->enumerator_channel) { + guac_rwlock_acquire_write_lock(&(rdp_client->lock)); + if (rdp_client->rdpecam_caps_updated && rdp_client->rdpecam_device_caps_count > 0) { + guac_rdp_rdpecam_send_device_notifications(plugin, client, rdp_client, plugin->enumerator_channel); + rdp_client->rdpecam_caps_updated = 0; /* Clear the flag */ + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM sent device notifications after late capability update"); + } + guac_rwlock_release_lock(&(rdp_client->lock)); + } + + return CHANNEL_RC_OK; +} + +/** + * Callback which is invoked when data is received along the RDPECAM channel. + * This callback is API-dependent and delegates to the API-independent + * guac_rdp_rdpecam_handle_data function. + * + * @param channel_callback + * The channel callback structure associated with the connection along + * which the data was received. + * + * @param stream + * The data received. + * + * @return + * Always zero. + */ +static UINT guac_rdp_rdpecam_data(IWTSVirtualChannelCallback* channel_callback, + wStream* stream) { + + guac_rdp_rdpecam_channel_callback* rdpecam_channel_callback = + (guac_rdp_rdpecam_channel_callback*) channel_callback; + IWTSVirtualChannel* channel = rdpecam_channel_callback->channel; + + /* Invoke generalized (API-independent) data handler with full callback context */ + guac_rdp_rdpecam_handle_data(rdpecam_channel_callback->client, channel, stream, rdpecam_channel_callback); + + return CHANNEL_RC_OK; +} + +/** + * Callback which is invoked when the RDPECAM channel is opened. + * This is where we initiate the protocol by sending SelectVersionRequest. + * + * @param channel_callback + * The channel callback structure associated with the connection. + * + * @return + * CHANNEL_RC_OK on success, an error code otherwise. + */ +static UINT guac_rdp_rdpecam_open(IWTSVirtualChannelCallback* channel_callback) { + + guac_rdp_rdpecam_channel_callback* rdpecam_channel_callback = + (guac_rdp_rdpecam_channel_callback*) channel_callback; + IWTSVirtualChannel* channel = rdpecam_channel_callback->channel; + guac_client* client = rdpecam_channel_callback->client; + const char* ch_name = rdpecam_channel_callback->channel_name; + const UINT32 channel_id = rdpecam_channel_callback ? rdpecam_channel_callback->channel_id : 0; + + guac_client_log(client, GUAC_LOG_DEBUG, "RDPECAM channel opened (%s) [id=%" PRIu32 "]", ch_name, + channel_id); + + /* On control channel: initiate version negotiation */ + if (strcasecmp(ch_name, GUAC_RDPECAM_CHANNEL_NAME) == 0) { + wStream* s = Stream_New(NULL, 8); + if (s && rdpecam_build_version_request(s)) { + Stream_SealLength(s); + const size_t out_len = Stream_Length(s); + guac_rdp_rdpecam_log_stream(client, "TX", ch_name, channel_id, s); + + /* Use message_lock to prevent blocking the RDP event loop */ + guac_rdp_client* rdp_client = (guac_rdp_client*) client->data; + pthread_mutex_lock(&(rdp_client->message_lock)); + UINT result = channel->Write(channel, (UINT32) out_len, Stream_Buffer(s), NULL); + pthread_mutex_unlock(&(rdp_client->message_lock)); + + Stream_Free(s, TRUE); + if (result != CHANNEL_RC_OK) { + guac_client_log(client, GUAC_LOG_ERROR, "Failed to send SelectVersionRequest: %u", result); + return result; + } + + /* Remember enumerator channel for DeviceRemovedNotification */ + guac_rdp_rdpecam_channel_callback* cb = (guac_rdp_rdpecam_channel_callback*) channel_callback; + if (cb && cb->plugin) { + guac_rdp_rdpecam_plugin* plugin = cb->plugin; + plugin->enumerator_channel = channel; + } + } + else { + if (s) Stream_Free(s, TRUE); + guac_client_log(client, GUAC_LOG_ERROR, "Failed to build SelectVersionRequest"); + return CHANNEL_RC_NO_MEMORY; + } + } + + return CHANNEL_RC_OK; +} + +/** + * Callback which is invoked when a connection to the RDPECAM channel is + * closed. + * + * @param channel_callback + * The channel callback structure associated with the connection that was + * closed. + * + * @return + * Always zero. + */ +static UINT guac_rdp_rdpecam_close(IWTSVirtualChannelCallback* channel_callback) { + + guac_rdp_rdpecam_channel_callback* rdpecam_channel_callback = + (guac_rdp_rdpecam_channel_callback*) channel_callback; + guac_rdpecam_device* device = rdpecam_channel_callback->device; + guac_rdp_rdpecam_plugin* plugin = rdpecam_channel_callback->plugin; + const char* ch_name = rdpecam_channel_callback->channel_name; + + /* Log channel close */ + guac_client_log(rdpecam_channel_callback->client, GUAC_LOG_DEBUG, + "RDPECAM channel connection closed (%s) [id=%" PRIu32 "]", + rdpecam_channel_callback->channel_name ? rdpecam_channel_callback->channel_name : "unknown", + rdpecam_channel_callback->channel_id); + + if (device) { + int remaining_refs = 0; + bool closing_stream_channel = false; + bool was_active_sender = false; + + pthread_mutex_lock(&device->lock); + + if (device->ref_count > 0) + device->ref_count--; + remaining_refs = device->ref_count; + + if (device->stream_channel == rdpecam_channel_callback->channel) + closing_stream_channel = true; + else if (rdpecam_channel_callback->is_stream_channel) + closing_stream_channel = true; + + /* Capture whether this device was the active sender BEFORE clearing state. + * Only the active sender should trigger browser camera-stop when its channel closes. */ + if (closing_stream_channel) + was_active_sender = device->is_active_sender; + + if (closing_stream_channel) { + device->stream_channel = NULL; + device->is_active_sender = false; + device->streaming = false; + device->need_keyframe = true; + pthread_cond_broadcast(&device->credits_signal); + } + + pthread_mutex_unlock(&device->lock); + + if (closing_stream_channel) { + guac_rdpecam_signal_stop(device->sink); + + /* Only notify browser to stop camera if this device was the active sender. + * When switching cameras, the old device's stream channel closes but it's + * no longer the active sender, so we shouldn't send camera-stop. */ + if (was_active_sender) { + guac_client_log(rdpecam_channel_callback->client, GUAC_LOG_DEBUG, + "RDPECAM sending camera-stop to browser (stream channel closed for active sender device %s)", + ch_name ? ch_name : "unknown"); + guac_client_for_owner(rdpecam_channel_callback->client, + guac_rdp_rdpecam_send_camera_stop_signal_callback, + NULL); + } else { + guac_client_log(rdpecam_channel_callback->client, GUAC_LOG_DEBUG, + "RDPECAM NOT sending camera-stop to browser (stream channel closed for non-active device %s)", + ch_name ? ch_name : "unknown"); + } + + guac_rdp_client* close_rdp_client = + (guac_rdp_client*) rdpecam_channel_callback->client->data; + if (close_rdp_client && close_rdp_client->rdpecam_sink == device->sink) + close_rdp_client->rdpecam_sink = NULL; + } + + if (plugin && plugin->devices && ch_name && remaining_refs == 0) { + pthread_mutex_lock(&device->lock); + device->stopping = true; + pthread_cond_broadcast(&device->credits_signal); + pthread_mutex_unlock(&device->lock); + + if (!closing_stream_channel) + guac_rdpecam_signal_stop(device->sink); + + /* Remove from registry; explicitly destroy device afterwards. */ + guac_rdpecam_device* to_destroy = device; + if (HashTable_Remove(plugin->devices, (void*) ch_name)) { + guac_client_log(rdpecam_channel_callback->client, GUAC_LOG_DEBUG, + "RDPECAM device removed from registry: %s", ch_name); + guac_rdpecam_device_destroy(to_destroy); + } + } + else if (remaining_refs != 0) { + guac_client_log(rdpecam_channel_callback->client, GUAC_LOG_DEBUG, + "RDPECAM device %s still referenced (%d), deferring destruction", + ch_name ? ch_name : "unknown", remaining_refs); + } + } + + /* Free channel callback */ + guac_mem_free(rdpecam_channel_callback); + + return CHANNEL_RC_OK; + +} + +/** + * Callback which is invoked when a new connection to the RDPECAM channel is + * established. This callback allocates and initializes the channel callback + * structure containing the required callbacks should be assigned. + * + * @param listener_callback + * The listener callback structure that was registered for the RDPECAM + * channel. + * + * @param channel + * The virtual channel instance along which the new connection was + * established. + * + * @param data + * The data associated with the new connection. + * + * @param accept + * Whether to accept the connection. + * + * @param channel_callback + * The channel callback structure containing the required callbacks should + * be assigned. + * + * @return + * Always zero. + */ +static UINT guac_rdp_rdpecam_new_connection( + IWTSListenerCallback* listener_callback, IWTSVirtualChannel* channel, + BYTE* data, int* accept, + IWTSVirtualChannelCallback** channel_callback) { + + guac_rdp_rdpecam_listener_callback* rdpecam_listener_callback = + (guac_rdp_rdpecam_listener_callback*) listener_callback; + guac_client* client = rdpecam_listener_callback->client; + guac_rdp_client* rdp_client = (guac_rdp_client*) client->data; + guac_rdp_rdpecam_plugin* plugin = rdpecam_listener_callback->plugin; + + /* Log new RDPECAM connection */ + const char* ch_name = rdpecam_listener_callback->channel_name ? rdpecam_listener_callback->channel_name : "rdpecam"; + UINT32 channel_id = 0; + if (plugin && plugin->manager && plugin->manager->GetChannelId) + channel_id = plugin->manager->GetChannelId(channel); + + guac_client_log(client, GUAC_LOG_DEBUG, "New RDPECAM channel connection (%s) [id=%" PRIu32 "]", ch_name, channel_id); + + /* Ensure there is a device structure for per-channel state. */ + guac_rdpecam_device* device = NULL; + + if (strcasecmp(ch_name, GUAC_RDPECAM_CHANNEL_NAME) != 0) { + + /* Handle device channel connections. */ + device = guac_rdpecam_device_lookup(plugin, ch_name); + + if (!device) { + device = guac_rdpecam_device_create(plugin, ch_name); + if (!device) { + guac_client_log(client, GUAC_LOG_ERROR, + "Failed to create RDPECAM device: %s", ch_name); + *accept = 0; + return CHANNEL_RC_OK; + } + + /* Prefer HashTable_Insert for WinPR3/FreeRDP3 forward-compatibility; + * fall back to HashTable_Add for older WinPR 2.x that may not export it. */ +#ifdef HAVE_WINPR_HASHTABLE_INSERT + if (!HashTable_Insert(plugin->devices, (void*) ch_name, (void*) device)) { +#else + /* Older WinPR may not export HashTable_Insert; HashTable_Add exists but + * may be compiled out unless WITH_WINPR_DEPRECATED is enabled. If missing + * at link time, fall back to a simple leak-safe path by not registering a + * destructor and relying on explicit removal. */ + if (HashTable_Add(plugin->devices, (void*) ch_name, (void*) device) < 0) { +#endif + guac_client_log(client, GUAC_LOG_ERROR, + "Failed to insert RDPECAM device into registry: %s", ch_name); + guac_rdpecam_device_destroy(device); + *accept = 0; + return CHANNEL_RC_OK; + } + + guac_client_log(client, GUAC_LOG_DEBUG, + "Created new RDPECAM device: %s", ch_name); + } + else { + pthread_mutex_lock(&device->lock); + device->ref_count++; + device->stopping = false; + pthread_mutex_unlock(&device->lock); + + guac_client_log(client, GUAC_LOG_DEBUG, + "Reusing existing RDPECAM device: %s (ref_count=%d)", + ch_name, device->ref_count); + } + + } + /* The control/enumerator channel intentionally proceeds without a device. */ + + /* Allocate new channel callback */ + guac_rdp_rdpecam_channel_callback* rdpecam_channel_callback = + guac_mem_zalloc(sizeof(guac_rdp_rdpecam_channel_callback)); + + /* Init channel callback with data from plugin */ + rdpecam_channel_callback->client = client; + rdpecam_channel_callback->channel = channel; + rdpecam_channel_callback->device = device; + rdpecam_channel_callback->channel_name = ch_name; + rdpecam_channel_callback->plugin = plugin; + rdpecam_channel_callback->is_stream_channel = false; + rdpecam_channel_callback->channel_id = channel_id; + rdpecam_channel_callback->parent.OnDataReceived = guac_rdp_rdpecam_data; + rdpecam_channel_callback->parent.OnOpen = guac_rdp_rdpecam_open; + rdpecam_channel_callback->parent.OnClose = guac_rdp_rdpecam_close; + + /* Accept connection and return callback */ + *accept = 1; + *channel_callback = (IWTSVirtualChannelCallback*) rdpecam_channel_callback; + + guac_client_log(client, GUAC_LOG_DEBUG, "RDPECAM channel connection established (%s) [id=%" PRIu32 "]", + ch_name, channel_id); + + /* Messages will be sent in OnOpen callback, not here */ + return CHANNEL_RC_OK; + +} + +/** + * Callback which is invoked when the RDPECAM plugin is being initialized and + * the listener callback structure containing the required callbacks for new + * connections must be registered. + * + * @param plugin + * The RDPECAM plugin being initialized. + * + * @param manager + * The virtual channel manager through which the listener callback + * structure containing the required callbacks should be registered. + * + * @return + * Always zero. + */ +static UINT guac_rdp_rdpecam_initialize(IWTSPlugin* plugin, + IWTSVirtualChannelManager* manager) { + + /* Allocate control listener */ + guac_rdp_rdpecam_plugin* rdpecam_plugin = (guac_rdp_rdpecam_plugin*) plugin; + guac_rdp_rdpecam_listener_callback* control_listener = + guac_mem_zalloc(sizeof(guac_rdp_rdpecam_listener_callback)); + guac_rdp_rdpecam_listener_callback* device0_listener = + guac_mem_zalloc(sizeof(guac_rdp_rdpecam_listener_callback)); + + if (!control_listener || !device0_listener) { + guac_client_log(rdpecam_plugin->client, GUAC_LOG_ERROR, + "Failed to allocate RDPECAM listener callbacks"); + guac_mem_free(control_listener); + guac_mem_free(device0_listener); + return CHANNEL_RC_NO_MEMORY; + } + + control_listener->client = rdpecam_plugin->client; + control_listener->channel_name = GUAC_RDPECAM_CHANNEL_NAME; + control_listener->plugin = rdpecam_plugin; + control_listener->parent.OnNewChannelConnection = guac_rdp_rdpecam_new_connection; + rdpecam_plugin->control_listener_callback = control_listener; + + device0_listener->client = rdpecam_plugin->client; + device0_listener->channel_name = GUAC_RDPECAM_DEVICE0_CHANNEL_NAME; + device0_listener->plugin = rdpecam_plugin; + device0_listener->parent.OnNewChannelConnection = guac_rdp_rdpecam_new_connection; + rdpecam_plugin->device0_listener_callback = device0_listener; + + /* Initialize hash table for multi-device support */ + rdpecam_plugin->devices = HashTable_New(FALSE); + if (!rdpecam_plugin->devices) { + guac_client_log(rdpecam_plugin->client, GUAC_LOG_ERROR, + "Failed to create device hash table"); + guac_mem_free(control_listener); + guac_mem_free(device0_listener); + return CHANNEL_RC_NO_MEMORY; + } + + /* Initialize hash table for device ID to channel name mapping */ + rdpecam_plugin->device_id_map = HashTable_New(FALSE); + if (!rdpecam_plugin->device_id_map) { + guac_client_log(rdpecam_plugin->client, GUAC_LOG_ERROR, + "Failed to create device ID map hash table"); + HashTable_Free(rdpecam_plugin->devices); + guac_mem_free(control_listener); + guac_mem_free(device0_listener); + return CHANNEL_RC_NO_MEMORY; + } + + /* Hash table keys use stable pointers; explicit destruction is handled + * on removal/termination. */ + + /* Keep manager for later (dynamic device channel creation) */ + rdpecam_plugin->manager = manager; + rdpecam_plugin->enumerator_channel = NULL; + + /* Register control channel listener */ + manager->CreateListener(manager, GUAC_RDPECAM_CHANNEL_NAME, 0, + (IWTSListenerCallback*) control_listener, NULL); + manager->CreateListener(manager, GUAC_RDPECAM_DEVICE0_CHANNEL_NAME, 0, + (IWTSListenerCallback*) device0_listener, NULL); + + guac_client_log(rdpecam_plugin->client, GUAC_LOG_DEBUG, + "RDPECAM plugin initialized with multi-device support"); + + return CHANNEL_RC_OK; + +} + +/** + * Callback which is invoked when all connections to the RDPECAM plugin + * have closed and the plugin is being unloaded. + * + * @param plugin + * The RDPECAM plugin being unloaded. + * + * @return + * Always zero. + */ +static UINT guac_rdp_rdpecam_terminated(IWTSPlugin* plugin) { + + guac_rdp_rdpecam_plugin* rdpecam_plugin = (guac_rdp_rdpecam_plugin*) plugin; + + /* Free listener callbacks if allocated */ + if (rdpecam_plugin->control_listener_callback != NULL) { + guac_mem_free(rdpecam_plugin->control_listener_callback); + rdpecam_plugin->control_listener_callback = NULL; + } + if (rdpecam_plugin->device0_listener_callback != NULL) { + guac_mem_free(rdpecam_plugin->device0_listener_callback); + rdpecam_plugin->device0_listener_callback = NULL; + } + + /* Destroy all devices in hash table */ + if (rdpecam_plugin->devices != NULL) { + /* Explicitly destroy values for all WinPR versions */ + ULONG_PTR* keys = NULL; + size_t count = HashTable_GetKeys(rdpecam_plugin->devices, &keys); + for (size_t i = 0; i < count; i++) { + void* key = (void*) keys[i]; + guac_rdpecam_device* dev = (guac_rdpecam_device*) HashTable_GetItemValue(rdpecam_plugin->devices, key); + if (dev) + guac_rdpecam_device_destroy(dev); + } + if (keys) + free(keys); + HashTable_Free(rdpecam_plugin->devices); + rdpecam_plugin->devices = NULL; + } + + /* Free device ID map */ + if (rdpecam_plugin->device_id_map != NULL) { + HashTable_Free(rdpecam_plugin->device_id_map); + rdpecam_plugin->device_id_map = NULL; + } + + guac_client_log(rdpecam_plugin->client, GUAC_LOG_DEBUG, + "RDPECAM plugin terminated - all devices destroyed"); + + return CHANNEL_RC_OK; + +} + +/** + * Creates a new RDPECAM device structure for multi-device support. + * This allocates and initializes all per-device state including the sink + * and prepares for thread creation. + * + * @param plugin + * The RDPECAM plugin instance. + * + * @param device_name + * Name of the device (e.g., "RDCamera_Device_0"). + * + * @return + * A newly-allocated device structure, or NULL on failure. + */ +static guac_rdpecam_device* guac_rdpecam_device_create( + guac_rdp_rdpecam_plugin* plugin, const char* device_name) { + + if (!plugin || !device_name) { + return NULL; + } + + guac_rdpecam_device* device = guac_mem_zalloc(sizeof(guac_rdpecam_device)); + if (!device) { + guac_client_log(plugin->client, GUAC_LOG_ERROR, + "Failed to allocate RDPECAM device structure"); + return NULL; + } + + /* Allocate and copy device name */ + device->device_name = guac_mem_alloc(strlen(device_name) + 1); + if (!device->device_name) { + guac_client_log(plugin->client, GUAC_LOG_ERROR, + "Failed to allocate device name"); + guac_mem_free(device); + return NULL; + } + strcpy(device->device_name, device_name); + + guac_rdp_client* rdp_client = plugin->client ? (guac_rdp_client*) plugin->client->data : NULL; + + /* Extract device index from channel name (e.g., "RDCamera_Device_0" -> 0) */ + unsigned int device_index = 0; + if (rdp_client && sscanf(device_name, "RDCamera_Device_%u", &device_index) == 1) { + /* Look up device capabilities and browser device ID */ + guac_rwlock_acquire_read_lock(&(rdp_client->lock)); + if (device_index < rdp_client->rdpecam_device_caps_count) { + guac_rdp_rdpecam_device_caps* caps = &rdp_client->rdpecam_device_caps[device_index]; + if (caps->device_id && caps->device_id[0] != '\0') { + size_t id_len = strlen(caps->device_id); + device->browser_device_id = guac_mem_alloc(id_len + 1); + if (device->browser_device_id) { + memcpy(device->browser_device_id, caps->device_id, id_len + 1); + guac_client_log(plugin->client, GUAC_LOG_DEBUG, + "RDPECAM device %s mapped to browser device ID: %s", + device_name, device->browser_device_id); + } + } + } + guac_rwlock_release_lock(&(rdp_client->lock)); + } + + /* Always create a fresh per-device sink for each device. + * Note: rdp_client->rdpecam_sink is used as a pointer to the active device's sink + * for the browser to push frames to. It should NOT be reused by new devices, + * as doing so would steal the sink from an already-active device. */ + device->sink = guac_rdpecam_create(plugin->client); + if (!device->sink) { + guac_client_log(plugin->client, GUAC_LOG_ERROR, + "Failed to create per-device sink for %s", device_name); + guac_mem_free(device->device_name); + guac_mem_free(device); + return NULL; + } + + guac_client_log(plugin->client, GUAC_LOG_DEBUG, + "RDPECAM sink created for device: %s", + device_name); + + /* Initialize thread synchronization primitives */ + if (pthread_mutex_init(&device->lock, NULL) != 0) { + guac_client_log(plugin->client, GUAC_LOG_ERROR, + "Failed to initialize device mutex"); + guac_rdpecam_destroy(device->sink); + guac_mem_free(device->device_name); + guac_mem_free(device); + return NULL; + } + + if (pthread_cond_init(&device->credits_signal, NULL) != 0) { + guac_client_log(plugin->client, GUAC_LOG_ERROR, + "Failed to initialize credits condition variable"); + pthread_mutex_destroy(&device->lock); + guac_rdpecam_destroy(device->sink); + guac_mem_free(device->device_name); + guac_mem_free(device); + return NULL; + } + + /* Initialize device fields */ + device->stream_channel = NULL; + device->dequeue_thread_started = false; + memset(&device->media_type, 0, sizeof(device->media_type)); + device->stream_index = 0; + device->credits = 0; + device->sample_sequence = 0; + device->is_active_sender = false; + device->streaming = false; + device->need_keyframe = true; + device->stopping = false; + device->ref_count = 1; + + /* Start per-device dequeue thread */ + if (pthread_create(&device->dequeue_thread, NULL, guac_rdp_rdpecam_dequeue_thread, device) != 0) { + guac_client_log(plugin->client, GUAC_LOG_ERROR, + "Failed to create dequeue thread for device: %s", device_name); + pthread_cond_destroy(&device->credits_signal); + pthread_mutex_destroy(&device->lock); + guac_rdpecam_destroy(device->sink); + guac_mem_free(device->device_name); + guac_mem_free(device); + return NULL; + } + + device->dequeue_thread_started = true; + + guac_client_log(plugin->client, GUAC_LOG_DEBUG, + "RDPECAM device created: %s (dequeue thread started)", device_name); + + return device; +} + +/** + * Destroys an RDPECAM device structure and frees all associated resources. + * This is called as a destructor by the hash table when devices are removed + * or when the plugin terminates. + * + * @param device + * The device to destroy. May be NULL. + */ +static void guac_rdpecam_device_destroy(guac_rdpecam_device* device) { + + if (!device) { + return; + } + + guac_client* client = device->sink ? device->sink->client : NULL; + guac_rdp_client* rdp_client = client ? (guac_rdp_client*) client->data : NULL; + + /* Signal the dequeue thread to stop */ + pthread_mutex_lock(&device->lock); + device->stopping = true; + pthread_cond_broadcast(&device->credits_signal); + pthread_mutex_unlock(&device->lock); + + if (device->sink) + guac_rdpecam_signal_stop(device->sink); + + if (device->dequeue_thread_started) { + int rc = pthread_join(device->dequeue_thread, NULL); + if (rc != 0 && client) { + guac_client_log(client, GUAC_LOG_WARNING, + "RDPECAM dequeue thread join failed for device %s (rc=%d)", + device->device_name ? device->device_name : "unknown", rc); + } + } + + /* Clean up synchronization primitives */ + pthread_cond_destroy(&device->credits_signal); + pthread_mutex_destroy(&device->lock); + + /* Destroy per-device sink */ + if (device->sink) { + if (rdp_client && rdp_client->rdpecam_sink == device->sink) + rdp_client->rdpecam_sink = NULL; + if (client) { + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM destroying sink for device: %s", + device->device_name ? device->device_name : "unknown"); + } + guac_rdpecam_destroy(device->sink); + } + + /* Free browser device ID */ + if (device->browser_device_id) { + guac_mem_free(device->browser_device_id); + } + + /* Free device name */ + if (device->device_name) { + guac_mem_free(device->device_name); + } + + /* Free device structure */ + guac_mem_free(device); +} + +/** + * Looks up a device in the plugin's device hash table. + * Returns the device structure if found, NULL otherwise. + * + * @param plugin + * The RDPECAM plugin instance. + * + * @param device_name + * Name of the device to look up. + * + * @return + * Pointer to the device structure, or NULL if not found. + */ +static guac_rdpecam_device* guac_rdpecam_device_lookup( + guac_rdp_rdpecam_plugin* plugin, const char* device_name) { + + if (!plugin || !device_name || !plugin->devices) { + return NULL; + } + + return (guac_rdpecam_device*) HashTable_GetItemValue( + plugin->devices, (void*) device_name); +} + +/** + * Gets device capabilities for a given channel name by extracting the device + * index from the channel name pattern and looking up capabilities. + * + * WARNING: The caller MUST hold rdp_client->lock (read or write) when calling + * this function and while using the returned pointer. The returned pointer is + * only valid while the lock is held. + * + * @param rdp_client + * The RDP client containing device capabilities. The caller must hold + * rdp_client->lock. + * + * @param channel_name + * The channel name (e.g., "RDCamera_Device_0"). + * + * @return + * Pointer to device capabilities if found, NULL otherwise. Valid only + * while rdp_client->lock is held by the caller. + */ +static guac_rdp_rdpecam_device_caps* guac_rdp_rdpecam_get_device_caps( + guac_rdp_client* rdp_client, const char* channel_name) { + + if (!rdp_client || !channel_name) + return NULL; + + /* Extract device index from channel name (e.g., "RDCamera_Device_0" -> 0) */ + unsigned int device_index = 0; + if (sscanf(channel_name, "RDCamera_Device_%u", &device_index) != 1) + return NULL; + + /* Caller must hold lock - we don't acquire/release it here */ + if (device_index < rdp_client->rdpecam_device_caps_count) + return &rdp_client->rdpecam_device_caps[device_index]; + + return NULL; +} + +/** + * Sends DeviceAddedNotification messages for all devices in capabilities. + * This function creates device ID mappings, registers listeners for device channels, + * and sends DeviceAddedNotification messages via the enumerator channel. + * + * @param plugin + * The RDPECAM plugin instance. + * + * @param client + * The guac_client instance. + * + * @param rdp_client + * The RDP client data (must have lock held). + * + * @param enumerator_channel + * The enumerator channel to send notifications through. + */ +void guac_rdp_rdpecam_send_device_notifications( + guac_rdp_rdpecam_plugin* plugin, guac_client* client, + guac_rdp_client* rdp_client, IWTSVirtualChannel* enumerator_channel) { + + if (!plugin || !client || !rdp_client || !enumerator_channel) + return; + + unsigned int device_count = rdp_client->rdpecam_device_caps_count; + + if (device_count == 0) { + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM no devices to announce"); + return; + } + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM sending DeviceAddedNotification for %u device(s)", device_count); + + /* Send DeviceAddedNotification for each device */ + for (unsigned int i = 0; i < device_count; i++) { + guac_rdp_rdpecam_device_caps* caps = &rdp_client->rdpecam_device_caps[i]; + + /* Generate channel name: "RDCamera_Device_N" */ + char channel_name[64]; + snprintf(channel_name, sizeof(channel_name), "RDCamera_Device_%u", i); + + /* Get device name with fallback */ + const char* device_name = "Redirected-Cam0"; + char fallback_name[64]; + if (caps->device_name && caps->device_name[0] != '\0') { + device_name = caps->device_name; + } else { + snprintf(fallback_name, sizeof(fallback_name), "Redirected-Cam%u", i); + device_name = fallback_name; + } + + /* Store device ID to channel name mapping */ + if (caps->device_id && caps->device_id[0] != '\0' && plugin->device_id_map) { + char* channel_name_copy = guac_mem_alloc(strlen(channel_name) + 1); + if (channel_name_copy) { + strcpy(channel_name_copy, channel_name); +#ifdef HAVE_WINPR_HASHTABLE_INSERT + if (!HashTable_Insert(plugin->device_id_map, (void*) caps->device_id, (void*) channel_name_copy)) { + guac_client_log(client, GUAC_LOG_ERROR, + "RDPECAM failed to insert device ID mapping"); + guac_mem_free(channel_name_copy); + } else { + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM mapped device ID '%s' to channel '%s'", + caps->device_id, channel_name); + } +#else + /* Fallback for older WinPR versions without HashTable_Insert */ + if (HashTable_Add(plugin->device_id_map, (void*) caps->device_id, (void*) channel_name_copy) < 0) { + guac_client_log(client, GUAC_LOG_ERROR, + "RDPECAM failed to add device ID mapping"); + guac_mem_free(channel_name_copy); + } else { + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM mapped device ID '%s' to channel '%s'", + caps->device_id, channel_name); + } +#endif + } + } + + /* Create listener for this device channel if not Device_0 (Device_0 is pre-created) */ + if (i > 0 && plugin->manager) { + guac_rdp_rdpecam_listener_callback* device_listener = + guac_mem_zalloc(sizeof(guac_rdp_rdpecam_listener_callback)); + if (device_listener) { + /* Allocate and copy channel name for listener */ + char* saved_channel_name = guac_mem_alloc(strlen(channel_name) + 1); + if (saved_channel_name) { + strcpy(saved_channel_name, channel_name); + device_listener->client = client; + device_listener->channel_name = saved_channel_name; + device_listener->plugin = plugin; + device_listener->parent.OnNewChannelConnection = guac_rdp_rdpecam_new_connection; + + /* Register listener for this device channel */ + plugin->manager->CreateListener(plugin->manager, channel_name, 0, + (IWTSListenerCallback*) device_listener, NULL); + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM registered listener for device channel: %s", channel_name); + } else { + guac_mem_free(device_listener); + } + } + } + + /* Send DeviceAddedNotification */ + wStream* rs = Stream_New(NULL, 256); + if (rs && rdpecam_build_device_added(rs, device_name, channel_name)) { + Stream_SealLength(rs); + const size_t out_len = Stream_Length(rs); + + UINT32 enum_channel_id = 0; + if (plugin->manager && plugin->manager->GetChannelId) + enum_channel_id = plugin->manager->GetChannelId(enumerator_channel); + + guac_rdp_rdpecam_log_stream(client, "TX", "RDCamera_Device_Enumerator", enum_channel_id, rs); + pthread_mutex_lock(&(rdp_client->message_lock)); + UINT result = enumerator_channel->Write(enumerator_channel, (UINT32) out_len, Stream_Buffer(rs), NULL); + pthread_mutex_unlock(&(rdp_client->message_lock)); + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM TX ChannelId=%" PRIu32 " MessageId=0x05 DeviceAddedNotification (device='%s', channel='%s')", + enum_channel_id, device_name, channel_name); + } + if (rs) Stream_Free(rs, TRUE); + } +} + +/** + * Entry point for RDPECAM dynamic virtual channel. + */ +UINT DVCPluginEntry(IDRDYNVC_ENTRY_POINTS* pEntryPoints) { + + /* Pull guac_client from arguments */ +#ifdef PLUGIN_DATA_CONST + const ADDIN_ARGV* args = pEntryPoints->GetPluginData(pEntryPoints); +#else + ADDIN_ARGV* args = pEntryPoints->GetPluginData(pEntryPoints); +#endif + + guac_client* client = (guac_client*) guac_rdp_string_to_ptr(args->argv[1]); + + /* Pull previously-allocated plugin */ + guac_rdp_rdpecam_plugin* rdpecam_plugin = (guac_rdp_rdpecam_plugin*) + pEntryPoints->GetPlugin(pEntryPoints, GUAC_RDPECAM_PLUGIN_NAME); + + /* If no such plugin allocated, allocate and register it now */ + if (rdpecam_plugin == NULL) { + + /* Init plugin callbacks and data */ + rdpecam_plugin = guac_mem_zalloc(sizeof(guac_rdp_rdpecam_plugin)); + rdpecam_plugin->parent.Initialize = guac_rdp_rdpecam_initialize; + rdpecam_plugin->parent.Terminated = guac_rdp_rdpecam_terminated; + rdpecam_plugin->client = client; + rdpecam_plugin->version_negotiated = false; + + /* Store plugin reference in rdp_client for access from callbacks */ + guac_rdp_client* rdp_client = (guac_rdp_client*) client->data; + if (rdp_client) { + rdp_client->rdpecam_plugin = rdpecam_plugin; + /* Register immediate caps notify callback */ + rdp_client->rdpecam_caps_notify = guac_rdp_rdpecam_caps_notify; + } + + /* Register plugin for later retrieval */ + pEntryPoints->RegisterPlugin(pEntryPoints, GUAC_RDPECAM_PLUGIN_NAME, + (IWTSPlugin*) rdpecam_plugin); + + guac_client_log(client, GUAC_LOG_DEBUG, "RDPECAM plugin loaded."); + } + + return CHANNEL_RC_OK; + +} diff --git a/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.h b/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.h new file mode 100644 index 0000000000..a6a4dff7f5 --- /dev/null +++ b/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.h @@ -0,0 +1,333 @@ +/* + * 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. + */ + +#ifndef GUAC_RDP_PLUGINS_GUACRDPECAM_H +#define GUAC_RDP_PLUGINS_GUACRDPECAM_H + +#include +#include +#include +#include +#include +#include +#include + +#include "channels/rdpecam/rdpecam_sink.h" +#include "rdpecam_proto.h" + +/* Forward declaration */ +typedef struct guac_rdp_client guac_rdp_client; + +/** + * The name of the RDPECAM control/enumeration dynamic virtual channel. + * This MUST match the MS-RDPECAM specification. + */ +#define GUAC_RDPECAM_CHANNEL_NAME "RDCamera_Device_Enumerator" + +/** + * Temporary device channel name for the first virtual camera device. + * This will be created as a separate listener. In a full implementation, + * device channels are named dynamically by deviceId. + * Windows expects the format "RDCamera_Device_N" where N is the device index. + */ +#define GUAC_RDPECAM_DEVICE0_CHANNEL_NAME "RDCamera_Device_0" + +/** + * The name of the guacrdpecam plugin. + */ +#define GUAC_RDPECAM_PLUGIN_NAME "guacrdpecam" + +/** + * Extended version of the IWTSListenerCallback structure, providing additional + * access to Guacamole-specific data. The IWTSListenerCallback provides access + * to callbacks related to the receipt of new connections to the RDPECAM + * channel. + */ +typedef struct guac_rdp_rdpecam_listener_callback { + + /** + * The parent IWTSListenerCallback structure that this structure extends. + * THIS MEMBER MUST BE FIRST! + */ + IWTSListenerCallback parent; + + /** + * The guac_client instance associated with the RDP connection using the + * RDPECAM plugin. + */ + guac_client* client; + + /** + * The channel name this listener is registered for. + */ + const char* channel_name; + + /** Back-reference to the RDPECAM plugin. */ + struct guac_rdp_rdpecam_plugin* plugin; + +} guac_rdp_rdpecam_listener_callback; + +/** + * Device state structure for multi-device support. + * Each connected camera device has one instance of this structure, + * managed by the plugin's hash table indexed by device/channel name. + */ +typedef struct guac_rdpecam_device { + + /** + * Device/channel name (e.g., "RDCamera_Device_0"). + * This is the key for the hash table lookup. + */ + char* device_name; + + /** + * Browser device ID from navigator.mediaDevices. + * Used to map between browser devices and Windows channels. + */ + char* browser_device_id; + + /** + * The current active virtual channel for this device's streaming data. + * Only set while a streaming-capable channel is connected. + */ + IWTSVirtualChannel* stream_channel; + + /** Cached numeric channel identifier for the current stream channel. */ + uint32_t stream_channel_id; + + /** + * Per-device frame sink for buffering video frames. + * Independent queue for each device. + */ + guac_rdpecam_sink* sink; + + /** + * Per-device dequeue thread for encoding and transmitting frames. + * Each device has its own thread reading from its own sink. + */ + pthread_t dequeue_thread; + + /** + * Whether the dequeue thread has been successfully started. + * Used to safely join the thread during shutdown. + */ + bool dequeue_thread_started; + + /** + * Per-device media type descriptor for the current stream. + * Independent for each device. + */ + rdpecam_media_type_desc media_type; + + /** + * Stream index from 0x0F (START_STREAMS_REQUEST) message. + * Independent for each device. + */ + uint32_t stream_index; + + /** + * Sample credits for flow control (independent per device). + * Only this device's threads decrement this counter. + */ + uint32_t credits; + + /** + * Monotonic sample sequence value used for outgoing samples. + */ + uint32_t sample_sequence; + + /** + * Whether this device is the active sender. + * Only one device may be actively sending frames per plugin session. + */ + bool is_active_sender; + + /** + * Whether streaming is currently active for this device. + */ + bool streaming; + + /** + * Whether the next frame must be a keyframe before streaming resumes. + */ + bool need_keyframe; + + /** + * Signal to stop the dequeue thread. + * Set by channel close handler, checked by dequeue thread. + */ + bool stopping; + + /** + * Reference count for handling multiple channel opens. + * Allows device to persist across channel reconnections. + */ + int ref_count; + + /** + * Mutex protecting all per-device fields. + * Ensures thread-safe access to credits, streaming state, etc. + */ + pthread_mutex_t lock; + + /** + * Condition variable for signaling credit availability. + * Woken when new sample credits arrive via SAMPLE_REQUEST. + */ + pthread_cond_t credits_signal; + +} guac_rdpecam_device; + +/** + * Extended version of the IWTSVirtualChannelCallback structure, providing + * additional access to Guacamole-specific data. The IWTSVirtualChannelCallback + * provides access to callbacks related to an active connection to the + * RDPECAM channel, including receipt of data. + */ +typedef struct guac_rdp_rdpecam_channel_callback { + + /** + * The parent IWTSVirtualChannelCallback structure that this structure + * extends. THIS MEMBER MUST BE FIRST! + */ + IWTSVirtualChannelCallback parent; + + /** + * The actual virtual channel instance along which the RDPECAM plugin + * should send any responses. + */ + IWTSVirtualChannel* channel; + + /** + * The guac_client instance associated with the RDP connection using the + * RDPECAM plugin. + */ + guac_client* client; + + /** + * Pointer to the device state for this channel connection, if any. + * Obtained from plugin->devices hash table using channel name. + */ + guac_rdpecam_device* device; + + /** + * The channel name associated with this callback (control vs device). + */ + const char* channel_name; + + /** Back-reference to the RDPECAM plugin. */ + struct guac_rdp_rdpecam_plugin* plugin; + + /** + * Whether this channel is the streaming channel for the device. + */ + bool is_stream_channel; + + /** + * The numeric channel identifier reported by FreeRDP, if known. + */ + uint32_t channel_id; + +} guac_rdp_rdpecam_channel_callback; + +/** + * All data associated with Guacamole's RDPECAM plugin for FreeRDP. + */ +typedef struct guac_rdp_rdpecam_plugin { + + /** + * The parent IWTSPlugin structure that this structure extends. THIS + * MEMBER MUST BE FIRST! + */ + IWTSPlugin parent; + + /** + * The listener callback structure allocated when the RDPECAM plugin + * was loaded, if any. If the plugin did not fully load, this will be NULL. + * If non-NULL, this callback structure must be freed when the plugin is + * terminated. + */ + guac_rdp_rdpecam_listener_callback* control_listener_callback; + guac_rdp_rdpecam_listener_callback* device0_listener_callback; + + /** + * Hash table for managing multiple device channels. + * Key: device/channel name (e.g., "RDCamera_Device_0") + * Value: guac_rdpecam_device* (per-device state) + * + * Replaces the old single-device architecture where only one device + * state could exist at a time. + */ + wHashTable* devices; + + /** + * Hash table mapping browser device IDs to Windows channel names. + * Key: browser device ID (from navigator.mediaDevices) + * Value: channel name (e.g., "RDCamera_Device_0") + * + * Used to route camera-start signals from Windows channel selection + * back to the correct browser device. + */ + wHashTable* device_id_map; + + /** + * The guac_client instance associated with the RDP connection using the + * RDPECAM plugin. + */ + guac_client* client; + + /** + * Virtual channel manager retained for creating additional listeners + * (per-device channels) after initialization. + */ + IWTSVirtualChannelManager* manager; + + /** Enumerator channel (RDCamera_Device_Enumerator) for notifications. */ + IWTSVirtualChannel* enumerator_channel; + + /** + * Whether version negotiation has completed (SelectVersionResponse received). + * Used to determine when to send DeviceAddedNotification messages. + */ + bool version_negotiated; + +} guac_rdp_rdpecam_plugin; + +/** + * Sends DeviceAddedNotification messages for all devices in capabilities. + * This function creates device ID mappings, registers listeners for device channels, + * and sends DeviceAddedNotification messages via the enumerator channel. + * + * @param plugin + * The RDPECAM plugin instance. + * + * @param client + * The guac_client instance. + * + * @param rdp_client + * The RDP client data (must have lock held). + * + * @param enumerator_channel + * The enumerator channel to send notifications through. + */ +void guac_rdp_rdpecam_send_device_notifications( + guac_rdp_rdpecam_plugin* plugin, guac_client* client, + guac_rdp_client* rdp_client, IWTSVirtualChannel* enumerator_channel); + +#endif diff --git a/src/protocols/rdp/plugins/guacrdpecam/rdpecam_proto.c b/src/protocols/rdp/plugins/guacrdpecam/rdpecam_proto.c new file mode 100644 index 0000000000..206dae6ed6 --- /dev/null +++ b/src/protocols/rdp/plugins/guacrdpecam/rdpecam_proto.c @@ -0,0 +1,325 @@ +/* + * 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. + */ + +#include "rdpecam_proto.h" + +#include +#include + +#include +#include + +BOOL rdpecam_write_sample_response_header(wStream* s, + uint32_t streamId, uint32_t sampleSequence, + uint32_t payloadLength, uint64_t ptsHundredsOfNs) { + WINPR_UNUSED(sampleSequence); + WINPR_UNUSED(payloadLength); + WINPR_UNUSED(ptsHundredsOfNs); + + if (!s) + return FALSE; + + /* FreeRDP/MS-RDPECAM SampleResponse header: + * [Version (1)][MsgId (1)==SampleResponse][StreamIndex (1)] + * The sample payload follows immediately. + */ + if (!Stream_EnsureRemainingCapacity(s, 3)) + return FALSE; + + Stream_Write_UINT8(s, RDPECAM_PROTO_VERSION); + Stream_Write_UINT8(s, RDPECAM_MSG_SAMPLE_RESPONSE); + Stream_Write_UINT8(s, (uint8_t)(streamId & 0xFF)); + + return TRUE; +} + +BOOL rdpecam_build_version_request(wStream* s) { + if (!s) return FALSE; + if (!Stream_EnsureRemainingCapacity(s, 2)) return FALSE; + Stream_Write_UINT8(s, RDPECAM_PROTO_VERSION); + Stream_Write_UINT8(s, RDPECAM_MSG_SELECT_VERSION_REQUEST); + return TRUE; +} + +BOOL rdpecam_build_version_response(wStream* s) { + if (!s) return FALSE; + if (!Stream_EnsureRemainingCapacity(s, 2)) return FALSE; + Stream_Write_UINT8(s, RDPECAM_PROTO_VERSION); + Stream_Write_UINT8(s, RDPECAM_MSG_SELECT_VERSION_RESPONSE); + return TRUE; +} + +BOOL rdpecam_build_device_added(wStream* s, const char* device_name, + const char* channel_name) { + if (!s || !device_name || !channel_name) return FALSE; + + /* Calculate UTF-16 length for device name (each char becomes 2 bytes + 2 for NUL) */ + size_t name_len = strlen(device_name); + size_t utf16_bytes = (name_len + 1) * 2; /* +1 for NUL */ + size_t ch_len = strlen(channel_name) + 1; /* include NUL */ + + if (!Stream_EnsureRemainingCapacity(s, 2 + utf16_bytes + ch_len)) return FALSE; + + Stream_Write_UINT8(s, RDPECAM_PROTO_VERSION); + Stream_Write_UINT8(s, RDPECAM_MSG_DEVICE_ADDED_NOTIFICATION); + + /* Write device name as UTF-16LE with NUL terminator */ + for (size_t i = 0; i < name_len; i++) { + Stream_Write_UINT16(s, (uint16_t)(unsigned char)device_name[i]); + } + Stream_Write_UINT16(s, 0); /* NUL terminator */ + + /* Write channel name as ASCII with NUL terminator */ + Stream_Write(s, channel_name, ch_len); + + return TRUE; +} + +BOOL rdpecam_build_success_response(wStream* s) { + if (!s) return FALSE; + if (!Stream_EnsureRemainingCapacity(s, 2)) return FALSE; + Stream_Write_UINT8(s, RDPECAM_PROTO_VERSION); + Stream_Write_UINT8(s, RDPECAM_MSG_SUCCESS_RESPONSE); + return TRUE; +} + +BOOL rdpecam_build_stream_list(wStream* s, const rdpecam_stream_desc* streams, size_t count) { + if (!s || !streams) return FALSE; + + /* Header (2) + count * 5 bytes per stream descriptor (no explicit count field) */ + if (!Stream_EnsureRemainingCapacity(s, 2 + count * 5)) return FALSE; + + Stream_Write_UINT8(s, RDPECAM_PROTO_VERSION); + Stream_Write_UINT8(s, RDPECAM_MSG_STREAM_LIST_RESPONSE); + /* No count field - server calculates it from message length / 5 */ + + for (size_t i = 0; i < count; i++) { + Stream_Write_UINT16(s, streams[i].FrameSourceType); + Stream_Write_UINT8(s, streams[i].Category); + Stream_Write_UINT8(s, streams[i].Selected); + Stream_Write_UINT8(s, streams[i].CanBeShared); + } + + return TRUE; +} + +BOOL rdpecam_build_media_type_list(wStream* s, const rdpecam_media_type_desc* media_types, + size_t count) { + if (!s || !media_types) return FALSE; + + /* Header (2) + count * 26 bytes per media type descriptor */ + if (!Stream_EnsureRemainingCapacity(s, 2 + count * 26)) return FALSE; + + Stream_Write_UINT8(s, RDPECAM_PROTO_VERSION); + Stream_Write_UINT8(s, RDPECAM_MSG_MEDIA_TYPE_LIST_RESPONSE); + + for (size_t i = 0; i < count; i++) { + Stream_Write_UINT8(s, media_types[i].Format); + Stream_Write_UINT32(s, media_types[i].Width); + Stream_Write_UINT32(s, media_types[i].Height); + Stream_Write_UINT32(s, media_types[i].FrameRateNumerator); + Stream_Write_UINT32(s, media_types[i].FrameRateDenominator); + Stream_Write_UINT32(s, media_types[i].PixelAspectRatioNumerator); + Stream_Write_UINT32(s, media_types[i].PixelAspectRatioDenominator); + Stream_Write_UINT8(s, media_types[i].Flags); + } + + return TRUE; +} + +BOOL rdpecam_build_current_media_type(wStream* s, const rdpecam_media_type_desc* media_type) { + if (!s || !media_type) return FALSE; + + /* Header (2) + 26 bytes for media type descriptor */ + if (!Stream_EnsureRemainingCapacity(s, 2 + 26)) return FALSE; + + Stream_Write_UINT8(s, RDPECAM_PROTO_VERSION); + Stream_Write_UINT8(s, RDPECAM_MSG_CURRENT_MEDIA_TYPE_RESPONSE); + + Stream_Write_UINT8(s, media_type->Format); + Stream_Write_UINT32(s, media_type->Width); + Stream_Write_UINT32(s, media_type->Height); + Stream_Write_UINT32(s, media_type->FrameRateNumerator); + Stream_Write_UINT32(s, media_type->FrameRateDenominator); + Stream_Write_UINT32(s, media_type->PixelAspectRatioNumerator); + Stream_Write_UINT32(s, media_type->PixelAspectRatioDenominator); + Stream_Write_UINT8(s, media_type->Flags); + + return TRUE; +} + +void rdpecam_log_hex_dump(guac_client* client, const char* prefix, + const void* data, size_t len, size_t max_len) { + + if (!client || !data) + return; + + const uint8_t* bytes = (const uint8_t*) data; + size_t dump_len = (len < max_len) ? len : max_len; + + char line[3 * 16 + 1]; + size_t pos = 0; + for (size_t i = 0; i < dump_len; i++) { + int n = snprintf(&line[pos], sizeof(line) - pos, "%02X ", bytes[i]); + if (n <= 0) break; + pos += (size_t) n; + if ((i % 16) == 15 || i + 1 == dump_len) { + line[pos] = '\0'; + guac_client_log(client, GUAC_LOG_DEBUG, "%s: %s", prefix, line); + pos = 0; + } + } +} + +BOOL rdpecam_parse_sample_credits(const uint8_t* payload, size_t payload_len, + uint32_t* out_credits) { + + if (!payload || !out_credits || payload_len < 4) + return FALSE; + + uint32_t value = + ((uint32_t) payload[0]) | + ((uint32_t) payload[1] << 8) | + ((uint32_t) payload[2] << 16) | + ((uint32_t) payload[3] << 24); + + *out_credits = value; + return TRUE; +} + +BOOL rdpecam_parse_start_streams(const uint8_t* payload, size_t payload_len, + uint8_t* out_stream_index, rdpecam_media_type_desc* out_media_type) { + if (!payload || !out_stream_index || !out_media_type) + return FALSE; + + /* Expect: [streamIndex (1)][MediaTypeDesc (26)] = 27 bytes */ + if (payload_len < 27) + return FALSE; + + const uint8_t* p = payload; + + /* Read stream index */ + *out_stream_index = *p++; + + /* Read media type descriptor (26 bytes) */ + out_media_type->Format = *p++; + + out_media_type->Width = + ((uint32_t)p[0]) | ((uint32_t)p[1] << 8) | + ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); + p += 4; + + out_media_type->Height = + ((uint32_t)p[0]) | ((uint32_t)p[1] << 8) | + ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); + p += 4; + + out_media_type->FrameRateNumerator = + ((uint32_t)p[0]) | ((uint32_t)p[1] << 8) | + ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); + p += 4; + + out_media_type->FrameRateDenominator = + ((uint32_t)p[0]) | ((uint32_t)p[1] << 8) | + ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); + p += 4; + + out_media_type->PixelAspectRatioNumerator = + ((uint32_t)p[0]) | ((uint32_t)p[1] << 8) | + ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); + p += 4; + + out_media_type->PixelAspectRatioDenominator = + ((uint32_t)p[0]) | ((uint32_t)p[1] << 8) | + ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); + p += 4; + + out_media_type->Flags = *p++; + + return TRUE; +} + +BOOL rdpecam_parse_current_media_type_request(const uint8_t* payload, size_t payload_len, + uint8_t* out_stream_index) { + if (!payload || !out_stream_index || payload_len < 1) + return FALSE; + *out_stream_index = payload[0]; + return TRUE; +} + +BOOL rdpecam_parse_media_type_list_request(const uint8_t* payload, size_t payload_len, + uint8_t* out_stream_index) { + if (!payload || !out_stream_index || payload_len < 1) + return FALSE; + *out_stream_index = payload[0]; + return TRUE; +} + +BOOL rdpecam_parse_sample_request(const uint8_t* payload, size_t payload_len, + uint8_t* out_stream_index) { + if (!payload || !out_stream_index || payload_len < 1) + return FALSE; + *out_stream_index = payload[0]; + return TRUE; +} + +BOOL rdpecam_parse_stop_streams(const uint8_t* payload, size_t payload_len) { + /* StopStreamsRequest has no payload in single-stream implementations */ + (void)payload; + (void)payload_len; + return TRUE; +} + +BOOL rdpecam_build_start_streams_response(wStream* s, uint32_t status) { + (void) status; /* SuccessResponse has no status payload */ + if (!s) return FALSE; + if (!Stream_EnsureRemainingCapacity(s, 2)) return FALSE; + Stream_Write_UINT8(s, RDPECAM_PROTO_VERSION); + Stream_Write_UINT8(s, RDPECAM_MSG_SUCCESS_RESPONSE); + return TRUE; +} + +BOOL rdpecam_build_stop_streams_response(wStream* s, uint32_t status) { + (void) status; /* SuccessResponse has no status payload */ + if (!s) return FALSE; + if (!Stream_EnsureRemainingCapacity(s, 2)) return FALSE; + Stream_Write_UINT8(s, RDPECAM_PROTO_VERSION); + Stream_Write_UINT8(s, RDPECAM_MSG_SUCCESS_RESPONSE); + return TRUE; +} + +BOOL rdpecam_build_sample_error_response(wStream* s, uint8_t streamIndex) { + if (!s) return FALSE; + if (!Stream_EnsureRemainingCapacity(s, 3)) return FALSE; + Stream_Write_UINT8(s, RDPECAM_PROTO_VERSION); + Stream_Write_UINT8(s, RDPECAM_MSG_SAMPLE_ERROR_RESPONSE); + Stream_Write_UINT8(s, streamIndex); + return TRUE; +} + +BOOL rdpecam_build_device_removed(wStream* s, const char* channel_name) { + if (!s || !channel_name) return FALSE; + size_t ch_len = strlen(channel_name) + 1; /* include NUL */ + if (!Stream_EnsureRemainingCapacity(s, 2 + ch_len)) return FALSE; + Stream_Write_UINT8(s, RDPECAM_PROTO_VERSION); + Stream_Write_UINT8(s, RDPECAM_MSG_DEVICE_REMOVED_NOTIFICATION); + Stream_Write(s, channel_name, ch_len); + return TRUE; +} + diff --git a/src/protocols/rdp/plugins/guacrdpecam/rdpecam_proto.h b/src/protocols/rdp/plugins/guacrdpecam/rdpecam_proto.h new file mode 100644 index 0000000000..a78b9e72a4 --- /dev/null +++ b/src/protocols/rdp/plugins/guacrdpecam/rdpecam_proto.h @@ -0,0 +1,456 @@ +/* + * 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. + */ + +#ifndef GUAC_RDP_PLUGINS_GUACRDPECAM_PROTO_H +#define GUAC_RDP_PLUGINS_GUACRDPECAM_PROTO_H + +#include +#include +#include + +#include +#include +#include + +/* + * RDPECAM protocol helpers. + * + * NOTE: These helpers provide a thin serialization layer for MS-RDPECAM + * messages. As of now, they intentionally avoid hard-coding GUID values. + * Where GUIDs/structures are required by the spec, call sites should provide + * the exact values (typically mirrored from FreeRDP) until we integrate the + * full set of constants. + */ + +#ifdef __cplusplus +extern "C" { +#endif + +/** Major version we support (spec-compliant implementation target). */ +#define RDPECAM_VERSION_MAJOR 1u + +/** Minor version we support (spec-compliant implementation target). */ +#define RDPECAM_VERSION_MINOR 0u + +/** Protocol version byte written in message headers (matches FreeRDP). */ +#define RDPECAM_PROTO_VERSION 0x02u + +/** + * Temporary message type identifiers (to be replaced with MS-RDPECAM values + * in a later step). Using named constants centralizes usage. + */ +/* Official MS-RDPECAM message IDs (mirroring FreeRDP's CAM_MSG_ID) */ +typedef enum rdpecam_msg_type { + RDPECAM_MSG_SUCCESS_RESPONSE = 0x01, + RDPECAM_MSG_ERROR_RESPONSE = 0x02, + RDPECAM_MSG_SELECT_VERSION_REQUEST = 0x03, + RDPECAM_MSG_SELECT_VERSION_RESPONSE = 0x04, + RDPECAM_MSG_DEVICE_ADDED_NOTIFICATION = 0x05, + RDPECAM_MSG_DEVICE_REMOVED_NOTIFICATION = 0x06, + RDPECAM_MSG_ACTIVATE_DEVICE_REQUEST = 0x07, + RDPECAM_MSG_DEACTIVATE_DEVICE_REQUEST = 0x08, + RDPECAM_MSG_STREAM_LIST_REQUEST = 0x09, + RDPECAM_MSG_STREAM_LIST_RESPONSE = 0x0A, + RDPECAM_MSG_MEDIA_TYPE_LIST_REQUEST = 0x0B, + RDPECAM_MSG_MEDIA_TYPE_LIST_RESPONSE = 0x0C, + RDPECAM_MSG_CURRENT_MEDIA_TYPE_REQUEST = 0x0D, + RDPECAM_MSG_CURRENT_MEDIA_TYPE_RESPONSE = 0x0E, + RDPECAM_MSG_START_STREAMS_REQUEST = 0x0F, + RDPECAM_MSG_STOP_STREAMS_REQUEST = 0x10, + RDPECAM_MSG_SAMPLE_REQUEST = 0x11, + RDPECAM_MSG_SAMPLE_RESPONSE = 0x12, + RDPECAM_MSG_SAMPLE_ERROR_RESPONSE = 0x13, + RDPECAM_MSG_PROPERTY_LIST_REQUEST = 0x14, + RDPECAM_MSG_PROPERTY_LIST_RESPONSE = 0x15, + RDPECAM_MSG_PROPERTY_VALUE_REQUEST = 0x16, + RDPECAM_MSG_PROPERTY_VALUE_RESPONSE = 0x17, + RDPECAM_MSG_SET_PROPERTY_VALUE_REQUEST = 0x18 +} rdpecam_msg_type; + +/** + * H.264 media subtype GUID used by MS-RDPECAM. This is the standard + * KSDATAFORMAT_SUBTYPE_H264 GUID: {34363248-0000-0010-8000-00AA00389B71}. + * Note: The first DWORD is the little-endian FOURCC for 'H264'. + */ +static const GUID RDPECAM_SUBTYPE_H264 = + { 0x34363248, 0x0000, 0x0010, { 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71 } }; + +/** + * Writes a SampleResponse header compliant with FreeRDP/MS-RDPECAM. + * Header layout: [Version (1)][MsgId (1)==SampleResponse][StreamIndex (1)]. + * + * @param s + * The output stream to write to. + * + * @param streamId + * Identifier of the capture stream. + * + * @param sampleSequence + * Monotonic sequence number of the sample for the stream. + * + * @param payloadLength + * Length in bytes of the following Annex-B payload. + * + * @param ptsHundredsOfNs + * Presentation timestamp in 100-ns units (HNS), per MS-RDPECAM. + * + * @return + * TRUE on success, FALSE on failure. + */ +BOOL rdpecam_write_sample_response_header(wStream* s, + uint32_t streamId, uint32_t sampleSequence, + uint32_t payloadLength, uint64_t ptsHundredsOfNs); + +/** + * Media type descriptor matching FreeRDP's CAM_MEDIA_TYPE_DESCRIPTION (26 bytes). + */ +typedef struct rdpecam_media_type_desc { + uint8_t Format; /* 1 byte - media format (1=H264) */ + uint32_t Width; /* 4 bytes */ + uint32_t Height; /* 4 bytes */ + uint32_t FrameRateNumerator; /* 4 bytes */ + uint32_t FrameRateDenominator; /* 4 bytes */ + uint32_t PixelAspectRatioNumerator; /* 4 bytes */ + uint32_t PixelAspectRatioDenominator; /* 4 bytes */ + uint8_t Flags; /* 1 byte - flags */ +} rdpecam_media_type_desc; + +/** + * Stream descriptor matching FreeRDP's CAM_STREAM_DESCRIPTION (5 bytes). + */ +typedef struct rdpecam_stream_desc { + uint16_t FrameSourceType; /* 0 = Color */ + uint8_t Category; /* 1 = Capture */ + uint8_t Selected; /* bool */ + uint8_t CanBeShared; /* bool */ +} rdpecam_stream_desc; + +/* Media format constants */ +#define CAM_MEDIA_FORMAT_H264 1 + +/* Stream constants */ +#define CAM_STREAM_FRAME_SOURCE_TYPE_Color 0x0001 +#define CAM_STREAM_CATEGORY_Capture 0x01 + +/* Media type flags */ +#define CAM_MEDIA_TYPE_DESCRIPTION_FLAG_DecodingRequired 1 + +/** + * Builds SelectVersionRequest: [Version][MsgId]. + * Sent by client to initiate version negotiation. + * + * @param s + * The output stream to write to. + * + * @return + * TRUE on success, FALSE on failure. + */ +BOOL rdpecam_build_version_request(wStream* s); + +/** + * Builds SelectVersionResponse: [Version][MsgId]. + * Sent by server in response to version request. + * + * @param s + * The output stream to write to. + * + * @return + * TRUE on success, FALSE on failure. + */ +BOOL rdpecam_build_version_response(wStream* s); + +/** + * Builds SuccessResponse: [Version][MsgId]. + * Generic success response for various requests. + * + * @param s + * The output stream to write to. + * + * @return + * TRUE on success, FALSE on failure. + */ +BOOL rdpecam_build_success_response(wStream* s); + +/** + * Builds DeviceAddedNotification: [Version][MsgId][DeviceName_UTF16][ChannelName_ASCII]. + * Device name is UTF-16 encoded with NUL terminator. + * Channel name is ASCII with NUL terminator. + * + * @param s + * The output stream to write to. + * + * @param device_name + * The device name to encode in the notification. + * + * @param channel_name + * The channel name to encode in the notification. + * + * @return + * TRUE on success, FALSE on failure. + */ +BOOL rdpecam_build_device_added(wStream* s, const char* device_name, + const char* channel_name); + +/** + * Builds StreamListResponse: [Version][MsgId][StreamDesc...]. + * Contains one or more stream descriptors (6 bytes each). + * + * @param s + * The output stream to write to. + * + * @param streams + * Array of stream descriptors to encode. + * + * @param count + * Number of stream descriptors in the array. + * + * @return + * TRUE on success, FALSE on failure. + */ +BOOL rdpecam_build_stream_list(wStream* s, const rdpecam_stream_desc* streams, size_t count); + +/** + * Builds MediaTypeListResponse: [Version][MsgId][MediaTypeDesc...]. + * Contains media type descriptors (26 bytes each). + * + * @param s + * The output stream to write to. + * + * @param media_types + * Array of media type descriptors to encode. + * + * @param count + * Number of media type descriptors in the array. + * + * @return + * TRUE on success, FALSE on failure. + */ +BOOL rdpecam_build_media_type_list(wStream* s, const rdpecam_media_type_desc* media_types, + size_t count); + +/** + * Builds CurrentMediaTypeResponse: [Version][MsgId][MediaTypeDesc]. + * Contains single media type descriptor (26 bytes). + * + * @param s + * The output stream to write to. + * + * @param media_type + * The media type descriptor to encode. + * + * @return + * TRUE on success, FALSE on failure. + */ +BOOL rdpecam_build_current_media_type(wStream* s, const rdpecam_media_type_desc* media_type); + +/** + * Hex-dumps at most max_len bytes to the Guacamole log at DEBUG level with a + * given prefix. Intended for temporary wire debugging. + * + * @param client + * The Guacamole client for logging. + * + * @param prefix + * Message prefix to precede the hex dump. + * + * @param data + * Buffer to dump. + * + * @param len + * Length of the buffer. + * + * @param max_len + * Maximum number of bytes to dump. + */ +void rdpecam_log_hex_dump(guac_client* client, const char* prefix, + const void* data, size_t len, size_t max_len); + +/** + * Parses a placeholder SampleRequest-style message payload that conveys the + * number of credits to grant. This is a shim-compatible parser expecting a + * 4-byte little-endian unsigned integer (no header), matching current wire. + * + * @param payload + * Pointer to the payload bytes, excluding any message type byte. + * + * @param payload_len + * Length of the payload in bytes. + * + * @param out_credits + * Output pointer to receive the credits value on success. + * + * @return + * TRUE on success, FALSE on parse error. + */ +BOOL rdpecam_parse_sample_credits(const uint8_t* payload, size_t payload_len, + uint32_t* out_credits); + +/** + * Parses StartStreamsRequest: [streamIndex (1)][MediaTypeDesc (26)]. + * Returns stream index and full media type descriptor. + * + * @param payload + * Pointer to the payload bytes. + * + * @param payload_len + * Length of the payload in bytes. + * + * @param out_stream_index + * Output pointer to receive the stream index. + * + * @param out_media_type + * Output pointer to receive the parsed media type descriptor. + * + * @return + * TRUE on success, FALSE on parse error. + */ +BOOL rdpecam_parse_start_streams(const uint8_t* payload, size_t payload_len, + uint8_t* out_stream_index, rdpecam_media_type_desc* out_media_type); + +/** + * Parses CurrentMediaTypeRequest: [streamIndex (1)]. + * + * @param payload + * Pointer to the payload bytes. + * + * @param payload_len + * Length of the payload in bytes. + * + * @param out_stream_index + * Output pointer to receive the stream index. + * + * @return + * TRUE on success, FALSE on parse error. + */ +BOOL rdpecam_parse_current_media_type_request(const uint8_t* payload, size_t payload_len, + uint8_t* out_stream_index); + +/** + * Parses MediaTypeListRequest: [streamIndex (1)]. + * + * @param payload + * Pointer to the payload bytes. + * + * @param payload_len + * Length of the payload in bytes. + * + * @param out_stream_index + * Output pointer to receive the stream index. + * + * @return + * TRUE on success, FALSE on parse error. + */ +BOOL rdpecam_parse_media_type_list_request(const uint8_t* payload, size_t payload_len, + uint8_t* out_stream_index); + +/** + * Parses SampleRequest: [streamIndex (1)]. + * + * @param payload + * Pointer to the payload bytes. + * + * @param payload_len + * Length of the payload in bytes. + * + * @param out_stream_index + * Output pointer to receive the stream index. + * + * @return + * TRUE on success, FALSE on parse error. + */ +BOOL rdpecam_parse_sample_request(const uint8_t* payload, size_t payload_len, + uint8_t* out_stream_index); + +/** + * Validates StopStreamsRequest payload (empty for single-stream). + * + * @param payload + * Pointer to the payload bytes. + * + * @param payload_len + * Length of the payload in bytes. + * + * @return + * TRUE if the payload is valid, FALSE otherwise. + */ +BOOL rdpecam_parse_stop_streams(const uint8_t* payload, size_t payload_len); + +/** + * Builds StartStreams response with a 32-bit status code. + * + * @param s + * The output stream to write to. + * + * @param status + * Status code to include in the response (currently unused, always 0). + * + * @return + * TRUE on success, FALSE on failure. + */ +BOOL rdpecam_build_start_streams_response(wStream* s, uint32_t status); + +/** + * Builds StopStreams response with a 32-bit status code. + * + * @param s + * The output stream to write to. + * + * @param status + * Status code to include in the response (currently unused, always 0). + * + * @return + * TRUE on success, FALSE on failure. + */ +BOOL rdpecam_build_stop_streams_response(wStream* s, uint32_t status); + +/** + * Builds SampleErrorResponse: [Version][MsgId][StreamIndex]. + * + * @param s + * The output stream to write to. + * + * @param streamIndex + * The stream index for which the error is being reported. + * + * @return + * TRUE on success, FALSE on failure. + */ +BOOL rdpecam_build_sample_error_response(wStream* s, uint8_t streamIndex); + +/** + * Builds DeviceRemovedNotification: [Version][MsgId][ChannelName_ASCII_NUL]. + * + * @param s + * The output stream to write to. + * + * @param channel_name + * The channel name to encode in the notification. + * + * @return + * TRUE on success, FALSE on failure. + */ +BOOL rdpecam_build_device_removed(wStream* s, const char* channel_name); + +#ifdef __cplusplus +} +#endif + +#endif + + From 63cca7bf6d811ed359017d88911fc77cc975b5cc Mon Sep 17 00:00:00 2001 From: Loic Date: Tue, 4 Nov 2025 12:38:27 -0500 Subject: [PATCH 04/12] GUACAMOLE-1415: Integrate RDPECAM with RDP protocol Wire RDPECAM plugin into the RDP protocol handler and add configuration support. This integration connects the video handler infrastructure with the RDPECAM plugin and channel. Changes: - Load RDPECAM plugin when enabled in rdp.c - Add enable_rdpecam configuration option in settings - Register video handler in user.c for RDP connections - Add plugin declarations and initialization in rdp.h/client.c This completes the integration layer that enables camera redirection for RDP connections when the feature is enabled. --- src/protocols/rdp/client.c | 15 +++++++++++ src/protocols/rdp/rdp.c | 27 +++++++++++++++++++ src/protocols/rdp/rdp.h | 51 ++++++++++++++++++++++++++++++++++++ src/protocols/rdp/settings.c | 12 +++++++++ src/protocols/rdp/settings.h | 5 ++++ src/protocols/rdp/user.c | 4 +++ 6 files changed, 114 insertions(+) diff --git a/src/protocols/rdp/client.c b/src/protocols/rdp/client.c index c75d02c656..c67b7c8822 100644 --- a/src/protocols/rdp/client.c +++ b/src/protocols/rdp/client.c @@ -21,6 +21,7 @@ #include "channels/audio-input/audio-buffer.h" #include "channels/cliprdr.h" #include "channels/disp.h" +#include "channels/rdpecam/rdpecam_sink.h" #include "channels/pipe-svc.h" #include "channels/rail.h" #include "config.h" @@ -316,6 +317,20 @@ int guac_rdp_client_free_handler(guac_client* client) { if (rdp_client->audio_input != NULL) guac_rdp_audio_buffer_free(rdp_client->audio_input); + /* Clean up RDPECAM sink, if allocated */ + if (rdp_client->rdpecam_sink != NULL) + guac_rdpecam_destroy(rdp_client->rdpecam_sink); + + /* Free RDPECAM device capabilities */ + for (unsigned int i = 0; i < rdp_client->rdpecam_device_caps_count; i++) { + guac_rdp_rdpecam_device_caps* caps = &rdp_client->rdpecam_device_caps[i]; + if (caps->device_id) + guac_mem_free(caps->device_id); + if (caps->device_name) + guac_mem_free(caps->device_name); + } + rdp_client->rdpecam_device_caps_count = 0; + guac_rwlock_destroy(&(rdp_client->lock)); pthread_mutex_destroy(&(rdp_client->message_lock)); diff --git a/src/protocols/rdp/rdp.c b/src/protocols/rdp/rdp.c index 669bc82ee0..286c050a94 100644 --- a/src/protocols/rdp/rdp.c +++ b/src/protocols/rdp/rdp.c @@ -42,6 +42,9 @@ #include "print-job.h" #include "rdp.h" #include "settings.h" +#include "channels/rdpecam/rdpecam_sink.h" +#include "channels/rdpecam/rdpecam_caps.h" +#include "plugins/guacrdpecam/guacrdpecam.h" #ifdef ENABLE_COMMON_SSH #include "common-ssh/sftp.h" @@ -74,9 +77,13 @@ #include #include +#include #include +#include #include + + /** * Initializes and loads the necessary FreeRDP plugins based on the current * RDP session settings. This function is designed to work in environments @@ -133,6 +140,26 @@ static BOOL rdp_freerdp_load_channels(freerdp* instance) { guac_rwlock_release_lock(&(rdp_client->lock)); } + /* Load "RDPECAM" plugin for camera redirection */ + if (settings->enable_rdpecam) { + if (guac_argv_register(GUAC_RDPECAM_ARG_CAPABILITIES, + guac_rdp_rdpecam_capabilities_callback, NULL, 0)) { + guac_client_log(client, GUAC_LOG_WARNING, + "Unable to register RDPECAM capability handler;" + " dynamic media type negotiation may be limited."); + } + + /* Initialize sink pointer to NULL. Each device will create its own sink. + * When a device starts streaming, rdp_client->rdpecam_sink will be set + * to point to that device's sink so the browser knows where to push frames. */ + guac_rwlock_acquire_write_lock(&(rdp_client->lock)); + rdp_client->rdpecam_sink = NULL; + guac_rwlock_release_lock(&(rdp_client->lock)); + + /* Load plugin without holding lock to avoid deadlock with parent read lock */ + guac_rdp_rdpecam_load_plugin(GUAC_RDP_CONTEXT(instance)); + } + /* Load "cliprdr" service if not disabled */ if (!(settings->disable_copy && settings->disable_paste)) guac_rdp_clipboard_load_plugin(rdp_client->clipboard, context); diff --git a/src/protocols/rdp/rdp.h b/src/protocols/rdp/rdp.h index dc7c264d5e..4569a97b4f 100644 --- a/src/protocols/rdp/rdp.h +++ b/src/protocols/rdp/rdp.h @@ -22,6 +22,8 @@ #include "channels/audio-input/audio-buffer.h" #include "channels/cliprdr.h" +#include "channels/rdpecam/rdpecam.h" +#include "channels/rdpecam/rdpecam_caps.h" #include "channels/disp.h" #include "channels/rdpei.h" #include "common/clipboard.h" @@ -72,6 +74,19 @@ */ #define GUAC_RDP_INPUT_EVENT_QUEUE_SIZE 4096 +struct guac_rdpecam_sink; + +/* Forward declaration for RDPECAM plugin */ +struct guac_rdp_rdpecam_plugin; + +/** + * Callback invoked when RDPECAM capabilities have been updated on the core + * side. Implemented by the RDPECAM plugin and set at runtime to allow the + * plugin to react immediately (e.g., send DeviceAddedNotification) without + * creating link-time dependencies between DSOs. + */ +typedef void (*guac_rdp_rdpecam_caps_notify_fn)(struct guac_client* client); + /** * RDP-specific client data. */ @@ -175,6 +190,42 @@ typedef struct guac_rdp_client { */ guac_rdp_audio_buffer* audio_input; + /** + * RDPECAM sink, if camera redirection is enabled. + */ + struct guac_rdpecam_sink* rdpecam_sink; + + /** + * Per-device camera capabilities reported by the browser via + * rdpecam-capabilities. Each entry represents one camera device. + */ + guac_rdp_rdpecam_device_caps rdpecam_device_caps[GUAC_RDP_RDPECAM_MAX_DEVICES]; + + /** + * Number of valid entries within rdpecam_device_caps array. + */ + unsigned int rdpecam_device_caps_count; + + /** + * Flag indicating that new RDPECAM capabilities have been received + * and need to be processed by the plugin. The plugin should check this + * flag and clear it after sending device notifications. + */ + int rdpecam_caps_updated; + + /** + * Reference to the RDPECAM plugin instance, if loaded. + * Used by capabilities callback to send device notifications. + */ + struct guac_rdp_rdpecam_plugin* rdpecam_plugin; + + /** + * Optional callback set by the RDPECAM plugin to be invoked when + * capabilities are updated. This avoids cross-library linking by allowing + * the core to trigger plugin behavior via a function pointer. + */ + guac_rdp_rdpecam_caps_notify_fn rdpecam_caps_notify; + /** * The filesystem being shared, if any. */ diff --git a/src/protocols/rdp/settings.c b/src/protocols/rdp/settings.c index 1696c511bd..675fb818e4 100644 --- a/src/protocols/rdp/settings.c +++ b/src/protocols/rdp/settings.c @@ -129,6 +129,7 @@ const char* GUAC_RDP_CLIENT_ARGS[] = { "recording-write-existing", "resize-method", "enable-audio-input", + "enable-rdpecam", "enable-touch", "read-only", @@ -605,6 +606,12 @@ enum RDP_ARGS_IDX { */ IDX_ENABLE_AUDIO_INPUT, + /** + * "true" if camera redirection (RDPECAM) should be enabled for the RDP + * connection, "false" or blank otherwise. + */ + IDX_ENABLE_RDPECAM, + /** * "true" if multi-touch support should be enabled for the RDP connection, * "false" or blank otherwise. @@ -1260,6 +1267,11 @@ guac_rdp_settings* guac_rdp_parse_args(guac_user* user, guac_user_parse_args_boolean(user, GUAC_RDP_CLIENT_ARGS, argv, IDX_ENABLE_AUDIO_INPUT, 0); + /* Camera redirection enable/disable */ + settings->enable_rdpecam = + guac_user_parse_args_boolean(user, GUAC_RDP_CLIENT_ARGS, argv, + IDX_ENABLE_RDPECAM, 1); + /* Set gateway hostname */ settings->gateway_hostname = guac_user_parse_args_string(user, GUAC_RDP_CLIENT_ARGS, argv, diff --git a/src/protocols/rdp/settings.h b/src/protocols/rdp/settings.h index 5f01a1adf3..eba044d969 100644 --- a/src/protocols/rdp/settings.h +++ b/src/protocols/rdp/settings.h @@ -606,6 +606,11 @@ typedef struct guac_rdp_settings { */ int enable_audio_input; + /** + * Whether camera redirection (RDPECAM) is enabled. + */ + int enable_rdpecam; + /** * Whether the RDP Graphics Pipeline Extension is enabled. */ diff --git a/src/protocols/rdp/user.c b/src/protocols/rdp/user.c index 3a1c84be55..5ac4a7ca67 100644 --- a/src/protocols/rdp/user.c +++ b/src/protocols/rdp/user.c @@ -83,6 +83,10 @@ int guac_rdp_user_join_handler(guac_user* user, int argc, char** argv) { if (settings->enable_audio_input) user->audio_handler = guac_rdp_audio_handler; + /* Handle inbound camera streams if RDPECAM is enabled */ + if (settings->enable_rdpecam) + user->video_handler = guac_rdp_rdpecam_video_handler; + } /* Only handle events if not read-only */ From cb656338c2229ce2a576be023a4e9bbe34f0643f Mon Sep 17 00:00:00 2001 From: Loic Date: Tue, 4 Nov 2025 14:12:07 -0500 Subject: [PATCH 05/12] GUACAMOLE-1415: Update build system for RDPECAM Add build configuration for RDPECAM channel and plugin components. Changes: - Add RDPECAM channel and plugin sources to Makefile.am - Add configure.ac checks and build rules This ensures the RDPECAM components are properly compiled and linked with the rest of the RDP protocol implementation. --- configure.ac | 23 +++++++++++++++++++++ src/protocols/rdp/Makefile.am | 39 ++++++++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/configure.ac b/configure.ac index 645e052270..7a76db526c 100644 --- a/configure.ac +++ b/configure.ac @@ -790,6 +790,29 @@ then fi +# Check for WinPR HashTable_Insert (may be missing on older FreeRDP/WinPR 2.x) +if test "x${have_freerdp}" = "xyes" +then + AC_MSG_CHECKING([for HashTable_Insert in WinPR]) + SAVE_LIBS="$LIBS" + LIBS="$RDP_LIBS $LIBS" + AC_LINK_IFELSE([AC_LANG_SOURCE([[ + #include + int main(void) { + wHashTable* t = HashTable_New(FALSE); + const char* k = "k"; const char* v = "v"; + (void)HashTable_Insert(t, k, v); + return 0; + } + ]])], + [AC_MSG_RESULT([yes]) + AC_DEFINE([HAVE_WINPR_HASHTABLE_INSERT], 1, [WinPR provides HashTable_Insert])], + [AC_MSG_RESULT([no])]) + LIBS="$SAVE_LIBS" +fi + +/* Removed unused WinPR HashTable checks; only HashTable_Insert is required */ + # It is difficult or impossible to test for variations in FreeRDP behavior in # between releases, as the change in behavior may not (yet) be associated with # a corresponding change in version number and may not have any detectable diff --git a/src/protocols/rdp/Makefile.am b/src/protocols/rdp/Makefile.am index dc9638e305..1a55bacd1b 100644 --- a/src/protocols/rdp/Makefile.am +++ b/src/protocols/rdp/Makefile.am @@ -43,6 +43,9 @@ libguac_client_rdp_la_SOURCES = \ channels/audio-input/audio-buffer.c \ channels/audio-input/audio-input.c \ channels/cliprdr.c \ + channels/rdpecam/rdpecam.c \ + channels/rdpecam/rdpecam_caps.c \ + channels/rdpecam/rdpecam_sink.c \ channels/common-svc.c \ channels/disp.c \ channels/pipe-svc.c \ @@ -89,6 +92,9 @@ noinst_HEADERS = \ channels/audio-input/audio-buffer.h \ channels/audio-input/audio-input.h \ channels/cliprdr.h \ + channels/rdpecam/rdpecam.h \ + channels/rdpecam/rdpecam_caps.h \ + channels/rdpecam/rdpecam_sink.h \ channels/common-svc.h \ channels/disp.h \ channels/pipe-svc.h \ @@ -120,6 +126,8 @@ noinst_HEADERS = \ plugins/channels.h \ plugins/guacai/guacai-messages.h \ plugins/guacai/guacai.h \ + plugins/guacrdpecam/guacrdpecam.h \ + plugins/guacrdpecam/rdpecam_proto.h \ plugins/ptr-string.h \ pointer.h \ print-job.h \ @@ -153,7 +161,8 @@ libguac_client_rdp_la_LIBADD = \ freerdp_LTLIBRARIES = \ libguac-common-svc-client.la \ - libguacai-client.la + libguacai-client.la \ + libguacrdpecam-client.la freerdpdir = @FREERDP_PLUGIN_DIR@ @@ -202,6 +211,34 @@ libguacai_client_la_LIBADD = \ @COMMON_LTLIB@ \ @LIBGUAC_LTLIB@ +# +# RDPECAM (Camera Redirection) - FreeRDP plugin +# + +libguacrdpecam_client_la_SOURCES = \ + plugins/guacrdpecam/guacrdpecam.c \ + plugins/guacrdpecam/rdpecam_proto.c + +libguacrdpecam_client_la_CFLAGS = \ + @COMMON_INCLUDE@ \ + @COMMON_SSH_INCLUDE@ \ + @LIBGUAC_INCLUDE@ \ + @RDP_CFLAGS@ + +libguacrdpecam_client_la_LDFLAGS = \ + -module -avoid-version -shared \ + @PTHREAD_LIBS@ \ + @RDP_LIBS@ + +libguacrdpecam_client_la_LIBADD = \ + @COMMON_LTLIB@ \ + @LIBGUAC_LTLIB@ \ + libguac-client-rdp.la + +# +# RDPECAM (Camera Redirection) - included in main RDP library +# + # # Optional SFTP support # From 72de77969f2cd0a6a45ddd1b4d21277ab8f9af8c Mon Sep 17 00:00:00 2001 From: Loic Date: Thu, 6 Nov 2025 11:30:24 -0500 Subject: [PATCH 06/12] GUACAMOLE-1415: Add dynamic camera enable/disable support for active RDP sessions --- .../rdp/channels/rdpecam/rdpecam_caps.c | 237 ++++++++++++++ .../rdp/channels/rdpecam/rdpecam_caps.h | 35 ++ .../rdp/plugins/guacrdpecam/guacrdpecam.c | 302 +++++++++++++++++- src/protocols/rdp/rdp.c | 7 + 4 files changed, 564 insertions(+), 17 deletions(-) diff --git a/src/protocols/rdp/channels/rdpecam/rdpecam_caps.c b/src/protocols/rdp/channels/rdpecam/rdpecam_caps.c index d8148766d5..94d749a868 100644 --- a/src/protocols/rdp/channels/rdpecam/rdpecam_caps.c +++ b/src/protocols/rdp/channels/rdpecam/rdpecam_caps.c @@ -281,3 +281,240 @@ int guac_rdp_rdpecam_capabilities_callback(guac_user* user, return 0; } +int guac_rdp_rdpecam_capabilities_update_callback(guac_user* user, + const char* mimetype, const char* name, const char* value, void* data) { + + guac_client* client = user ? user->client : NULL; + guac_rdp_client* rdp_client = client ? (guac_rdp_client*) client->data : NULL; + + if (!client || !rdp_client || !value) + return 0; + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM capability update received: %zu bytes", strlen(value)); + + /* Handle empty capability string (all cameras disabled) */ + if (strlen(value) == 0) { + guac_rwlock_acquire_write_lock(&(rdp_client->lock)); + + /* Free old device capabilities */ + for (unsigned int i = 0; i < rdp_client->rdpecam_device_caps_count; i++) { + guac_rdp_rdpecam_device_caps* caps = &rdp_client->rdpecam_device_caps[i]; + if (caps->device_id) + guac_mem_free(caps->device_id); + if (caps->device_name) + guac_mem_free(caps->device_name); + caps->device_id = NULL; + caps->device_name = NULL; + caps->format_count = 0; + } + rdp_client->rdpecam_device_caps_count = 0; + + /* Set flag and notify plugin */ + rdp_client->rdpecam_caps_updated = 1; + if (rdp_client->rdpecam_caps_notify) + rdp_client->rdpecam_caps_notify(client); + + guac_rwlock_release_lock(&(rdp_client->lock)); + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM all cameras disabled, notifying plugin"); + return 0; + } + + /* Reuse the same parsing logic as the initial capabilities callback */ + size_t len = strlen(value); + char* copy = guac_mem_alloc(len + 1); + if (!copy) + return 0; + memcpy(copy, value, len + 1); + + guac_rwlock_acquire_write_lock(&(rdp_client->lock)); + + /* Free old device capabilities */ + for (unsigned int i = 0; i < rdp_client->rdpecam_device_caps_count; i++) { + guac_rdp_rdpecam_device_caps* caps = &rdp_client->rdpecam_device_caps[i]; + if (caps->device_id) + guac_mem_free(caps->device_id); + if (caps->device_name) + guac_mem_free(caps->device_name); + caps->device_id = NULL; + caps->device_name = NULL; + caps->format_count = 0; + } + rdp_client->rdpecam_device_caps_count = 0; + + /* Parse multi-device capabilities (same format as initial capabilities) */ + unsigned int device_count = 0; + char* device_saveptr = NULL; + char* device_entry = strtok_r(copy, ";", &device_saveptr); + + if (!device_entry) { + guac_client_log(client, GUAC_LOG_WARNING, + "RDPECAM capability update in invalid format (expected semicolon-separated device list)"); + guac_mem_free(copy); + guac_rwlock_release_lock(&(rdp_client->lock)); + return 0; + } + + while (device_entry && device_count < GUAC_RDP_RDPECAM_MAX_DEVICES) { + guac_rdp_rdpecam_device_caps* caps = &rdp_client->rdpecam_device_caps[device_count]; + + /* Find pipe separator (between device info and formats) */ + char* formats_str = device_entry; + char* pipe_pos = strchr(device_entry, '|'); + char* device_info = NULL; + + if (!pipe_pos) { + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM skipping device entry without pipe separator: '%s'", device_entry); + device_entry = strtok_r(NULL, ";", &device_saveptr); + continue; + } + + *pipe_pos = '\0'; + device_info = device_entry; + formats_str = pipe_pos + 1; + + /* Parse device ID and name */ + char* device_id_parsed = NULL; + char* device_name_parsed = NULL; + + if (!device_info || !*device_info) { + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM skipping device entry without device info"); + device_entry = strtok_r(NULL, ";", &device_saveptr); + continue; + } + + char* colon_pos = strchr(device_info, ':'); + if (!colon_pos) { + guac_client_log(client, GUAC_LOG_WARNING, + "RDPECAM skipping device entry without device ID: '%s'", device_info); + device_entry = strtok_r(NULL, ";", &device_saveptr); + continue; + } + + *colon_pos = '\0'; + device_id_parsed = device_info; + device_name_parsed = colon_pos + 1; + + if (!device_id_parsed || !*device_id_parsed) { + guac_client_log(client, GUAC_LOG_WARNING, + "RDPECAM skipping device entry with empty device ID"); + device_entry = strtok_r(NULL, ";", &device_saveptr); + continue; + } + + /* Store device ID */ + size_t id_len = strlen(device_id_parsed); + caps->device_id = guac_mem_alloc(id_len + 1); + if (!caps->device_id) { + guac_client_log(client, GUAC_LOG_ERROR, + "RDPECAM failed to allocate device ID string"); + device_entry = strtok_r(NULL, ";", &device_saveptr); + continue; + } + memcpy(caps->device_id, device_id_parsed, id_len + 1); + + /* Sanitize and store device name */ + if (device_name_parsed && *device_name_parsed) { + char sanitized[256]; + size_t sanitized_len = guac_rdp_rdpecam_sanitize_device_name( + device_name_parsed, sanitized, sizeof(sanitized)); + + if (sanitized_len > 0) { + caps->device_name = guac_mem_alloc(sanitized_len + 1); + if (caps->device_name) { + memcpy(caps->device_name, sanitized, sanitized_len + 1); + } + } + } + + /* Parse formats */ + unsigned int format_count = 0; + char* format_saveptr = NULL; + char* format_token = strtok_r(formats_str, ",", &format_saveptr); + + while (format_token && format_count < GUAC_RDP_RDPECAM_MAX_FORMATS) { + /* Trim whitespace */ + while (isspace((unsigned char) *format_token)) + format_token++; + + char* end = format_token + strlen(format_token); + while (end > format_token && isspace((unsigned char) *(end - 1))) + *(--end) = '\0'; + + unsigned int width = 0; + unsigned int height = 0; + unsigned int fps_num = 0; + unsigned int fps_den = 1; + + int parsed = sscanf(format_token, "%ux%u@%u/%u", &width, &height, &fps_num, &fps_den); + if (parsed < 4) { + fps_den = 1; + parsed = sscanf(format_token, "%ux%u@%u", &width, &height, &fps_num); + } + + if (parsed >= 3 && width && height && fps_num) { + guac_rdp_rdpecam_format* fmt = &caps->formats[format_count++]; + fmt->width = width; + fmt->height = height; + fmt->fps_num = fps_num; + fmt->fps_den = fps_den ? fps_den : 1; + } + else { + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM ignored unparseable format entry: '%s'", format_token); + } + + format_token = strtok_r(NULL, ",", &format_saveptr); + } + + caps->format_count = format_count; + + /* Only add device if it has valid formats */ + if (format_count > 0) { + device_count++; + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM update device %u: id='%s', name='%s', formats=%u", + device_count - 1, + caps->device_id, + caps->device_name ? caps->device_name : "(none)", + format_count); + } else { + guac_client_log(client, GUAC_LOG_WARNING, + "RDPECAM skipping device '%s' (id='%s') with no valid formats", + caps->device_name ? caps->device_name : "(unnamed)", + caps->device_id); + if (caps->device_id) { + guac_mem_free(caps->device_id); + caps->device_id = NULL; + } + if (caps->device_name) { + guac_mem_free(caps->device_name); + caps->device_name = NULL; + } + } + + device_entry = strtok_r(NULL, ";", &device_saveptr); + } + + rdp_client->rdpecam_device_caps_count = device_count; + + /* Set flag to notify plugin that capabilities have been updated */ + rdp_client->rdpecam_caps_updated = 1; + + /* Notify plugin to process the update (compare old vs new and add/remove devices) */ + if (rdp_client->rdpecam_caps_notify) + rdp_client->rdpecam_caps_notify(client); + + guac_rwlock_release_lock(&(rdp_client->lock)); + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM capability update processed (%u devices), notifying plugin", device_count); + + guac_mem_free(copy); + return 0; +} + diff --git a/src/protocols/rdp/channels/rdpecam/rdpecam_caps.h b/src/protocols/rdp/channels/rdpecam/rdpecam_caps.h index 4471a017f5..a46650ab2b 100644 --- a/src/protocols/rdp/channels/rdpecam/rdpecam_caps.h +++ b/src/protocols/rdp/channels/rdpecam/rdpecam_caps.h @@ -28,6 +28,12 @@ */ #define GUAC_RDPECAM_ARG_CAPABILITIES "rdpecam-capabilities" +/** + * The name of the guacamole protocol argument for camera capability updates. + * This is sent when the user enables/disables cameras during an active session. + */ +#define GUAC_RDPECAM_ARG_CAPABILITIES_UPDATE "rdpecam-capabilities-update" + /** * Maximum number of RDPECAM formats remembered from the browser. */ @@ -136,5 +142,34 @@ size_t guac_rdp_rdpecam_sanitize_device_name(const char* name, char* sanitized, int guac_rdp_rdpecam_capabilities_callback(guac_user* user, const char* mimetype, const char* name, const char* value, void* data); +/** + * Callback invoked when camera capability updates are received from the browser. + * This is called when the user enables/disables cameras during an active session. + * The plugin is responsible for comparing old and new capabilities to determine + * which devices were added or removed. + * + * @param user + * The user who sent the capability update. + * + * @param mimetype + * The mimetype of the data (unused). + * + * @param name + * The name of the argument (should be "rdpecam-capabilities-update"). + * + * @param value + * The capability string in the same format as initial capabilities: + * "DEVICE_ID:DEVICE_NAME|WIDTHxHEIGHT@FPS_NUM/FPS_DEN,...;..." + * Can be empty string if all cameras are disabled. + * + * @param data + * User-defined data (unused). + * + * @return + * Always returns 0. + */ +int guac_rdp_rdpecam_capabilities_update_callback(guac_user* user, + const char* mimetype, const char* name, const char* value, void* data); + #endif diff --git a/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.c b/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.c index 577d718019..1b460e03a9 100644 --- a/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.c +++ b/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.c @@ -207,6 +207,7 @@ static UINT guac_rdp_rdpecam_new_connection( * Invoked when RDPECAM capabilities have been updated on the core side. * If the plugin is ready (version negotiated) and the enumerator channel is * known, immediately sends DeviceAddedNotification for all devices. + * For capability updates, also removes devices that are no longer in the list. */ void guac_rdp_rdpecam_caps_notify(guac_client* client) { if (!client) @@ -217,14 +218,281 @@ void guac_rdp_rdpecam_caps_notify(guac_client* client) { guac_rdp_rdpecam_plugin* plugin = rdp_client->rdpecam_plugin; if (!plugin || !plugin->version_negotiated || !plugin->enumerator_channel) return; + guac_rwlock_acquire_write_lock(&(rdp_client->lock)); - if (rdp_client->rdpecam_caps_updated && rdp_client->rdpecam_device_caps_count > 0) { - guac_rdp_rdpecam_send_device_notifications(plugin, client, rdp_client, plugin->enumerator_channel); - rdp_client->rdpecam_caps_updated = 0; + + if (!rdp_client->rdpecam_caps_updated) { + guac_rwlock_release_lock(&(rdp_client->lock)); + return; + } + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM caps_notify: processing capability update"); + + /* Build set of new device IDs from capabilities */ + char* new_device_ids[GUAC_RDP_RDPECAM_MAX_DEVICES] = {NULL}; + unsigned int new_device_count = rdp_client->rdpecam_device_caps_count; + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM caps_notify: new capability count = %u", new_device_count); + + for (unsigned int i = 0; i < new_device_count; i++) { + guac_rdp_rdpecam_device_caps* caps = &rdp_client->rdpecam_device_caps[i]; + if (caps->device_id) { + new_device_ids[i] = caps->device_id; + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM caps_notify: new device[%u] = '%s'", i, caps->device_id); + } + } + + /* Since HashTable_GetKeys is broken in WinPR, we can't reliably iterate device_id_map + * to find what needs to be removed. Instead, we'll: + * 1. Scan all channel slots to find channels that should be removed (not in new capabilities) + * 2. Send removal notifications for those channels (whether Windows opened them or not) + * 3. Clear device_id_map completely + * 4. Rebuild it from new capabilities + * This avoids the HashTable_GetKeys API compatibility issue entirely. */ + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM caps_notify: removing all previously advertised channels before rebuild"); + + /* Step 1: Since we're about to clear and rebuild device_id_map, send DeviceRemovedNotification + * for ALL channel slots (0-10) to ensure Windows cleans up any previously advertised devices. + * Windows will ignore removals for channels that were never advertised. */ + + char channels_to_remove[11][64]; /* Slots 0-10 should cover most reasonable scenarios */ + unsigned int remove_count = 0; + + for (unsigned int slot = 0; slot <= 10 && slot < GUAC_RDP_RDPECAM_MAX_DEVICES; slot++) { + snprintf(channels_to_remove[remove_count], sizeof(channels_to_remove[remove_count]), + "RDCamera_Device_%u", slot); + remove_count++; + } + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM caps_notify: will send removal for slots 0-%u to clean up old advertisements", + remove_count - 1); + + /* Step 2: Send removal notifications for all slots */ + for (unsigned int i = 0; i < remove_count; i++) { + char* channel_name = channels_to_remove[i]; + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM sending removal for channel '%s'", channel_name); + + /* Send DeviceRemovedNotification to Windows */ + wStream* rs = Stream_New(NULL, 256); + if (rs && rdpecam_build_device_removed(rs, channel_name)) { + Stream_SealLength(rs); + const size_t out_len = Stream_Length(rs); + + UINT32 enum_channel_id = 0; + if (plugin->manager && plugin->manager->GetChannelId) + enum_channel_id = plugin->manager->GetChannelId(plugin->enumerator_channel); + + pthread_mutex_lock(&(rdp_client->message_lock)); + UINT result = plugin->enumerator_channel->Write(plugin->enumerator_channel, + (UINT32) out_len, Stream_Buffer(rs), NULL); + pthread_mutex_unlock(&(rdp_client->message_lock)); + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM TX ChannelId=%" PRIu32 " MessageId=0x06 DeviceRemovedNotification (channel='%s') result=%u", + enum_channel_id, channel_name, result); + } + if (rs) Stream_Free(rs, TRUE); + + /* Clean up device structure */ + guac_rdpecam_device* device = (guac_rdpecam_device*) + HashTable_GetItemValue(plugin->devices, channel_name); + + if (device) { + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM cleaning up device structure for channel '%s'", channel_name); + + /* Stop streaming if active */ + if (device->streaming) { + device->stopping = true; + device->streaming = false; + } + + /* Remove from devices hash table */ + HashTable_Remove(plugin->devices, channel_name); + } + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM caps_notify: completed removal notification for channel '%s'", channel_name); + } + + /* Step 3: Clear and rebuild device_id_map to avoid stale entries */ + if (plugin->device_id_map) { + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM caps_notify: clearing device_id_map to rebuild from new capabilities"); + HashTable_Clear(plugin->device_id_map); + } + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM caps_notify: starting device addition phase"); + + /* Now send DeviceAddedNotification ONLY for NEW devices (not already in device_id_map) */ + if (new_device_count > 0) { + unsigned int added_count = 0; + guac_client_log(client, GUAC_LOG_DEBUG, - "RDPECAM sent device notifications via immediate caps notify"); + "RDPECAM caps_notify: processing %u potential new devices", new_device_count); + + for (unsigned int i = 0; i < new_device_count; i++) { + guac_rdp_rdpecam_device_caps* caps = &rdp_client->rdpecam_device_caps[i]; + + /* Find next available channel index (device_id_map was just cleared, so all devices need assignment) */ + char channel_name[64]; + unsigned int assigned_channel_idx = 0; + int found_slot = 0; + + /* Find the next available RDCamera_Device_N slot */ + for (unsigned int check_idx = 0; check_idx < 100 && !found_slot; check_idx++) { + snprintf(channel_name, sizeof(channel_name), "RDCamera_Device_%u", check_idx); + + /* Check if this channel name is already in use */ + int in_use = 0; + + /* Check plugin->devices (may be empty if Windows hasn't opened channels yet) */ + if (plugin->devices) { + guac_rdpecam_device* existing_device = (guac_rdpecam_device*) + HashTable_GetItemValue(plugin->devices, channel_name); + if (existing_device) { + in_use = 1; + } + } + + /* Also check device_id_map to see if any device already maps to this channel */ + if (!in_use && plugin->device_id_map) { + /* Check each new device we're processing to see if it maps to this channel */ + for (unsigned int j = 0; j < new_device_count; j++) { + if (!new_device_ids[j]) + continue; + + char* mapped_channel = (char*) HashTable_GetItemValue(plugin->device_id_map, new_device_ids[j]); + if (mapped_channel && strcmp(mapped_channel, channel_name) == 0) { + in_use = 1; + break; + } + } + } + + if (!in_use) { + assigned_channel_idx = check_idx; + found_slot = 1; + } + } + + if (!found_slot) { + guac_client_log(client, GUAC_LOG_ERROR, + "RDPECAM no available channel slots for new device"); + continue; + } + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM assigning new device '%s' to channel '%s'", + caps->device_id, channel_name); + + const char* device_name = "Redirected-Cam0"; + char fallback_name[64]; + if (caps->device_name && caps->device_name[0] != '\0') { + device_name = caps->device_name; + } else { + snprintf(fallback_name, sizeof(fallback_name), "Redirected-Cam%u", i); + device_name = fallback_name; + } + + /* Store device ID to channel name mapping */ + if (caps->device_id && caps->device_id[0] != '\0' && plugin->device_id_map) { + char* channel_name_copy = guac_mem_alloc(strlen(channel_name) + 1); + if (channel_name_copy) { + strcpy(channel_name_copy, channel_name); +#ifdef HAVE_WINPR_HASHTABLE_INSERT + if (!HashTable_Insert(plugin->device_id_map, (void*) caps->device_id, (void*) channel_name_copy)) { + guac_client_log(client, GUAC_LOG_ERROR, + "RDPECAM failed to insert device ID mapping"); + guac_mem_free(channel_name_copy); + } else { + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM mapped device ID '%s' to channel '%s'", + caps->device_id, channel_name); + } +#else + if (HashTable_Add(plugin->device_id_map, (void*) caps->device_id, (void*) channel_name_copy) < 0) { + guac_client_log(client, GUAC_LOG_ERROR, + "RDPECAM failed to add device ID mapping"); + guac_mem_free(channel_name_copy); + } else { + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM mapped device ID '%s' to channel '%s'", + caps->device_id, channel_name); + } +#endif + } + } + + /* Create listener for this device channel if not Device_0 */ + if (assigned_channel_idx > 0 && plugin->manager) { + guac_rdp_rdpecam_listener_callback* device_listener = + guac_mem_zalloc(sizeof(guac_rdp_rdpecam_listener_callback)); + if (device_listener) { + char* saved_channel_name = guac_mem_alloc(strlen(channel_name) + 1); + if (saved_channel_name) { + strcpy(saved_channel_name, channel_name); + device_listener->client = client; + device_listener->channel_name = saved_channel_name; + device_listener->plugin = plugin; + device_listener->parent.OnNewChannelConnection = guac_rdp_rdpecam_new_connection; + + plugin->manager->CreateListener(plugin->manager, channel_name, 0, + (IWTSListenerCallback*) device_listener, NULL); + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM registered listener for device channel: %s", channel_name); + } else { + guac_mem_free(device_listener); + } + } + } + + /* Send DeviceAddedNotification */ + wStream* rs = Stream_New(NULL, 256); + if (rs && rdpecam_build_device_added(rs, device_name, channel_name)) { + Stream_SealLength(rs); + const size_t out_len = Stream_Length(rs); + + UINT32 enum_channel_id = 0; + if (plugin->manager && plugin->manager->GetChannelId) + enum_channel_id = plugin->manager->GetChannelId(plugin->enumerator_channel); + + pthread_mutex_lock(&(rdp_client->message_lock)); + UINT result = plugin->enumerator_channel->Write(plugin->enumerator_channel, + (UINT32) out_len, Stream_Buffer(rs), NULL); + pthread_mutex_unlock(&(rdp_client->message_lock)); + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM TX ChannelId=%" PRIu32 " MessageId=0x05 DeviceAddedNotification (device='%s', channel='%s')", + enum_channel_id, device_name, channel_name); + + added_count++; + } + if (rs) Stream_Free(rs, TRUE); + } + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM capability update: added %u new device(s)", added_count); + } else { + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM all cameras disabled"); } + + rdp_client->rdpecam_caps_updated = 0; guac_rwlock_release_lock(&(rdp_client->lock)); + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM caps_notify: completed capability update processing"); } /** @@ -1075,11 +1343,13 @@ static UINT guac_rdp_rdpecam_handle_data(guac_client* client, IWTSVirtualChannel /* Find and stop the old device that owns old_sink */ if (plugin && plugin->devices) { - ULONG_PTR* keys = NULL; - int count = HashTable_GetKeys(plugin->devices, &keys); - for (int i = 0; i < count; i++) { + /* Iterate through channel slots instead of using broken HashTable_GetKeys */ + for (unsigned int check_idx = 0; check_idx < 100; check_idx++) { + char check_channel[64]; + snprintf(check_channel, sizeof(check_channel), "RDCamera_Device_%u", check_idx); + guac_rdpecam_device* old_device = - (guac_rdpecam_device*) HashTable_GetItemValue(plugin->devices, (void*) keys[i]); + (guac_rdpecam_device*) HashTable_GetItemValue(plugin->devices, check_channel); if (old_device && old_device->sink == old_sink) { /* Stop the old device */ pthread_mutex_lock(&old_device->lock); @@ -1095,7 +1365,6 @@ static UINT guac_rdp_rdpecam_handle_data(guac_client* client, IWTSVirtualChannel break; } } - free(keys); } /* Clear the old sink's streaming state */ @@ -1833,17 +2102,16 @@ static UINT guac_rdp_rdpecam_terminated(IWTSPlugin* plugin) { /* Destroy all devices in hash table */ if (rdpecam_plugin->devices != NULL) { - /* Explicitly destroy values for all WinPR versions */ - ULONG_PTR* keys = NULL; - size_t count = HashTable_GetKeys(rdpecam_plugin->devices, &keys); - for (size_t i = 0; i < count; i++) { - void* key = (void*) keys[i]; - guac_rdpecam_device* dev = (guac_rdpecam_device*) HashTable_GetItemValue(rdpecam_plugin->devices, key); + /* Iterate through channel slots instead of using broken HashTable_GetKeys */ + for (unsigned int check_idx = 0; check_idx < 100; check_idx++) { + char channel_name[64]; + snprintf(channel_name, sizeof(channel_name), "RDCamera_Device_%u", check_idx); + + guac_rdpecam_device* dev = (guac_rdpecam_device*) + HashTable_GetItemValue(rdpecam_plugin->devices, channel_name); if (dev) guac_rdpecam_device_destroy(dev); } - if (keys) - free(keys); HashTable_Free(rdpecam_plugin->devices); rdpecam_plugin->devices = NULL; } diff --git a/src/protocols/rdp/rdp.c b/src/protocols/rdp/rdp.c index 286c050a94..8d1c0666de 100644 --- a/src/protocols/rdp/rdp.c +++ b/src/protocols/rdp/rdp.c @@ -149,6 +149,13 @@ static BOOL rdp_freerdp_load_channels(freerdp* instance) { " dynamic media type negotiation may be limited."); } + if (guac_argv_register(GUAC_RDPECAM_ARG_CAPABILITIES_UPDATE, + guac_rdp_rdpecam_capabilities_update_callback, NULL, 0)) { + guac_client_log(client, GUAC_LOG_WARNING, + "Unable to register RDPECAM capability update handler;" + " dynamic camera enable/disable may be limited."); + } + /* Initialize sink pointer to NULL. Each device will create its own sink. * When a device starts streaming, rdp_client->rdpecam_sink will be set * to point to that device's sink so the browser knows where to push frames. */ From 3a7a8156988892c7ba7e825159d544249ffbc8a9 Mon Sep 17 00:00:00 2001 From: Loic Date: Thu, 6 Nov 2025 16:02:01 -0500 Subject: [PATCH 07/12] GUACAMOLE-1415: Add CUnit unit tests for RDPECAM implementation - Add comprehensive tests for rdpecam_sink (frame queue operations) - Add tests for rdpecam_proto (protocol message building/parsing) - Add tests for rdpecam_caps (capability parsing and device name sanitization) - Update Makefile.am to include new test files - All tests follow test_SUITENAME__TESTNAME() naming convention --- src/protocols/rdp/tests/Makefile.am | 12 +- .../rdp/tests/rdpecam/rdpecam_caps_test.c | 351 ++++++++++++ .../rdp/tests/rdpecam/rdpecam_proto_test.c | 482 ++++++++++++++++ .../rdp/tests/rdpecam/rdpecam_sink_test.c | 520 ++++++++++++++++++ 4 files changed, 1361 insertions(+), 4 deletions(-) create mode 100644 src/protocols/rdp/tests/rdpecam/rdpecam_caps_test.c create mode 100644 src/protocols/rdp/tests/rdpecam/rdpecam_proto_test.c create mode 100644 src/protocols/rdp/tests/rdpecam/rdpecam_sink_test.c diff --git a/src/protocols/rdp/tests/Makefile.am b/src/protocols/rdp/tests/Makefile.am index e1e22e3699..e3d5930777 100644 --- a/src/protocols/rdp/tests/Makefile.am +++ b/src/protocols/rdp/tests/Makefile.am @@ -33,9 +33,12 @@ ACLOCAL_AMFLAGS = -I m4 check_PROGRAMS = test_rdp TESTS = $(check_PROGRAMS) -test_rdp_SOURCES = \ - fs/basename.c \ - fs/normalize_path.c +test_rdp_SOURCES = \ + fs/basename.c \ + fs/normalize_path.c \ + rdpecam/rdpecam_sink_test.c \ + rdpecam/rdpecam_proto_test.c \ + rdpecam/rdpecam_caps_test.c test_rdp_CFLAGS = \ -Werror -Wall -pedantic \ @@ -45,7 +48,8 @@ test_rdp_CFLAGS = \ test_rdp_LDADD = \ @CUNIT_LIBS@ \ @LIBGUAC_CLIENT_RDP_LTLIB@ \ - @LIBGUAC_LTLIB@ + @LIBGUAC_LTLIB@ \ + @RDP_LIBS@ # # Autogenerate test runner diff --git a/src/protocols/rdp/tests/rdpecam/rdpecam_caps_test.c b/src/protocols/rdp/tests/rdpecam/rdpecam_caps_test.c new file mode 100644 index 0000000000..a7074adbe5 --- /dev/null +++ b/src/protocols/rdp/tests/rdpecam/rdpecam_caps_test.c @@ -0,0 +1,351 @@ +/* + * 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. + */ + +#include "channels/rdpecam/rdpecam_caps.h" +#include "rdp.h" + +#include +#include +#include + +#include +#include +#include + +/** + * Creates a minimal mock guac_client and guac_rdp_client for testing. + * Returns the guac_client, with rdp_client stored in client->data. + */ +static guac_client* create_mock_client_with_rdp(void) { + guac_client* client = guac_mem_zalloc(sizeof(guac_client)); + if (!client) + return NULL; + + guac_rdp_client* rdp_client = guac_mem_zalloc(sizeof(guac_rdp_client)); + if (!rdp_client) { + guac_mem_free(client); + return NULL; + } + + client->data = rdp_client; + client->log_level = GUAC_LOG_DEBUG; + + if (guac_rwlock_init(&rdp_client->lock) != 0) { + guac_mem_free(rdp_client); + guac_mem_free(client); + return NULL; + } + + rdp_client->rdpecam_device_caps = guac_mem_zalloc( + sizeof(guac_rdp_rdpecam_device_caps) * GUAC_RDP_RDPECAM_MAX_DEVICES); + if (!rdp_client->rdpecam_device_caps) { + guac_rwlock_destroy(&rdp_client->lock); + guac_mem_free(rdp_client); + guac_mem_free(client); + return NULL; + } + + return client; +} + +/** + * Frees a mock guac_client created by create_mock_client_with_rdp(). + */ +static void free_mock_client_with_rdp(guac_client* client) { + if (!client) + return; + + guac_rdp_client* rdp_client = (guac_rdp_client*) client->data; + if (!rdp_client) + return; + + /* Free device capabilities */ + for (unsigned int i = 0; i < rdp_client->rdpecam_device_caps_count; i++) { + guac_rdp_rdpecam_device_caps* caps = &rdp_client->rdpecam_device_caps[i]; + if (caps->device_id) + guac_mem_free(caps->device_id); + if (caps->device_name) + guac_mem_free(caps->device_name); + } + + if (rdp_client->rdpecam_device_caps) + guac_mem_free(rdp_client->rdpecam_device_caps); + + guac_rwlock_destroy(&rdp_client->lock); + guac_mem_free(rdp_client); + guac_mem_free(client); +} + +/** + * Creates a minimal mock guac_user for testing. + */ +static guac_user* create_mock_user(guac_client* client) { + guac_user* user = guac_mem_zalloc(sizeof(guac_user)); + if (user) { + user->client = client; + } + return user; +} + +/** + * Frees a mock guac_user created by create_mock_user(). + */ +static void free_mock_user(guac_user* user) { + if (user) + guac_mem_free(user); +} + +/** + * Test which verifies that sanitize_device_name handles valid names. + */ +void test_rdpecam_caps__sanitize_valid_name(void) { + char sanitized[256]; + size_t result = guac_rdp_rdpecam_sanitize_device_name("My Camera", sanitized, sizeof(sanitized)); + CU_ASSERT_EQUAL(result, strlen("My Camera")); + CU_ASSERT_NSTRING_EQUAL(sanitized, "My Camera", result); +} + +/** + * Test which verifies that sanitize_device_name replaces invalid characters. + */ +void test_rdpecam_caps__sanitize_invalid_chars(void) { + char sanitized[256]; + size_t result = guac_rdp_rdpecam_sanitize_device_name("Camera/Name\\Test:Device*", sanitized, sizeof(sanitized)); + CU_ASSERT(result > 0); + CU_ASSERT(strchr(sanitized, '/') == NULL); + CU_ASSERT(strchr(sanitized, '\\') == NULL); + CU_ASSERT(strchr(sanitized, ':') == NULL); + CU_ASSERT(strchr(sanitized, '*') == NULL); +} + +/** + * Test which verifies that sanitize_device_name handles NULL input. + */ +void test_rdpecam_caps__sanitize_null_name(void) { + char sanitized[256]; + size_t result = guac_rdp_rdpecam_sanitize_device_name(NULL, sanitized, sizeof(sanitized)); + CU_ASSERT_EQUAL(result, 0); +} + +/** + * Test which verifies that sanitize_device_name handles NULL output buffer. + */ +void test_rdpecam_caps__sanitize_null_buffer(void) { + size_t result = guac_rdp_rdpecam_sanitize_device_name("Camera", NULL, 256); + CU_ASSERT_EQUAL(result, 0); +} + +/** + * Test which verifies that sanitize_device_name truncates to 255 characters. + */ +void test_rdpecam_caps__sanitize_truncate(void) { + char long_name[300]; + memset(long_name, 'A', 299); + long_name[299] = '\0'; + + char sanitized[256]; + size_t result = guac_rdp_rdpecam_sanitize_device_name(long_name, sanitized, sizeof(sanitized)); + CU_ASSERT_EQUAL(result, 255); + CU_ASSERT_EQUAL(strlen(sanitized), 255); +} + +/** + * Test which verifies that sanitize_device_name handles zero-length buffer. + */ +void test_rdpecam_caps__sanitize_zero_buffer(void) { + char sanitized[256]; + size_t result = guac_rdp_rdpecam_sanitize_device_name("Camera", sanitized, 0); + CU_ASSERT_EQUAL(result, 0); +} + +/** + * Test which verifies that capabilities_callback parses a single device correctly. + */ +void test_rdpecam_caps__capabilities_single_device(void) { + guac_client* client = create_mock_client_with_rdp(); + CU_ASSERT_PTR_NOT_NULL_FATAL(client); + + guac_rdp_client* rdp_client = (guac_rdp_client*) client->data; + guac_user* user = create_mock_user(client); + CU_ASSERT_PTR_NOT_NULL_FATAL(user); + + const char* capabilities = "device123:My Camera|640x480@30/1,1280x720@30/1"; + int result = guac_rdp_rdpecam_capabilities_callback(user, NULL, + GUAC_RDPECAM_ARG_CAPABILITIES, capabilities, NULL); + + CU_ASSERT_EQUAL(result, 0); + CU_ASSERT_EQUAL(rdp_client->rdpecam_device_caps_count, 1); + CU_ASSERT_PTR_NOT_NULL(rdp_client->rdpecam_device_caps[0].device_id); + CU_ASSERT_NSTRING_EQUAL(rdp_client->rdpecam_device_caps[0].device_id, "device123", strlen("device123")); + CU_ASSERT_PTR_NOT_NULL(rdp_client->rdpecam_device_caps[0].device_name); + CU_ASSERT_NSTRING_EQUAL(rdp_client->rdpecam_device_caps[0].device_name, "My Camera", strlen("My Camera")); + CU_ASSERT_EQUAL(rdp_client->rdpecam_device_caps[0].format_count, 2); + + free_mock_user(user); + free_mock_client_with_rdp(client); +} + +/** + * Test which verifies that capabilities_callback parses multiple devices correctly. + */ +void test_rdpecam_caps__capabilities_multiple_devices(void) { + guac_client* client = create_mock_client_with_rdp(); + CU_ASSERT_PTR_NOT_NULL_FATAL(client); + + guac_rdp_client* rdp_client = (guac_rdp_client*) client->data; + guac_user* user = create_mock_user(client); + CU_ASSERT_PTR_NOT_NULL_FATAL(user); + + const char* capabilities = "device1:Camera 1|640x480@30/1;device2:Camera 2|1280x720@60/1"; + int result = guac_rdp_rdpecam_capabilities_callback(user, NULL, + GUAC_RDPECAM_ARG_CAPABILITIES, capabilities, NULL); + + CU_ASSERT_EQUAL(result, 0); + CU_ASSERT_EQUAL(rdp_client->rdpecam_device_caps_count, 2); + CU_ASSERT_PTR_NOT_NULL(rdp_client->rdpecam_device_caps[0].device_id); + CU_ASSERT_PTR_NOT_NULL(rdp_client->rdpecam_device_caps[1].device_id); + + free_mock_user(user); + free_mock_client_with_rdp(client); +} + +/** + * Test which verifies that capabilities_callback handles invalid format. + */ +void test_rdpecam_caps__capabilities_invalid_format(void) { + guac_client* client = create_mock_client_with_rdp(); + CU_ASSERT_PTR_NOT_NULL_FATAL(client); + + guac_rdp_client* rdp_client = (guac_rdp_client*) client->data; + guac_user* user = create_mock_user(client); + CU_ASSERT_PTR_NOT_NULL_FATAL(user); + + const char* capabilities = "invalid-format-without-semicolon"; + int result = guac_rdp_rdpecam_capabilities_callback(user, NULL, + GUAC_RDPECAM_ARG_CAPABILITIES, capabilities, NULL); + + CU_ASSERT_EQUAL(result, 0); + CU_ASSERT_EQUAL(rdp_client->rdpecam_device_caps_count, 0); + + free_mock_user(user); + free_mock_client_with_rdp(client); +} + +/** + * Test which verifies that capabilities_callback handles NULL user. + */ +void test_rdpecam_caps__capabilities_null_user(void) { + int result = guac_rdp_rdpecam_capabilities_callback(NULL, NULL, + GUAC_RDPECAM_ARG_CAPABILITIES, "device1:Camera|640x480@30/1", NULL); + CU_ASSERT_EQUAL(result, 0); +} + +/** + * Test which verifies that capabilities_callback handles NULL value. + */ +void test_rdpecam_caps__capabilities_null_value(void) { + guac_client* client = create_mock_client_with_rdp(); + CU_ASSERT_PTR_NOT_NULL_FATAL(client); + + guac_user* user = create_mock_user(client); + CU_ASSERT_PTR_NOT_NULL_FATAL(user); + + int result = guac_rdp_rdpecam_capabilities_callback(user, NULL, + GUAC_RDPECAM_ARG_CAPABILITIES, NULL, NULL); + + CU_ASSERT_EQUAL(result, 0); + + free_mock_user(user); + free_mock_client_with_rdp(client); +} + +/** + * Test which verifies that capabilities_update_callback handles empty string. + */ +void test_rdpecam_caps__capabilities_update_empty(void) { + guac_client* client = create_mock_client_with_rdp(); + CU_ASSERT_PTR_NOT_NULL_FATAL(client); + + guac_rdp_client* rdp_client = (guac_rdp_client*) client->data; + guac_user* user = create_mock_user(client); + CU_ASSERT_PTR_NOT_NULL_FATAL(user); + + /* First set some capabilities */ + const char* capabilities = "device1:Camera|640x480@30/1"; + guac_rdp_rdpecam_capabilities_callback(user, NULL, + GUAC_RDPECAM_ARG_CAPABILITIES, capabilities, NULL); + CU_ASSERT_EQUAL(rdp_client->rdpecam_device_caps_count, 1); + + /* Then clear them with empty update */ + int result = guac_rdp_rdpecam_capabilities_update_callback(user, NULL, + GUAC_RDPECAM_ARG_CAPABILITIES_UPDATE, "", NULL); + + CU_ASSERT_EQUAL(result, 0); + CU_ASSERT_EQUAL(rdp_client->rdpecam_device_caps_count, 0); + + free_mock_user(user); + free_mock_client_with_rdp(client); +} + +/** + * Test which verifies that capabilities_callback handles device without colon separator. + */ +void test_rdpecam_caps__capabilities_no_colon(void) { + guac_client* client = create_mock_client_with_rdp(); + CU_ASSERT_PTR_NOT_NULL_FATAL(client); + + guac_rdp_client* rdp_client = (guac_rdp_client*) client->data; + guac_user* user = create_mock_user(client); + CU_ASSERT_PTR_NOT_NULL_FATAL(user); + + const char* capabilities = "device1|640x480@30/1;device2:Camera|1280x720@30/1"; + int result = guac_rdp_rdpecam_capabilities_callback(user, NULL, + GUAC_RDPECAM_ARG_CAPABILITIES, capabilities, NULL); + + CU_ASSERT_EQUAL(result, 0); + /* First device should be skipped, second should be parsed */ + CU_ASSERT_EQUAL(rdp_client->rdpecam_device_caps_count, 1); + + free_mock_user(user); + free_mock_client_with_rdp(client); +} + +/** + * Test which verifies that capabilities_callback handles device without formats. + */ +void test_rdpecam_caps__capabilities_no_formats(void) { + guac_client* client = create_mock_client_with_rdp(); + CU_ASSERT_PTR_NOT_NULL_FATAL(client); + + guac_rdp_client* rdp_client = (guac_rdp_client*) client->data; + guac_user* user = create_mock_user(client); + CU_ASSERT_PTR_NOT_NULL_FATAL(user); + + const char* capabilities = "device1:Camera|;device2:Camera 2|640x480@30/1"; + int result = guac_rdp_rdpecam_capabilities_callback(user, NULL, + GUAC_RDPECAM_ARG_CAPABILITIES, capabilities, NULL); + + CU_ASSERT_EQUAL(result, 0); + /* First device should be skipped (no formats), second should be parsed */ + CU_ASSERT_EQUAL(rdp_client->rdpecam_device_caps_count, 1); + + free_mock_user(user); + free_mock_client_with_rdp(client); +} + diff --git a/src/protocols/rdp/tests/rdpecam/rdpecam_proto_test.c b/src/protocols/rdp/tests/rdpecam/rdpecam_proto_test.c new file mode 100644 index 0000000000..28b15532fc --- /dev/null +++ b/src/protocols/rdp/tests/rdpecam/rdpecam_proto_test.c @@ -0,0 +1,482 @@ +/* + * 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. + */ + +#include "plugins/guacrdpecam/rdpecam_proto.h" + +#include +#include +#include + +#include +#include +#include + +/** + * Creates a minimal mock guac_client for testing. + */ +static guac_client* create_mock_client(void) { + guac_client* client = guac_mem_zalloc(sizeof(guac_client)); + if (client) { + client->log_level = GUAC_LOG_DEBUG; + } + return client; +} + +/** + * Frees a mock guac_client created by create_mock_client(). + */ +static void free_mock_client(guac_client* client) { + if (client) + guac_mem_free(client); +} + +/** + * Test which verifies that build_version_request creates a valid message. + */ +void test_rdpecam_proto__build_version_request(void) { + wStream* s = Stream_New(NULL, 1024); + CU_ASSERT_PTR_NOT_NULL_FATAL(s); + + BOOL result = rdpecam_build_version_request(s); + CU_ASSERT_TRUE(result); + + Stream_Seek(s, 0); + uint8_t version = Stream_Read_UINT8(s); + uint8_t msg_id = Stream_Read_UINT8(s); + + CU_ASSERT_EQUAL(version, RDPECAM_PROTO_VERSION); + CU_ASSERT_EQUAL(msg_id, RDPECAM_MSG_SELECT_VERSION_REQUEST); + + Stream_Free(s, TRUE); +} + +/** + * Test which verifies that build_version_request with NULL stream fails. + */ +void test_rdpecam_proto__build_version_request_null(void) { + BOOL result = rdpecam_build_version_request(NULL); + CU_ASSERT_FALSE(result); +} + +/** + * Test which verifies that build_version_response creates a valid message. + */ +void test_rdpecam_proto__build_version_response(void) { + wStream* s = Stream_New(NULL, 1024); + CU_ASSERT_PTR_NOT_NULL_FATAL(s); + + BOOL result = rdpecam_build_version_response(s); + CU_ASSERT_TRUE(result); + + Stream_Seek(s, 0); + uint8_t version = Stream_Read_UINT8(s); + uint8_t msg_id = Stream_Read_UINT8(s); + + CU_ASSERT_EQUAL(version, RDPECAM_PROTO_VERSION); + CU_ASSERT_EQUAL(msg_id, RDPECAM_MSG_SELECT_VERSION_RESPONSE); + + Stream_Free(s, TRUE); +} + +/** + * Test which verifies that build_success_response creates a valid message. + */ +void test_rdpecam_proto__build_success_response(void) { + wStream* s = Stream_New(NULL, 1024); + CU_ASSERT_PTR_NOT_NULL_FATAL(s); + + BOOL result = rdpecam_build_success_response(s); + CU_ASSERT_TRUE(result); + + Stream_Seek(s, 0); + uint8_t version = Stream_Read_UINT8(s); + uint8_t msg_id = Stream_Read_UINT8(s); + + CU_ASSERT_EQUAL(version, RDPECAM_PROTO_VERSION); + CU_ASSERT_EQUAL(msg_id, RDPECAM_MSG_SUCCESS_RESPONSE); + + Stream_Free(s, TRUE); +} + +/** + * Test which verifies that build_device_added creates a valid message. + */ +void test_rdpecam_proto__build_device_added(void) { + wStream* s = Stream_New(NULL, 1024); + CU_ASSERT_PTR_NOT_NULL_FATAL(s); + + const char* device_name = "Test Camera"; + const char* channel_name = "CAMERA#0"; + + BOOL result = rdpecam_build_device_added(s, device_name, channel_name); + CU_ASSERT_TRUE(result); + + Stream_Seek(s, 0); + uint8_t version = Stream_Read_UINT8(s); + uint8_t msg_id = Stream_Read_UINT8(s); + CU_ASSERT_EQUAL(version, RDPECAM_PROTO_VERSION); + CU_ASSERT_EQUAL(msg_id, RDPECAM_MSG_DEVICE_ADDED_NOTIFICATION); + + /* Verify device name (UTF-16LE) */ + size_t name_len = strlen(device_name); + for (size_t i = 0; i < name_len; i++) { + uint16_t ch = Stream_Read_UINT16(s); + CU_ASSERT_EQUAL(ch, (uint16_t)(unsigned char)device_name[i]); + } + uint16_t nul = Stream_Read_UINT16(s); + CU_ASSERT_EQUAL(nul, 0); + + /* Verify channel name (ASCII) */ + char read_channel[256]; + Stream_Read(s, read_channel, strlen(channel_name) + 1); + CU_ASSERT_NSTRING_EQUAL(read_channel, channel_name, strlen(channel_name) + 1); + + Stream_Free(s, TRUE); +} + +/** + * Test which verifies that build_device_added with NULL parameters fails. + */ +void test_rdpecam_proto__build_device_added_null(void) { + wStream* s = Stream_New(NULL, 1024); + CU_ASSERT_PTR_NOT_NULL_FATAL(s); + + BOOL result = rdpecam_build_device_added(NULL, "device", "channel"); + CU_ASSERT_FALSE(result); + + result = rdpecam_build_device_added(s, NULL, "channel"); + CU_ASSERT_FALSE(result); + + result = rdpecam_build_device_added(s, "device", NULL); + CU_ASSERT_FALSE(result); + + Stream_Free(s, TRUE); +} + +/** + * Test which verifies that build_device_removed creates a valid message. + */ +void test_rdpecam_proto__build_device_removed(void) { + wStream* s = Stream_New(NULL, 1024); + CU_ASSERT_PTR_NOT_NULL_FATAL(s); + + const char* channel_name = "CAMERA#0"; + + BOOL result = rdpecam_build_device_removed(s, channel_name); + CU_ASSERT_TRUE(result); + + Stream_Seek(s, 0); + uint8_t version = Stream_Read_UINT8(s); + uint8_t msg_id = Stream_Read_UINT8(s); + CU_ASSERT_EQUAL(version, RDPECAM_PROTO_VERSION); + CU_ASSERT_EQUAL(msg_id, RDPECAM_MSG_DEVICE_REMOVED_NOTIFICATION); + + char read_channel[256]; + Stream_Read(s, read_channel, strlen(channel_name) + 1); + CU_ASSERT_NSTRING_EQUAL(read_channel, channel_name, strlen(channel_name) + 1); + + Stream_Free(s, TRUE); +} + +/** + * Test which verifies that build_device_removed with NULL parameters fails. + */ +void test_rdpecam_proto__build_device_removed_null(void) { + wStream* s = Stream_New(NULL, 1024); + CU_ASSERT_PTR_NOT_NULL_FATAL(s); + + BOOL result = rdpecam_build_device_removed(NULL, "channel"); + CU_ASSERT_FALSE(result); + + result = rdpecam_build_device_removed(s, NULL); + CU_ASSERT_FALSE(result); + + Stream_Free(s, TRUE); +} + +/** + * Test which verifies that build_stream_list creates a valid message. + */ +void test_rdpecam_proto__build_stream_list(void) { + wStream* s = Stream_New(NULL, 1024); + CU_ASSERT_PTR_NOT_NULL_FATAL(s); + + rdpecam_stream_desc streams[2] = { + { CAM_STREAM_FRAME_SOURCE_TYPE_Color, CAM_STREAM_CATEGORY_Capture, 1, 0 }, + { CAM_STREAM_FRAME_SOURCE_TYPE_Color, CAM_STREAM_CATEGORY_Capture, 0, 1 } + }; + + BOOL result = rdpecam_build_stream_list(s, streams, 2); + CU_ASSERT_TRUE(result); + + Stream_Seek(s, 0); + uint8_t version = Stream_Read_UINT8(s); + uint8_t msg_id = Stream_Read_UINT8(s); + CU_ASSERT_EQUAL(version, RDPECAM_PROTO_VERSION); + CU_ASSERT_EQUAL(msg_id, RDPECAM_MSG_STREAM_LIST_RESPONSE); + + for (int i = 0; i < 2; i++) { + uint16_t frame_source = Stream_Read_UINT16(s); + uint8_t category = Stream_Read_UINT8(s); + uint8_t selected = Stream_Read_UINT8(s); + uint8_t can_be_shared = Stream_Read_UINT8(s); + + CU_ASSERT_EQUAL(frame_source, streams[i].FrameSourceType); + CU_ASSERT_EQUAL(category, streams[i].Category); + CU_ASSERT_EQUAL(selected, streams[i].Selected); + CU_ASSERT_EQUAL(can_be_shared, streams[i].CanBeShared); + } + + Stream_Free(s, TRUE); +} + +/** + * Test which verifies that build_stream_list with NULL parameters fails. + */ +void test_rdpecam_proto__build_stream_list_null(void) { + wStream* s = Stream_New(NULL, 1024); + CU_ASSERT_PTR_NOT_NULL_FATAL(s); + + rdpecam_stream_desc streams[1] = {{0}}; + + BOOL result = rdpecam_build_stream_list(NULL, streams, 1); + CU_ASSERT_FALSE(result); + + result = rdpecam_build_stream_list(s, NULL, 1); + CU_ASSERT_FALSE(result); + + Stream_Free(s, TRUE); +} + +/** + * Test which verifies that build_media_type_list creates a valid message. + */ +void test_rdpecam_proto__build_media_type_list(void) { + wStream* s = Stream_New(NULL, 1024); + CU_ASSERT_PTR_NOT_NULL_FATAL(s); + + rdpecam_media_type_desc media_types[2] = { + { CAM_MEDIA_FORMAT_H264, 640, 480, 30, 1, 1, 1, 0 }, + { CAM_MEDIA_FORMAT_H264, 1280, 720, 60, 1, 1, 1, 0 } + }; + + BOOL result = rdpecam_build_media_type_list(s, media_types, 2); + CU_ASSERT_TRUE(result); + + Stream_Seek(s, 0); + uint8_t version = Stream_Read_UINT8(s); + uint8_t msg_id = Stream_Read_UINT8(s); + CU_ASSERT_EQUAL(version, RDPECAM_PROTO_VERSION); + CU_ASSERT_EQUAL(msg_id, RDPECAM_MSG_MEDIA_TYPE_LIST_RESPONSE); + + for (int i = 0; i < 2; i++) { + uint8_t format = Stream_Read_UINT8(s); + uint32_t width = Stream_Read_UINT32(s); + uint32_t height = Stream_Read_UINT32(s); + uint32_t fps_num = Stream_Read_UINT32(s); + uint32_t fps_den = Stream_Read_UINT32(s); + uint32_t par_num = Stream_Read_UINT32(s); + uint32_t par_den = Stream_Read_UINT32(s); + uint8_t flags = Stream_Read_UINT8(s); + + CU_ASSERT_EQUAL(format, media_types[i].Format); + CU_ASSERT_EQUAL(width, media_types[i].Width); + CU_ASSERT_EQUAL(height, media_types[i].Height); + CU_ASSERT_EQUAL(fps_num, media_types[i].FrameRateNumerator); + CU_ASSERT_EQUAL(fps_den, media_types[i].FrameRateDenominator); + CU_ASSERT_EQUAL(par_num, media_types[i].PixelAspectRatioNumerator); + CU_ASSERT_EQUAL(par_den, media_types[i].PixelAspectRatioDenominator); + CU_ASSERT_EQUAL(flags, media_types[i].Flags); + } + + Stream_Free(s, TRUE); +} + +/** + * Test which verifies that build_current_media_type creates a valid message. + */ +void test_rdpecam_proto__build_current_media_type(void) { + wStream* s = Stream_New(NULL, 1024); + CU_ASSERT_PTR_NOT_NULL_FATAL(s); + + rdpecam_media_type_desc media_type = { + CAM_MEDIA_FORMAT_H264, 1920, 1080, 30, 1, 1, 1, 0 + }; + + BOOL result = rdpecam_build_current_media_type(s, &media_type); + CU_ASSERT_TRUE(result); + + Stream_Seek(s, 0); + uint8_t version = Stream_Read_UINT8(s); + uint8_t msg_id = Stream_Read_UINT8(s); + CU_ASSERT_EQUAL(version, RDPECAM_PROTO_VERSION); + CU_ASSERT_EQUAL(msg_id, RDPECAM_MSG_CURRENT_MEDIA_TYPE_RESPONSE); + + uint8_t format = Stream_Read_UINT8(s); + uint32_t width = Stream_Read_UINT32(s); + uint32_t height = Stream_Read_UINT32(s); + uint32_t fps_num = Stream_Read_UINT32(s); + uint32_t fps_den = Stream_Read_UINT32(s); + uint32_t par_num = Stream_Read_UINT32(s); + uint32_t par_den = Stream_Read_UINT32(s); + uint8_t flags = Stream_Read_UINT8(s); + + CU_ASSERT_EQUAL(format, media_type.Format); + CU_ASSERT_EQUAL(width, media_type.Width); + CU_ASSERT_EQUAL(height, media_type.Height); + CU_ASSERT_EQUAL(fps_num, media_type.FrameRateNumerator); + CU_ASSERT_EQUAL(fps_den, media_type.FrameRateDenominator); + CU_ASSERT_EQUAL(par_num, media_type.PixelAspectRatioNumerator); + CU_ASSERT_EQUAL(par_den, media_type.PixelAspectRatioDenominator); + CU_ASSERT_EQUAL(flags, media_type.Flags); + + Stream_Free(s, TRUE); +} + +/** + * Test which verifies that parse_sample_credits parses correctly. + */ +void test_rdpecam_proto__parse_sample_credits(void) { + uint8_t payload[4] = { 0x34, 0x12, 0x00, 0x00 }; /* Little-endian 0x1234 */ + uint32_t credits = 0; + + BOOL result = rdpecam_parse_sample_credits(payload, sizeof(payload), &credits); + CU_ASSERT_TRUE(result); + CU_ASSERT_EQUAL(credits, 0x1234); +} + +/** + * Test which verifies that parse_sample_credits with NULL parameters fails. + */ +void test_rdpecam_proto__parse_sample_credits_null(void) { + uint8_t payload[4] = {0}; + + BOOL result = rdpecam_parse_sample_credits(NULL, 4, NULL); + CU_ASSERT_FALSE(result); + + uint32_t credits = 0; + result = rdpecam_parse_sample_credits(payload, 3, &credits); /* Too small */ + CU_ASSERT_FALSE(result); +} + +/** + * Test which verifies that parse_start_streams parses correctly. + */ +void test_rdpecam_proto__parse_start_streams(void) { + uint8_t payload[27]; + uint8_t* p = payload; + + *p++ = 0; /* stream index */ + *p++ = CAM_MEDIA_FORMAT_H264; /* Format */ + + /* Width = 640 (little-endian) */ + *p++ = 0x80; *p++ = 0x02; *p++ = 0x00; *p++ = 0x00; + /* Height = 480 (little-endian) */ + *p++ = 0xE0; *p++ = 0x01; *p++ = 0x00; *p++ = 0x00; + /* FPS numerator = 30 */ + *p++ = 0x1E; *p++ = 0x00; *p++ = 0x00; *p++ = 0x00; + /* FPS denominator = 1 */ + *p++ = 0x01; *p++ = 0x00; *p++ = 0x00; *p++ = 0x00; + /* PAR numerator = 1 */ + *p++ = 0x01; *p++ = 0x00; *p++ = 0x00; *p++ = 0x00; + /* PAR denominator = 1 */ + *p++ = 0x01; *p++ = 0x00; *p++ = 0x00; *p++ = 0x00; + *p++ = 0; /* Flags */ + + uint8_t stream_index = 0; + rdpecam_media_type_desc media_type = {0}; + + BOOL result = rdpecam_parse_start_streams(payload, sizeof(payload), + &stream_index, &media_type); + CU_ASSERT_TRUE(result); + CU_ASSERT_EQUAL(stream_index, 0); + CU_ASSERT_EQUAL(media_type.Format, CAM_MEDIA_FORMAT_H264); + CU_ASSERT_EQUAL(media_type.Width, 640); + CU_ASSERT_EQUAL(media_type.Height, 480); + CU_ASSERT_EQUAL(media_type.FrameRateNumerator, 30); + CU_ASSERT_EQUAL(media_type.FrameRateDenominator, 1); +} + +/** + * Test which verifies that parse_start_streams with invalid parameters fails. + */ +void test_rdpecam_proto__parse_start_streams_invalid(void) { + uint8_t payload[27] = {0}; + uint8_t stream_index = 0; + rdpecam_media_type_desc media_type = {0}; + + BOOL result = rdpecam_parse_start_streams(NULL, 27, &stream_index, &media_type); + CU_ASSERT_FALSE(result); + + result = rdpecam_parse_start_streams(payload, 26, &stream_index, &media_type); /* Too small */ + CU_ASSERT_FALSE(result); +} + +/** + * Test which verifies that parse_sample_request parses correctly. + */ +void test_rdpecam_proto__parse_sample_request(void) { + uint8_t payload[1] = { 5 }; /* stream index */ + uint8_t stream_index = 0; + + BOOL result = rdpecam_parse_sample_request(payload, sizeof(payload), &stream_index); + CU_ASSERT_TRUE(result); + CU_ASSERT_EQUAL(stream_index, 5); +} + +/** + * Test which verifies that parse_stop_streams succeeds. + */ +void test_rdpecam_proto__parse_stop_streams(void) { + uint8_t payload[0] = {}; + + BOOL result = rdpecam_parse_stop_streams(payload, 0); + CU_ASSERT_TRUE(result); +} + +/** + * Test which verifies that write_sample_response_header creates a valid message. + */ +void test_rdpecam_proto__write_sample_response_header(void) { + wStream* s = Stream_New(NULL, 1024); + CU_ASSERT_PTR_NOT_NULL_FATAL(s); + + BOOL result = rdpecam_write_sample_response_header(s, 0, 1, 100, 1000000); + CU_ASSERT_TRUE(result); + + Stream_Seek(s, 0); + uint8_t version = Stream_Read_UINT8(s); + uint8_t msg_id = Stream_Read_UINT8(s); + uint8_t stream_id = Stream_Read_UINT8(s); + + CU_ASSERT_EQUAL(version, RDPECAM_PROTO_VERSION); + CU_ASSERT_EQUAL(msg_id, RDPECAM_MSG_SAMPLE_RESPONSE); + CU_ASSERT_EQUAL(stream_id, 0); + + Stream_Free(s, TRUE); +} + +/** + * Test which verifies that write_sample_response_header with NULL stream fails. + */ +void test_rdpecam_proto__write_sample_response_header_null(void) { + BOOL result = rdpecam_write_sample_response_header(NULL, 0, 1, 100, 1000000); + CU_ASSERT_FALSE(result); +} + diff --git a/src/protocols/rdp/tests/rdpecam/rdpecam_sink_test.c b/src/protocols/rdp/tests/rdpecam/rdpecam_sink_test.c new file mode 100644 index 0000000000..ab5953e098 --- /dev/null +++ b/src/protocols/rdp/tests/rdpecam/rdpecam_sink_test.c @@ -0,0 +1,520 @@ +/* + * 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. + */ + +#include "channels/rdpecam/rdpecam_sink.h" + +#include +#include + +#include +#include +#include + +/** + * Creates a minimal mock guac_client for testing. + */ +static guac_client* create_mock_client(void) { + guac_client* client = guac_mem_zalloc(sizeof(guac_client)); + if (client) { + client->log_level = GUAC_LOG_DEBUG; + } + return client; +} + +/** + * Frees a mock guac_client created by create_mock_client(). + */ +static void free_mock_client(guac_client* client) { + if (client) + guac_mem_free(client); +} + +/** + * Creates a valid RDPECAM frame header with the given parameters. + */ +static void create_frame_header(guac_rdpecam_frame_header* header, + uint32_t payload_len, uint32_t pts_ms, bool keyframe) { + header->version = 1; + header->flags = keyframe ? 0x01 : 0x00; + header->reserved = 0; + header->pts_ms = pts_ms; + header->payload_len = payload_len; +} + +/** + * Test which verifies that a sink can be created and destroyed. + */ +void test_rdpecam_sink__create_destroy(void) { + guac_client* client = create_mock_client(); + CU_ASSERT_PTR_NOT_NULL_FATAL(client); + + guac_rdpecam_sink* sink = guac_rdpecam_create(client); + CU_ASSERT_PTR_NOT_NULL(sink); + CU_ASSERT_EQUAL(guac_rdpecam_get_queue_size(sink), 0); + + guac_rdpecam_destroy(sink); + free_mock_client(client); +} + +/** + * Test which verifies that creating a sink with NULL client returns NULL. + */ +void test_rdpecam_sink__create_null_client(void) { + guac_rdpecam_sink* sink = guac_rdpecam_create(NULL); + CU_ASSERT_PTR_NULL(sink); +} + +/** + * Test which verifies that destroying a NULL sink is safe. + */ +void test_rdpecam_sink__destroy_null(void) { + guac_rdpecam_destroy(NULL); +} + +/** + * Test which verifies that pushing a valid frame succeeds. + */ +void test_rdpecam_sink__push_valid_frame(void) { + guac_client* client = create_mock_client(); + CU_ASSERT_PTR_NOT_NULL_FATAL(client); + + guac_rdpecam_sink* sink = guac_rdpecam_create(client); + CU_ASSERT_PTR_NOT_NULL_FATAL(sink); + + uint8_t payload[100] = {0}; + guac_rdpecam_frame_header header; + create_frame_header(&header, sizeof(payload), 1000, false); + + uint8_t frame_data[sizeof(header) + sizeof(payload)]; + memcpy(frame_data, &header, sizeof(header)); + memcpy(frame_data + sizeof(header), payload, sizeof(payload)); + + bool result = guac_rdpecam_push(sink, frame_data, sizeof(frame_data)); + CU_ASSERT_TRUE(result); + CU_ASSERT_EQUAL(guac_rdpecam_get_queue_size(sink), 1); + + guac_rdpecam_destroy(sink); + free_mock_client(client); +} + +/** + * Test which verifies that pushing a keyframe succeeds. + */ +void test_rdpecam_sink__push_keyframe(void) { + guac_client* client = create_mock_client(); + CU_ASSERT_PTR_NOT_NULL_FATAL(client); + + guac_rdpecam_sink* sink = guac_rdpecam_create(client); + CU_ASSERT_PTR_NOT_NULL_FATAL(sink); + + uint8_t payload[200] = {0}; + guac_rdpecam_frame_header header; + create_frame_header(&header, sizeof(payload), 2000, true); + + uint8_t frame_data[sizeof(header) + sizeof(payload)]; + memcpy(frame_data, &header, sizeof(header)); + memcpy(frame_data + sizeof(header), payload, sizeof(payload)); + + bool result = guac_rdpecam_push(sink, frame_data, sizeof(frame_data)); + CU_ASSERT_TRUE(result); + CU_ASSERT_EQUAL(guac_rdpecam_get_queue_size(sink), 1); + + guac_rdpecam_destroy(sink); + free_mock_client(client); +} + +/** + * Test which verifies that pushing with NULL sink fails. + */ +void test_rdpecam_sink__push_null_sink(void) { + uint8_t data[100] = {0}; + bool result = guac_rdpecam_push(NULL, data, sizeof(data)); + CU_ASSERT_FALSE(result); +} + +/** + * Test which verifies that pushing with NULL data fails. + */ +void test_rdpecam_sink__push_null_data(void) { + guac_client* client = create_mock_client(); + CU_ASSERT_PTR_NOT_NULL_FATAL(client); + + guac_rdpecam_sink* sink = guac_rdpecam_create(client); + CU_ASSERT_PTR_NOT_NULL_FATAL(sink); + + bool result = guac_rdpecam_push(sink, NULL, 100); + CU_ASSERT_FALSE(result); + + guac_rdpecam_destroy(sink); + free_mock_client(client); +} + +/** + * Test which verifies that pushing with zero length fails. + */ +void test_rdpecam_sink__push_zero_length(void) { + guac_client* client = create_mock_client(); + CU_ASSERT_PTR_NOT_NULL_FATAL(client); + + guac_rdpecam_sink* sink = guac_rdpecam_create(client); + CU_ASSERT_PTR_NOT_NULL_FATAL(sink); + + uint8_t data[100] = {0}; + bool result = guac_rdpecam_push(sink, data, 0); + CU_ASSERT_FALSE(result); + + guac_rdpecam_destroy(sink); + free_mock_client(client); +} + +/** + * Test which verifies that pushing a frame that's too small fails. + */ +void test_rdpecam_sink__push_too_small(void) { + guac_client* client = create_mock_client(); + CU_ASSERT_PTR_NOT_NULL_FATAL(client); + + guac_rdpecam_sink* sink = guac_rdpecam_create(client); + CU_ASSERT_PTR_NOT_NULL_FATAL(sink); + + uint8_t data[1] = {0}; + bool result = guac_rdpecam_push(sink, data, sizeof(data)); + CU_ASSERT_FALSE(result); + + guac_rdpecam_destroy(sink); + free_mock_client(client); +} + +/** + * Test which verifies that pushing a frame with invalid version fails. + */ +void test_rdpecam_sink__push_invalid_version(void) { + guac_client* client = create_mock_client(); + CU_ASSERT_PTR_NOT_NULL_FATAL(client); + + guac_rdpecam_sink* sink = guac_rdpecam_create(client); + CU_ASSERT_PTR_NOT_NULL_FATAL(sink); + + uint8_t payload[100] = {0}; + guac_rdpecam_frame_header header; + create_frame_header(&header, sizeof(payload), 1000, false); + header.version = 2; /* Invalid version */ + + uint8_t frame_data[sizeof(header) + sizeof(payload)]; + memcpy(frame_data, &header, sizeof(header)); + memcpy(frame_data + sizeof(header), payload, sizeof(payload)); + + bool result = guac_rdpecam_push(sink, frame_data, sizeof(frame_data)); + CU_ASSERT_FALSE(result); + + guac_rdpecam_destroy(sink); + free_mock_client(client); +} + +/** + * Test which verifies that pushing a frame with payload too large fails. + */ +void test_rdpecam_sink__push_payload_too_large(void) { + guac_client* client = create_mock_client(); + CU_ASSERT_PTR_NOT_NULL_FATAL(client); + + guac_rdpecam_sink* sink = guac_rdpecam_create(client); + CU_ASSERT_PTR_NOT_NULL_FATAL(sink); + + uint32_t large_payload_len = GUAC_RDPECAM_MAX_FRAME_SIZE + 1; + guac_rdpecam_frame_header header; + create_frame_header(&header, large_payload_len, 1000, false); + + uint8_t frame_data[sizeof(header) + 100]; + memcpy(frame_data, &header, sizeof(header)); + + bool result = guac_rdpecam_push(sink, frame_data, sizeof(frame_data)); + CU_ASSERT_FALSE(result); + + guac_rdpecam_destroy(sink); + free_mock_client(client); +} + +/** + * Test which verifies that pushing frames up to the maximum queue size succeeds. + */ +void test_rdpecam_sink__push_max_frames(void) { + guac_client* client = create_mock_client(); + CU_ASSERT_PTR_NOT_NULL_FATAL(client); + + guac_rdpecam_sink* sink = guac_rdpecam_create(client); + CU_ASSERT_PTR_NOT_NULL_FATAL(sink); + + uint8_t payload[100] = {0}; + guac_rdpecam_frame_header header; + create_frame_header(&header, sizeof(payload), 1000, false); + + uint8_t frame_data[sizeof(header) + sizeof(payload)]; + memcpy(frame_data, &header, sizeof(header)); + memcpy(frame_data + sizeof(header), payload, sizeof(payload)); + + /* Push up to maximum */ + for (int i = 0; i < GUAC_RDPECAM_MAX_FRAMES; i++) { + header.pts_ms = 1000 + i; + memcpy(frame_data, &header, sizeof(header)); + bool result = guac_rdpecam_push(sink, frame_data, sizeof(frame_data)); + CU_ASSERT_TRUE(result); + } + + CU_ASSERT_EQUAL(guac_rdpecam_get_queue_size(sink), GUAC_RDPECAM_MAX_FRAMES); + + /* Next push should fail */ + bool result = guac_rdpecam_push(sink, frame_data, sizeof(frame_data)); + CU_ASSERT_FALSE(result); + CU_ASSERT_EQUAL(guac_rdpecam_get_queue_size(sink), GUAC_RDPECAM_MAX_FRAMES); + + guac_rdpecam_destroy(sink); + free_mock_client(client); +} + +/** + * Test which verifies that popping from an empty sink fails. + */ +void test_rdpecam_sink__pop_empty(void) { + guac_client* client = create_mock_client(); + CU_ASSERT_PTR_NOT_NULL_FATAL(client); + + guac_rdpecam_sink* sink = guac_rdpecam_create(client); + CU_ASSERT_PTR_NOT_NULL_FATAL(sink); + + uint8_t* out_buf = NULL; + size_t out_len = 0; + bool out_keyframe = false; + uint32_t out_pts_ms = 0; + + bool result = guac_rdpecam_pop(sink, &out_buf, &out_len, &out_keyframe, &out_pts_ms); + CU_ASSERT_FALSE(result); + + guac_rdpecam_destroy(sink); + free_mock_client(client); +} + +/** + * Test which verifies that popping with NULL parameters fails. + */ +void test_rdpecam_sink__pop_null_params(void) { + guac_client* client = create_mock_client(); + CU_ASSERT_PTR_NOT_NULL_FATAL(client); + + guac_rdpecam_sink* sink = guac_rdpecam_create(client); + CU_ASSERT_PTR_NOT_NULL_FATAL(sink); + + bool result = guac_rdpecam_pop(sink, NULL, NULL, NULL, NULL); + CU_ASSERT_FALSE(result); + + guac_rdpecam_destroy(sink); + free_mock_client(client); +} + +/** + * Test which verifies that push and pop operations work correctly. + */ +void test_rdpecam_sink__push_pop(void) { + guac_client* client = create_mock_client(); + CU_ASSERT_PTR_NOT_NULL_FATAL(client); + + guac_rdpecam_sink* sink = guac_rdpecam_create(client); + CU_ASSERT_PTR_NOT_NULL_FATAL(sink); + + uint8_t payload[100]; + memset(payload, 0xAA, sizeof(payload)); + guac_rdpecam_frame_header header; + create_frame_header(&header, sizeof(payload), 5000, true); + + uint8_t frame_data[sizeof(header) + sizeof(payload)]; + memcpy(frame_data, &header, sizeof(header)); + memcpy(frame_data + sizeof(header), payload, sizeof(payload)); + + /* Push frame */ + bool push_result = guac_rdpecam_push(sink, frame_data, sizeof(frame_data)); + CU_ASSERT_TRUE(push_result); + CU_ASSERT_EQUAL(guac_rdpecam_get_queue_size(sink), 1); + + /* Pop frame */ + uint8_t* out_buf = NULL; + size_t out_len = 0; + bool out_keyframe = false; + uint32_t out_pts_ms = 0; + + bool pop_result = guac_rdpecam_pop(sink, &out_buf, &out_len, &out_keyframe, &out_pts_ms); + CU_ASSERT_TRUE(pop_result); + CU_ASSERT_PTR_NOT_NULL(out_buf); + CU_ASSERT_EQUAL(out_len, sizeof(payload)); + CU_ASSERT_TRUE(out_keyframe); + CU_ASSERT_EQUAL(out_pts_ms, 5000); + CU_ASSERT_EQUAL(guac_rdpecam_get_queue_size(sink), 0); + + /* Verify payload content */ + CU_ASSERT_EQUAL(memcmp(out_buf, payload, sizeof(payload)), 0); + + guac_mem_free(out_buf); + guac_rdpecam_destroy(sink); + free_mock_client(client); +} + +/** + * Test which verifies that multiple frames can be pushed and popped in order. + */ +void test_rdpecam_sink__push_pop_multiple(void) { + guac_client* client = create_mock_client(); + CU_ASSERT_PTR_NOT_NULL_FATAL(client); + + guac_rdpecam_sink* sink = guac_rdpecam_create(client); + CU_ASSERT_PTR_NOT_NULL_FATAL(sink); + + uint8_t payload[50] = {0}; + guac_rdpecam_frame_header header; + create_frame_header(&header, sizeof(payload), 0, false); + + uint8_t frame_data[sizeof(header) + sizeof(payload)]; + memcpy(frame_data, &header, sizeof(header)); + memcpy(frame_data + sizeof(header), payload, sizeof(payload)); + + /* Push multiple frames */ + for (int i = 0; i < 5; i++) { + header.pts_ms = 1000 * i; + header.flags = (i == 0) ? 0x01 : 0x00; /* First frame is keyframe */ + memcpy(frame_data, &header, sizeof(header)); + memset(payload, (uint8_t)i, sizeof(payload)); + memcpy(frame_data + sizeof(header), payload, sizeof(payload)); + + bool result = guac_rdpecam_push(sink, frame_data, sizeof(frame_data)); + CU_ASSERT_TRUE(result); + } + + CU_ASSERT_EQUAL(guac_rdpecam_get_queue_size(sink), 5); + + /* Pop frames and verify order */ + for (int i = 0; i < 5; i++) { + uint8_t* out_buf = NULL; + size_t out_len = 0; + bool out_keyframe = false; + uint32_t out_pts_ms = 0; + + bool result = guac_rdpecam_pop(sink, &out_buf, &out_len, &out_keyframe, &out_pts_ms); + CU_ASSERT_TRUE(result); + CU_ASSERT_EQUAL(out_pts_ms, (uint32_t)(1000 * i)); + CU_ASSERT_EQUAL(out_keyframe, (i == 0)); + CU_ASSERT_EQUAL(out_len, sizeof(payload)); + + guac_mem_free(out_buf); + } + + CU_ASSERT_EQUAL(guac_rdpecam_get_queue_size(sink), 0); + + guac_rdpecam_destroy(sink); + free_mock_client(client); +} + +/** + * Test which verifies that signal_stop wakes up waiting threads. + */ +void test_rdpecam_sink__signal_stop(void) { + guac_client* client = create_mock_client(); + CU_ASSERT_PTR_NOT_NULL_FATAL(client); + + guac_rdpecam_sink* sink = guac_rdpecam_create(client); + CU_ASSERT_PTR_NOT_NULL_FATAL(sink); + + guac_rdpecam_signal_stop(sink); + + /* After signal_stop, pop should fail */ + uint8_t* out_buf = NULL; + size_t out_len = 0; + bool out_keyframe = false; + uint32_t out_pts_ms = 0; + + bool result = guac_rdpecam_pop(sink, &out_buf, &out_len, &out_keyframe, &out_pts_ms); + CU_ASSERT_FALSE(result); + + guac_rdpecam_destroy(sink); + free_mock_client(client); +} + +/** + * Test which verifies that pushing to a stopped sink fails. + */ +void test_rdpecam_sink__push_after_stop(void) { + guac_client* client = create_mock_client(); + CU_ASSERT_PTR_NOT_NULL_FATAL(client); + + guac_rdpecam_sink* sink = guac_rdpecam_create(client); + CU_ASSERT_PTR_NOT_NULL_FATAL(sink); + + guac_rdpecam_signal_stop(sink); + + uint8_t payload[100] = {0}; + guac_rdpecam_frame_header header; + create_frame_header(&header, sizeof(payload), 1000, false); + + uint8_t frame_data[sizeof(header) + sizeof(payload)]; + memcpy(frame_data, &header, sizeof(header)); + memcpy(frame_data + sizeof(header), payload, sizeof(payload)); + + bool result = guac_rdpecam_push(sink, frame_data, sizeof(frame_data)); + CU_ASSERT_FALSE(result); + + guac_rdpecam_destroy(sink); + free_mock_client(client); +} + +/** + * Test which verifies that get_queue_size returns correct values. + */ +void test_rdpecam_sink__get_queue_size(void) { + guac_client* client = create_mock_client(); + CU_ASSERT_PTR_NOT_NULL_FATAL(client); + + guac_rdpecam_sink* sink = guac_rdpecam_create(client); + CU_ASSERT_PTR_NOT_NULL_FATAL(sink); + + CU_ASSERT_EQUAL(guac_rdpecam_get_queue_size(sink), 0); + + uint8_t payload[100] = {0}; + guac_rdpecam_frame_header header; + create_frame_header(&header, sizeof(payload), 1000, false); + + uint8_t frame_data[sizeof(header) + sizeof(payload)]; + memcpy(frame_data, &header, sizeof(header)); + memcpy(frame_data + sizeof(header), payload, sizeof(payload)); + + for (int i = 1; i <= 3; i++) { + bool result = guac_rdpecam_push(sink, frame_data, sizeof(frame_data)); + CU_ASSERT_TRUE(result); + CU_ASSERT_EQUAL(guac_rdpecam_get_queue_size(sink), i); + } + + guac_rdpecam_destroy(sink); + free_mock_client(client); +} + +/** + * Test which verifies that get_queue_size with NULL sink returns 0. + */ +void test_rdpecam_sink__get_queue_size_null(void) { + int size = guac_rdpecam_get_queue_size(NULL); + CU_ASSERT_EQUAL(size, 0); +} + + From a00eb82c94344743237fecd5702395147bb904cd Mon Sep 17 00:00:00 2001 From: Loic Date: Thu, 6 Nov 2025 16:02:03 -0500 Subject: [PATCH 08/12] GUACAMOLE-1415: Improve RDPECAM dequeue thread efficiency using condition variables - Replace usleep() polling with pthread_cond_wait() for better responsiveness - Add pthread_cond_broadcast() signals at all state change points: - When stream_channel is set/changed - When streaming starts/stops - When is_active_sender changes - When credits are granted - When device is stopping - Eliminates unnecessary CPU wake-ups and improves thread synchronization --- .../rdp/plugins/guacrdpecam/guacrdpecam.c | 71 +++++++++++++------ 1 file changed, 49 insertions(+), 22 deletions(-) diff --git a/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.c b/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.c index 1b460e03a9..a31e414067 100644 --- a/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.c +++ b/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.c @@ -614,39 +614,58 @@ static void* guac_rdp_rdpecam_dequeue_thread(void* arg) { while (true) { - /* Snapshot current state; the loop will sleep with the lock released. */ + /* Lock mutex to check state and wait on condition variable if needed. */ pthread_mutex_lock(&device->lock); - bool should_stop = device->stopping; - bool is_streaming = device->streaming; - bool is_active_sender = device->is_active_sender; - uint32_t available_credits = device->credits; - IWTSVirtualChannel* current_channel = device->stream_channel; - pthread_mutex_unlock(&device->lock); - if (should_stop) + /* Check if we should stop */ + if (device->stopping) { + pthread_mutex_unlock(&device->lock); break; + } - if (!current_channel) { - usleep(10000); - continue; + /* Wait for channel to be available */ + while (!device->stream_channel && !device->stopping) { + pthread_cond_wait(&device->credits_signal, &device->lock); + } + if (device->stopping) { + pthread_mutex_unlock(&device->lock); + break; } - /* Streaming proceeds only after the host sends StartStreams. */ - if (!is_streaming) { - usleep(50000); - continue; + /* Wait for streaming to start */ + while (!device->streaming && !device->stopping) { + pthread_cond_wait(&device->credits_signal, &device->lock); + } + if (device->stopping) { + pthread_mutex_unlock(&device->lock); + break; } - if (!is_active_sender) { - usleep(10000); - continue; + /* Wait for active sender role */ + while (!device->is_active_sender && !device->stopping) { + pthread_cond_wait(&device->credits_signal, &device->lock); + } + if (device->stopping) { + pthread_mutex_unlock(&device->lock); + break; } - if (available_credits == 0) { - usleep(10000); - continue; + /* Wait for credits to be available */ + while (device->credits == 0 && !device->stopping) { + pthread_cond_wait(&device->credits_signal, &device->lock); + } + if (device->stopping) { + pthread_mutex_unlock(&device->lock); + break; } + /* Snapshot state for use after unlocking */ + bool is_streaming = device->streaming; + bool is_active_sender = device->is_active_sender; + uint32_t available_credits = device->credits; + IWTSVirtualChannel* current_channel = device->stream_channel; + pthread_mutex_unlock(&device->lock); + /* We have credits; attempt to pull a frame from the shared sink. */ uint8_t* frame_data = NULL; size_t frame_length = 0; @@ -771,6 +790,7 @@ static void* guac_rdp_rdpecam_dequeue_thread(void* arg) { pthread_mutex_lock(&device->lock); device->streaming = false; device->is_active_sender = false; + pthread_cond_broadcast(&device->credits_signal); pthread_mutex_unlock(&device->lock); pthread_mutex_lock(&sink->lock); sink->streaming = false; @@ -1020,6 +1040,7 @@ static UINT guac_rdp_rdpecam_handle_data(guac_client* client, IWTSVirtualChannel device->streaming = false; device->is_active_sender = false; device->need_keyframe = true; + pthread_cond_broadcast(&device->credits_signal); pthread_mutex_unlock(&device->lock); guac_client_log(client, GUAC_LOG_DEBUG, @@ -1358,6 +1379,7 @@ static UINT guac_rdp_rdpecam_handle_data(guac_client* client, IWTSVirtualChannel old_device->credits = 0; old_device->stream_channel = NULL; old_device->stream_channel_id = 0; + pthread_cond_broadcast(&old_device->credits_signal); pthread_mutex_unlock(&old_device->lock); guac_client_log(client, GUAC_LOG_DEBUG, "RDPECAM stopped streaming on device %s for camera switch", @@ -1401,6 +1423,7 @@ static UINT guac_rdp_rdpecam_handle_data(guac_client* client, IWTSVirtualChannel device->stopping = false; device->stream_channel = channel; device->stream_channel_id = channel_id; + pthread_cond_broadcast(&device->credits_signal); pthread_mutex_unlock(&device->lock); rdpecam_channel_callback->is_stream_channel = true; @@ -1495,8 +1518,10 @@ static UINT guac_rdp_rdpecam_handle_data(guac_client* client, IWTSVirtualChannel uint32_t stream_index = 0; pthread_mutex_lock(&device->lock); - if (!device->stream_channel) + if (!device->stream_channel) { device->stream_channel = channel; + pthread_cond_broadcast(&device->credits_signal); + } rdpecam_channel_callback->is_stream_channel = true; outstanding = device->credits; stream_index = device->stream_index; @@ -1504,6 +1529,7 @@ static UINT guac_rdp_rdpecam_handle_data(guac_client* client, IWTSVirtualChannel device->streaming = false; device->is_active_sender = false; device->need_keyframe = true; + pthread_cond_broadcast(&device->credits_signal); pthread_mutex_unlock(&device->lock); guac_rdpecam_sink* sink = device->sink; @@ -1614,6 +1640,7 @@ static UINT guac_rdp_rdpecam_handle_data(guac_client* client, IWTSVirtualChannel if (device->stream_channel != channel) { device->stream_channel = channel; device->stream_channel_id = channel_id; + pthread_cond_broadcast(&device->credits_signal); } rdpecam_channel_callback->is_stream_channel = true; uint32_t before = device->credits; From 8a9dbde6e94453c4b24827e5b93c396ba2b4cf2f Mon Sep 17 00:00:00 2001 From: Loic Date: Thu, 6 Nov 2025 16:40:16 -0500 Subject: [PATCH 09/12] GUACAMOLE-1415: Fix RDPECAM device mapping cleanup --- .../rdp/plugins/guacrdpecam/guacrdpecam.c | 229 +++++++++++++++--- .../rdp/plugins/guacrdpecam/guacrdpecam.h | 8 + 2 files changed, 201 insertions(+), 36 deletions(-) diff --git a/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.c b/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.c index a31e414067..4d11bd3863 100644 --- a/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.c +++ b/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.c @@ -59,6 +59,30 @@ #define GUAC_RDPECAM_DEFAULT_FPS_NUM 30u #define GUAC_RDPECAM_DEFAULT_FPS_DEN 1u +/** + * Represents a mapping between a browser device ID and the dynamically + * assigned Windows channel name. Stored both in the lookup hash table and in + * a linked list to allow deterministic cleanup of allocated memory. + */ +typedef struct guac_rdp_rdpecam_device_mapping { + /** Pointer to the browser device ID string used as the hash key. */ + const char* device_id_key; + /** Copy of the channel name advertised to Windows. */ + char* channel_name; + /** Next entry in the linked list. */ + struct guac_rdp_rdpecam_device_mapping* next; +} guac_rdp_rdpecam_device_mapping; + +static void guac_rdp_rdpecam_mapping_clear( + guac_rdp_rdpecam_plugin* plugin); +static void guac_rdp_rdpecam_mapping_remove_by_channel( + guac_rdp_rdpecam_plugin* plugin, const char* channel_name); +static void guac_rdp_rdpecam_mapping_remove_by_device_id( + guac_rdp_rdpecam_plugin* plugin, const char* device_id); +static BOOL guac_rdp_rdpecam_mapping_add( + guac_rdp_rdpecam_plugin* plugin, const char* device_id, + const char* channel_name); + /** * Returns true if RDPECAM hexdump logging is enabled. RDPECAM traffic is always * dumped to the log (subject to the overall guacd log level filtering). @@ -309,14 +333,24 @@ void guac_rdp_rdpecam_caps_notify(guac_client* client) { guac_client_log(client, GUAC_LOG_DEBUG, "RDPECAM cleaning up device structure for channel '%s'", channel_name); - /* Stop streaming if active */ - if (device->streaming) { - device->stopping = true; - device->streaming = false; - } + pthread_mutex_lock(&device->lock); + device->stopping = true; + device->streaming = false; + pthread_cond_broadcast(&device->credits_signal); + pthread_mutex_unlock(&device->lock); /* Remove from devices hash table */ HashTable_Remove(plugin->devices, channel_name); + + /* Remove associated browser mapping */ + guac_rdp_rdpecam_mapping_remove_by_channel(plugin, channel_name); + + /* Destroy device resources (threads, sinks, etc.) */ + guac_rdpecam_device_destroy(device); + } + else { + /* Ensure any lingering mapping for this channel is removed */ + guac_rdp_rdpecam_mapping_remove_by_channel(plugin, channel_name); } guac_client_log(client, GUAC_LOG_DEBUG, @@ -324,11 +358,7 @@ void guac_rdp_rdpecam_caps_notify(guac_client* client) { } /* Step 3: Clear and rebuild device_id_map to avoid stale entries */ - if (plugin->device_id_map) { - guac_client_log(client, GUAC_LOG_DEBUG, - "RDPECAM caps_notify: clearing device_id_map to rebuild from new capabilities"); - HashTable_Clear(plugin->device_id_map); - } + guac_rdp_rdpecam_mapping_clear(plugin); guac_client_log(client, GUAC_LOG_DEBUG, "RDPECAM caps_notify: starting device addition phase"); @@ -371,8 +401,11 @@ void guac_rdp_rdpecam_caps_notify(guac_client* client) { if (!new_device_ids[j]) continue; - char* mapped_channel = (char*) HashTable_GetItemValue(plugin->device_id_map, new_device_ids[j]); - if (mapped_channel && strcmp(mapped_channel, channel_name) == 0) { + guac_rdp_rdpecam_device_mapping* mapping_entry = + (guac_rdp_rdpecam_device_mapping*) HashTable_GetItemValue( + plugin->device_id_map, new_device_ids[j]); + if (mapping_entry && mapping_entry->channel_name + && strcmp(mapping_entry->channel_name, channel_name) == 0) { in_use = 1; break; } @@ -406,30 +439,13 @@ void guac_rdp_rdpecam_caps_notify(guac_client* client) { /* Store device ID to channel name mapping */ if (caps->device_id && caps->device_id[0] != '\0' && plugin->device_id_map) { - char* channel_name_copy = guac_mem_alloc(strlen(channel_name) + 1); - if (channel_name_copy) { - strcpy(channel_name_copy, channel_name); -#ifdef HAVE_WINPR_HASHTABLE_INSERT - if (!HashTable_Insert(plugin->device_id_map, (void*) caps->device_id, (void*) channel_name_copy)) { - guac_client_log(client, GUAC_LOG_ERROR, - "RDPECAM failed to insert device ID mapping"); - guac_mem_free(channel_name_copy); - } else { - guac_client_log(client, GUAC_LOG_DEBUG, - "RDPECAM mapped device ID '%s' to channel '%s'", - caps->device_id, channel_name); - } -#else - if (HashTable_Add(plugin->device_id_map, (void*) caps->device_id, (void*) channel_name_copy) < 0) { - guac_client_log(client, GUAC_LOG_ERROR, - "RDPECAM failed to add device ID mapping"); - guac_mem_free(channel_name_copy); - } else { - guac_client_log(client, GUAC_LOG_DEBUG, - "RDPECAM mapped device ID '%s' to channel '%s'", - caps->device_id, channel_name); - } -#endif + if (!guac_rdp_rdpecam_mapping_add(plugin, caps->device_id, channel_name)) { + guac_client_log(client, GUAC_LOG_ERROR, + "RDPECAM failed to record device mapping for '%s'", caps->device_id); + } else { + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM mapped device ID '%s' to channel '%s'", + caps->device_id, channel_name); } } @@ -2074,6 +2090,7 @@ static UINT guac_rdp_rdpecam_initialize(IWTSPlugin* plugin, /* Initialize hash table for device ID to channel name mapping */ rdpecam_plugin->device_id_map = HashTable_New(FALSE); + rdpecam_plugin->device_id_mappings = NULL; if (!rdpecam_plugin->device_id_map) { guac_client_log(rdpecam_plugin->client, GUAC_LOG_ERROR, "Failed to create device ID map hash table"); @@ -2143,6 +2160,9 @@ static UINT guac_rdp_rdpecam_terminated(IWTSPlugin* plugin) { rdpecam_plugin->devices = NULL; } + /* Clear device ID mappings and associated memory */ + guac_rdp_rdpecam_mapping_clear(rdpecam_plugin); + /* Free device ID map */ if (rdpecam_plugin->device_id_map != NULL) { HashTable_Free(rdpecam_plugin->device_id_map); @@ -2287,6 +2307,143 @@ static guac_rdpecam_device* guac_rdpecam_device_create( return device; } +/** + * Removes the mapping entry associated with the given device ID, if present. + */ +static void guac_rdp_rdpecam_mapping_remove_by_device_id( + guac_rdp_rdpecam_plugin* plugin, const char* device_id) { + + if (!plugin || !device_id) + return; + + guac_rdp_rdpecam_device_mapping* prev = NULL; + guac_rdp_rdpecam_device_mapping* current = plugin->device_id_mappings; + + while (current) { + if (current->device_id_key && strcmp(current->device_id_key, device_id) == 0) { + if (plugin->device_id_map) + HashTable_Remove(plugin->device_id_map, (void*) current->device_id_key); + + if (prev) + prev->next = current->next; + else + plugin->device_id_mappings = current->next; + + if (current->channel_name) + guac_mem_free(current->channel_name); + guac_mem_free(current); + return; + } + + prev = current; + current = current->next; + } +} + +/** + * Removes the mapping entry associated with the given channel name, if present. + */ +static void guac_rdp_rdpecam_mapping_remove_by_channel( + guac_rdp_rdpecam_plugin* plugin, const char* channel_name) { + + if (!plugin || !channel_name) + return; + + guac_rdp_rdpecam_device_mapping* prev = NULL; + guac_rdp_rdpecam_device_mapping* current = plugin->device_id_mappings; + + while (current) { + if (current->channel_name && strcmp(current->channel_name, channel_name) == 0) { + if (plugin->device_id_map) + HashTable_Remove(plugin->device_id_map, (void*) current->device_id_key); + + if (prev) + prev->next = current->next; + else + plugin->device_id_mappings = current->next; + + guac_mem_free(current->channel_name); + guac_mem_free(current); + return; + } + + prev = current; + current = current->next; + } +} + +/** + * Adds or replaces a device ID mapping to the given channel. + * + * @return + * TRUE if the mapping was successfully recorded, FALSE otherwise. + */ +static BOOL guac_rdp_rdpecam_mapping_add( + guac_rdp_rdpecam_plugin* plugin, const char* device_id, + const char* channel_name) { + + if (!plugin || !device_id || !channel_name) + return FALSE; + + /* Remove any existing mapping for this device ID */ + guac_rdp_rdpecam_mapping_remove_by_device_id(plugin, device_id); + + char* channel_copy = guac_mem_alloc(strlen(channel_name) + 1); + if (!channel_copy) + return FALSE; + strcpy(channel_copy, channel_name); + + guac_rdp_rdpecam_device_mapping* entry = + guac_mem_zalloc(sizeof(guac_rdp_rdpecam_device_mapping)); + if (!entry) { + guac_mem_free(channel_copy); + return FALSE; + } + + entry->device_id_key = device_id; + entry->channel_name = channel_copy; + entry->next = plugin->device_id_mappings; + +#ifdef HAVE_WINPR_HASHTABLE_INSERT + if (!HashTable_Insert(plugin->device_id_map, (void*) device_id, (void*) entry)) { +#else + if (HashTable_Add(plugin->device_id_map, (void*) device_id, (void*) entry) < 0) { +#endif + guac_mem_free(channel_copy); + guac_mem_free(entry); + return FALSE; + } + + plugin->device_id_mappings = entry; + return TRUE; +} + +/** + * Clears all device ID mappings, releasing any allocated memory. + */ +static void guac_rdp_rdpecam_mapping_clear( + guac_rdp_rdpecam_plugin* plugin) { + + if (!plugin) + return; + + guac_rdp_rdpecam_device_mapping* current = plugin->device_id_mappings; + plugin->device_id_mappings = NULL; + + while (current) { + guac_rdp_rdpecam_device_mapping* next = current->next; + + if (plugin->device_id_map) + HashTable_Remove(plugin->device_id_map, (void*) current->device_id_key); + + if (current->channel_name) + guac_mem_free(current->channel_name); + guac_mem_free(current); + + current = next; + } +} + /** * Destroys an RDPECAM device structure and frees all associated resources. * This is called as a destructor by the hash table when devices are removed diff --git a/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.h b/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.h index a6a4dff7f5..612268e1cc 100644 --- a/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.h +++ b/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.h @@ -33,6 +33,7 @@ /* Forward declaration */ typedef struct guac_rdp_client guac_rdp_client; +struct guac_rdp_rdpecam_device_mapping; /** * The name of the RDPECAM control/enumeration dynamic virtual channel. @@ -286,6 +287,13 @@ typedef struct guac_rdp_rdpecam_plugin { */ wHashTable* device_id_map; + /** + * Linked list of device ID to channel mappings owned by the plugin. + * Maintained alongside device_id_map to ensure allocated strings are + * freed when mappings are removed. + */ + struct guac_rdp_rdpecam_device_mapping* device_id_mappings; + /** * The guac_client instance associated with the RDP connection using the * RDPECAM plugin. From 1943bb5bbbf6f0327031472a7a35e67daab62bf3 Mon Sep 17 00:00:00 2001 From: Loic Date: Fri, 7 Nov 2025 11:48:21 -0500 Subject: [PATCH 10/12] GUACAMOLE-1415: Unify RDPECAM capability callbacks --- .../rdp/channels/rdpecam/rdpecam_caps.c | 279 +++--------------- .../rdp/channels/rdpecam/rdpecam_caps.h | 41 +-- src/protocols/rdp/rdp.c | 2 +- .../rdp/tests/rdpecam/rdpecam_caps_test.c | 4 +- 4 files changed, 56 insertions(+), 270 deletions(-) diff --git a/src/protocols/rdp/channels/rdpecam/rdpecam_caps.c b/src/protocols/rdp/channels/rdpecam/rdpecam_caps.c index 94d749a868..97490a971b 100644 --- a/src/protocols/rdp/channels/rdpecam/rdpecam_caps.c +++ b/src/protocols/rdp/channels/rdpecam/rdpecam_caps.c @@ -26,6 +26,7 @@ #include #include +#include #include #include @@ -67,11 +68,30 @@ size_t guac_rdp_rdpecam_sanitize_device_name(const char* name, char* sanitized, return pos; } -int guac_rdp_rdpecam_capabilities_callback(guac_user* user, - const char* mimetype, const char* name, const char* value, void* data) { - - guac_client* client = user ? user->client : NULL; - guac_rdp_client* rdp_client = client ? (guac_rdp_client*) client->data : NULL; +/** + * Parses RDPECAM capabilities string and updates the RDP client's device + * capabilities. This is a shared implementation used by both the initial + * capabilities callback and the update callback. + * + * @param client + * The guac_client instance. + * + * @param rdp_client + * The RDP client data (must have write lock held). + * + * @param value + * The capability string to parse. Must not be NULL. + * + * @param is_update + * Whether this is an update (true) or initial capabilities (false). + * Affects log messages only. + * + * @return + * The number of devices parsed, or 0 on error. + */ +static unsigned int guac_rdp_rdpecam_parse_capabilities( + guac_client* client, guac_rdp_client* rdp_client, + const char* value, bool is_update) { if (!client || !rdp_client || !value) return 0; @@ -82,8 +102,6 @@ int guac_rdp_rdpecam_capabilities_callback(guac_user* user, return 0; memcpy(copy, value, len + 1); - guac_rwlock_acquire_write_lock(&(rdp_client->lock)); - /* Free old device capabilities */ for (unsigned int i = 0; i < rdp_client->rdpecam_device_caps_count; i++) { guac_rdp_rdpecam_device_caps* caps = &rdp_client->rdpecam_device_caps[i]; @@ -97,6 +115,12 @@ int guac_rdp_rdpecam_capabilities_callback(guac_user* user, } rdp_client->rdpecam_device_caps_count = 0; + /* Empty string simply clears capabilities */ + if (len == 0) { + guac_mem_free(copy); + return 0; + } + /* Parse multi-device capabilities format (required): * "DEVICE_ID:DEVICE_NAME|640x480@30/1,...;DEVICE_ID:DEVICE_NAME|320x240@30/1,..." * Format requires semicolon-separated device list, each entry must include device ID. @@ -237,7 +261,8 @@ int guac_rdp_rdpecam_capabilities_callback(guac_user* user, if (format_count > 0) { device_count++; guac_client_log(client, GUAC_LOG_DEBUG, - "RDPECAM device %u: id='%s', name='%s', formats=%u", + "RDPECAM %s device %u: id='%s', name='%s', formats=%u", + is_update ? "update" : "initial", device_count - 1, caps->device_id, caps->device_name ? caps->device_name : "(none)", @@ -263,25 +288,12 @@ int guac_rdp_rdpecam_capabilities_callback(guac_user* user, } rdp_client->rdpecam_device_caps_count = device_count; - - /* Set flag to notify plugin that capabilities have been updated. */ - rdp_client->rdpecam_caps_updated = 1; - - /* If plugin registered a notification callback, invoke it now to allow - * immediate processing (e.g., sending DeviceAddedNotification). */ - if (rdp_client->rdpecam_caps_notify) - rdp_client->rdpecam_caps_notify(client); - - guac_rwlock_release_lock(&(rdp_client->lock)); - - guac_client_log(client, GUAC_LOG_DEBUG, - "RDPECAM capabilities updated (%u devices), notifying plugin", device_count); guac_mem_free(copy); - return 0; + return device_count; } -int guac_rdp_rdpecam_capabilities_update_callback(guac_user* user, +int guac_rdp_rdpecam_capabilities_callback(guac_user* user, const char* mimetype, const char* name, const char* value, void* data) { guac_client* client = user ? user->client : NULL; @@ -290,231 +302,26 @@ int guac_rdp_rdpecam_capabilities_update_callback(guac_user* user, if (!client || !rdp_client || !value) return 0; - guac_client_log(client, GUAC_LOG_DEBUG, - "RDPECAM capability update received: %zu bytes", strlen(value)); - - /* Handle empty capability string (all cameras disabled) */ - if (strlen(value) == 0) { - guac_rwlock_acquire_write_lock(&(rdp_client->lock)); - - /* Free old device capabilities */ - for (unsigned int i = 0; i < rdp_client->rdpecam_device_caps_count; i++) { - guac_rdp_rdpecam_device_caps* caps = &rdp_client->rdpecam_device_caps[i]; - if (caps->device_id) - guac_mem_free(caps->device_id); - if (caps->device_name) - guac_mem_free(caps->device_name); - caps->device_id = NULL; - caps->device_name = NULL; - caps->format_count = 0; - } - rdp_client->rdpecam_device_caps_count = 0; - - /* Set flag and notify plugin */ - rdp_client->rdpecam_caps_updated = 1; - if (rdp_client->rdpecam_caps_notify) - rdp_client->rdpecam_caps_notify(client); - - guac_rwlock_release_lock(&(rdp_client->lock)); - - guac_client_log(client, GUAC_LOG_DEBUG, - "RDPECAM all cameras disabled, notifying plugin"); - return 0; - } - - /* Reuse the same parsing logic as the initial capabilities callback */ - size_t len = strlen(value); - char* copy = guac_mem_alloc(len + 1); - if (!copy) - return 0; - memcpy(copy, value, len + 1); + bool is_update = (name && strcmp(name, GUAC_RDPECAM_ARG_CAPABILITIES_UPDATE) == 0); guac_rwlock_acquire_write_lock(&(rdp_client->lock)); - /* Free old device capabilities */ - for (unsigned int i = 0; i < rdp_client->rdpecam_device_caps_count; i++) { - guac_rdp_rdpecam_device_caps* caps = &rdp_client->rdpecam_device_caps[i]; - if (caps->device_id) - guac_mem_free(caps->device_id); - if (caps->device_name) - guac_mem_free(caps->device_name); - caps->device_id = NULL; - caps->device_name = NULL; - caps->format_count = 0; - } - rdp_client->rdpecam_device_caps_count = 0; - - /* Parse multi-device capabilities (same format as initial capabilities) */ - unsigned int device_count = 0; - char* device_saveptr = NULL; - char* device_entry = strtok_r(copy, ";", &device_saveptr); - - if (!device_entry) { - guac_client_log(client, GUAC_LOG_WARNING, - "RDPECAM capability update in invalid format (expected semicolon-separated device list)"); - guac_mem_free(copy); - guac_rwlock_release_lock(&(rdp_client->lock)); - return 0; - } - - while (device_entry && device_count < GUAC_RDP_RDPECAM_MAX_DEVICES) { - guac_rdp_rdpecam_device_caps* caps = &rdp_client->rdpecam_device_caps[device_count]; - - /* Find pipe separator (between device info and formats) */ - char* formats_str = device_entry; - char* pipe_pos = strchr(device_entry, '|'); - char* device_info = NULL; - - if (!pipe_pos) { - guac_client_log(client, GUAC_LOG_DEBUG, - "RDPECAM skipping device entry without pipe separator: '%s'", device_entry); - device_entry = strtok_r(NULL, ";", &device_saveptr); - continue; - } - - *pipe_pos = '\0'; - device_info = device_entry; - formats_str = pipe_pos + 1; - - /* Parse device ID and name */ - char* device_id_parsed = NULL; - char* device_name_parsed = NULL; - - if (!device_info || !*device_info) { - guac_client_log(client, GUAC_LOG_DEBUG, - "RDPECAM skipping device entry without device info"); - device_entry = strtok_r(NULL, ";", &device_saveptr); - continue; - } - - char* colon_pos = strchr(device_info, ':'); - if (!colon_pos) { - guac_client_log(client, GUAC_LOG_WARNING, - "RDPECAM skipping device entry without device ID: '%s'", device_info); - device_entry = strtok_r(NULL, ";", &device_saveptr); - continue; - } - - *colon_pos = '\0'; - device_id_parsed = device_info; - device_name_parsed = colon_pos + 1; - - if (!device_id_parsed || !*device_id_parsed) { - guac_client_log(client, GUAC_LOG_WARNING, - "RDPECAM skipping device entry with empty device ID"); - device_entry = strtok_r(NULL, ";", &device_saveptr); - continue; - } - - /* Store device ID */ - size_t id_len = strlen(device_id_parsed); - caps->device_id = guac_mem_alloc(id_len + 1); - if (!caps->device_id) { - guac_client_log(client, GUAC_LOG_ERROR, - "RDPECAM failed to allocate device ID string"); - device_entry = strtok_r(NULL, ";", &device_saveptr); - continue; - } - memcpy(caps->device_id, device_id_parsed, id_len + 1); - - /* Sanitize and store device name */ - if (device_name_parsed && *device_name_parsed) { - char sanitized[256]; - size_t sanitized_len = guac_rdp_rdpecam_sanitize_device_name( - device_name_parsed, sanitized, sizeof(sanitized)); - - if (sanitized_len > 0) { - caps->device_name = guac_mem_alloc(sanitized_len + 1); - if (caps->device_name) { - memcpy(caps->device_name, sanitized, sanitized_len + 1); - } - } - } - - /* Parse formats */ - unsigned int format_count = 0; - char* format_saveptr = NULL; - char* format_token = strtok_r(formats_str, ",", &format_saveptr); - - while (format_token && format_count < GUAC_RDP_RDPECAM_MAX_FORMATS) { - /* Trim whitespace */ - while (isspace((unsigned char) *format_token)) - format_token++; - - char* end = format_token + strlen(format_token); - while (end > format_token && isspace((unsigned char) *(end - 1))) - *(--end) = '\0'; - - unsigned int width = 0; - unsigned int height = 0; - unsigned int fps_num = 0; - unsigned int fps_den = 1; - - int parsed = sscanf(format_token, "%ux%u@%u/%u", &width, &height, &fps_num, &fps_den); - if (parsed < 4) { - fps_den = 1; - parsed = sscanf(format_token, "%ux%u@%u", &width, &height, &fps_num); - } - - if (parsed >= 3 && width && height && fps_num) { - guac_rdp_rdpecam_format* fmt = &caps->formats[format_count++]; - fmt->width = width; - fmt->height = height; - fmt->fps_num = fps_num; - fmt->fps_den = fps_den ? fps_den : 1; - } - else { - guac_client_log(client, GUAC_LOG_DEBUG, - "RDPECAM ignored unparseable format entry: '%s'", format_token); - } - - format_token = strtok_r(NULL, ",", &format_saveptr); - } - - caps->format_count = format_count; + unsigned int device_count = guac_rdp_rdpecam_parse_capabilities( + client, rdp_client, value, is_update); - /* Only add device if it has valid formats */ - if (format_count > 0) { - device_count++; - guac_client_log(client, GUAC_LOG_DEBUG, - "RDPECAM update device %u: id='%s', name='%s', formats=%u", - device_count - 1, - caps->device_id, - caps->device_name ? caps->device_name : "(none)", - format_count); - } else { - guac_client_log(client, GUAC_LOG_WARNING, - "RDPECAM skipping device '%s' (id='%s') with no valid formats", - caps->device_name ? caps->device_name : "(unnamed)", - caps->device_id); - if (caps->device_id) { - guac_mem_free(caps->device_id); - caps->device_id = NULL; - } - if (caps->device_name) { - guac_mem_free(caps->device_name); - caps->device_name = NULL; - } - } - - device_entry = strtok_r(NULL, ";", &device_saveptr); - } - - rdp_client->rdpecam_device_caps_count = device_count; - - /* Set flag to notify plugin that capabilities have been updated */ + /* Set flag to notify plugin that capabilities have been updated. */ rdp_client->rdpecam_caps_updated = 1; - /* Notify plugin to process the update (compare old vs new and add/remove devices) */ + /* If plugin registered a notification callback, invoke it now to allow + * immediate processing (e.g., sending DeviceAddedNotification). */ if (rdp_client->rdpecam_caps_notify) rdp_client->rdpecam_caps_notify(client); guac_rwlock_release_lock(&(rdp_client->lock)); guac_client_log(client, GUAC_LOG_DEBUG, - "RDPECAM capability update processed (%u devices), notifying plugin", device_count); + "RDPECAM %s capability data processed (%u devices), notifying plugin", + is_update ? "update" : "initial", device_count); - guac_mem_free(copy); return 0; } - diff --git a/src/protocols/rdp/channels/rdpecam/rdpecam_caps.h b/src/protocols/rdp/channels/rdpecam/rdpecam_caps.h index a46650ab2b..c3c0fd15e7 100644 --- a/src/protocols/rdp/channels/rdpecam/rdpecam_caps.h +++ b/src/protocols/rdp/channels/rdpecam/rdpecam_caps.h @@ -34,6 +34,11 @@ */ #define GUAC_RDPECAM_ARG_CAPABILITIES_UPDATE "rdpecam-capabilities-update" +/** + * The name of the guacamole protocol argument for camera capability updates. + * This is sent when the user enables/disables cameras during an active session. + */ + /** * Maximum number of RDPECAM formats remembered from the browser. */ @@ -118,7 +123,8 @@ size_t guac_rdp_rdpecam_sanitize_device_name(const char* name, char* sanitized, /** * Callback invoked when camera capabilities are received from the browser. * This function parses the multi-device capability string and updates the - * RDP client's device capability storage. + * RDP client's device capability storage. An empty string clears all previously + * advertised devices. * * @param user * The user who sent the capabilities. @@ -127,11 +133,13 @@ size_t guac_rdp_rdpecam_sanitize_device_name(const char* name, char* sanitized, * The mimetype of the data (unused). * * @param name - * The name of the argument (should be "rdpecam-capabilities"). + * The name of the argument. Either "rdpecam-capabilities" or + * "rdpecam-capabilities-update". * * @param value * The capability string in format: * "DEVICE_ID:DEVICE_NAME|WIDTHxHEIGHT@FPS_NUM/FPS_DEN,...;..." + * or empty if all cameras are disabled. * * @param data * User-defined data (unused). @@ -142,34 +150,5 @@ size_t guac_rdp_rdpecam_sanitize_device_name(const char* name, char* sanitized, int guac_rdp_rdpecam_capabilities_callback(guac_user* user, const char* mimetype, const char* name, const char* value, void* data); -/** - * Callback invoked when camera capability updates are received from the browser. - * This is called when the user enables/disables cameras during an active session. - * The plugin is responsible for comparing old and new capabilities to determine - * which devices were added or removed. - * - * @param user - * The user who sent the capability update. - * - * @param mimetype - * The mimetype of the data (unused). - * - * @param name - * The name of the argument (should be "rdpecam-capabilities-update"). - * - * @param value - * The capability string in the same format as initial capabilities: - * "DEVICE_ID:DEVICE_NAME|WIDTHxHEIGHT@FPS_NUM/FPS_DEN,...;..." - * Can be empty string if all cameras are disabled. - * - * @param data - * User-defined data (unused). - * - * @return - * Always returns 0. - */ -int guac_rdp_rdpecam_capabilities_update_callback(guac_user* user, - const char* mimetype, const char* name, const char* value, void* data); - #endif diff --git a/src/protocols/rdp/rdp.c b/src/protocols/rdp/rdp.c index 8d1c0666de..1e36881ccc 100644 --- a/src/protocols/rdp/rdp.c +++ b/src/protocols/rdp/rdp.c @@ -150,7 +150,7 @@ static BOOL rdp_freerdp_load_channels(freerdp* instance) { } if (guac_argv_register(GUAC_RDPECAM_ARG_CAPABILITIES_UPDATE, - guac_rdp_rdpecam_capabilities_update_callback, NULL, 0)) { + guac_rdp_rdpecam_capabilities_callback, NULL, 0)) { guac_client_log(client, GUAC_LOG_WARNING, "Unable to register RDPECAM capability update handler;" " dynamic camera enable/disable may be limited."); diff --git a/src/protocols/rdp/tests/rdpecam/rdpecam_caps_test.c b/src/protocols/rdp/tests/rdpecam/rdpecam_caps_test.c index a7074adbe5..5dde7731e3 100644 --- a/src/protocols/rdp/tests/rdpecam/rdpecam_caps_test.c +++ b/src/protocols/rdp/tests/rdpecam/rdpecam_caps_test.c @@ -293,8 +293,8 @@ void test_rdpecam_caps__capabilities_update_empty(void) { CU_ASSERT_EQUAL(rdp_client->rdpecam_device_caps_count, 1); /* Then clear them with empty update */ - int result = guac_rdp_rdpecam_capabilities_update_callback(user, NULL, - GUAC_RDPECAM_ARG_CAPABILITIES_UPDATE, "", NULL); + int result = guac_rdp_rdpecam_capabilities_callback(user, NULL, + GUAC_RDPECAM_ARG_CAPABILITIES, "", NULL); CU_ASSERT_EQUAL(result, 0); CU_ASSERT_EQUAL(rdp_client->rdpecam_device_caps_count, 0); From 43fe3a848081098708bc6cacbbb34e96c87be822 Mon Sep 17 00:00:00 2001 From: Loic Date: Fri, 7 Nov 2025 11:48:41 -0500 Subject: [PATCH 11/12] GUACAMOLE-1415: Remove RDPECAM channels via existing mappings --- .../rdp/plugins/guacrdpecam/guacrdpecam.c | 56 +++++-------------- 1 file changed, 13 insertions(+), 43 deletions(-) diff --git a/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.c b/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.c index 4d11bd3863..6def49bd22 100644 --- a/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.c +++ b/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.c @@ -269,42 +269,18 @@ void guac_rdp_rdpecam_caps_notify(guac_client* client) { } } - /* Since HashTable_GetKeys is broken in WinPR, we can't reliably iterate device_id_map - * to find what needs to be removed. Instead, we'll: - * 1. Scan all channel slots to find channels that should be removed (not in new capabilities) - * 2. Send removal notifications for those channels (whether Windows opened them or not) - * 3. Clear device_id_map completely - * 4. Rebuild it from new capabilities - * This avoids the HashTable_GetKeys API compatibility issue entirely. */ - - guac_client_log(client, GUAC_LOG_DEBUG, - "RDPECAM caps_notify: removing all previously advertised channels before rebuild"); - - /* Step 1: Since we're about to clear and rebuild device_id_map, send DeviceRemovedNotification - * for ALL channel slots (0-10) to ensure Windows cleans up any previously advertised devices. - * Windows will ignore removals for channels that were never advertised. */ - - char channels_to_remove[11][64]; /* Slots 0-10 should cover most reasonable scenarios */ - unsigned int remove_count = 0; - - for (unsigned int slot = 0; slot <= 10 && slot < GUAC_RDP_RDPECAM_MAX_DEVICES; slot++) { - snprintf(channels_to_remove[remove_count], sizeof(channels_to_remove[remove_count]), - "RDCamera_Device_%u", slot); - remove_count++; - } - guac_client_log(client, GUAC_LOG_DEBUG, - "RDPECAM caps_notify: will send removal for slots 0-%u to clean up old advertisements", - remove_count - 1); + "RDPECAM caps_notify: removing previously advertised channels before rebuild"); - /* Step 2: Send removal notifications for all slots */ - for (unsigned int i = 0; i < remove_count; i++) { - char* channel_name = channels_to_remove[i]; + /* Send DeviceRemovedNotification for each channel currently mapped. */ + while (plugin->device_id_mappings) { + guac_rdp_rdpecam_device_mapping* mapping = plugin->device_id_mappings; + plugin->device_id_mappings = mapping->next; + const char* channel_name = mapping->channel_name; guac_client_log(client, GUAC_LOG_DEBUG, "RDPECAM sending removal for channel '%s'", channel_name); - /* Send DeviceRemovedNotification to Windows */ wStream* rs = Stream_New(NULL, 256); if (rs && rdpecam_build_device_removed(rs, channel_name)) { Stream_SealLength(rs); @@ -339,27 +315,21 @@ void guac_rdp_rdpecam_caps_notify(guac_client* client) { pthread_cond_broadcast(&device->credits_signal); pthread_mutex_unlock(&device->lock); - /* Remove from devices hash table */ HashTable_Remove(plugin->devices, channel_name); - - /* Remove associated browser mapping */ - guac_rdp_rdpecam_mapping_remove_by_channel(plugin, channel_name); - - /* Destroy device resources (threads, sinks, etc.) */ guac_rdpecam_device_destroy(device); } - else { - /* Ensure any lingering mapping for this channel is removed */ - guac_rdp_rdpecam_mapping_remove_by_channel(plugin, channel_name); - } + + if (plugin->device_id_map) + HashTable_Remove(plugin->device_id_map, (void*) mapping->device_id_key); + + if (mapping->channel_name) + guac_mem_free(mapping->channel_name); + guac_mem_free(mapping); guac_client_log(client, GUAC_LOG_DEBUG, "RDPECAM caps_notify: completed removal notification for channel '%s'", channel_name); } - /* Step 3: Clear and rebuild device_id_map to avoid stale entries */ - guac_rdp_rdpecam_mapping_clear(plugin); - guac_client_log(client, GUAC_LOG_DEBUG, "RDPECAM caps_notify: starting device addition phase"); From ca4df357b71ce41000915be0a136d125c4af5a11 Mon Sep 17 00:00:00 2001 From: Loic Date: Fri, 7 Nov 2025 12:51:29 -0500 Subject: [PATCH 12/12] GUACAMOLE-1415: Create RDPECAM listeners on demand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In short, registering _Device_0 up front was just defensive boilerplate; it wasn’t required by the protocol. Now that the listener is created at the point of advertisement (and only then), we avoid the extra allocation and keep the behavior identical. --- .../rdp/plugins/guacrdpecam/guacrdpecam.c | 30 ++++--------------- .../rdp/plugins/guacrdpecam/guacrdpecam.h | 1 - 2 files changed, 6 insertions(+), 25 deletions(-) diff --git a/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.c b/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.c index 6def49bd22..a230a88163 100644 --- a/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.c +++ b/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.c @@ -419,8 +419,8 @@ void guac_rdp_rdpecam_caps_notify(guac_client* client) { } } - /* Create listener for this device channel if not Device_0 */ - if (assigned_channel_idx > 0 && plugin->manager) { + /* Create listener for this device channel */ + if (plugin->manager) { guac_rdp_rdpecam_listener_callback* device_listener = guac_mem_zalloc(sizeof(guac_rdp_rdpecam_listener_callback)); if (device_listener) { @@ -2025,14 +2025,10 @@ static UINT guac_rdp_rdpecam_initialize(IWTSPlugin* plugin, guac_rdp_rdpecam_plugin* rdpecam_plugin = (guac_rdp_rdpecam_plugin*) plugin; guac_rdp_rdpecam_listener_callback* control_listener = guac_mem_zalloc(sizeof(guac_rdp_rdpecam_listener_callback)); - guac_rdp_rdpecam_listener_callback* device0_listener = - guac_mem_zalloc(sizeof(guac_rdp_rdpecam_listener_callback)); - if (!control_listener || !device0_listener) { + if (!control_listener) { guac_client_log(rdpecam_plugin->client, GUAC_LOG_ERROR, - "Failed to allocate RDPECAM listener callbacks"); - guac_mem_free(control_listener); - guac_mem_free(device0_listener); + "Failed to allocate RDPECAM listener callback"); return CHANNEL_RC_NO_MEMORY; } @@ -2042,19 +2038,12 @@ static UINT guac_rdp_rdpecam_initialize(IWTSPlugin* plugin, control_listener->parent.OnNewChannelConnection = guac_rdp_rdpecam_new_connection; rdpecam_plugin->control_listener_callback = control_listener; - device0_listener->client = rdpecam_plugin->client; - device0_listener->channel_name = GUAC_RDPECAM_DEVICE0_CHANNEL_NAME; - device0_listener->plugin = rdpecam_plugin; - device0_listener->parent.OnNewChannelConnection = guac_rdp_rdpecam_new_connection; - rdpecam_plugin->device0_listener_callback = device0_listener; - /* Initialize hash table for multi-device support */ rdpecam_plugin->devices = HashTable_New(FALSE); if (!rdpecam_plugin->devices) { guac_client_log(rdpecam_plugin->client, GUAC_LOG_ERROR, "Failed to create device hash table"); guac_mem_free(control_listener); - guac_mem_free(device0_listener); return CHANNEL_RC_NO_MEMORY; } @@ -2066,7 +2055,6 @@ static UINT guac_rdp_rdpecam_initialize(IWTSPlugin* plugin, "Failed to create device ID map hash table"); HashTable_Free(rdpecam_plugin->devices); guac_mem_free(control_listener); - guac_mem_free(device0_listener); return CHANNEL_RC_NO_MEMORY; } @@ -2080,8 +2068,6 @@ static UINT guac_rdp_rdpecam_initialize(IWTSPlugin* plugin, /* Register control channel listener */ manager->CreateListener(manager, GUAC_RDPECAM_CHANNEL_NAME, 0, (IWTSListenerCallback*) control_listener, NULL); - manager->CreateListener(manager, GUAC_RDPECAM_DEVICE0_CHANNEL_NAME, 0, - (IWTSListenerCallback*) device0_listener, NULL); guac_client_log(rdpecam_plugin->client, GUAC_LOG_DEBUG, "RDPECAM plugin initialized with multi-device support"); @@ -2109,10 +2095,6 @@ static UINT guac_rdp_rdpecam_terminated(IWTSPlugin* plugin) { guac_mem_free(rdpecam_plugin->control_listener_callback); rdpecam_plugin->control_listener_callback = NULL; } - if (rdpecam_plugin->device0_listener_callback != NULL) { - guac_mem_free(rdpecam_plugin->device0_listener_callback); - rdpecam_plugin->device0_listener_callback = NULL; - } /* Destroy all devices in hash table */ if (rdpecam_plugin->devices != NULL) { @@ -2623,8 +2605,8 @@ void guac_rdp_rdpecam_send_device_notifications( } } - /* Create listener for this device channel if not Device_0 (Device_0 is pre-created) */ - if (i > 0 && plugin->manager) { + /* Create listener for this device channel */ + if (plugin->manager) { guac_rdp_rdpecam_listener_callback* device_listener = guac_mem_zalloc(sizeof(guac_rdp_rdpecam_listener_callback)); if (device_listener) { diff --git a/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.h b/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.h index 612268e1cc..168669f342 100644 --- a/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.h +++ b/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.h @@ -265,7 +265,6 @@ typedef struct guac_rdp_rdpecam_plugin { * terminated. */ guac_rdp_rdpecam_listener_callback* control_listener_callback; - guac_rdp_rdpecam_listener_callback* device0_listener_callback; /** * Hash table for managing multiple device channels.