diff --git a/.build-info.json.in b/.build-info.json.in index 7dd75637..3836b869 100644 --- a/.build-info.json.in +++ b/.build-info.json.in @@ -4,5 +4,6 @@ "cpp_git_commit": "@GIT_COMMIT@", "rust_submodule_commit": "@RUST_GIT_COMMIT@", "livekit_ffi_version": "@LIVEKIT_FFI_VERSION@", + "livekit_uniffi_version": "@LIVEKIT_UNIFFI_VERSION@", "build_date": "@BUILD_DATE@" } diff --git a/.github/workflows/make-release.yml b/.github/workflows/make-release.yml index 45bb289d..fe029ca5 100644 --- a/.github/workflows/make-release.yml +++ b/.github/workflows/make-release.yml @@ -238,6 +238,13 @@ jobs: grep -F "\"sdk_version\": \"${version}\"" "$buildInfo" grep -Eq '"rust_submodule_commit": "[0-9a-f]{7,}"' "$buildInfo" grep -Eq '"livekit_ffi_version": "[0-9]+\.[0-9]+\.[0-9]+' "$buildInfo" + grep -Eq '"livekit_uniffi_version": "[0-9]+\.[0-9]+\.[0-9]+' "$buildInfo" + test -f "${bundleDir}/include/livekit/uniffi/livekit_uniffi.hpp" + if [[ "$RUNNER_OS" == "macOS" ]]; then + test -f "${bundleDir}/lib/liblivekit_uniffi.dylib" + else + test -f "${bundleDir}/lib/liblivekit_uniffi.so" + fi # ---------- Build + Bundle (Windows) ---------- - name: Build and Bundle (Windows) @@ -277,6 +284,12 @@ jobs: fi fi python3 .github/scripts/check_no_private_symbols.py "${lib}" + if [[ "$RUNNER_OS" == "macOS" ]]; then + uniffi_lib="${libdir}/liblivekit_uniffi.dylib" + else + uniffi_lib="${libdir}/liblivekit_uniffi.so" + fi + python3 .github/scripts/check_no_private_symbols.py "${uniffi_lib}" - name: Symbol leak check (Windows) if: runner.os == 'Windows' @@ -295,6 +308,15 @@ jobs: exit 1 } python .github/scripts/check_no_private_symbols.py "$lib" + $uniffiLib = "build-release/bin/livekit_uniffi.dll" + if (!(Test-Path -LiteralPath $uniffiLib)) { + $uniffiLib = "build-release/lib/livekit_uniffi.dll" + } + if (!(Test-Path -LiteralPath $uniffiLib)) { + Write-Error "livekit_uniffi.dll not found" + exit 1 + } + python .github/scripts/check_no_private_symbols.py "$uniffiLib" - name: Validate bundle version metadata (Windows) if: runner.os == 'Windows' @@ -328,6 +350,13 @@ jobs: if ($buildInfoText -match '"livekit_ffi_version": "unknown"') { throw "Build info is missing livekit-ffi version" } + if ($buildInfoText -match '"livekit_uniffi_version": "unknown"') { + throw "Build info is missing livekit-uniffi version" + } + $uniffiHeader = Join-Path $bundleDir "include/livekit/uniffi/livekit_uniffi.hpp" + if (!(Test-Path $uniffiHeader)) { throw "Missing $uniffiHeader" } + $uniffiRuntime = Join-Path $bundleDir "bin/livekit_uniffi.dll" + if (!(Test-Path $uniffiRuntime)) { throw "Missing $uniffiRuntime" } # ---------- Upload artifact (raw directory, no pre-compression) ---------- - name: Upload build artifact diff --git a/AGENTS.md b/AGENTS.md index 55da0430..8e5927f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,12 +85,13 @@ Be sure to update the directory layout in this file if the directory layout chan |------|-------------| | `include/livekit/` | Public API headers (what SDK consumers include) | | `src/` | Implementation files and internal-only headers (`ffi_client.h`, `lk_log.h`, etc.) | -| `src/tests/` | Google Test integration and stress tests | +| `src/tests/` | Google Test unit, integration, and stress tests | | `examples/` | In-tree example applications | | `client-sdk-rust/` | Git submodule holding the Rust core of the SDK| | `cpp-tools/` | Git submodule holding shared LiveKit C++ engineering guidance, clang-format / clang-tidy configs, scripts, docs, and CI workflow | | `client-sdk-rust/livekit-ffi/protocol/*.proto` | FFI contract (protobuf definitions, read-only reference) | -| `cmake/` | Build helpers (`protobuf.cmake`, `spdlog.cmake`, `nlohmann_json.cmake`, `LiveKitConfig.cmake.in`) | +| `client-sdk-rust/livekit-uniffi/` | UniFFI Rust crate, built and bundled alongside the protobuf FFI | +| `cmake/` | Build helpers (`protobuf.cmake`, `uniffi.cmake`, `spdlog.cmake`, `nlohmann_json.cmake`, `LiveKitConfig.cmake.in`) | | `scripts/` | Local helper scripts for SDK-specific development tasks | | `docs/` | Documentation root. `docs/` holds hand-written long-form Markdown intended to also read well on GitHub. | | `docs/doxygen/` | Doxygen tool config, theme assets, and Doxygen-only content (`Doxyfile`, `index.md` mainpage, `customization/*.css`, `customization/header.html`, `customization/favicon.ico`). Files here use Doxygen-only syntax (`@ref`, `@brief`, …) and are not intended for human reading on their own. | diff --git a/CMakeLists.txt b/CMakeLists.txt index 1c7b244c..ef95d3e5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -198,6 +198,17 @@ if(EXISTS "${LIVEKIT_FFI_CARGO_TOML}") endif() endif() +set(LIVEKIT_UNIFFI_VERSION "unknown") +set(LIVEKIT_UNIFFI_CARGO_TOML "${RUST_ROOT}/livekit-uniffi/Cargo.toml") +if(EXISTS "${LIVEKIT_UNIFFI_CARGO_TOML}") + file(READ "${LIVEKIT_UNIFFI_CARGO_TOML}" LIVEKIT_UNIFFI_CARGO_TOML_CONTENTS) + string(REGEX MATCH "version[ \t]*=[ \t]*\"([^\"]+)\"" _LIVEKIT_UNIFFI_VERSION_MATCH + "${LIVEKIT_UNIFFI_CARGO_TOML_CONTENTS}") + if(_LIVEKIT_UNIFFI_VERSION_MATCH) + set(LIVEKIT_UNIFFI_VERSION "${CMAKE_MATCH_1}") + endif() +endif() + string(TIMESTAMP BUILD_DATE "%Y-%m-%dT%H:%M:%SZ" UTC) set(GENERATED_COMMENT "This file was auto-generated by CMake on ${BUILD_DATE}. Do NOT edit manually.") @@ -256,7 +267,11 @@ if(NOT DEFINED CARGO) message(FATAL_ERROR \"CARGO not set\") endif() -set(ARGS build --package livekit-ffi) +if(NOT DEFINED PACKAGE OR PACKAGE STREQUAL \"\") + set(PACKAGE livekit-ffi) +endif() + +set(ARGS build --package \"\${PACKAGE}\") if(NOT CFG STREQUAL \"Debug\") list(APPEND ARGS --release) endif() @@ -357,6 +372,7 @@ add_custom_command( -DPROTOC_PATH=${Protobuf_PROTOC_EXECUTABLE} -DRUST_TARGET=${RUST_TARGET_TRIPLE} -DGCC_LIB_DIR=${GCC_LIB_DIR} + -DPACKAGE=livekit-ffi -P "${RUN_CARGO_SCRIPT}" WORKING_DIRECTORY "${RUST_ROOT}" DEPENDS ${RUST_SOURCES} @@ -643,6 +659,9 @@ endif() # -------------------- Install / Package (SDK bundle) -------------------- include(GNUInstallDirs) include(CMakePackageConfigHelpers) +include(uniffi) +add_dependencies(livekit livekit_uniffi_cpp) +target_link_libraries(livekit PRIVATE livekit_uniffi_cpp livekit_uniffi) set(LIVEKIT_PACKAGE_NAME "LiveKit") set(LIVEKIT_EXPORT_NAMESPACE "LiveKit::") @@ -695,6 +714,7 @@ if(APPLE) set(_livekit "${_libdir}/liblivekit.dylib") set(_ffi "${_libdir}/liblivekit_ffi.dylib") + set(_uniffi "${_libdir}/liblivekit_uniffi.dylib") message(STATUS "macOS fixup: prefix=${_prefix}") message(STATUS "macOS fixup: libdir=${_libdir}") @@ -707,6 +727,9 @@ if(APPLE) if(NOT EXISTS "${_livekit}") message(FATAL_ERROR "Expected ${_livekit} to exist at install time") endif() + if(NOT EXISTS "${_uniffi}") + message(FATAL_ERROR "Expected ${_uniffi} to exist at install time") + endif() # Make the dylib's "id" relocatable execute_process( @@ -717,6 +740,14 @@ if(APPLE) message(FATAL_ERROR "install_name_tool -id failed for ${_ffi}") endif() + execute_process( + COMMAND /usr/bin/install_name_tool -id "@rpath/liblivekit_uniffi.dylib" "${_uniffi}" + RESULT_VARIABLE _rv + ) + if(NOT _rv EQUAL 0) + message(FATAL_ERROR "install_name_tool -id failed for ${_uniffi}") + endif() + # Make liblivekit depend on @rpath/liblivekit_ffi.dylib (replace any absolute build path) execute_process( COMMAND /usr/bin/install_name_tool @@ -792,8 +823,9 @@ if(LIVEKIT_BUILD_TESTS) endif() add_custom_target(clean_generated - COMMAND ${CMAKE_COMMAND} -E rm -rf "${PROTO_BINARY_DIR}" - COMMENT "Clean generated protobuf files" + COMMAND ${CMAKE_COMMAND} -E rm -rf + "${PROTO_BINARY_DIR}" "${LIVEKIT_UNIFFI_CPP_GENERATED_DIR}" + COMMENT "Clean generated protobuf and UniFFI files" VERBATIM ) diff --git a/client-sdk-rust b/client-sdk-rust index 1a477bc4..a2f82422 160000 --- a/client-sdk-rust +++ b/client-sdk-rust @@ -1 +1 @@ -Subproject commit 1a477bc422c6890537b3bcdb017f0ac094d49661 +Subproject commit a2f82422399c384320123f2ccd892bccc6b3257e diff --git a/cmake/LiveKitConfig.cmake.in b/cmake/LiveKitConfig.cmake.in index b5805e4d..28f005b4 100644 --- a/cmake/LiveKitConfig.cmake.in +++ b/cmake/LiveKitConfig.cmake.in @@ -2,3 +2,34 @@ include("${CMAKE_CURRENT_LIST_DIR}/LiveKitTargets.cmake") +if(NOT TARGET LiveKit::uniffi_runtime) + if(WIN32) + set(_livekit_uniffi_runtime "${PACKAGE_PREFIX_DIR}/bin/livekit_uniffi.dll") + set(_livekit_uniffi_implib "${PACKAGE_PREFIX_DIR}/lib/livekit_uniffi.dll.lib") + elseif(APPLE) + set(_livekit_uniffi_runtime "${PACKAGE_PREFIX_DIR}/lib/liblivekit_uniffi.dylib") + else() + set(_livekit_uniffi_runtime "${PACKAGE_PREFIX_DIR}/lib/liblivekit_uniffi.so") + endif() + + set_and_check(_livekit_uniffi_runtime "${_livekit_uniffi_runtime}") + add_library(LiveKit::uniffi_runtime SHARED IMPORTED GLOBAL) + set_target_properties(LiveKit::uniffi_runtime PROPERTIES + IMPORTED_LOCATION "${_livekit_uniffi_runtime}" + ) + if(WIN32) + set_and_check(_livekit_uniffi_implib "${_livekit_uniffi_implib}") + set_target_properties(LiveKit::uniffi_runtime PROPERTIES + IMPORTED_IMPLIB "${_livekit_uniffi_implib}" + ) + elseif(UNIX AND NOT APPLE) + set_target_properties(LiveKit::uniffi_runtime PROPERTIES IMPORTED_NO_SONAME TRUE) + endif() +endif() + +if(TARGET LiveKit::uniffi) + set_property(TARGET LiveKit::uniffi APPEND PROPERTY + INTERFACE_LINK_LIBRARIES LiveKit::uniffi_runtime + ) +endif() + diff --git a/cmake/uniffi.cmake b/cmake/uniffi.cmake new file mode 100644 index 00000000..35955ba1 --- /dev/null +++ b/cmake/uniffi.cmake @@ -0,0 +1,191 @@ +# Copyright 2026 LiveKit, Inc. +# +# 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. + +# Keep this pin aligned with client-sdk-rust/livekit-uniffi/Makefile.toml. +set(LIVEKIT_UNIFFI_CPP_BINDGEN_REPOSITORY "https://github.com/alan-george-lk/uniffi-bindgen-cpp.git") +set(LIVEKIT_UNIFFI_CPP_BINDGEN_REVISION "abd001d064d87973f6bb2ee5fd70e30e686a72ae") +set(LIVEKIT_UNIFFI_CPP_BINDGEN_ROOT + "${RUST_ROOT}/target/uniffi-bindgen-cpp/${LIVEKIT_UNIFFI_CPP_BINDGEN_REVISION}") +set(LIVEKIT_UNIFFI_CPP_BINDGEN + "${LIVEKIT_UNIFFI_CPP_BINDGEN_ROOT}/bin/uniffi-bindgen-cpp${CMAKE_EXECUTABLE_SUFFIX}") +set(LIVEKIT_UNIFFI_CPP_GENERATED_DIR "${LIVEKIT_BINARY_DIR}/generated/uniffi") + +if(WIN32) + set(LIVEKIT_UNIFFI_LIBRARY_NAME "livekit_uniffi.dll") + set(LIVEKIT_UNIFFI_IMPLIB_NAME "livekit_uniffi.dll.lib") +elseif(APPLE) + set(LIVEKIT_UNIFFI_LIBRARY_NAME "liblivekit_uniffi.dylib") +else() + set(LIVEKIT_UNIFFI_LIBRARY_NAME "liblivekit_uniffi.so") +endif() + +set(LIVEKIT_UNIFFI_LIBRARY_DEBUG "${RUST_TARGET_DIR}/debug/${LIVEKIT_UNIFFI_LIBRARY_NAME}") +set(LIVEKIT_UNIFFI_LIBRARY_RELEASE "${RUST_TARGET_DIR}/release/${LIVEKIT_UNIFFI_LIBRARY_NAME}") + +file(GLOB_RECURSE LIVEKIT_UNIFFI_RUST_SOURCES + "${RUST_ROOT}/livekit-uniffi/src/*.rs" + "${RUST_ROOT}/livekit-uniffi/Cargo.toml" + "${RUST_ROOT}/livekit-common/src/*.rs" + "${RUST_ROOT}/livekit-common/Cargo.toml" + "${RUST_ROOT}/livekit-datatrack/src/*.rs" + "${RUST_ROOT}/livekit-datatrack/Cargo.toml" + "${RUST_ROOT}/livekit-net/src/*.rs" + "${RUST_ROOT}/livekit-net/Cargo.toml" + "${RUST_ROOT}/livekit-token/src/*.rs" + "${RUST_ROOT}/livekit-token/Cargo.toml" + "${RUST_ROOT}/livekit-protocol/src/*.rs" + "${RUST_ROOT}/livekit-protocol/Cargo.toml" + "${RUST_ROOT}/Cargo.toml" + "${RUST_ROOT}/Cargo.lock" +) + +set(LIVEKIT_UNIFFI_GENERATED_SOURCES + "${LIVEKIT_UNIFFI_CPP_GENERATED_DIR}/livekit_common.cpp" + "${LIVEKIT_UNIFFI_CPP_GENERATED_DIR}/livekit_datatrack.cpp" + "${LIVEKIT_UNIFFI_CPP_GENERATED_DIR}/livekit_net.cpp" + "${LIVEKIT_UNIFFI_CPP_GENERATED_DIR}/livekit_uniffi.cpp" +) +set(LIVEKIT_UNIFFI_GENERATED_HEADERS + "${LIVEKIT_UNIFFI_CPP_GENERATED_DIR}/livekit_common.hpp" + "${LIVEKIT_UNIFFI_CPP_GENERATED_DIR}/livekit_datatrack.hpp" + "${LIVEKIT_UNIFFI_CPP_GENERATED_DIR}/livekit_net.hpp" + "${LIVEKIT_UNIFFI_CPP_GENERATED_DIR}/livekit_uniffi.hpp" +) + +add_custom_command( + OUTPUT "${LIVEKIT_UNIFFI_CPP_BINDGEN}" + COMMAND "${CARGO_EXECUTABLE}" install --locked + --git "${LIVEKIT_UNIFFI_CPP_BINDGEN_REPOSITORY}" + --rev "${LIVEKIT_UNIFFI_CPP_BINDGEN_REVISION}" + --root "${LIVEKIT_UNIFFI_CPP_BINDGEN_ROOT}" + uniffi-bindgen-cpp + COMMENT "Installing pinned uniffi-bindgen-cpp" + VERBATIM +) +add_custom_target(build_uniffi_bindgen DEPENDS "${LIVEKIT_UNIFFI_CPP_BINDGEN}") + +add_custom_command( + OUTPUT "${LIVEKIT_UNIFFI_LIBRARY_DEBUG}" "${LIVEKIT_UNIFFI_LIBRARY_RELEASE}" + COMMAND "${CMAKE_COMMAND}" + -DCFG=$ + -DRUST_ROOT=${RUST_ROOT} + -DCARGO=${CARGO_EXECUTABLE} + -DPROTOC_PATH=${Protobuf_PROTOC_EXECUTABLE} + -DRUST_TARGET=${RUST_TARGET_TRIPLE} + -DGCC_LIB_DIR=${GCC_LIB_DIR} + -DPACKAGE=livekit-uniffi + -P "${RUN_CARGO_SCRIPT}" + WORKING_DIRECTORY "${RUST_ROOT}" + DEPENDS ${LIVEKIT_UNIFFI_RUST_SOURCES} + COMMENT "Building livekit-uniffi via cargo" + VERBATIM +) +add_custom_target(build_rust_uniffi + DEPENDS "${LIVEKIT_UNIFFI_LIBRARY_DEBUG}" "${LIVEKIT_UNIFFI_LIBRARY_RELEASE}" +) + +add_custom_command( + OUTPUT ${LIVEKIT_UNIFFI_GENERATED_SOURCES} ${LIVEKIT_UNIFFI_GENERATED_HEADERS} + COMMAND "${CMAKE_COMMAND}" -E make_directory "${LIVEKIT_UNIFFI_CPP_GENERATED_DIR}" + COMMAND "${LIVEKIT_UNIFFI_CPP_BINDGEN}" + --library + "$,${LIVEKIT_UNIFFI_LIBRARY_DEBUG},${LIVEKIT_UNIFFI_LIBRARY_RELEASE}>" + --out-dir "${LIVEKIT_UNIFFI_CPP_GENERATED_DIR}" + WORKING_DIRECTORY "${RUST_ROOT}" + DEPENDS build_uniffi_bindgen build_rust_uniffi ${LIVEKIT_UNIFFI_RUST_SOURCES} + COMMENT "Generating C++ bindings for livekit-uniffi" + VERBATIM +) + +set_source_files_properties( + ${LIVEKIT_UNIFFI_GENERATED_SOURCES} ${LIVEKIT_UNIFFI_GENERATED_HEADERS} + PROPERTIES GENERATED TRUE +) + +add_library(livekit_uniffi SHARED IMPORTED GLOBAL) +set_target_properties(livekit_uniffi PROPERTIES + IMPORTED_CONFIGURATIONS "Debug;Release;RelWithDebInfo;MinSizeRel" + IMPORTED_LOCATION_DEBUG "${LIVEKIT_UNIFFI_LIBRARY_DEBUG}" + IMPORTED_LOCATION_RELEASE "${LIVEKIT_UNIFFI_LIBRARY_RELEASE}" + IMPORTED_LOCATION_RELWITHDEBINFO "${LIVEKIT_UNIFFI_LIBRARY_RELEASE}" + IMPORTED_LOCATION_MINSIZEREL "${LIVEKIT_UNIFFI_LIBRARY_RELEASE}" +) +if(WIN32) + set_target_properties(livekit_uniffi PROPERTIES + IMPORTED_IMPLIB_DEBUG "${RUST_TARGET_DIR}/debug/${LIVEKIT_UNIFFI_IMPLIB_NAME}" + IMPORTED_IMPLIB_RELEASE "${RUST_TARGET_DIR}/release/${LIVEKIT_UNIFFI_IMPLIB_NAME}" + IMPORTED_IMPLIB_RELWITHDEBINFO "${RUST_TARGET_DIR}/release/${LIVEKIT_UNIFFI_IMPLIB_NAME}" + IMPORTED_IMPLIB_MINSIZEREL "${RUST_TARGET_DIR}/release/${LIVEKIT_UNIFFI_IMPLIB_NAME}" + ) +elseif(UNIX AND NOT APPLE) + set_target_properties(livekit_uniffi PROPERTIES IMPORTED_NO_SONAME TRUE) +endif() + +add_library(livekit_uniffi_cpp STATIC + ${LIVEKIT_UNIFFI_GENERATED_SOURCES} ${LIVEKIT_UNIFFI_GENERATED_HEADERS} +) +add_dependencies(livekit_uniffi_cpp build_rust_uniffi) +set_target_properties(livekit_uniffi_cpp PROPERTIES EXPORT_NAME uniffi) +target_compile_features(livekit_uniffi_cpp PRIVATE cxx_std_17) +target_include_directories(livekit_uniffi_cpp + PUBLIC + $ + $ +) +target_link_libraries(livekit_uniffi_cpp PRIVATE $) +livekit_disable_warnings(livekit_uniffi_cpp) + +if(LIVEKIT_IS_TOPLEVEL) + add_custom_command(TARGET livekit_uniffi_cpp POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + $ + "$/${LIVEKIT_UNIFFI_LIBRARY_NAME}" + COMMENT "Copying livekit-uniffi runtime to output directory" + ) + if(APPLE) + add_custom_command(TARGET livekit_uniffi_cpp POST_BUILD + COMMAND /usr/bin/install_name_tool + -id "@rpath/liblivekit_uniffi.dylib" + "$/liblivekit_uniffi.dylib" + COMMENT "Fix install_name id for liblivekit_uniffi.dylib" + VERBATIM + ) + endif() +endif() + +install(TARGETS livekit_uniffi_cpp + EXPORT LiveKitTargets + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/livekit/uniffi +) +install(DIRECTORY "${LIVEKIT_UNIFFI_CPP_GENERATED_DIR}/" + DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}/livekit/uniffi" + FILES_MATCHING PATTERN "*.hpp" +) +install(IMPORTED_RUNTIME_ARTIFACTS livekit_uniffi + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} +) +if(WIN32) + install(FILES + $ + DESTINATION ${CMAKE_INSTALL_LIBDIR} + CONFIGURATIONS Release RelWithDebInfo MinSizeRel + ) + install(FILES + $ + DESTINATION ${CMAKE_INSTALL_LIBDIR} + CONFIGURATIONS Debug + ) +endif() diff --git a/docs/building.md b/docs/building.md index fd4cbb83..fe99a5c6 100644 --- a/docs/building.md +++ b/docs/building.md @@ -211,11 +211,14 @@ build-release/ ├── lib/ │ ├── liblivekit.{so,dylib} # Main SDK shared library (Linux/macOS) │ ├── liblivekit_ffi.{so,dylib} # Rust FFI shared library (Linux/macOS) -│ └── livekit{,_ffi}.lib # Import libraries (Windows) +│ ├── liblivekit_uniffi.{so,dylib} # UniFFI Rust runtime (Linux/macOS) +│ ├── liblivekit_uniffi_cpp.a # Generated UniFFI C++ bindings +│ └── livekit{,_ffi,_uniffi}.lib # Import libraries (Windows) ├── include/ # Public headers (auto-synced) │ └── livekit/ +│ └── uniffi/ # Generated UniFFI C++ headers └── bin/ - └── livekit{,_ffi}.dll # SDK DLLs (Windows) + └── livekit{,_ffi,_uniffi}.dll # SDK DLLs (Windows) ``` Release archives use the same layout: `include/`, `lib/`, and (on Windows) @@ -233,8 +236,18 @@ target_link_libraries(your_target PRIVATE livekit) # Method 2: find_package (after install) find_package(LiveKit CONFIG REQUIRED) target_link_libraries(your_target PRIVATE LiveKit::livekit) + +# Link this alongside LiveKit::livekit to use the UniFFI API. +target_link_libraries(your_target PRIVATE LiveKit::uniffi) ``` +### UniFFI bindings + +The SDK always builds and bundles generated C++ UniFFI bindings and the +`livekit-uniffi` Rust runtime. `LiveKit::uniffi` links the installed static +bindings library and its runtime dependency. Include generated APIs from +`` and the other headers under `include/livekit/uniffi/`. + ### Using prebuilt releases The easiest way to consume the SDK without building from source is via diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index 9583af73..0e9724c6 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -89,6 +89,8 @@ if(UNIT_TEST_SOURCES) target_link_libraries(livekit_unit_tests PRIVATE livekit + livekit_uniffi_cpp + livekit_uniffi spdlog::spdlog ${LIVEKIT_PROTOBUF_TARGET} GTest::gtest_main @@ -146,6 +148,12 @@ if(UNIT_TEST_SOURCES) COMMENT "Copying shared libraries to unit test directory" ) endif() + add_custom_command(TARGET livekit_unit_tests POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMENT "Copying UniFFI library to unit test directory" + ) # Register tests with CTest gtest_discover_tests(livekit_unit_tests diff --git a/src/tests/unit/test_token_source.cpp b/src/tests/unit/test_token_source.cpp index fa835b9d..e3b8788b 100644 --- a/src/tests/unit/test_token_source.cpp +++ b/src/tests/unit/test_token_source.cpp @@ -31,9 +31,19 @@ namespace livekit::test { namespace { -// A non-expired unsigned JWT (alg=none, exp far in the future) used for stubbed -// token-endpoint responses. -constexpr const char* kValidToken = "eyJhbGciOiJub25lIn0.eyJleHAiOjk5OTk5OTk5OTk5fQ."; +// Non-production ES256 fixtures shared with the JS SDK's token-source tests. +// Their signatures have no corresponding private key in this repository. +constexpr const char* kValidToken = + "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9." + "eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjo5ODc2NTQzMjEwLCJuYmYiOjEyMzQ1Njc4OTAsImlhdCI6MTIzNDU2Nzg5MCwicm9vbUNvbmZpZyI6ey" + "JuYW1lIjoidGVzdCByb29tIG5hbWUiLCJlbXB0eVRpbWVvdXQiOjAsImRlcGFydHVyZVRpbWVvdXQiOjAsIm1heFBhcnRpY2lwYW50cyI6MCwibWlu" + "UGxheW91dERlbGF5IjowLCJtYXhQbGF5b3V0RGVsYXkiOjAsInN5bmNTdHJlYW1zIjpmYWxzZSwiYWdlbnRzIjpbeyJhZ2VudE5hbWUiOiJ0ZXN0IG" + "FnZW50IG5hbWUiLCJtZXRhZGF0YSI6InRlc3QgYWdlbnQgbWV0YWRhdGEifV0sIm1ldGFkYXRhIjoiIn19." + "EDetpHG8cSubaApzgWJaQrpCiSy9KDBlfCfVdIydbQ-_CHiNnXOK_f_mCJbTf9A-duT1jmvPOkLrkkWFT60XPQ"; +constexpr const char* kExpiredToken = + "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9." + "eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjoxMjM0NTY3ODkxLCJuYmYiOjEyMzQ1Njc4OTAsImlhdCI6MTIzNDU2Nzg5MH0." + "OYP1NITayotBYt0mioInLJmaIM0bHyyR-yG6iwKyQDzhoGha15qbsc7dOJlzz4za1iW5EzCgjc2_xGxqaSu5XA"; constexpr const char* kServerUrl = "wss://localhost:7000"; // Captures the arguments the token source passed to the HTTP transport so tests @@ -299,11 +309,8 @@ TEST(TokenSourceJsonTest, ParseResponseMissingParticipantTokenFails) { } TEST(TokenSourceJwtTest, ValidAndExpiredTokens) { - const std::string valid_token = "eyJhbGciOiJub25lIn0.eyJleHAiOjk5OTk5OTk5OTk5fQ."; - const std::string expired_token = "eyJhbGciOiJub25lIn0.eyJleHAiOjF9."; - - EXPECT_TRUE(isParticipantTokenValid(valid_token)); - EXPECT_FALSE(isParticipantTokenValid(expired_token)); + EXPECT_TRUE(isParticipantTokenValid(kValidToken)); + EXPECT_FALSE(isParticipantTokenValid(kExpiredToken)); } TEST(TokenSourceJwtTest, UnparseableTokenIsInvalid) { EXPECT_FALSE(isParticipantTokenValid("not-a-jwt")); } @@ -347,7 +354,7 @@ TEST(TokenSourceFactoryTest, CachingTokenSourceReusesValidToken) { ++fetch_count; TokenSourceResponse details; details.server_url = "wss://example.livekit.io"; - details.participant_token = "eyJhbGciOiJub25lIn0.eyJleHAiOjk5OTk5OTk5OTk5fQ."; + details.participant_token = kValidToken; std::promise> promise; promise.set_value(Result::success(details)); return promise.get_future(); @@ -371,7 +378,7 @@ TEST(TokenSourceFactoryTest, CachingTokenSourceRefetchesAfterInvalidate) { ++fetch_count; TokenSourceResponse details; details.server_url = "wss://example.livekit.io"; - details.participant_token = "eyJhbGciOiJub25lIn0.eyJleHAiOjk5OTk5OTk5OTk5fQ."; + details.participant_token = kValidToken; std::promise> promise; promise.set_value(Result::success(details)); return promise.get_future(); @@ -391,7 +398,7 @@ TEST(TokenSourceFactoryTest, CachingTokenSourceExposesCachedResponse) { [](const TokenRequestOptions&) -> std::future> { TokenSourceResponse details; details.server_url = "wss://example.livekit.io"; - details.participant_token = "eyJhbGciOiJub25lIn0.eyJleHAiOjk5OTk5OTk5OTk5fQ."; + details.participant_token = kValidToken; std::promise> promise; promise.set_value(Result::success(details)); return promise.get_future(); @@ -418,7 +425,7 @@ TEST(TokenSourceFactoryTest, CachingTokenSourceRefetchesWhenOptionsChange) { ++fetch_count; TokenSourceResponse details; details.server_url = "wss://example.livekit.io"; - details.participant_token = "eyJhbGciOiJub25lIn0.eyJleHAiOjk5OTk5OTk5OTk5fQ."; + details.participant_token = kValidToken; std::promise> promise; promise.set_value(Result::success(details)); return promise.get_future(); @@ -443,8 +450,7 @@ TEST(TokenSourceFactoryTest, CachingTokenSourceRefetchesWhenTokenExpired) { const int count = ++fetch_count; TokenSourceResponse details; details.server_url = "wss://example.livekit.io"; - details.participant_token = - (count == 1) ? "eyJhbGciOiJub25lIn0.eyJleHAiOjF9." : "eyJhbGciOiJub25lIn0.eyJleHAiOjk5OTk5OTk5OTk5fQ."; + details.participant_token = (count == 1) ? kExpiredToken : kValidToken; std::promise> promise; promise.set_value(Result::success(details)); return promise.get_future(); @@ -470,7 +476,7 @@ TEST(TokenSourceFactoryTest, CachingTokenSourceRefetchesWhenTokenUnparseable) { const int count = ++fetch_count; TokenSourceResponse details; details.server_url = "wss://example.livekit.io"; - details.participant_token = (count == 1) ? "not-a-jwt" : "eyJhbGciOiJub25lIn0.eyJleHAiOjk5OTk5OTk5OTk5fQ."; + details.participant_token = (count == 1) ? "not-a-jwt" : kValidToken; std::promise> promise; promise.set_value(Result::success(details)); return promise.get_future(); @@ -506,7 +512,7 @@ TEST(TokenSourceFactoryTest, CachingTokenSourceSerializesConcurrentFetches) { TokenSourceResponse details; details.server_url = "wss://example.livekit.io"; - details.participant_token = "eyJhbGciOiJub25lIn0.eyJleHAiOjk5OTk5OTk5OTk5fQ."; + details.participant_token = kValidToken; std::promise> promise; promise.set_value(Result::success(details)); --concurrent_calls; diff --git a/src/tests/unit/test_uniffi.cpp b/src/tests/unit/test_uniffi.cpp new file mode 100644 index 00000000..421b209e --- /dev/null +++ b/src/tests/unit/test_uniffi.cpp @@ -0,0 +1,52 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * 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 + +namespace livekit { +namespace { + +TEST(UniFfiBuildInfoTest, ReturnsCrateVersion) { + const auto version = livekit_uniffi::build_version(); + EXPECT_FALSE(version.empty()); + EXPECT_NE(version, "unknown"); +} + +TEST(UniFfiLogForwardTest, ForwardsRustLogEntriesWithoutSdkInitialization) { + livekit_uniffi::log_forward_bootstrap(livekit_uniffi::LogForwardFilter::kDebug); + + const livekit_uniffi::ApiCredentials credentials{"devkey", "secret"}; + const livekit_uniffi::TokenOptions options{}; + (void)livekit_uniffi::token_generate(options, credentials); + + const auto entry = livekit_uniffi::log_forward_receive().get(); + std::cout << "Entry: " << entry->message << std::endl; + ASSERT_TRUE(entry.has_value()); + EXPECT_EQ(entry->level, livekit_uniffi::LogForwardLevel::kDebug); + EXPECT_EQ(entry->message, "Generating access token"); +} + +TEST(UniFfiGeneratedBindingsTest, ExposesAllGeneratedComponents) { + SUCCEED() << "All livekit-uniffi generated component headers compiled and linked"; +} + +} // namespace +} // namespace livekit diff --git a/src/tests/unit/test_uniffi_access_token.cpp b/src/tests/unit/test_uniffi_access_token.cpp new file mode 100644 index 00000000..31cce1f1 --- /dev/null +++ b/src/tests/unit/test_uniffi_access_token.cpp @@ -0,0 +1,49 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * 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 + +namespace livekit { +namespace { + +TEST(UniFfiAccessTokenTest, GeneratesAndVerifiesParticipantToken) { + livekit_uniffi::VideoGrants grants{}; + grants.room_join = true; + grants.room = "hello-world"; + + livekit_uniffi::TokenOptions options{}; + options.identity = "cpp-participant"; + options.name = "C++ Participant"; + options.video_grants = grants; + + const livekit_uniffi::ApiCredentials credentials{"devkey", "secret"}; + const auto token = livekit_uniffi::token_generate(options, credentials); + + ASSERT_FALSE(token.empty()); + + const auto claims = livekit_uniffi::token_verify(token, credentials); + EXPECT_EQ(claims.iss, credentials.key); + EXPECT_EQ(claims.sub, *options.identity); + EXPECT_EQ(claims.name, *options.name); + EXPECT_TRUE(claims.video.room_join); + EXPECT_EQ(claims.video.room, grants.room); +} + +} // namespace +} // namespace livekit diff --git a/src/token_source_jwt.cpp b/src/token_source_jwt.cpp index a17d6371..6460b428 100644 --- a/src/token_source_jwt.cpp +++ b/src/token_source_jwt.cpp @@ -15,127 +15,27 @@ #include #include -#include -#include +#include +#include #include -#include #include "token_source_internal.h" namespace livekit { -namespace { - -std::optional> base64UrlDecode(const std::string& input) { - std::string normalized; - normalized.reserve(input.size()); - for (const char ch : input) { - if (ch == '-') { - normalized += '+'; - } else if (ch == '_') { - normalized += '/'; - } else { - normalized += ch; - } - } - - while (normalized.size() % 4 != 0) { - normalized += '='; - } - - static const int kDecodeTable[256] = { - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, -1, 63, 52, 53, 54, 55, 56, 57, - 58, 59, 60, 61, -1, -1, -1, -1, -1, -1, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, - 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, -1, -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, - 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}; - - std::vector output; - output.reserve(normalized.size() * 3 / 4); - - std::uint32_t buffer = 0; - int bits = 0; - for (const unsigned char ch : normalized) { - if (ch == '=') { - break; - } - const int value = kDecodeTable[ch]; - if (value < 0) { - return std::nullopt; - } - buffer = (buffer << 6) | static_cast(value); - bits += 6; - if (bits >= 8) { - bits -= 8; - output.push_back(static_cast((buffer >> bits) & 0xFF)); - } - } - - return output; -} - -std::optional extractJwtPayloadJson(const std::string& token) { - const std::size_t first_dot = token.find('.'); - if (first_dot == std::string::npos) { - return std::nullopt; - } - const std::size_t second_dot = token.find('.', first_dot + 1); - if (second_dot == std::string::npos) { - return std::nullopt; - } - - const std::string payload_segment = token.substr(first_dot + 1, second_dot - first_dot - 1); - const auto decoded = base64UrlDecode(payload_segment); - if (!decoded.has_value() || decoded->empty()) { - return std::nullopt; - } - - return std::string(decoded->begin(), decoded->end()); -} - -// Read an integer-valued JWT claim (e.g. "nbf"/"exp"). JWT numeric date claims -// are seconds since the epoch; non-integer or absent claims return nullopt. -std::optional readNumericClaim(const nlohmann::json& payload, const char* key) { - const auto it = payload.find(key); - if (it == payload.end() || !it->is_number()) { - return std::nullopt; - } - return it->get(); -} - -} // namespace bool isParticipantTokenValid(const std::string& participant_token) { - const auto payload_json = extractJwtPayloadJson(participant_token); - if (!payload_json.has_value()) { - return false; - } - - const nlohmann::json payload = nlohmann::json::parse(*payload_json, nullptr, /*allow_exceptions=*/false); - if (!payload.is_object()) { - return false; - } - - const auto now_seconds = - std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); - - const auto nbf = readNumericClaim(payload, "nbf"); - if (nbf.has_value() && *nbf > now_seconds) { - return false; - } - - const auto exp = readNumericClaim(payload, "exp"); - if (exp.has_value()) { - constexpr std::int64_t kExpiryBufferSeconds = 60; - if (*exp <= now_seconds + kExpiryBufferSeconds) { + try { + const auto claims = livekit_uniffi::token_claims_from_unverified(participant_token); + constexpr std::uint64_t kExpiryBufferSeconds = 60; + const auto now_seconds = + std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); + if (now_seconds < 0) { return false; } + return claims.exp > static_cast(now_seconds) + kExpiryBufferSeconds; + } catch (const std::exception&) { + return false; } - - return true; } } // namespace livekit