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/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 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 # 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..97490a971b --- /dev/null +++ b/src/protocols/rdp/channels/rdpecam/rdpecam_caps.c @@ -0,0 +1,327 @@ +/* + * 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 +#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; +} + +/** + * 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; + + size_t len = strlen(value); + char* copy = guac_mem_alloc(len + 1); + if (!copy) + return 0; + memcpy(copy, value, len + 1); + + /* 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; + + /* 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. + */ + + 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 %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)", + 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; + + guac_mem_free(copy); + return device_count; +} + +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; + + bool is_update = (name && strcmp(name, GUAC_RDPECAM_ARG_CAPABILITIES_UPDATE) == 0); + + guac_rwlock_acquire_write_lock(&(rdp_client->lock)); + + unsigned int device_count = guac_rdp_rdpecam_parse_capabilities( + client, rdp_client, value, is_update); + + /* 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 %s capability data processed (%u devices), notifying plugin", + is_update ? "update" : "initial", device_count); + + 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..c3c0fd15e7 --- /dev/null +++ b/src/protocols/rdp/channels/rdpecam/rdpecam_caps.h @@ -0,0 +1,154 @@ +/* + * 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" + +/** + * 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" + +/** + * 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. + */ +#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. An empty string clears all previously + * advertised devices. + * + * @param user + * The user who sent the capabilities. + * + * @param mimetype + * The mimetype of the data (unused). + * + * @param name + * 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). + * + * @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 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/plugins/guacrdpecam/guacrdpecam.c b/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.c new file mode 100644 index 0000000000..a230a88163 --- /dev/null +++ b/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.c @@ -0,0 +1,2701 @@ +/* + * 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 + +/** + * 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). + * + * @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. + * For capability updates, also removes devices that are no longer in the list. + */ +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) { + 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); + } + } + + guac_client_log(client, GUAC_LOG_DEBUG, + "RDPECAM caps_notify: removing previously advertised channels before rebuild"); + + /* 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); + + 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); + + pthread_mutex_lock(&device->lock); + device->stopping = true; + device->streaming = false; + pthread_cond_broadcast(&device->credits_signal); + pthread_mutex_unlock(&device->lock); + + HashTable_Remove(plugin->devices, channel_name); + guac_rdpecam_device_destroy(device); + } + + 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); + } + + 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 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; + + 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; + } + } + } + + 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) { + 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); + } + } + + /* 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) { + 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"); +} + +/** + * 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) { + + /* Lock mutex to check state and wait on condition variable if needed. */ + pthread_mutex_lock(&device->lock); + + /* Check if we should stop */ + if (device->stopping) { + pthread_mutex_unlock(&device->lock); + break; + } + + /* 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; + } + + /* 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; + } + + /* 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; + } + + /* 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; + 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_cond_broadcast(&device->credits_signal); + 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_cond_broadcast(&device->credits_signal); + 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) { + /* 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, check_channel); + 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_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", + old_device->device_name); + break; + } + } + } + + /* 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_cond_broadcast(&device->credits_signal); + 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; + pthread_cond_broadcast(&device->credits_signal); + } + 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_cond_broadcast(&device->credits_signal); + 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; + pthread_cond_broadcast(&device->credits_signal); + } + 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)); + + if (!control_listener) { + guac_client_log(rdpecam_plugin->client, GUAC_LOG_ERROR, + "Failed to allocate RDPECAM listener callback"); + 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; + + /* 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); + return CHANNEL_RC_NO_MEMORY; + } + + /* 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"); + HashTable_Free(rdpecam_plugin->devices); + guac_mem_free(control_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); + + 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; + } + + /* Destroy all devices in hash table */ + if (rdpecam_plugin->devices != NULL) { + /* 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); + } + HashTable_Free(rdpecam_plugin->devices); + 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); + 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; +} + +/** + * 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 + * 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 (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..168669f342 --- /dev/null +++ b/src/protocols/rdp/plugins/guacrdpecam/guacrdpecam.h @@ -0,0 +1,340 @@ +/* + * 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; +struct guac_rdp_rdpecam_device_mapping; + +/** + * 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; + + /** + * 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; + + /** + * 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. + */ + 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 + + diff --git a/src/protocols/rdp/rdp.c b/src/protocols/rdp/rdp.c index 669bc82ee0..1e36881ccc 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,33 @@ 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."); + } + + if (guac_argv_register(GUAC_RDPECAM_ARG_CAPABILITIES_UPDATE, + 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."); + } + + /* 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/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..5dde7731e3 --- /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_callback(user, NULL, + GUAC_RDPECAM_ARG_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 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); +} + + 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 */