diff --git a/CMakeLists.txt b/CMakeLists.txt index 1c7b244c..a02ebfd8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -85,6 +85,8 @@ set(FFI_PROTO_FILES ${FFI_PROTO_DIR}/data_track.proto ${FFI_PROTO_DIR}/rpc.proto ${FFI_PROTO_DIR}/track_publication.proto + ${FFI_PROTO_DIR}/capture.proto + ${FFI_PROTO_DIR}/client_info.proto ) set(PROTO_BINARY_DIR ${LIVEKIT_BINARY_DIR}/generated) file(MAKE_DIRECTORY ${PROTO_BINARY_DIR}) diff --git a/client-sdk-rust b/client-sdk-rust index 06371a33..516eb7e4 160000 --- a/client-sdk-rust +++ b/client-sdk-rust @@ -1 +1 @@ -Subproject commit 06371a336d485cf6b51c75a3fee3d208f8f2eedb +Subproject commit 516eb7e40126f4a6c94df59bba6b7fa35fde1e39 diff --git a/include/livekit/client_info.h b/include/livekit/client_info.h new file mode 100644 index 00000000..85e26aec --- /dev/null +++ b/include/livekit/client_info.h @@ -0,0 +1,103 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed 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. + */ + +#pragma once + +#include +#include +#include + +#include "livekit/visibility.h" + +namespace livekit { + +/// @brief Describes the SDK and platform metadata advertised by this client. +struct ClientInfo { + /// @brief Identifies the LiveKit SDK implementation. + enum class Sdk { + Unknown = 0, + Js = 1, + Swift = 2, + Android = 3, + Flutter = 4, + Go = 5, + Unity = 6, + ReactNative = 7, + Rust = 8, + Python = 9, + Cpp = 10, + UnityWeb = 11, + Node = 12, + Unreal = 13, + Esp32 = 14, + }; + + /// @brief Identifies an optional feature advertised by the client. + enum class Capability { + Unused = 0, + PacketTrailer = 1, + CompressionDeflateRaw = 2, + }; + + /// LiveKit SDK implementation. + Sdk sdk = Sdk::Unknown; + + /// LiveKit SDK version. + std::string version; + + /// LiveKit signaling protocol version. + std::int32_t protocol = 0; + + /// Operating system name. + std::string os; + + /// Operating system version. + std::string os_version; + + /// Device model, when available. + std::string device_model; + + /// Browser name, when applicable. + std::string browser; + + /// Browser version, when applicable. + std::string browser_version; + + /// Client network address, when available. + std::string address; + + /// Network type such as wifi, wired, cellular, or VPN, when available. + std::string network; + + /// Comma-separated additional LiveKit SDKs used by the client. + std::string other_sdks; + + /// Client feature protocol version advertised to other participants. + std::int32_t client_protocol = 0; + + /// Optional features advertised by the client. + std::vector capabilities; +}; + +/// @brief Retrieve the metadata this SDK advertises to LiveKit when connecting. +/// +/// The SDK must be initialized before calling this function. +/// +/// @return A snapshot of the current client metadata. +/// @throws std::runtime_error If the SDK is not initialized or the FFI request fails. +LIVEKIT_API ClientInfo getClientInfo(); + +} // namespace livekit diff --git a/include/livekit/livekit.h b/include/livekit/livekit.h index d0aaee89..8b783277 100644 --- a/include/livekit/livekit.h +++ b/include/livekit/livekit.h @@ -21,6 +21,7 @@ #include "livekit/audio_source.h" #include "livekit/audio_stream.h" #include "livekit/build.h" +#include "livekit/client_info.h" #include "livekit/e2ee.h" #include "livekit/local_audio_track.h" #include "livekit/local_participant.h" diff --git a/include/livekit/room.h b/include/livekit/room.h index 10995731..a0319c54 100644 --- a/include/livekit/room.h +++ b/include/livekit/room.h @@ -121,6 +121,12 @@ struct RoomOptions { /// /// If unset, the Rust SDK default is used. std::optional connect_timeout; + + /// Comma-separated additional LiveKit SDKs used by the application, with versions. + /// + /// For example: `"ros_portal:1.2.3,another-sdk:2.0.0"`. + /// Older servers or legacy signaling connections may ignore this value. + std::string other_sdks; }; /// Represents a LiveKit room session. diff --git a/src/livekit.cpp b/src/livekit.cpp index 6b76759f..c580788e 100644 --- a/src/livekit.cpp +++ b/src/livekit.cpp @@ -16,11 +16,87 @@ #include "livekit/livekit.h" +#include + +#include "ffi.pb.h" #include "ffi_client.h" #include "lk_log.h" namespace livekit { +namespace { + +ClientInfo::Sdk toClientSdk(proto::ClientInfo_SDK sdk) { + switch (sdk) { + case proto::ClientInfo::JS: + return ClientInfo::Sdk::Js; + case proto::ClientInfo::SWIFT: + return ClientInfo::Sdk::Swift; + case proto::ClientInfo::ANDROID: + return ClientInfo::Sdk::Android; + case proto::ClientInfo::FLUTTER: + return ClientInfo::Sdk::Flutter; + case proto::ClientInfo::GO: + return ClientInfo::Sdk::Go; + case proto::ClientInfo::UNITY: + return ClientInfo::Sdk::Unity; + case proto::ClientInfo::REACT_NATIVE: + return ClientInfo::Sdk::ReactNative; + case proto::ClientInfo::RUST: + return ClientInfo::Sdk::Rust; + case proto::ClientInfo::PYTHON: + return ClientInfo::Sdk::Python; + case proto::ClientInfo::CPP: + return ClientInfo::Sdk::Cpp; + case proto::ClientInfo::UNITY_WEB: + return ClientInfo::Sdk::UnityWeb; + case proto::ClientInfo::NODE: + return ClientInfo::Sdk::Node; + case proto::ClientInfo::UNREAL: + return ClientInfo::Sdk::Unreal; + case proto::ClientInfo::ESP32: + return ClientInfo::Sdk::Esp32; + case proto::ClientInfo::UNKNOWN: + default: + return ClientInfo::Sdk::Unknown; + } +} + +ClientInfo::Capability toClientCapability(int capability) { + switch (capability) { + case proto::ClientInfo::CAP_PACKET_TRAILER: + return ClientInfo::Capability::PacketTrailer; + case proto::ClientInfo::CAP_COMPRESSION_DEFLATE_RAW: + return ClientInfo::Capability::CompressionDeflateRaw; + case proto::ClientInfo::CAP_UNUSED: + default: + return ClientInfo::Capability::Unused; + } +} + +ClientInfo fromProto(const proto::ClientInfo& input) { + ClientInfo output; + output.sdk = toClientSdk(input.sdk()); + output.version = input.version(); + output.protocol = input.protocol(); + output.os = input.os(); + output.os_version = input.os_version(); + output.device_model = input.device_model(); + output.browser = input.browser(); + output.browser_version = input.browser_version(); + output.address = input.address(); + output.network = input.network(); + output.other_sdks = input.other_sdks(); + output.client_protocol = input.client_protocol(); + output.capabilities.reserve(input.capabilities_size()); + for (const auto capability : input.capabilities()) { + output.capabilities.push_back(toClientCapability(capability)); + } + return output; +} + +} // namespace + bool initialize(const LogLevel& level) { // Initializes logger if singleton instance is not already initialized setLogLevel(level); @@ -29,6 +105,16 @@ bool initialize(const LogLevel& level) { return ffi_client.initialize(false); } +ClientInfo getClientInfo() { + proto::FfiRequest request; + (void)request.mutable_get_client_info(); + const auto response = FfiClient::instance().sendRequest(request); + if (!response.has_get_client_info()) { + throw std::runtime_error("FfiResponse missing get_client_info"); + } + return fromProto(response.get_client_info().info()); +} + bool isInitialized() { return FfiClient::instance().isInitialized(); } void shutdown() { diff --git a/src/room_proto_converter.cpp b/src/room_proto_converter.cpp index a1265aa3..812b741c 100644 --- a/src/room_proto_converter.cpp +++ b/src/room_proto_converter.cpp @@ -469,6 +469,9 @@ proto::RoomOptions toProto(const RoomOptions& in) { if (in.connect_timeout) { out.set_connect_timeout_ms(static_cast(in.connect_timeout->count())); } + if (!in.other_sdks.empty()) { + out.set_other_sdks(in.other_sdks); + } return out; } diff --git a/src/tests/stress/test_client_info_stress.cpp b/src/tests/stress/test_client_info_stress.cpp new file mode 100644 index 00000000..56c21753 --- /dev/null +++ b/src/tests/stress/test_client_info_stress.cpp @@ -0,0 +1,79 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed 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 +#include + +#include +#include +#include +#include +#include + +namespace livekit::test { + +class ClientInfoStressTest : public ::testing::Test { +protected: + void SetUp() override { ASSERT_TRUE(livekit::initialize(livekit::LogLevel::Info)); } + + void TearDown() override { livekit::shutdown(); } +}; + +TEST_F(ClientInfoStressTest, RepeatedQueries) { + constexpr int kIterations = 1000; + const auto start = std::chrono::steady_clock::now(); + + for (int i = 0; i < kIterations; ++i) { + const auto info = livekit::getClientInfo(); + ASSERT_EQ(info.sdk, livekit::ClientInfo::Sdk::Cpp); + } + + const auto elapsed = std::chrono::steady_clock::now() - start; + const auto elapsed_us = std::chrono::duration_cast(elapsed).count(); + std::cout << "Retrieved ClientInfo " << kIterations << " times in " << elapsed_us << "us\n"; +} + +TEST_F(ClientInfoStressTest, ConcurrentQueries) { + constexpr int kThreadCount = 8; + constexpr int kQueriesPerThread = 250; + std::atomic successful_queries{0}; + std::vector threads; + threads.reserve(kThreadCount); + + const auto start = std::chrono::steady_clock::now(); + for (int thread_index = 0; thread_index < kThreadCount; ++thread_index) { + threads.emplace_back([&successful_queries] { + for (int query_index = 0; query_index < kQueriesPerThread; ++query_index) { + const auto info = livekit::getClientInfo(); + if (info.sdk == livekit::ClientInfo::Sdk::Cpp) { + successful_queries.fetch_add(1, std::memory_order_relaxed); + } + } + }); + } + + for (auto& thread : threads) { + thread.join(); + } + + const auto elapsed = std::chrono::steady_clock::now() - start; + const auto elapsed_us = std::chrono::duration_cast(elapsed).count(); + EXPECT_EQ(successful_queries.load(std::memory_order_relaxed), kThreadCount * kQueriesPerThread); + std::cout << "Retrieved ClientInfo " << successful_queries.load(std::memory_order_relaxed) << " times across " + << kThreadCount << " threads in " << elapsed_us << "us\n"; +} + +} // namespace livekit::test diff --git a/src/tests/unit/test_room.cpp b/src/tests/unit/test_room.cpp index 6ea52d0c..c8e10be6 100644 --- a/src/tests/unit/test_room.cpp +++ b/src/tests/unit/test_room.cpp @@ -94,7 +94,7 @@ TEST_F(RoomTest, ConnectWithoutInitialize) { Room room; // Default room options okay here, will return before FFI layer since not initialized - bool result = room.connect("wss://localhost:7880", "test", livekit::RoomOptions()); + const bool result = room.connect("wss://localhost:7880", "test", livekit::RoomOptions()); EXPECT_FALSE(result) << "Connecting without initializing should return false"; EXPECT_TRUE(room.localParticipant().expired()) << "Local participant should be empty after failed connect"; EXPECT_TRUE(room.remoteParticipants().empty()) << "Remote participants should be empty after failed connect"; @@ -176,13 +176,13 @@ TEST(RoomOptionsProtoTest, TokenRefreshedFromProto) { } TEST_F(RoomTest, CreateRoom) { - Room room; + const Room room; // Room should be created without issues EXPECT_TRUE(room.localParticipant().expired()) << "Local participant should be empty before connect"; } TEST_F(RoomTest, RoomOptionsDefaults) { - RoomOptions options; + const RoomOptions options; EXPECT_TRUE(options.auto_subscribe) << "auto_subscribe should default to true"; EXPECT_FALSE(options.adaptive_stream.has_value()) << "adaptive_stream should defer to Rust default"; @@ -192,6 +192,7 @@ TEST_F(RoomTest, RoomOptionsDefaults) { EXPECT_FALSE(options.join_retries.has_value()) << "join_retries should defer to Rust default"; EXPECT_TRUE(options.single_peer_connection) << "single_peer_connection should default to true"; EXPECT_FALSE(options.connect_timeout.has_value()) << "connect_timeout should defer to Rust default"; + EXPECT_TRUE(options.other_sdks.empty()) << "other_sdks should default to empty"; } TEST_F(RoomTest, RoomOptionsToProtoSerializesDefaults) { @@ -208,6 +209,7 @@ TEST_F(RoomTest, RoomOptionsToProtoSerializesDefaults) { EXPECT_TRUE(proto_options.has_single_peer_connection()); EXPECT_TRUE(proto_options.single_peer_connection()); EXPECT_FALSE(proto_options.has_connect_timeout_ms()); + EXPECT_FALSE(proto_options.has_other_sdks()); } TEST_F(RoomTest, RoomOptionsProtoConverter) { @@ -227,6 +229,7 @@ TEST_F(RoomTest, RoomOptionsProtoConverter) { options.join_retries = 8; options.single_peer_connection = false; options.connect_timeout = std::chrono::milliseconds(750); + options.other_sdks = "ros_portal:1.2.3"; const proto::RoomOptions proto_options = toProto(options); @@ -255,12 +258,15 @@ TEST_F(RoomTest, RoomOptionsProtoConverter) { EXPECT_FALSE(proto_options.single_peer_connection()); EXPECT_TRUE(proto_options.has_connect_timeout_ms()); EXPECT_EQ(proto_options.connect_timeout_ms(), 750U); + ASSERT_TRUE(proto_options.has_other_sdks()); + EXPECT_EQ(proto_options.other_sdks(), "ros_portal:1.2.3"); } TEST(RoomOptionsProtoTest, ConnectRequestSerializesRetryOptions) { RoomOptions options; options.join_retries = 8; options.connect_timeout = std::chrono::milliseconds(750); + options.other_sdks = "ros_portal:1.2.3"; proto::FfiRequest request; auto* connect = request.mutable_connect(); @@ -272,6 +278,8 @@ TEST(RoomOptionsProtoTest, ConnectRequestSerializesRetryOptions) { EXPECT_EQ(connect->options().join_retries(), 8U); ASSERT_TRUE(connect->options().has_connect_timeout_ms()); EXPECT_EQ(connect->options().connect_timeout_ms(), 750U); + ASSERT_TRUE(connect->options().has_other_sdks()); + EXPECT_EQ(connect->options().other_sdks(), "ros_portal:1.2.3"); ASSERT_TRUE(request.IsInitialized()) << request.InitializationErrorString(); @@ -283,10 +291,11 @@ TEST(RoomOptionsProtoTest, ConnectRequestSerializesRetryOptions) { ASSERT_TRUE(decoded.ParseFromString(serialized)); EXPECT_EQ(decoded.connect().options().join_retries(), 8U); EXPECT_EQ(decoded.connect().options().connect_timeout_ms(), 750U); + EXPECT_EQ(decoded.connect().options().other_sdks(), "ros_portal:1.2.3"); } TEST_F(RoomTest, RtcConfigDefaults) { - RtcConfig config; + const RtcConfig config; EXPECT_EQ(config.ice_transport_type, 0); EXPECT_EQ(config.continual_gathering_policy, 0); @@ -322,13 +331,13 @@ TEST_F(RoomTest, RoomWithCustomRtcConfig) { } TEST_F(RoomTest, RemoteParticipantsEmptyBeforeConnect) { - Room room; + const Room room; auto participants = room.remoteParticipants(); EXPECT_TRUE(participants.empty()) << "Remote participants should be empty before connect"; } TEST_F(RoomTest, RemoteParticipantLookupBeforeConnect) { - Room room; + const Room room; EXPECT_TRUE(room.remoteParticipant("nonexistent").expired()) << "Looking up participant before connect should return an empty handle"; } diff --git a/src/tests/unit/test_sdk_initialization.cpp b/src/tests/unit/test_sdk_initialization.cpp index 45137dc9..7a0a4a9d 100644 --- a/src/tests/unit/test_sdk_initialization.cpp +++ b/src/tests/unit/test_sdk_initialization.cpp @@ -17,6 +17,8 @@ #include #include +#include +#include #include namespace livekit::test { @@ -29,31 +31,53 @@ class SDKInitializationTest : public ::testing::Test { }; TEST_F(SDKInitializationTest, InitializeDefault) { - bool result = livekit::initialize(); + const bool result = livekit::initialize(); EXPECT_TRUE(result) << "First initialization should succeed"; } TEST_F(SDKInitializationTest, InitializeWithLogLevel) { - bool result = livekit::initialize(livekit::LogLevel::Debug); + const bool result = livekit::initialize(livekit::LogLevel::Debug); EXPECT_TRUE(result) << "Initialization with explicit log level should succeed"; EXPECT_EQ(livekit::getLogLevel(), livekit::LogLevel::Debug); } +TEST_F(SDKInitializationTest, GetClientInfoRequiresInitialization) { + EXPECT_THROW((void)livekit::getClientInfo(), std::runtime_error); +} + +TEST_F(SDKInitializationTest, GetClientInfoReturnsAdvertisedMetadata) { + ASSERT_TRUE(livekit::initialize()); + + const auto info = livekit::getClientInfo(); + + EXPECT_EQ(info.sdk, livekit::ClientInfo::Sdk::Cpp); + EXPECT_EQ(info.version, LIVEKIT_BUILD_VERSION); + EXPECT_GT(info.protocol, 0); + EXPECT_FALSE(info.os.empty()); + EXPECT_GT(info.client_protocol, 0); + EXPECT_NE( + std::find(info.capabilities.begin(), info.capabilities.end(), livekit::ClientInfo::Capability::PacketTrailer), + info.capabilities.end()); + EXPECT_NE(std::find(info.capabilities.begin(), info.capabilities.end(), + livekit::ClientInfo::Capability::CompressionDeflateRaw), + info.capabilities.end()); +} + TEST_F(SDKInitializationTest, DoubleInitializationReturnsFalse) { - bool first = livekit::initialize(); + const bool first = livekit::initialize(); EXPECT_TRUE(first) << "First initialization should succeed"; - bool second = livekit::initialize(); + const bool second = livekit::initialize(); EXPECT_FALSE(second) << "Second initialization should return false"; } TEST_F(SDKInitializationTest, ReinitializeAfterShutdown) { - bool first = livekit::initialize(); + const bool first = livekit::initialize(); EXPECT_TRUE(first) << "First initialization should succeed"; livekit::shutdown(); - bool second = livekit::initialize(); + const bool second = livekit::initialize(); EXPECT_TRUE(second) << "Re-initialization after shutdown should succeed"; } @@ -75,4 +99,11 @@ TEST(SDKBuildInfoTest, ServerFacingVersionDoesNotIncludeBuildFlavorSuffix) { EXPECT_EQ(version.find("-release"), std::string::npos); } +TEST(SDKBuildInfoTest, ClientInfoEnumValuesMatchLiveKitModels) { + static_assert(static_cast(livekit::ClientInfo::Sdk::Cpp) == 10); + static_assert(static_cast(livekit::ClientInfo::Sdk::Esp32) == 14); + static_assert(static_cast(livekit::ClientInfo::Capability::PacketTrailer) == 1); + static_assert(static_cast(livekit::ClientInfo::Capability::CompressionDeflateRaw) == 2); +} + } // namespace livekit::test