diff --git a/CMakeLists.txt b/CMakeLists.txt index 2be358a..e138df7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,8 +1,8 @@ -cmake_minimum_required(VERSION 3.16) +cmake_minimum_required(VERSION 3.19) project(obs-streamer-tools-plugin - VERSION 0.0.1 - DESCRIPTION "OBS Studio source plugin for streamer-tools camera feeds (scaffold, no LiveKit integration yet)" + VERSION 0.1.0 + DESCRIPTION "OBS Studio source plugin for streamer-tools camera feeds" LANGUAGES C CXX ) @@ -11,19 +11,40 @@ set(CMAKE_C_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) +endif() + enable_testing() -# --- Scaffolding note ------------------------------------------------------ -# This deliberately does NOT use the full obsproject/obs-plugintemplate -# build system (its cmake/common/bootstrap.cmake + buildspec.json, which -# download complete OBS source archives and prebuilt dependency bundles -# for macOS/Windows). That machinery is real and may be worth adopting -# wholesale in a later phase; for this scaffolding pass the goal is a much -# simpler CMakeLists.txt that proves out find_package(libobs) plus the -# core/adapter split on the platform we can actually verify locally -# (Linux, via the system libobs-dev package). See README.md for the full -# writeup of what was verified vs. what remains. -# ---------------------------------------------------------------------------- +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") + +# --- LiveKit C++ client SDK ------------------------------------------------- +# Pinned, prebuilt release of livekit/client-sdk-cpp, downloaded and unpacked +# by cmake/LiveKitSDK.cmake, then consumed through its own CMake package +# config as the LiveKit::livekit imported target. See the design doc's +# "Resolved (2026-09-07)" section: an exact pin, never "latest". +set(STPLUGIN_LIVEKIT_SDK_VERSION "1.10.1" CACHE STRING + "Pinned livekit/client-sdk-cpp release version") +set(STPLUGIN_LIVEKIT_SDK_TRIPLE "" CACHE STRING + "Override the client-sdk-cpp release triple (e.g. ubuntu-24.04-x64); empty = autodetect") +set(STPLUGIN_LIVEKIT_SDK_DIR "${CMAKE_BINARY_DIR}/_deps/livekit-sdk" CACHE PATH + "Directory the client-sdk-cpp release archive is extracted into (point at a persistent path to cache it across CI builds)") + +include(LiveKitSDK) +if(STPLUGIN_LIVEKIT_SDK_TRIPLE) + livekit_sdk_setup( + VERSION "${STPLUGIN_LIVEKIT_SDK_VERSION}" + SDK_DIR "${STPLUGIN_LIVEKIT_SDK_DIR}" + TRIPLE "${STPLUGIN_LIVEKIT_SDK_TRIPLE}" + ) +else() + livekit_sdk_setup( + VERSION "${STPLUGIN_LIVEKIT_SDK_VERSION}" + SDK_DIR "${STPLUGIN_LIVEKIT_SDK_DIR}" + ) +endif() +find_package(LiveKit CONFIG REQUIRED) add_subdirectory(core) diff --git a/cmake/LiveKitSDK.cmake b/cmake/LiveKitSDK.cmake new file mode 100644 index 0000000..895fb6e --- /dev/null +++ b/cmake/LiveKitSDK.cmake @@ -0,0 +1,209 @@ +# LiveKitSDK.cmake +# +# Downloads the prebuilt LiveKit C++ SDK (livekit/client-sdk-cpp) release +# asset for the host OS/arch, extracts it, and points +# `find_package(LiveKit CONFIG REQUIRED)` at it. +# +# Adapted from livekit-examples/cpp-example-collection's cmake/LiveKitSDK.cmake +# (fetched 2026-09-06). Deliberate changes from the upstream reference: +# +# 1. VERSION must be an exact release number. The upstream helper accepted +# VERSION "latest" and resolved it through the GitHub releases API at +# configure time. That is exactly what this project must not do: the +# design doc calls for a pinned release tag, treating version bumps as +# deliberate work (the SDK is young and ships roughly weekly). Dropping +# the "latest" path also removes a GitHub-API call -- and its rate +# limiting / GITHUB_TOKEN plumbing -- from every CI configure. +# 2. Exports LIVEKIT_SDK_RUNTIME_LIBS: the shared libraries that must be +# staged next to the built OBS plugin module for it to load at runtime +# (liblivekit + liblivekit_ffi). Upstream examples run out of the build +# tree and never needed this; a redistributable OBS plugin does. +# 3. Exports LIVEKIT_SDK_INCLUDE_DIR / _LIB_DIR / _BIN_DIR for packaging. +# +# Usage: +# list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") +# include(LiveKitSDK) +# livekit_sdk_setup(VERSION "1.10.1" SDK_DIR "${CMAKE_BINARY_DIR}/_deps/livekit-sdk") +# find_package(LiveKit CONFIG REQUIRED) + +include_guard(GLOBAL) + +# -------------------- Host detection -------------------- +function(_lk_detect_host out_os out_arch) + if(WIN32) + set(_os "windows") + elseif(APPLE) + set(_os "macos") + elseif(UNIX) + set(_os "linux") + else() + message(FATAL_ERROR "LiveKitSDK: unsupported host OS") + endif() + + # Prefer the *target* processor when cross-compiling is expressed through + # CMAKE_OSX_ARCHITECTURES (macOS CI runners are arm64 but may target x64). + set(_proc "${CMAKE_HOST_SYSTEM_PROCESSOR}") + if(APPLE AND CMAKE_OSX_ARCHITECTURES) + list(LENGTH CMAKE_OSX_ARCHITECTURES _n_arch) + if(_n_arch GREATER 1) + message(FATAL_ERROR + "LiveKitSDK: CMAKE_OSX_ARCHITECTURES lists ${_n_arch} architectures " + "(${CMAKE_OSX_ARCHITECTURES}). client-sdk-cpp ships single-arch " + "dylibs only, so universal binaries are not supported. Build one " + "architecture at a time.") + endif() + list(GET CMAKE_OSX_ARCHITECTURES 0 _proc) + endif() + + string(TOLOWER "${_proc}" _proc_l) + if(_proc_l MATCHES "^(x86_64|amd64)$") + set(_arch "x64") + elseif(_proc_l MATCHES "^(arm64|aarch64)$") + set(_arch "arm64") + else() + message(FATAL_ERROR "LiveKitSDK: unsupported host arch: ${_proc}") + endif() + + set(${out_os} "${_os}" PARENT_SCOPE) + set(${out_arch} "${_arch}" PARENT_SCOPE) +endfunction() + +function(_lk_default_triple out_triple) + _lk_detect_host(_os _arch) + set(${out_triple} "${_os}-${_arch}" PARENT_SCOPE) +endfunction() + +function(_lk_archive_ext out_ext) + if(WIN32) + set(${out_ext} "zip" PARENT_SCOPE) + else() + set(${out_ext} "tar.gz" PARENT_SCOPE) + endif() +endfunction() + +# -------------------- Public entrypoint -------------------- +# livekit_sdk_setup( +# VERSION REQUIRED, exact -- "latest" is rejected +# SDK_DIR REQUIRED, where the archive is extracted +# [REPO ] default: livekit/client-sdk-cpp +# [SHA256 ] optional: verify the downloaded archive +# [TRIPLE ] optional override (e.g. ubuntu-24.04-x64) +# [DOWNLOAD_DIR ] default: /_downloads +# [NO_DOWNLOAD] fail instead of downloading if absent +# ) +function(livekit_sdk_setup) + set(options NO_DOWNLOAD) + set(oneValueArgs VERSION SDK_DIR REPO SHA256 TRIPLE DOWNLOAD_DIR) + cmake_parse_arguments(LK "${options}" "${oneValueArgs}" "" ${ARGN}) + + if(NOT LK_VERSION) + message(FATAL_ERROR "livekit_sdk_setup: VERSION is required") + endif() + if(LK_VERSION STREQUAL "latest") + message(FATAL_ERROR + "livekit_sdk_setup: VERSION=\"latest\" is deliberately not supported. " + "This project pins an exact client-sdk-cpp release; see the comment at " + "the top of cmake/LiveKitSDK.cmake.") + endif() + if(NOT LK_VERSION MATCHES "^[0-9]+\\.[0-9]+\\.[0-9]+$") + message(FATAL_ERROR "livekit_sdk_setup: VERSION must be x.y.z, got '${LK_VERSION}'") + endif() + if(NOT LK_SDK_DIR) + message(FATAL_ERROR "livekit_sdk_setup: SDK_DIR is required") + endif() + + if(NOT LK_REPO) + set(LK_REPO "livekit/client-sdk-cpp") + endif() + if(NOT LK_DOWNLOAD_DIR) + set(LK_DOWNLOAD_DIR "${CMAKE_BINARY_DIR}/_downloads") + endif() + if(NOT LK_TRIPLE) + _lk_default_triple(LK_TRIPLE) + endif() + + _lk_archive_ext(_ext) + set(_archive "livekit-sdk-${LK_TRIPLE}-${LK_VERSION}.${_ext}") + set(_url "https://github.com/${LK_REPO}/releases/download/v${LK_VERSION}/${_archive}") + set(_archive_path "${LK_DOWNLOAD_DIR}/${_archive}") + + # The archive contains a single top-level folder named after the asset. + set(_extracted_root "${LK_SDK_DIR}/livekit-sdk-${LK_TRIPLE}-${LK_VERSION}") + + file(MAKE_DIRECTORY "${LK_DOWNLOAD_DIR}") + file(MAKE_DIRECTORY "${LK_SDK_DIR}") + + if(NOT EXISTS "${_extracted_root}/lib/cmake") + if(LK_NO_DOWNLOAD) + message(FATAL_ERROR + "LiveKitSDK: SDK not found at:\n ${_extracted_root}\nand NO_DOWNLOAD was set.") + endif() + + message(STATUS "LiveKitSDK: downloading ${_url}") + + if(LK_SHA256) + file(DOWNLOAD "${_url}" "${_archive_path}" + SHOW_PROGRESS TLS_VERIFY ON + EXPECTED_HASH "SHA256=${LK_SHA256}" + STATUS _st LOG _log) + else() + file(DOWNLOAD "${_url}" "${_archive_path}" + SHOW_PROGRESS TLS_VERIFY ON + STATUS _st LOG _log) + endif() + + list(GET _st 0 _code) + list(GET _st 1 _msg) + if(NOT _code EQUAL 0) + file(REMOVE "${_archive_path}") + message(STATUS "LiveKitSDK: download log:\n${_log}") + message(FATAL_ERROR + "LiveKitSDK: download failed\nURL: ${_url}\nStatus: ${_code}\nMessage: ${_msg}\n" + "If this triple has no release asset, pass TRIPLE explicitly.") + endif() + + # Remove any previous partial extraction. + file(REMOVE_RECURSE "${_extracted_root}") + + message(STATUS "LiveKitSDK: extracting ${_archive_path}") + file(ARCHIVE_EXTRACT INPUT "${_archive_path}" DESTINATION "${LK_SDK_DIR}") + endif() + + if(NOT EXISTS "${_extracted_root}/lib/cmake/LiveKit/LiveKitConfig.cmake") + message(FATAL_ERROR + "LiveKitSDK: extracted SDK does not look valid (missing " + "lib/cmake/LiveKit/LiveKitConfig.cmake)\nExpected under: ${_extracted_root}") + endif() + + # Make find_package(LiveKit CONFIG REQUIRED) work in the caller's scope. + list(PREPEND CMAKE_PREFIX_PATH "${_extracted_root}") + set(CMAKE_PREFIX_PATH "${CMAKE_PREFIX_PATH}" PARENT_SCOPE) + set(LiveKit_DIR "${_extracted_root}/lib/cmake/LiveKit" PARENT_SCOPE) + + # --- Runtime libraries, for staging next to the plugin module ------------ + # Windows keeps the DLLs in bin/ and the import libs in lib/; the Unix + # platforms put the shared objects directly in lib/. + if(WIN32) + file(GLOB _runtime_libs "${_extracted_root}/bin/*.dll") + elseif(APPLE) + file(GLOB _runtime_libs "${_extracted_root}/lib/*.dylib") + else() + file(GLOB _runtime_libs "${_extracted_root}/lib/*.so" "${_extracted_root}/lib/*.so.*") + endif() + if(NOT _runtime_libs) + message(FATAL_ERROR + "LiveKitSDK: found no runtime shared libraries under ${_extracted_root}. " + "The release layout may have changed for version ${LK_VERSION}.") + endif() + + set(LIVEKIT_SDK_EXTRACTED_ROOT "${_extracted_root}" CACHE PATH "LiveKit SDK extracted root" FORCE) + set(LIVEKIT_SDK_INCLUDE_DIR "${_extracted_root}/include" CACHE PATH "LiveKit SDK include dir" FORCE) + set(LIVEKIT_SDK_LIB_DIR "${_extracted_root}/lib" CACHE PATH "LiveKit SDK lib dir" FORCE) + set(LIVEKIT_SDK_BIN_DIR "${_extracted_root}/bin" CACHE PATH "LiveKit SDK bin dir" FORCE) + set(LIVEKIT_SDK_RUNTIME_LIBS "${_runtime_libs}" CACHE STRING "LiveKit SDK runtime shared libraries" FORCE) + set(LIVEKIT_SDK_URL_USED "${_url}" CACHE STRING "LiveKit SDK URL used" FORCE) + set(LIVEKIT_SDK_VERSION_RESOLVED "${LK_VERSION}" CACHE STRING "LiveKit SDK version" FORCE) + set(LIVEKIT_SDK_TRIPLE_USED "${LK_TRIPLE}" CACHE STRING "LiveKit SDK triple" FORCE) + + message(STATUS "LiveKitSDK: using SDK ${LK_VERSION} (${LK_TRIPLE}) at ${_extracted_root}") +endfunction() diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index 618c517..7a3d5a3 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -13,6 +13,11 @@ target_include_directories(stplugin_core ${CMAKE_CURRENT_SOURCE_DIR}/include ) +target_link_libraries(stplugin_core + PUBLIC + LiveKit::livekit +) + set_target_properties(stplugin_core PROPERTIES POSITION_INDEPENDENT_CODE ON ) diff --git a/core/tests/CMakeLists.txt b/core/tests/CMakeLists.txt index c517532..2b1f2f4 100644 --- a/core/tests/CMakeLists.txt +++ b/core/tests/CMakeLists.txt @@ -1,7 +1,18 @@ add_executable(stplugin_core_tests test_core.cpp ) - target_link_libraries(stplugin_core_tests PRIVATE stplugin_core) - add_test(NAME stplugin_core_tests COMMAND stplugin_core_tests) + +# Smoke test for the LiveKit SDK link: initialize()/shutdown() must succeed +# in-process. This is the cheapest possible proof that LiveKit::livekit is +# not just linked but loadable and callable (it dlopen-chains into +# liblivekit_ffi, which is where a broken RPATH would show up). +add_executable(stplugin_livekit_smoke + test_livekit_smoke.cpp +) +target_link_libraries(stplugin_livekit_smoke PRIVATE stplugin_core) +target_compile_definitions(stplugin_livekit_smoke PRIVATE + STPLUGIN_EXPECTED_LIVEKIT_VERSION="${LIVEKIT_SDK_VERSION_RESOLVED}" +) +add_test(NAME stplugin_livekit_smoke COMMAND stplugin_livekit_smoke) diff --git a/core/tests/test_livekit_smoke.cpp b/core/tests/test_livekit_smoke.cpp new file mode 100644 index 0000000..0170a61 --- /dev/null +++ b/core/tests/test_livekit_smoke.cpp @@ -0,0 +1,64 @@ +/* +streamer-tools OBS Camera Plugin - LiveKit SDK link smoke test +Copyright (C) 2026 CyberCoveLLC + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program. If not, see +*/ + +// Proves the pinned client-sdk-cpp release is genuinely linked and callable: +// the SDK's own global init/shutdown runs in-process without crashing, and +// the header-reported build version matches the version CMake pinned. +// +// This does NOT touch the network -- livekit::initialize() only sets up +// global SDK state and log routing. + +#include +#include + +#include +#include +#include + +#include "test_util.h" + +int main() +{ + // The pin CMake resolved is passed in as a define; the SDK's own + // generated build.h reports what was actually unpacked. A mismatch means + // a stale extracted SDK directory is being reused. + ST_ASSERT_EQ(std::string(LIVEKIT_BUILD_VERSION), std::string(STPLUGIN_EXPECTED_LIVEKIT_VERSION)); + + // initialize() returns true when this call performed the init, false if + // the SDK was already initialized. Either way it must not crash, and a + // second call must report "already initialized". + bool first = livekit::initialize(livekit::LogLevel::Error); + ST_ASSERT(first); + + bool second = livekit::initialize(livekit::LogLevel::Error); + ST_ASSERT(!second); + + // Log level round-trips through the SDK's global state. + livekit::setLogLevel(livekit::LogLevel::Warn); + ST_ASSERT(livekit::getLogLevel() == livekit::LogLevel::Warn); + + livekit::shutdown(); + + // The SDK documents that initialize() may be called again after + // shutdown(); exercise that so a half-torn-down global state would show + // up here rather than in OBS. + ST_ASSERT(livekit::initialize(livekit::LogLevel::Error)); + livekit::shutdown(); + + return st_test_report("livekit_smoke"); +} diff --git a/core/tests/test_util.h b/core/tests/test_util.h new file mode 100644 index 0000000..d88f9db --- /dev/null +++ b/core/tests/test_util.h @@ -0,0 +1,91 @@ +/* +streamer-tools OBS Camera Plugin - minimal test harness +Copyright (C) 2026 CyberCoveLLC + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program. If not, see +*/ + +#pragma once + +// Deliberately dependency-free (no gtest/catch2) so the core library's test +// targets add no package-manager or network step to any of the three CI +// platforms. Unlike bare assert(), this keeps running after a failure and +// prints a real pass/fail count, so CI output says how much actually ran. + +#include +#include + +namespace st_test_detail { +inline int &checks_run() +{ + static int n = 0; + return n; +} +inline int &checks_failed() +{ + static int n = 0; + return n; +} +inline std::string to_display(const std::string &v) +{ + return "\"" + v + "\""; +} +inline std::string to_display(const char *v) +{ + return v ? ("\"" + std::string(v) + "\"") : std::string("(null)"); +} +inline std::string to_display(bool v) +{ + return v ? "true" : "false"; +} +template inline std::string to_display(const T &v) +{ + return std::to_string(v); +} +} // namespace st_test_detail + +#define ST_ASSERT(expr) \ + do { \ + ++st_test_detail::checks_run(); \ + if (!(expr)) { \ + ++st_test_detail::checks_failed(); \ + std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #expr); \ + } \ + } while (0) + +#define ST_ASSERT_EQ(actual, expected) \ + do { \ + ++st_test_detail::checks_run(); \ + auto _st_a = (actual); \ + auto _st_e = (expected); \ + if (!(_st_a == _st_e)) { \ + ++st_test_detail::checks_failed(); \ + std::fprintf(stderr, "FAIL %s:%d: %s\n actual: %s\n expected: %s\n", \ + __FILE__, __LINE__, #actual " == " #expected, \ + st_test_detail::to_display(_st_a).c_str(), \ + st_test_detail::to_display(_st_e).c_str()); \ + } \ + } while (0) + +inline int st_test_report(const char *suite) +{ + const int run = st_test_detail::checks_run(); + const int failed = st_test_detail::checks_failed(); + if (failed == 0) { + std::printf("%s: %d checks passed\n", suite, run); + return 0; + } + std::printf("%s: %d/%d checks FAILED\n", suite, failed, run); + return 1; +}