From e5951730492acdf7a51a99c2fc3dd0f30cc9033a Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 6 Sep 2026 21:22:14 -0700 Subject: [PATCH 01/17] Link the pinned LiveKit C++ SDK into the core library Adds cmake/LiveKitSDK.cmake, adapted from livekit-examples/cpp-example-collection's helper of the same name, with the VERSION="latest" GitHub-API resolution path removed: this project pins an exact client-sdk-cpp release (1.10.1, the newest tag as of today), and the pin should not be silently bypassable. The module also now exports the runtime shared libraries so packaging can stage liblivekit/liblivekit_ffi next to the plugin module later. core/ links LiveKit::livekit PUBLIC. A new smoke test proves the SDK is not just linked but loadable and callable: livekit::initialize()/shutdown() round-trip in-process, a second initialize() reports "already initialized", the log level round-trips, and the SDK's generated LIVEKIT_BUILD_VERSION is asserted equal to the version CMake pinned (so a stale extracted SDK directory fails loudly rather than being silently reused). Also adds core/tests/test_util.h, a dependency-free assertion harness that keeps running after a failure and prints a pass/fail count, so CI output says how much actually ran instead of dying on the first bare assert(). cmake_minimum_required goes 3.16 -> 3.19 (file(ARCHIVE_EXTRACT)). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE --- CMakeLists.txt | 49 +++++-- cmake/LiveKitSDK.cmake | 209 ++++++++++++++++++++++++++++++ core/CMakeLists.txt | 5 + core/tests/CMakeLists.txt | 15 ++- core/tests/test_livekit_smoke.cpp | 64 +++++++++ core/tests/test_util.h | 91 +++++++++++++ 6 files changed, 417 insertions(+), 16 deletions(-) create mode 100644 cmake/LiveKitSDK.cmake create mode 100644 core/tests/test_livekit_smoke.cpp create mode 100644 core/tests/test_util.h 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; +} -- 2.52.0 From bf33966a4e6dd5cd5b5b0714e2fe27484bcb3278 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 6 Sep 2026 21:28:55 -0700 Subject: [PATCH 02/17] Add the streamer-tools API client, with a real HTTP backend per platform Implements the two read-key-scoped calls in apps/server/src/obs/plugin.routes.ts: GET /api/obs/:slug/slots and POST /api/obs/:slug/token. Three pieces, all in core/ with no OBS dependency: - stplugin::json -- a small, strict JSON reader. Hand-rolled rather than vendoring nlohmann because the only JSON this plugin ever sees is two fixed-shape responses from its own server, and the parser has to build on three platforms with no package-manager step in CI. It never throws, bounds its recursion (kMaxDepth=32) so a hostile response cannot overflow the stack inside OBS, rejects trailing garbage, and returns the caller's fallback for wrong-typed access instead of aborting. - stplugin::HttpClient -- a two-method injectable interface, with libcurl behind it on Linux/macOS and WinHTTP on Windows. WinHTTP rather than curl on Windows because it ships with the OS and does TLS through SChannel: the self-hosted winvm-builder runner has no package manager, and per the scaffold README does not even have cmake preinstalled. Both backends cap the response body at 4 MiB, keep TLS verification on (the read key is a credential), and honour a whole-request timeout. - stplugin::ApiClient -- maps the responses onto an ApiStatus enum that distinguishes NotFound (404), Unavailable (503), NetworkError, MalformedResponse and InvalidConfig. It deliberately does not claim to know whether a 404 was a wrong key or an unknown slug, because the server deliberately does not say. Server URLs are normalised the way an operator actually pastes them, defaulting to https so the read key is never sent in the clear by accident, and redactedUrl() exists so a URL can be logged without the key. Tests (279 checks across two new suites) run at two levels: a fake HttpClient covering every response and error branch, and a real loopback HTTP server on 127.0.0.1 driving the actual platform backend -- so libcurl on Linux/macOS and WinHTTP on Windows are each exercised in CI rather than assumed. The loopback cases deliberately include the ones that must not hang OBS: a truncated JSON body, a connection accepted and closed without a reply, non-HTTP garbage, a dead port, and a stalled server that has to be cut off by the client's own timeout. Verified locally on Ubuntu 24.04: ctest --test-dir build --output-on-failure -> 4/4 passed test_json: 158 checks passed test_api_client: 121 checks passed Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE --- core/CMakeLists.txt | 25 +- core/include/stplugin/api_client.h | 118 ++++++++ core/include/stplugin/http.h | 78 +++++ core/include/stplugin/json.h | 106 +++++++ core/src/api_client.cpp | 211 ++++++++++++++ core/src/http_common.cpp | 46 +++ core/src/http_curl.cpp | 137 +++++++++ core/src/http_winhttp.cpp | 208 ++++++++++++++ core/src/json.cpp | 442 +++++++++++++++++++++++++++++ core/tests/CMakeLists.txt | 32 ++- core/tests/loopback_server.h | 246 ++++++++++++++++ core/tests/test_api_client.cpp | 434 ++++++++++++++++++++++++++++ core/tests/test_json.cpp | 163 +++++++++++ 13 files changed, 2234 insertions(+), 12 deletions(-) create mode 100644 core/include/stplugin/api_client.h create mode 100644 core/include/stplugin/http.h create mode 100644 core/include/stplugin/json.h create mode 100644 core/src/api_client.cpp create mode 100644 core/src/http_common.cpp create mode 100644 core/src/http_curl.cpp create mode 100644 core/src/http_winhttp.cpp create mode 100644 core/src/json.cpp create mode 100644 core/tests/loopback_server.h create mode 100644 core/tests/test_api_client.cpp create mode 100644 core/tests/test_json.cpp diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index 7a3d5a3..a8bf2ec 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -4,10 +4,23 @@ # 2026-09-06-obs-camera-plugin-design.md in the streamer-tools repo) -- # this must build and test headlessly on every platform. -add_library(stplugin_core STATIC +set(STPLUGIN_CORE_SOURCES src/core.cpp + src/json.cpp + src/http_common.cpp + src/api_client.cpp ) +# HTTP backend, one per platform. See core/include/stplugin/http.h for why +# this is split rather than using libcurl everywhere. +if(WIN32) + list(APPEND STPLUGIN_CORE_SOURCES src/http_winhttp.cpp) +else() + list(APPEND STPLUGIN_CORE_SOURCES src/http_curl.cpp) +endif() + +add_library(stplugin_core STATIC ${STPLUGIN_CORE_SOURCES}) + target_include_directories(stplugin_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include @@ -18,6 +31,16 @@ target_link_libraries(stplugin_core LiveKit::livekit ) +if(WIN32) + target_link_libraries(stplugin_core PRIVATE winhttp) +else() + find_package(CURL REQUIRED) + target_link_libraries(stplugin_core PRIVATE CURL::libcurl) +endif() + +find_package(Threads REQUIRED) +target_link_libraries(stplugin_core PUBLIC Threads::Threads) + set_target_properties(stplugin_core PROPERTIES POSITION_INDEPENDENT_CODE ON ) diff --git a/core/include/stplugin/api_client.h b/core/include/stplugin/api_client.h new file mode 100644 index 0000000..bf00faf --- /dev/null +++ b/core/include/stplugin/api_client.h @@ -0,0 +1,118 @@ +/* +streamer-tools OBS Camera Plugin - streamer-tools API client +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 + +// Client for the two read-key-scoped endpoints in +// apps/server/src/obs/plugin.routes.ts (streamer-tools repo): +// +// GET /api/obs/:slug/slots?key= +// 200 { slots: [ { identity, displayName, live } ] } +// 404 { error: 'not found' } wrong key OR unknown room +// 503 { error: 'livekit not configured' } +// +// POST /api/obs/:slug/token?key= +// 200 { lkToken, wsUrl, identity } +// 404 / 503 as above +// +// The server deliberately answers a wrong key and an unknown slug identically +// (404), so this client must not claim to know which it was. + +#include +#include +#include + +#include "stplugin/core.h" +#include "stplugin/http.h" + +namespace stplugin { + +enum class ApiStatus { + Ok, + /// server URL / slug / key were not all filled in + InvalidConfig, + /// request never completed (DNS, TLS, timeout, ...) + NetworkError, + /// HTTP 404: unknown room slug or wrong read key -- indistinguishable + NotFound, + /// HTTP 503: the server has no LiveKit credentials configured + Unavailable, + /// any other non-2xx status + HttpError, + /// 2xx but the body was not the JSON shape this client expects + MalformedResponse, +}; + +/// A short, operator-facing description. Never includes the read key. +const char *describeApiStatus(ApiStatus status); + +struct SlotInfo { + /// LiveKit participant identity -- this is what the session wrapper + /// subscribes to, and what gets persisted in the OBS source settings. + std::string identity; + /// Human label for the dropdown; the server falls back to identity. + std::string display_name; + /// Currently publishing camera video. + bool live = false; +}; + +struct SlotsResult { + ApiStatus status = ApiStatus::InvalidConfig; + /// Detail for logs/UI. Never contains the read key. + std::string message; + std::vector slots; + + bool ok() const { return status == ApiStatus::Ok; } +}; + +struct TokenResult { + ApiStatus status = ApiStatus::InvalidConfig; + std::string message; + /// LiveKit JWT for a hidden, subscribe-only participant. + std::string lk_token; + /// LiveKit websocket URL to connect to. + std::string ws_url; + /// The obs:: identity the server minted for us. + std::string identity; + + bool ok() const { return status == ApiStatus::Ok; } +}; + +class ApiClient { +public: + /// Takes ownership of the HTTP client, so tests can inject a fake. + explicit ApiClient(std::shared_ptr http); + + SlotsResult fetchSlots(const ConnectionConfig &config) const; + TokenResult requestToken(const ConnectionConfig &config) const; + + /// Accepts what an operator would actually paste: a bare hostname, a URL + /// with a trailing slash, extra whitespace. Returns an empty string if + /// nothing usable is left. Defaults to https:// when no scheme is given, + /// because the read key must never be sent in the clear by accident. + static std::string normalizeServerUrl(const std::string &raw); + + /// Exposed for tests and for logging: the exact URL a call will hit, + /// with the read key replaced by "***". + static std::string redactedUrl(const std::string &url); + +private: + std::shared_ptr http_; +}; + +} // namespace stplugin diff --git a/core/include/stplugin/http.h b/core/include/stplugin/http.h new file mode 100644 index 0000000..d578a69 --- /dev/null +++ b/core/include/stplugin/http.h @@ -0,0 +1,78 @@ +/* +streamer-tools OBS Camera Plugin - HTTP client interface +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 + +// A two-method HTTP interface, injectable so ApiClient can be unit-tested +// without a network (mirroring the streamer-tools repo's injectable-deps +// convention, per the design doc's Testing section). +// +// Backends, chosen per platform so no third-party HTTP dependency has to be +// built on any of the three CI runners: +// - Linux/macOS: libcurl. Already present on both (client-sdk-cpp's own +// liblivekit links libcurl on Linux, and macOS ships libcurl in the SDK). +// - Windows: WinHTTP, which ships with the OS and handles TLS through +// SChannel -- avoiding an OpenSSL or curl build on the Windows runner. + +#include +#include + +namespace stplugin { + +struct HttpResponse { + /// HTTP status code, or 0 when the request never completed (DNS failure, + /// TLS failure, timeout, ...). Callers must check `network_error` first. + long status = 0; + + /// Response body. May be empty, may be arbitrary bytes: never assume it + /// parses as JSON. + std::string body; + + /// Empty on success. Non-empty means the request did not complete and + /// `status`/`body` are meaningless. + std::string network_error; + + bool ok() const { return network_error.empty(); } +}; + +struct HttpRequest { + std::string method = "GET"; + std::string url; + std::string body; + std::string content_type; + + /// Whole-request timeout. Kept short: this runs on OBS's UI thread when + /// the properties dropdown is refreshed, and on the source's own worker + /// thread when a token is minted. + int timeout_ms = 10000; +}; + +class HttpClient { +public: + virtual ~HttpClient() = default; + virtual HttpResponse send(const HttpRequest &request) = 0; +}; + +/// Percent-encode a string for use in a URL query value. +std::string urlEncode(const std::string &value); + +/// Construct the platform's real HTTP client. Returns nullptr if no backend +/// was compiled in. +HttpClient *createPlatformHttpClient(); + +} // namespace stplugin diff --git a/core/include/stplugin/json.h b/core/include/stplugin/json.h new file mode 100644 index 0000000..20d08e8 --- /dev/null +++ b/core/include/stplugin/json.h @@ -0,0 +1,106 @@ +/* +streamer-tools OBS Camera Plugin - minimal JSON reader +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 + +// A deliberately small, strict, allocation-bounded JSON reader. +// +// Why hand-rolled rather than vendoring nlohmann/json: the only JSON this +// plugin ever parses is two small, fixed-shape responses from its own +// server (apps/server/src/obs/plugin.routes.ts in the streamer-tools repo), +// and the parser has to build unmodified on three platforms with no package +// manager step in CI. The scope is small enough to test exhaustively -- +// including the malformed inputs a compromised or misconfigured endpoint +// could return, which is the case that must not crash or hang OBS. +// +// Properties this parser guarantees, all covered by core/tests/test_json.cpp: +// - never throws; every failure is reported as Value::invalid() +// - bounded recursion (kMaxDepth) so nesting cannot blow the stack +// - trailing garbage after the top-level value is an error +// - accessors on a wrong-typed value return the caller's default rather +// than aborting, so callers can be written without type interrogation + +#include +#include +#include +#include + +namespace stplugin { +namespace json { + +/// Maximum nesting depth accepted by parse(). Any deeper input is rejected +/// as invalid rather than recursed into. +constexpr int kMaxDepth = 32; + +class Value { +public: + enum class Type { Invalid, Null, Bool, Number, String, Array, Object }; + + Value() = default; + + static Value invalid() { return Value(); } + static Value makeNull(); + static Value makeBool(bool v); + static Value makeNumber(double v); + static Value makeString(std::string v); + static Value makeArray(std::vector v); + static Value makeObject(std::map v); + + Type type() const { return type_; } + bool valid() const { return type_ != Type::Invalid; } + bool isNull() const { return type_ == Type::Null; } + bool isBool() const { return type_ == Type::Bool; } + bool isNumber() const { return type_ == Type::Number; } + bool isString() const { return type_ == Type::String; } + bool isArray() const { return type_ == Type::Array; } + bool isObject() const { return type_ == Type::Object; } + + /// Object member lookup. Returns invalid() for a missing key or when this + /// value is not an object. + const Value &operator[](const std::string &key) const; + + /// Array element access. Returns invalid() when out of range or when this + /// value is not an array. + const Value &at(std::size_t index) const; + + std::size_t size() const; + + /// Typed accessors. Each returns `fallback` when this value is missing or + /// of the wrong type, so callers never have to check first. + std::string asString(const std::string &fallback = std::string()) const; + bool asBool(bool fallback = false) const; + double asNumber(double fallback = 0.0) const; + + const std::vector &elements() const { return array_; } + +private: + Type type_ = Type::Invalid; + bool bool_ = false; + double number_ = 0.0; + std::string string_; + std::vector array_; + std::map object_; +}; + +/// Parse a complete JSON document. Returns Value::invalid() on any syntax +/// error, on trailing non-whitespace content, or on excessive nesting. +/// Never throws. +Value parse(const std::string &text); + +} // namespace json +} // namespace stplugin diff --git a/core/src/api_client.cpp b/core/src/api_client.cpp new file mode 100644 index 0000000..8b49bd7 --- /dev/null +++ b/core/src/api_client.cpp @@ -0,0 +1,211 @@ +/* +streamer-tools OBS Camera Plugin - streamer-tools API client +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 +*/ + +#include "stplugin/api_client.h" + +#include "stplugin/json.h" + +#include + +namespace stplugin { + +namespace { + +std::string trim(const std::string &s) +{ + std::size_t begin = 0; + std::size_t end = s.size(); + auto is_space = [](char c) { return c == ' ' || c == '\t' || c == '\r' || c == '\n'; }; + while (begin < end && is_space(s[begin])) + ++begin; + while (end > begin && is_space(s[end - 1])) + --end; + return s.substr(begin, end - begin); +} + +/// Map a completed HTTP response onto the shared status codes. Returns +/// ApiStatus::Ok when the caller should go on to parse the body. +ApiStatus classify(const HttpResponse &response, std::string &message) +{ + if (!response.ok()) { + message = response.network_error; + return ApiStatus::NetworkError; + } + if (response.status >= 200 && response.status < 300) + return ApiStatus::Ok; + if (response.status == 404) { + message = "unknown room slug, or the read key is wrong or has been rotated"; + return ApiStatus::NotFound; + } + if (response.status == 503) { + message = "the streamer-tools server has no LiveKit credentials configured"; + return ApiStatus::Unavailable; + } + message = "HTTP " + std::to_string(response.status); + return ApiStatus::HttpError; +} + +std::string buildUrl(const ConnectionConfig &config, const char *suffix) +{ + return ApiClient::normalizeServerUrl(config.server_url) + "/api/obs/" + + urlEncode(trim(config.room_slug)) + suffix + "?key=" + urlEncode(trim(config.read_key)); +} + +} // namespace + +const char *describeApiStatus(ApiStatus status) +{ + switch (status) { + case ApiStatus::Ok: return "ok"; + case ApiStatus::InvalidConfig: return "server URL, room slug and read key are all required"; + case ApiStatus::NetworkError: return "could not reach the streamer-tools server"; + case ApiStatus::NotFound: return "room not found, or the read key is wrong"; + case ApiStatus::Unavailable: return "server has no LiveKit configured"; + case ApiStatus::HttpError: return "unexpected response from the streamer-tools server"; + case ApiStatus::MalformedResponse: return "unreadable response from the streamer-tools server"; + } + return "unknown error"; +} + +ApiClient::ApiClient(std::shared_ptr http) : http_(std::move(http)) {} + +std::string ApiClient::normalizeServerUrl(const std::string &raw) +{ + std::string url = trim(raw); + if (url.empty()) + return url; + + // A bare "streamers.example.com" is what an operator will paste half the + // time. Defaulting to https (never http) keeps the read key off the wire + // in the clear. + const bool has_scheme = url.compare(0, 7, "http://") == 0 || url.compare(0, 8, "https://") == 0; + if (!has_scheme) + url = "https://" + url; + + while (!url.empty() && url.back() == '/') + url.pop_back(); + + // "https://" with nothing after it is not a server. + if (url == "https:/" || url == "https:" || url == "http:/" || url == "http:" || + url == "https://" || url == "http://") + return std::string(); + + return url; +} + +std::string ApiClient::redactedUrl(const std::string &url) +{ + const std::size_t at = url.find("key="); + if (at == std::string::npos) + return url; + const std::size_t value = at + 4; + std::size_t end = url.find('&', value); + if (end == std::string::npos) + end = url.size(); + return url.substr(0, value) + "***" + url.substr(end); +} + +SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config) const +{ + SlotsResult result; + if (!config.is_valid() || normalizeServerUrl(config.server_url).empty() || !http_) { + result.status = ApiStatus::InvalidConfig; + result.message = describeApiStatus(ApiStatus::InvalidConfig); + return result; + } + + HttpRequest request; + request.method = "GET"; + request.url = buildUrl(config, "/slots"); + + const HttpResponse response = http_->send(request); + const ApiStatus status = classify(response, result.message); + if (status != ApiStatus::Ok) { + result.status = status; + return result; + } + + const json::Value root = json::parse(response.body); + const json::Value &slots = root["slots"]; + if (!root.isObject() || !slots.isArray()) { + result.status = ApiStatus::MalformedResponse; + result.message = "expected a JSON object with a \"slots\" array"; + return result; + } + + for (const json::Value &entry : slots.elements()) { + // A slot without an identity is unusable -- it is what the session + // wrapper subscribes by -- so skip it rather than surfacing a + // dropdown row that can never connect. Anything else is best-effort: + // a missing displayName falls back to the identity exactly as the + // server itself does. + const std::string identity = entry["identity"].asString(); + if (identity.empty()) + continue; + SlotInfo slot; + slot.identity = identity; + slot.display_name = entry["displayName"].asString(identity); + if (slot.display_name.empty()) + slot.display_name = identity; + slot.live = entry["live"].asBool(false); + result.slots.push_back(std::move(slot)); + } + + result.status = ApiStatus::Ok; + return result; +} + +TokenResult ApiClient::requestToken(const ConnectionConfig &config) const +{ + TokenResult result; + if (!config.is_valid() || normalizeServerUrl(config.server_url).empty() || !http_) { + result.status = ApiStatus::InvalidConfig; + result.message = describeApiStatus(ApiStatus::InvalidConfig); + return result; + } + + HttpRequest request; + request.method = "POST"; + request.url = buildUrl(config, "/token"); + request.content_type = "application/json"; + request.body = "{}"; + + const HttpResponse response = http_->send(request); + const ApiStatus status = classify(response, result.message); + if (status != ApiStatus::Ok) { + result.status = status; + return result; + } + + const json::Value root = json::parse(response.body); + const std::string token = root["lkToken"].asString(); + const std::string ws_url = root["wsUrl"].asString(); + if (!root.isObject() || token.empty() || ws_url.empty()) { + result.status = ApiStatus::MalformedResponse; + result.message = "expected a JSON object with non-empty \"lkToken\" and \"wsUrl\""; + return result; + } + + result.lk_token = token; + result.ws_url = ws_url; + result.identity = root["identity"].asString(); + result.status = ApiStatus::Ok; + return result; +} + +} // namespace stplugin diff --git a/core/src/http_common.cpp b/core/src/http_common.cpp new file mode 100644 index 0000000..a4e1ccc --- /dev/null +++ b/core/src/http_common.cpp @@ -0,0 +1,46 @@ +/* +streamer-tools OBS Camera Plugin - HTTP helpers shared by all backends +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 +*/ + +#include "stplugin/http.h" + +namespace stplugin { + +// Hand-rolled rather than curl_easy_escape so the WinHTTP backend gets the +// same behaviour, and so this is testable without a live HTTP client. +// Unreserved set per RFC 3986 section 2.3. +std::string urlEncode(const std::string &value) +{ + static const char *kHex = "0123456789ABCDEF"; + std::string out; + out.reserve(value.size()); + for (const char raw : value) { + const unsigned char c = static_cast(raw); + const bool unreserved = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~'; + if (unreserved) { + out.push_back(static_cast(c)); + } else { + out.push_back('%'); + out.push_back(kHex[c >> 4]); + out.push_back(kHex[c & 0x0F]); + } + } + return out; +} + +} // namespace stplugin diff --git a/core/src/http_curl.cpp b/core/src/http_curl.cpp new file mode 100644 index 0000000..96d8933 --- /dev/null +++ b/core/src/http_curl.cpp @@ -0,0 +1,137 @@ +/* +streamer-tools OBS Camera Plugin - libcurl HTTP backend (Linux/macOS) +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 +*/ + +#include "stplugin/http.h" + +#include + +#include +#include + +namespace stplugin { + +namespace { + +/// Hard cap on a response body. The two endpoints this client talks to return +/// a few hundred bytes; anything larger is a misconfigured proxy or a wrong +/// URL, and must not be allowed to grow OBS's heap without bound. +constexpr std::size_t kMaxResponseBytes = 4u * 1024u * 1024u; + +struct WriteContext { + std::string body; + bool overflowed = false; +}; + +std::size_t writeCallback(char *ptr, std::size_t size, std::size_t nmemb, void *userdata) +{ + auto *ctx = static_cast(userdata); + const std::size_t bytes = size * nmemb; + if (ctx->body.size() + bytes > kMaxResponseBytes) { + ctx->overflowed = true; + return 0; // aborts the transfer with CURLE_WRITE_ERROR + } + ctx->body.append(ptr, bytes); + return bytes; +} + +/// curl_global_init is not thread-safe and must run once per process before +/// any easy handle is created. OBS may create several sources concurrently. +void ensureCurlGlobalInit() +{ + static std::once_flag once; + std::call_once(once, [] { curl_global_init(CURL_GLOBAL_DEFAULT); }); +} + +class CurlHttpClient : public HttpClient { +public: + HttpResponse send(const HttpRequest &request) override + { + ensureCurlGlobalInit(); + + HttpResponse response; + CURL *curl = curl_easy_init(); + if (!curl) { + response.network_error = "curl_easy_init failed"; + return response; + } + + WriteContext ctx; + struct curl_slist *headers = nullptr; + + curl_easy_setopt(curl, CURLOPT_URL, request.url.c_str()); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &ctx); + curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, static_cast(request.timeout_ms)); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, static_cast(request.timeout_ms)); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "streamer-tools-obs-plugin/1.0"); + // NOSIGNAL is required whenever curl is used off the main thread: + // without it curl installs a SIGALRM handler for DNS timeouts, which + // is process-global and would be a rude thing to do inside OBS. + curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); + // TLS verification stays on. The read key is a credential; sending it + // to an unverified host is exactly the failure this must not have. + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); + + if (request.method == "POST") { + curl_easy_setopt(curl, CURLOPT_POST, 1L); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, request.body.c_str()); + curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, static_cast(request.body.size())); + } else if (request.method != "GET") { + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, request.method.c_str()); + } + + if (!request.content_type.empty()) { + const std::string header = "Content-Type: " + request.content_type; + headers = curl_slist_append(headers, header.c_str()); + } + // Fastify answers a bare POST with no body fine, but some proxies + // insert an Expect: 100-continue round trip; suppress it. + headers = curl_slist_append(headers, "Expect:"); + if (headers) + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + + const CURLcode rc = curl_easy_perform(curl); + if (rc == CURLE_OK) { + long status = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status); + response.status = status; + response.body = std::move(ctx.body); + } else if (ctx.overflowed) { + response.network_error = "response body exceeded 4 MiB"; + } else { + response.network_error = curl_easy_strerror(rc); + } + + if (headers) + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + return response; + } +}; + +} // namespace + +HttpClient *createPlatformHttpClient() +{ + return new CurlHttpClient(); +} + +} // namespace stplugin diff --git a/core/src/http_winhttp.cpp b/core/src/http_winhttp.cpp new file mode 100644 index 0000000..82161ed --- /dev/null +++ b/core/src/http_winhttp.cpp @@ -0,0 +1,208 @@ +/* +streamer-tools OBS Camera Plugin - WinHTTP backend (Windows) +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 +*/ + +// WinHTTP rather than libcurl on Windows: it ships with the OS, does TLS +// through SChannel (so no OpenSSL to build or ship), and needs no package +// manager on the self-hosted `winvm-builder` runner -- which, per the +// scaffold README, is a bare VM without even cmake preinstalled. + +#include "stplugin/http.h" + +#include +#include + +#include +#include +#include + +namespace stplugin { + +namespace { + +constexpr std::size_t kMaxResponseBytes = 4u * 1024u * 1024u; + +std::wstring widen(const std::string &s) +{ + if (s.empty()) + return std::wstring(); + const int needed = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), static_cast(s.size()), nullptr, 0); + if (needed <= 0) + return std::wstring(); + std::wstring out(static_cast(needed), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, s.c_str(), static_cast(s.size()), &out[0], needed); + return out; +} + +std::string lastErrorMessage(const char *what) +{ + return std::string(what) + " failed (GetLastError=" + std::to_string(GetLastError()) + ")"; +} + +/// RAII for the three WinHTTP handle kinds, which all close the same way. +class Handle { +public: + Handle() = default; + explicit Handle(HINTERNET h) : h_(h) {} + ~Handle() + { + if (h_) + WinHttpCloseHandle(h_); + } + Handle(const Handle &) = delete; + Handle &operator=(const Handle &) = delete; + + void reset(HINTERNET h) + { + if (h_) + WinHttpCloseHandle(h_); + h_ = h; + } + HINTERNET get() const { return h_; } + explicit operator bool() const { return h_ != nullptr; } + +private: + HINTERNET h_ = nullptr; +}; + +class WinHttpClient : public HttpClient { +public: + HttpResponse send(const HttpRequest &request) override + { + HttpResponse response; + + const std::wstring url = widen(request.url); + if (url.empty()) { + response.network_error = "empty or non-UTF-8 URL"; + return response; + } + + URL_COMPONENTS parts{}; + parts.dwStructSize = sizeof(parts); + wchar_t host[256] = {0}; + wchar_t path[4096] = {0}; + wchar_t extra[4096] = {0}; + parts.lpszHostName = host; + parts.dwHostNameLength = static_cast(sizeof(host) / sizeof(host[0])); + parts.lpszUrlPath = path; + parts.dwUrlPathLength = static_cast(sizeof(path) / sizeof(path[0])); + parts.lpszExtraInfo = extra; + parts.dwExtraInfoLength = static_cast(sizeof(extra) / sizeof(extra[0])); + + if (!WinHttpCrackUrl(url.c_str(), static_cast(url.size()), 0, &parts)) { + response.network_error = lastErrorMessage("WinHttpCrackUrl"); + return response; + } + if (parts.nScheme != INTERNET_SCHEME_HTTP && parts.nScheme != INTERNET_SCHEME_HTTPS) { + response.network_error = "unsupported URL scheme"; + return response; + } + + Handle session(WinHttpOpen(L"streamer-tools-obs-plugin/1.0", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, + WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0)); + if (!session) { + response.network_error = lastErrorMessage("WinHttpOpen"); + return response; + } + + const DWORD timeout = static_cast(request.timeout_ms); + WinHttpSetTimeouts(session.get(), static_cast(timeout), static_cast(timeout), + static_cast(timeout), static_cast(timeout)); + + Handle connect(WinHttpConnect(session.get(), host, parts.nPort, 0)); + if (!connect) { + response.network_error = lastErrorMessage("WinHttpConnect"); + return response; + } + + std::wstring target(path); + target += extra; + + const DWORD flags = (parts.nScheme == INTERNET_SCHEME_HTTPS) ? WINHTTP_FLAG_SECURE : 0u; + Handle req(WinHttpOpenRequest(connect.get(), widen(request.method).c_str(), target.c_str(), nullptr, + WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, flags)); + if (!req) { + response.network_error = lastErrorMessage("WinHttpOpenRequest"); + return response; + } + + std::wstring headers; + if (!request.content_type.empty()) + headers = L"Content-Type: " + widen(request.content_type) + L"\r\n"; + + const LPCWSTR header_ptr = headers.empty() ? WINHTTP_NO_ADDITIONAL_HEADERS : headers.c_str(); + const DWORD header_len = headers.empty() ? 0u : static_cast(headers.size()); + + void *body_ptr = request.body.empty() ? WINHTTP_NO_REQUEST_DATA + : const_cast(request.body.data()); + const DWORD body_len = static_cast(request.body.size()); + + if (!WinHttpSendRequest(req.get(), header_ptr, header_len, body_ptr, body_len, body_len, 0)) { + response.network_error = lastErrorMessage("WinHttpSendRequest"); + return response; + } + if (!WinHttpReceiveResponse(req.get(), nullptr)) { + response.network_error = lastErrorMessage("WinHttpReceiveResponse"); + return response; + } + + DWORD status = 0; + DWORD status_size = sizeof(status); + if (!WinHttpQueryHeaders(req.get(), WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_HEADER_NAME_BY_INDEX, &status, &status_size, WINHTTP_NO_HEADER_INDEX)) { + response.network_error = lastErrorMessage("WinHttpQueryHeaders"); + return response; + } + response.status = static_cast(status); + + std::string body; + for (;;) { + DWORD available = 0; + if (!WinHttpQueryDataAvailable(req.get(), &available)) { + response.network_error = lastErrorMessage("WinHttpQueryDataAvailable"); + return response; + } + if (available == 0) + break; + if (body.size() + available > kMaxResponseBytes) { + response.network_error = "response body exceeded 4 MiB"; + return response; + } + std::vector chunk(available); + DWORD read = 0; + if (!WinHttpReadData(req.get(), chunk.data(), available, &read)) { + response.network_error = lastErrorMessage("WinHttpReadData"); + return response; + } + if (read == 0) + break; + body.append(chunk.data(), read); + } + + response.body = std::move(body); + return response; + } +}; + +} // namespace + +HttpClient *createPlatformHttpClient() +{ + return new WinHttpClient(); +} + +} // namespace stplugin diff --git a/core/src/json.cpp b/core/src/json.cpp new file mode 100644 index 0000000..8d1fe0b --- /dev/null +++ b/core/src/json.cpp @@ -0,0 +1,442 @@ +/* +streamer-tools OBS Camera Plugin - minimal JSON reader +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 +*/ + +#include "stplugin/json.h" + +#include +#include + +namespace stplugin { +namespace json { + +namespace { +const Value &invalidSingleton() +{ + static const Value v; + return v; +} +} // namespace + +Value Value::makeNull() +{ + Value v; + v.type_ = Type::Null; + return v; +} + +Value Value::makeBool(bool b) +{ + Value v; + v.type_ = Type::Bool; + v.bool_ = b; + return v; +} + +Value Value::makeNumber(double n) +{ + Value v; + v.type_ = Type::Number; + v.number_ = n; + return v; +} + +Value Value::makeString(std::string s) +{ + Value v; + v.type_ = Type::String; + v.string_ = std::move(s); + return v; +} + +Value Value::makeArray(std::vector a) +{ + Value v; + v.type_ = Type::Array; + v.array_ = std::move(a); + return v; +} + +Value Value::makeObject(std::map o) +{ + Value v; + v.type_ = Type::Object; + v.object_ = std::move(o); + return v; +} + +const Value &Value::operator[](const std::string &key) const +{ + if (type_ != Type::Object) + return invalidSingleton(); + auto it = object_.find(key); + if (it == object_.end()) + return invalidSingleton(); + return it->second; +} + +const Value &Value::at(std::size_t index) const +{ + if (type_ != Type::Array || index >= array_.size()) + return invalidSingleton(); + return array_[index]; +} + +std::size_t Value::size() const +{ + if (type_ == Type::Array) + return array_.size(); + if (type_ == Type::Object) + return object_.size(); + if (type_ == Type::String) + return string_.size(); + return 0; +} + +std::string Value::asString(const std::string &fallback) const +{ + return type_ == Type::String ? string_ : fallback; +} + +bool Value::asBool(bool fallback) const +{ + return type_ == Type::Bool ? bool_ : fallback; +} + +double Value::asNumber(double fallback) const +{ + return type_ == Type::Number ? number_ : fallback; +} + +// --------------------------------------------------------------------------- +// Parser +// --------------------------------------------------------------------------- + +namespace { + +class Parser { +public: + explicit Parser(const std::string &text) : s_(text) {} + + bool parseDocument(Value &out) + { + skipWs(); + if (!parseValue(out, 0)) + return false; + skipWs(); + // Trailing content is an error: "{}garbage" must not silently parse + // as an empty object. + return pos_ == s_.size(); + } + +private: + const std::string &s_; + std::size_t pos_ = 0; + + bool eof() const { return pos_ >= s_.size(); } + char peek() const { return s_[pos_]; } + + void skipWs() + { + while (!eof()) { + const char c = s_[pos_]; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') + ++pos_; + else + break; + } + } + + bool literal(const char *lit) + { + const std::size_t n = std::strlen(lit); + if (s_.compare(pos_, n, lit) != 0) + return false; + pos_ += n; + return true; + } + + bool parseValue(Value &out, int depth) + { + if (depth > kMaxDepth) + return false; + if (eof()) + return false; + + switch (peek()) { + case '{': + return parseObject(out, depth); + case '[': + return parseArray(out, depth); + case '"': { + std::string str; + if (!parseString(str)) + return false; + out = Value::makeString(std::move(str)); + return true; + } + case 't': + if (!literal("true")) + return false; + out = Value::makeBool(true); + return true; + case 'f': + if (!literal("false")) + return false; + out = Value::makeBool(false); + return true; + case 'n': + if (!literal("null")) + return false; + out = Value::makeNull(); + return true; + default: + return parseNumber(out); + } + } + + bool parseObject(Value &out, int depth) + { + ++pos_; // '{' + std::map members; + skipWs(); + if (!eof() && peek() == '}') { + ++pos_; + out = Value::makeObject(std::move(members)); + return true; + } + for (;;) { + skipWs(); + std::string key; + if (!parseString(key)) + return false; + skipWs(); + if (eof() || peek() != ':') + return false; + ++pos_; + skipWs(); + Value v; + if (!parseValue(v, depth + 1)) + return false; + members[key] = std::move(v); + skipWs(); + if (eof()) + return false; + if (peek() == ',') { + ++pos_; + continue; + } + if (peek() == '}') { + ++pos_; + out = Value::makeObject(std::move(members)); + return true; + } + return false; + } + } + + bool parseArray(Value &out, int depth) + { + ++pos_; // '[' + std::vector items; + skipWs(); + if (!eof() && peek() == ']') { + ++pos_; + out = Value::makeArray(std::move(items)); + return true; + } + for (;;) { + skipWs(); + Value v; + if (!parseValue(v, depth + 1)) + return false; + items.push_back(std::move(v)); + skipWs(); + if (eof()) + return false; + if (peek() == ',') { + ++pos_; + continue; + } + if (peek() == ']') { + ++pos_; + out = Value::makeArray(std::move(items)); + return true; + } + return false; + } + } + + bool parseHex4(unsigned &out) + { + if (pos_ + 4 > s_.size()) + return false; + unsigned value = 0; + for (int i = 0; i < 4; ++i) { + const char c = s_[pos_ + static_cast(i)]; + unsigned digit; + if (c >= '0' && c <= '9') + digit = static_cast(c - '0'); + else if (c >= 'a' && c <= 'f') + digit = static_cast(c - 'a') + 10u; + else if (c >= 'A' && c <= 'F') + digit = static_cast(c - 'A') + 10u; + else + return false; + value = (value << 4) | digit; + } + pos_ += 4; + out = value; + return true; + } + + static void appendUtf8(std::string &out, unsigned cp) + { + if (cp < 0x80) { + out.push_back(static_cast(cp)); + } else if (cp < 0x800) { + out.push_back(static_cast(0xC0u | (cp >> 6))); + out.push_back(static_cast(0x80u | (cp & 0x3Fu))); + } else if (cp < 0x10000) { + out.push_back(static_cast(0xE0u | (cp >> 12))); + out.push_back(static_cast(0x80u | ((cp >> 6) & 0x3Fu))); + out.push_back(static_cast(0x80u | (cp & 0x3Fu))); + } else { + out.push_back(static_cast(0xF0u | (cp >> 18))); + out.push_back(static_cast(0x80u | ((cp >> 12) & 0x3Fu))); + out.push_back(static_cast(0x80u | ((cp >> 6) & 0x3Fu))); + out.push_back(static_cast(0x80u | (cp & 0x3Fu))); + } + } + + bool parseString(std::string &out) + { + if (eof() || peek() != '"') + return false; + ++pos_; + out.clear(); + for (;;) { + if (eof()) + return false; // unterminated string + const unsigned char c = static_cast(s_[pos_]); + if (c == '"') { + ++pos_; + return true; + } + if (c == '\\') { + ++pos_; + if (eof()) + return false; + const char esc = s_[pos_++]; + switch (esc) { + case '"': out.push_back('"'); break; + case '\\': out.push_back('\\'); break; + case '/': out.push_back('/'); break; + case 'b': out.push_back('\b'); break; + case 'f': out.push_back('\f'); break; + case 'n': out.push_back('\n'); break; + case 'r': out.push_back('\r'); break; + case 't': out.push_back('\t'); break; + case 'u': { + unsigned cp = 0; + if (!parseHex4(cp)) + return false; + if (cp >= 0xD800 && cp <= 0xDBFF) { + // High surrogate: a low surrogate must follow. + if (pos_ + 1 < s_.size() && s_[pos_] == '\\' && s_[pos_ + 1] == 'u') { + pos_ += 2; + unsigned lo = 0; + if (!parseHex4(lo)) + return false; + if (lo < 0xDC00 || lo > 0xDFFF) + return false; + cp = 0x10000u + ((cp - 0xD800u) << 10) + (lo - 0xDC00u); + } else { + return false; + } + } else if (cp >= 0xDC00 && cp <= 0xDFFF) { + return false; // lone low surrogate + } + appendUtf8(out, cp); + break; + } + default: + return false; + } + continue; + } + if (c < 0x20) + return false; // raw control character + out.push_back(static_cast(c)); + ++pos_; + } + } + + bool parseNumber(Value &out) + { + const std::size_t start = pos_; + if (!eof() && peek() == '-') + ++pos_; + if (eof()) + return false; + if (peek() == '0') { + ++pos_; + } else if (peek() >= '1' && peek() <= '9') { + while (!eof() && peek() >= '0' && peek() <= '9') + ++pos_; + } else { + return false; + } + if (!eof() && peek() == '.') { + ++pos_; + if (eof() || peek() < '0' || peek() > '9') + return false; + while (!eof() && peek() >= '0' && peek() <= '9') + ++pos_; + } + if (!eof() && (peek() == 'e' || peek() == 'E')) { + ++pos_; + if (!eof() && (peek() == '+' || peek() == '-')) + ++pos_; + if (eof() || peek() < '0' || peek() > '9') + return false; + while (!eof() && peek() >= '0' && peek() <= '9') + ++pos_; + } + const std::string token = s_.substr(start, pos_ - start); + // strtod is locale-sensitive for the decimal separator, but the + // grammar above only ever hands it ASCII digits with a '.', and OBS + // does not switch the C locale away from "C". Using strtod rather + // than std::stod keeps this noexcept. + out = Value::makeNumber(std::strtod(token.c_str(), nullptr)); + return true; + } +}; + +} // namespace + +Value parse(const std::string &text) +{ + Parser p(text); + Value v; + if (!p.parseDocument(v)) + return Value::invalid(); + return v; +} + +} // namespace json +} // namespace stplugin diff --git a/core/tests/CMakeLists.txt b/core/tests/CMakeLists.txt index 2b1f2f4..343c97c 100644 --- a/core/tests/CMakeLists.txt +++ b/core/tests/CMakeLists.txt @@ -1,18 +1,28 @@ -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) +# Dependency-free CTest targets (see test_util.h for why there is no gtest). + +function(stplugin_add_test name) + add_executable(${name} ${name}.cpp) + target_link_libraries(${name} PRIVATE stplugin_core) + target_include_directories(${name} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + if(WIN32) + # loopback_server.h needs Winsock for the real-backend tests. + target_link_libraries(${name} PRIVATE ws2_32) + endif() + add_test(NAME ${name} COMMAND ${name}) + # Nothing here should ever take a minute; a hang is a failure, not a + # reason for CI to sit for its default 1500s. + set_tests_properties(${name} PROPERTIES TIMEOUT 120) +endfunction() + +stplugin_add_test(test_core) +stplugin_add_test(test_json) +stplugin_add_test(test_api_client) # 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_add_test(test_livekit_smoke) +target_compile_definitions(test_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/loopback_server.h b/core/tests/loopback_server.h new file mode 100644 index 0000000..cfb1726 --- /dev/null +++ b/core/tests/loopback_server.h @@ -0,0 +1,246 @@ +/* +streamer-tools OBS Camera Plugin - minimal loopback HTTP server for tests +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 + +// A single-threaded, one-request-at-a-time HTTP/1.1 server on 127.0.0.1, used +// to exercise the *real* platform HTTP backend (libcurl on Linux/macOS, +// WinHTTP on Windows) rather than only a fake. The handler returns raw bytes, +// so tests can serve deliberately malformed responses and half-closed +// connections -- the cases that must not hang or crash OBS. +// +// Plain HTTP only: a TLS listener would need a certificate and would test +// libcurl/SChannel rather than this plugin. + +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +using st_socket_t = SOCKET; +#define ST_INVALID_SOCKET INVALID_SOCKET +#define ST_CLOSE_SOCKET closesocket +#else +#include +#include +#include +#include +#include +using st_socket_t = int; +#define ST_INVALID_SOCKET (-1) +#define ST_CLOSE_SOCKET ::close +#endif + +namespace sttest { + +/// Returns raw response bytes for a received raw request. Returning an empty +/// string means "close the connection without replying". +using LoopbackHandler = std::function; + +class LoopbackServer { +public: + explicit LoopbackServer(LoopbackHandler handler) : handler_(std::move(handler)) + { +#ifdef _WIN32 + WSADATA wsa; + WSAStartup(MAKEWORD(2, 2), &wsa); +#endif + listen_ = ::socket(AF_INET, SOCK_STREAM, 0); + if (listen_ == ST_INVALID_SOCKET) + return; + + int reuse = 1; + ::setsockopt(listen_, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast(&reuse), sizeof(reuse)); + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; // let the OS pick a free port + if (::bind(listen_, reinterpret_cast(&addr), sizeof(addr)) != 0) { + ST_CLOSE_SOCKET(listen_); + listen_ = ST_INVALID_SOCKET; + return; + } + if (::listen(listen_, 4) != 0) { + ST_CLOSE_SOCKET(listen_); + listen_ = ST_INVALID_SOCKET; + return; + } + + sockaddr_in bound{}; +#ifdef _WIN32 + int len = sizeof(bound); +#else + socklen_t len = sizeof(bound); +#endif + if (::getsockname(listen_, reinterpret_cast(&bound), &len) != 0) { + ST_CLOSE_SOCKET(listen_); + listen_ = ST_INVALID_SOCKET; + return; + } + port_ = ntohs(bound.sin_port); + + thread_ = std::thread([this] { run(); }); + } + + ~LoopbackServer() + { + stop_.store(true); + if (thread_.joinable()) + thread_.join(); + if (listen_ != ST_INVALID_SOCKET) + ST_CLOSE_SOCKET(listen_); +#ifdef _WIN32 + WSACleanup(); +#endif + } + + LoopbackServer(const LoopbackServer &) = delete; + LoopbackServer &operator=(const LoopbackServer &) = delete; + + bool valid() const { return listen_ != ST_INVALID_SOCKET; } + int port() const { return port_; } + std::string baseUrl() const { return "http://127.0.0.1:" + std::to_string(port_); } + int requestCount() const { return requests_.load(); } + + /// The most recent raw request, for asserting on method/path/body. + std::string lastRequest() const + { + std::lock_guard guard(mutex_); + return last_request_; + } + +private: + void run() + { + while (!stop_.load()) { + // select() with a short timeout rather than a blocking accept(), + // so the destructor's stop flag is honoured promptly on every + // platform (closing a socket another thread is blocked in + // accept() on is not portable). + fd_set readable; + FD_ZERO(&readable); + FD_SET(listen_, &readable); + timeval tv{}; + tv.tv_sec = 0; + tv.tv_usec = 50000; // 50ms + const int ready = ::select(static_cast(listen_) + 1, &readable, nullptr, nullptr, &tv); + if (ready <= 0) + continue; + + st_socket_t client = ::accept(listen_, nullptr, nullptr); + if (client == ST_INVALID_SOCKET) + continue; + + const std::string request = readRequest(client); + { + std::lock_guard guard(mutex_); + last_request_ = request; + } + requests_.fetch_add(1); + + const std::string response = handler_ ? handler_(request) : std::string(); + if (!response.empty()) + sendAll(client, response); + ST_CLOSE_SOCKET(client); + } + } + + static std::string readRequest(st_socket_t client) + { + std::string data; + char buffer[4096]; + std::size_t header_end = std::string::npos; + long content_length = 0; + + for (;;) { +#ifdef _WIN32 + const int n = ::recv(client, buffer, static_cast(sizeof(buffer)), 0); +#else + const ssize_t n = ::recv(client, buffer, sizeof(buffer), 0); +#endif + if (n <= 0) + break; + data.append(buffer, static_cast(n)); + + if (header_end == std::string::npos) { + header_end = data.find("\r\n\r\n"); + if (header_end != std::string::npos) + content_length = parseContentLength(data.substr(0, header_end)); + } + if (header_end != std::string::npos && + data.size() >= header_end + 4 + static_cast(content_length)) + break; + } + return data; + } + + static long parseContentLength(const std::string &headers) + { + std::string lower; + lower.reserve(headers.size()); + for (char c : headers) + lower.push_back(static_cast(c >= 'A' && c <= 'Z' ? c + 32 : c)); + const std::size_t at = lower.find("content-length:"); + if (at == std::string::npos) + return 0; + return std::strtol(headers.c_str() + at + 15, nullptr, 10); + } + + static void sendAll(st_socket_t client, const std::string &data) + { + std::size_t sent = 0; + while (sent < data.size()) { +#ifdef _WIN32 + const int n = ::send(client, data.data() + sent, static_cast(data.size() - sent), 0); +#else + const ssize_t n = ::send(client, data.data() + sent, data.size() - sent, 0); +#endif + if (n <= 0) + return; + sent += static_cast(n); + } + } + + LoopbackHandler handler_; + st_socket_t listen_ = ST_INVALID_SOCKET; + int port_ = 0; + std::thread thread_; + std::atomic stop_{false}; + std::atomic requests_{0}; + mutable std::mutex mutex_; + std::string last_request_; +}; + +/// Build a well-formed HTTP/1.1 response with an explicit Content-Length and +/// Connection: close, so the client never waits for keep-alive reuse. +inline std::string httpResponse(int status, const std::string &reason, const std::string &body, + const std::string &content_type = "application/json") +{ + return "HTTP/1.1 " + std::to_string(status) + " " + reason + "\r\n" + "Content-Type: " + content_type + + "\r\n" + "Content-Length: " + std::to_string(body.size()) + "\r\n" + "Connection: close\r\n\r\n" + + body; +} + +} // namespace sttest diff --git a/core/tests/test_api_client.cpp b/core/tests/test_api_client.cpp new file mode 100644 index 0000000..0c74260 --- /dev/null +++ b/core/tests/test_api_client.cpp @@ -0,0 +1,434 @@ +/* +streamer-tools OBS Camera Plugin - API client tests +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 +*/ + +// Two layers of coverage: +// 1. a fake HttpClient, for response parsing and every error branch; +// 2. a real loopback HTTP server driven through the *platform* backend +// (libcurl or WinHTTP), so the backend itself is exercised in CI on all +// three runners rather than assumed to work. + +#include +#include +#include +#include +#include + +#include "stplugin/api_client.h" +#include "stplugin/http.h" + +#include "loopback_server.h" +#include "test_util.h" + +using namespace stplugin; + +namespace { + +class FakeHttpClient : public HttpClient { +public: + HttpResponse next; + HttpRequest last; + int calls = 0; + + HttpResponse send(const HttpRequest &request) override + { + last = request; + ++calls; + return next; + } +}; + +ConnectionConfig testConfig() +{ + return ConnectionConfig{"https://streamers.example.com", "main-room", "readkey123"}; +} + +std::shared_ptr makeFake(long status, const std::string &body) +{ + auto fake = std::make_shared(); + fake->next.status = status; + fake->next.body = body; + return fake; +} + +// --------------------------------------------------------------------------- +// URL handling +// --------------------------------------------------------------------------- + +void testNormalizeServerUrl() +{ + ST_ASSERT_EQ(ApiClient::normalizeServerUrl("https://a.example.com"), std::string("https://a.example.com")); + ST_ASSERT_EQ(ApiClient::normalizeServerUrl("https://a.example.com/"), std::string("https://a.example.com")); + ST_ASSERT_EQ(ApiClient::normalizeServerUrl("https://a.example.com///"), std::string("https://a.example.com")); + ST_ASSERT_EQ(ApiClient::normalizeServerUrl(" https://a.example.com "), std::string("https://a.example.com")); + // No scheme defaults to https, never http: the read key is a credential. + ST_ASSERT_EQ(ApiClient::normalizeServerUrl("a.example.com"), std::string("https://a.example.com")); + // An explicit http:// is honoured -- the test LXC is reachable that way. + ST_ASSERT_EQ(ApiClient::normalizeServerUrl("http://192.168.1.175:3000"), std::string("http://192.168.1.175:3000")); + ST_ASSERT_EQ(ApiClient::normalizeServerUrl(""), std::string("")); + ST_ASSERT_EQ(ApiClient::normalizeServerUrl(" "), std::string("")); + ST_ASSERT_EQ(ApiClient::normalizeServerUrl("https://"), std::string("")); + ST_ASSERT_EQ(ApiClient::normalizeServerUrl("/"), std::string("")); +} + +void testUrlEncodeAndRedaction() +{ + ST_ASSERT_EQ(urlEncode("plain-slug_1.0~"), std::string("plain-slug_1.0~")); + ST_ASSERT_EQ(urlEncode("a b"), std::string("a%20b")); + ST_ASSERT_EQ(urlEncode("a/b?c=d&e"), std::string("a%2Fb%3Fc%3Dd%26e")); + ST_ASSERT_EQ(urlEncode("k\xc3\xa9y"), std::string("k%C3%A9y")); + + ST_ASSERT_EQ(ApiClient::redactedUrl("https://h/api/obs/r/slots?key=secret"), + std::string("https://h/api/obs/r/slots?key=***")); + ST_ASSERT_EQ(ApiClient::redactedUrl("https://h/api/obs/r/slots?key=secret&x=1"), + std::string("https://h/api/obs/r/slots?key=***&x=1")); + ST_ASSERT_EQ(ApiClient::redactedUrl("https://h/nothing"), std::string("https://h/nothing")); +} + +void testRequestShape() +{ + auto fake = makeFake(200, R"({"slots":[]})"); + ApiClient client(fake); + ConnectionConfig config = testConfig(); + // Values that need encoding, and stray whitespace an operator would paste. + config.room_slug = " main room "; + config.read_key = " a+b/c "; + (void)client.fetchSlots(config); + ST_ASSERT_EQ(fake->last.method, std::string("GET")); + ST_ASSERT_EQ(fake->last.url, + std::string("https://streamers.example.com/api/obs/main%20room/slots?key=a%2Bb%2Fc")); + + auto fake2 = makeFake(200, R"({"lkToken":"t","wsUrl":"wss://x","identity":"obs:r:1"})"); + ApiClient client2(fake2); + (void)client2.requestToken(testConfig()); + ST_ASSERT_EQ(fake2->last.method, std::string("POST")); + ST_ASSERT_EQ(fake2->last.url, + std::string("https://streamers.example.com/api/obs/main-room/token?key=readkey123")); + ST_ASSERT_EQ(fake2->last.content_type, std::string("application/json")); +} + +// --------------------------------------------------------------------------- +// Response parsing +// --------------------------------------------------------------------------- + +void testSlotsHappyPath() +{ + auto fake = makeFake(200, + R"({"slots":[{"identity":"cam1","displayName":"Alice","live":true},)" + R"({"identity":"cam2","displayName":"Bob","live":false}]})"); + ApiClient client(fake); + const SlotsResult result = client.fetchSlots(testConfig()); + ST_ASSERT(result.ok()); + ST_ASSERT_EQ(result.slots.size(), std::size_t(2)); + ST_ASSERT_EQ(result.slots[0].identity, std::string("cam1")); + ST_ASSERT_EQ(result.slots[0].display_name, std::string("Alice")); + ST_ASSERT_EQ(result.slots[0].live, true); + ST_ASSERT_EQ(result.slots[1].live, false); +} + +void testSlotsEdgeCases() +{ + // Empty room: a valid answer, not an error. + { + ApiClient client(makeFake(200, R"({"slots":[]})")); + const SlotsResult result = client.fetchSlots(testConfig()); + ST_ASSERT(result.ok()); + ST_ASSERT_EQ(result.slots.size(), std::size_t(0)); + } + // A missing/blank displayName falls back to the identity, matching what + // the server itself does for a slot with no display_name. + { + ApiClient client(makeFake(200, R"({"slots":[{"identity":"cam1"},{"identity":"cam2","displayName":""}]})")); + const SlotsResult result = client.fetchSlots(testConfig()); + ST_ASSERT(result.ok()); + ST_ASSERT_EQ(result.slots.size(), std::size_t(2)); + ST_ASSERT_EQ(result.slots[0].display_name, std::string("cam1")); + ST_ASSERT_EQ(result.slots[1].display_name, std::string("cam2")); + ST_ASSERT_EQ(result.slots[0].live, false); // missing `live` is not live + } + // An entry with no identity is unusable and is dropped, not surfaced as a + // dropdown row that could never connect. + { + ApiClient client(makeFake(200, R"({"slots":[{"displayName":"ghost"},{"identity":"cam1"}]})")); + const SlotsResult result = client.fetchSlots(testConfig()); + ST_ASSERT(result.ok()); + ST_ASSERT_EQ(result.slots.size(), std::size_t(1)); + ST_ASSERT_EQ(result.slots[0].identity, std::string("cam1")); + } + // Wrong types where the shape is otherwise right: don't crash, don't + // invent values. + { + ApiClient client(makeFake(200, R"({"slots":[{"identity":"cam1","displayName":42,"live":"yes"}]})")); + const SlotsResult result = client.fetchSlots(testConfig()); + ST_ASSERT(result.ok()); + ST_ASSERT_EQ(result.slots.size(), std::size_t(1)); + ST_ASSERT_EQ(result.slots[0].display_name, std::string("cam1")); + ST_ASSERT_EQ(result.slots[0].live, false); + } +} + +void testTokenHappyPath() +{ + ApiClient client(makeFake(200, + R"({"lkToken":"eyJhbGciOiJIUzI1NiJ9.abc.def",)" + R"("wsUrl":"wss://streamers.example.com","identity":"obs:main-room:Ab_1"})")); + const TokenResult result = client.requestToken(testConfig()); + ST_ASSERT(result.ok()); + ST_ASSERT_EQ(result.lk_token, std::string("eyJhbGciOiJIUzI1NiJ9.abc.def")); + ST_ASSERT_EQ(result.ws_url, std::string("wss://streamers.example.com")); + ST_ASSERT_EQ(result.identity, std::string("obs:main-room:Ab_1")); +} + +void testHttpErrorStatuses() +{ + // 404 -- a wrong read key and an unknown slug are deliberately + // indistinguishable server-side, so the message must not claim to know. + { + ApiClient client(makeFake(404, R"({"error":"not found"})")); + const SlotsResult slots = client.fetchSlots(testConfig()); + ST_ASSERT(!slots.ok()); + ST_ASSERT(slots.status == ApiStatus::NotFound); + ST_ASSERT_EQ(slots.slots.size(), std::size_t(0)); + + const TokenResult token = client.requestToken(testConfig()); + ST_ASSERT(token.status == ApiStatus::NotFound); + ST_ASSERT_EQ(token.lk_token, std::string("")); + } + // 503 -- server reachable, LiveKit not configured. + { + ApiClient client(makeFake(503, R"({"error":"livekit not configured"})")); + ST_ASSERT(client.fetchSlots(testConfig()).status == ApiStatus::Unavailable); + ST_ASSERT(client.requestToken(testConfig()).status == ApiStatus::Unavailable); + } + // Anything else, e.g. a reverse proxy answering before the app does. + { + ApiClient client(makeFake(502, "502 Bad Gateway")); + const SlotsResult result = client.fetchSlots(testConfig()); + ST_ASSERT(result.status == ApiStatus::HttpError); + ST_ASSERT_EQ(result.message, std::string("HTTP 502")); + } + { + ApiClient client(makeFake(401, "")); + ST_ASSERT(client.fetchSlots(testConfig()).status == ApiStatus::HttpError); + } +} + +void testMalformedSuccessBodies() +{ + // 200 with a body that is not the expected shape must be reported, not + // silently treated as "no slots". + const char *bad_slots[] = { + "", + "not json at all", + "{}", + R"({"slots":null})", + R"({"slots":{}})", + R"({"slots":"cam1"})", + "[]", + R"({"slots":[)", + "login page", + }; + for (const char *body : bad_slots) { + ApiClient client(makeFake(200, body)); + const SlotsResult result = client.fetchSlots(testConfig()); + ST_ASSERT(result.status == ApiStatus::MalformedResponse); + ST_ASSERT_EQ(result.slots.size(), std::size_t(0)); + } + + const char *bad_token[] = { + "", + "{}", + R"({"lkToken":""})", + R"({"lkToken":"t"})", // no wsUrl + R"({"wsUrl":"wss://x"})", // no token + R"({"lkToken":123,"wsUrl":"wss://x"})", // wrong type + R"({"lkToken":"t","wsUrl":""})", + "[]", + "\xff\xfe binary", + }; + for (const char *body : bad_token) { + ApiClient client(makeFake(200, body)); + const TokenResult result = client.requestToken(testConfig()); + ST_ASSERT(result.status == ApiStatus::MalformedResponse); + ST_ASSERT_EQ(result.lk_token, std::string("")); + } +} + +void testNetworkErrorAndInvalidConfig() +{ + { + auto fake = std::make_shared(); + fake->next.network_error = "Could not resolve host"; + ApiClient client(fake); + const SlotsResult result = client.fetchSlots(testConfig()); + ST_ASSERT(result.status == ApiStatus::NetworkError); + ST_ASSERT_EQ(result.message, std::string("Could not resolve host")); + } + // An incomplete config must never reach the HTTP layer at all. + { + auto fake = makeFake(200, R"({"slots":[]})"); + ApiClient client(fake); + ST_ASSERT(client.fetchSlots(ConnectionConfig{"", "r", "k"}).status == ApiStatus::InvalidConfig); + ST_ASSERT(client.fetchSlots(ConnectionConfig{"https://h", "", "k"}).status == ApiStatus::InvalidConfig); + ST_ASSERT(client.fetchSlots(ConnectionConfig{"https://h", "r", ""}).status == ApiStatus::InvalidConfig); + ST_ASSERT(client.requestToken(ConnectionConfig{"https://", "r", "k"}).status == ApiStatus::InvalidConfig); + ST_ASSERT_EQ(fake->calls, 0); + } + // A null HTTP client is a programming error, not a crash. + { + ApiClient client(nullptr); + ST_ASSERT(client.fetchSlots(testConfig()).status == ApiStatus::InvalidConfig); + } +} + +// --------------------------------------------------------------------------- +// Real platform HTTP backend, against a real loopback socket +// --------------------------------------------------------------------------- + +ConnectionConfig loopbackConfig(const sttest::LoopbackServer &server) +{ + return ConnectionConfig{server.baseUrl(), "main-room", "readkey123"}; +} + +void testPlatformBackendAgainstLoopback() +{ + std::shared_ptr http(createPlatformHttpClient()); + ST_ASSERT(http != nullptr); + if (!http) + return; + ApiClient client(http); + + // 200 with real slots, and the request line/headers the server sees. + { + sttest::LoopbackServer server([](const std::string &) { + return sttest::httpResponse(200, "OK", + R"({"slots":[{"identity":"cam1","displayName":"Alice","live":true}]})"); + }); + ST_ASSERT(server.valid()); + const SlotsResult result = client.fetchSlots(loopbackConfig(server)); + ST_ASSERT(result.ok()); + ST_ASSERT_EQ(result.slots.size(), std::size_t(1)); + ST_ASSERT_EQ(result.slots[0].identity, std::string("cam1")); + ST_ASSERT(server.lastRequest().find("GET /api/obs/main-room/slots?key=readkey123") == 0); + } + + // POST /token: verify the method and that a body is actually sent. + { + sttest::LoopbackServer server([](const std::string &) { + return sttest::httpResponse(200, "OK", R"({"lkToken":"tok","wsUrl":"wss://lk.example","identity":"obs:r:1"})"); + }); + ST_ASSERT(server.valid()); + const TokenResult result = client.requestToken(loopbackConfig(server)); + ST_ASSERT(result.ok()); + ST_ASSERT_EQ(result.lk_token, std::string("tok")); + ST_ASSERT_EQ(result.ws_url, std::string("wss://lk.example")); + ST_ASSERT(server.lastRequest().find("POST /api/obs/main-room/token?key=readkey123") == 0); + } + + // 404 and 503 over a real socket. + { + sttest::LoopbackServer server([](const std::string &) { + return sttest::httpResponse(404, "Not Found", R"({"error":"not found"})"); + }); + ST_ASSERT(client.fetchSlots(loopbackConfig(server)).status == ApiStatus::NotFound); + } + { + sttest::LoopbackServer server([](const std::string &) { + return sttest::httpResponse(503, "Service Unavailable", R"({"error":"livekit not configured"})"); + }); + ST_ASSERT(client.requestToken(loopbackConfig(server)).status == ApiStatus::Unavailable); + } + + // 200 with a truncated JSON body: must be MalformedResponse, not a hang + // and not a crash. + { + sttest::LoopbackServer server([](const std::string &) { + return sttest::httpResponse(200, "OK", R"({"slots":[{"identity":)"); + }); + ST_ASSERT(client.fetchSlots(loopbackConfig(server)).status == ApiStatus::MalformedResponse); + } + + // A server that accepts the connection and closes without replying at + // all. This is a network error, and it must come back promptly. + { + sttest::LoopbackServer server([](const std::string &) { return std::string(); }); + const auto start = std::chrono::steady_clock::now(); + const SlotsResult result = client.fetchSlots(loopbackConfig(server)); + const auto elapsed = std::chrono::steady_clock::now() - start; + ST_ASSERT(result.status == ApiStatus::NetworkError); + ST_ASSERT(std::chrono::duration_cast(elapsed).count() < 15); + } + + // Garbage that is not HTTP at all. + { + sttest::LoopbackServer server([](const std::string &) { return std::string("\x01\x02not http\r\n\r\n"); }); + const SlotsResult result = client.fetchSlots(loopbackConfig(server)); + ST_ASSERT(result.status == ApiStatus::NetworkError || result.status == ApiStatus::MalformedResponse || + result.status == ApiStatus::HttpError); + } + + // Nothing listening on the port at all: a clean NetworkError. + { + int dead_port = 0; + { + sttest::LoopbackServer server([](const std::string &) { return std::string(); }); + dead_port = server.port(); + } // server destroyed, port closed + ConnectionConfig config{"http://127.0.0.1:" + std::to_string(dead_port), "main-room", "readkey123"}; + ST_ASSERT(client.fetchSlots(config).status == ApiStatus::NetworkError); + } +} + +void testPlatformBackendTimeout() +{ + // A server that accepts and then stalls. The plugin must give up on its + // own timeout rather than blocking an OBS thread indefinitely. + sttest::LoopbackServer server([](const std::string &) { + std::this_thread::sleep_for(std::chrono::seconds(5)); + return sttest::httpResponse(200, "OK", R"({"slots":[]})"); + }); + ST_ASSERT(server.valid()); + + std::shared_ptr http(createPlatformHttpClient()); + HttpRequest request; + request.url = server.baseUrl() + "/api/obs/main-room/slots?key=k"; + request.timeout_ms = 700; + + const auto start = std::chrono::steady_clock::now(); + const HttpResponse response = http->send(request); + const auto elapsed = std::chrono::steady_clock::now() - start; + ST_ASSERT(!response.ok()); + ST_ASSERT(std::chrono::duration_cast(elapsed).count() < 4000); +} + +} // namespace + +int main() +{ + testNormalizeServerUrl(); + testUrlEncodeAndRedaction(); + testRequestShape(); + testSlotsHappyPath(); + testSlotsEdgeCases(); + testTokenHappyPath(); + testHttpErrorStatuses(); + testMalformedSuccessBodies(); + testNetworkErrorAndInvalidConfig(); + testPlatformBackendAgainstLoopback(); + testPlatformBackendTimeout(); + return st_test_report("api_client"); +} diff --git a/core/tests/test_json.cpp b/core/tests/test_json.cpp new file mode 100644 index 0000000..de17412 --- /dev/null +++ b/core/tests/test_json.cpp @@ -0,0 +1,163 @@ +/* +streamer-tools OBS Camera Plugin - JSON reader tests +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 +*/ + +#include + +#include "stplugin/json.h" +#include "test_util.h" + +using stplugin::json::Value; +using stplugin::json::parse; + +static void testRealResponses() +{ + // The exact shape apps/server/src/obs/plugin.routes.ts returns. + const Value slots = parse( + R"({"slots":[{"identity":"cam1","displayName":"Alice","live":true},)" + R"({"identity":"cam2","displayName":"Bob","live":false}]})"); + ST_ASSERT(slots.valid()); + ST_ASSERT(slots.isObject()); + ST_ASSERT(slots["slots"].isArray()); + ST_ASSERT_EQ(slots["slots"].size(), std::size_t(2)); + ST_ASSERT_EQ(slots["slots"].at(0)["identity"].asString(), std::string("cam1")); + ST_ASSERT_EQ(slots["slots"].at(0)["displayName"].asString(), std::string("Alice")); + ST_ASSERT_EQ(slots["slots"].at(0)["live"].asBool(), true); + ST_ASSERT_EQ(slots["slots"].at(1)["live"].asBool(true), false); + + const Value token = parse( + R"({"lkToken":"eyJhbGciOiJIUzI1NiJ9.abc.def","wsUrl":"wss://streamers.example.com",)" + R"("identity":"obs:main-room:Ab_1-cd2"})"); + ST_ASSERT_EQ(token["lkToken"].asString(), std::string("eyJhbGciOiJIUzI1NiJ9.abc.def")); + ST_ASSERT_EQ(token["wsUrl"].asString(), std::string("wss://streamers.example.com")); + ST_ASSERT_EQ(token["identity"].asString(), std::string("obs:main-room:Ab_1-cd2")); + + const Value error = parse(R"({"error":"not found"})"); + ST_ASSERT_EQ(error["error"].asString(), std::string("not found")); +} + +static void testScalarsAndEscapes() +{ + ST_ASSERT(parse("null").isNull()); + ST_ASSERT_EQ(parse("true").asBool(), true); + ST_ASSERT_EQ(parse("false").asBool(true), false); + ST_ASSERT_EQ(parse("0").asNumber(), 0.0); + ST_ASSERT_EQ(parse("-12").asNumber(), -12.0); + ST_ASSERT_EQ(parse("1.5e2").asNumber(), 150.0); + ST_ASSERT_EQ(parse("\"\"").asString("x"), std::string("")); + ST_ASSERT_EQ(parse(R"("a\"b\\c\/d")").asString(), std::string("a\"b\\c/d")); + ST_ASSERT_EQ(parse(R"("\n\t\r\b\f")").asString(), std::string("\n\t\r\b\f")); + + // \u escapes, including a surrogate pair (an emoji in a display name is + // entirely plausible and must not corrupt the dropdown). + ST_ASSERT_EQ(parse(R"("\u0041")").asString(), std::string("A")); + ST_ASSERT_EQ(parse(R"("caf\u00e9")").asString(), std::string("caf\xc3\xa9")); + ST_ASSERT_EQ(parse(R"("\ud83d\ude00")").asString(), std::string("\xf0\x9f\x98\x80")); + + // Whitespace everywhere legal. + ST_ASSERT_EQ(parse(" {\n \"a\" :\t[ 1 , 2 ]\r\n} ")["a"].size(), std::size_t(2)); +} + +static void testMalformedIsRejectedNotCrashed() +{ + const char *bad[] = { + "", + " ", + "{", + "}", + "[", + "[1,", + "[1,]", + "{\"a\"}", + "{\"a\":}", + "{\"a\":1,}", + "{a:1}", + "{'a':1}", + "\"unterminated", + "\"bad\\escape\"", + "\"\\u00\"", + "\"\\uZZZZ\"", + "\"\\ud83d\"", // lone high surrogate + "\"\\ude00\"", // lone low surrogate + "01", // leading zero + "+1", + ".5", + "1.", + "1e", + "1e+", + "tru", + "nulll", + "{}garbage", // trailing content + "[1,2] [3]", + "\"raw\ncontrol\"", // literal control char inside a string + "\xff\xfe", // binary garbage, e.g. an HTML error page prefix + "502 Bad Gateway", + }; + for (const char *text : bad) { + const Value v = parse(text); + ST_ASSERT(!v.valid()); + // Accessors on an invalid value must still be safe and return the + // caller's fallback. + ST_ASSERT_EQ(v["anything"].asString("fallback"), std::string("fallback")); + ST_ASSERT_EQ(v.at(0).asNumber(-1.0), -1.0); + ST_ASSERT_EQ(v.size(), std::size_t(0)); + } +} + +static void testDepthLimit() +{ + // Deep-but-legal nesting is rejected rather than recursed into, so a + // hostile response cannot overflow the stack inside OBS. + std::string deep; + const int depth = stplugin::json::kMaxDepth + 50; + for (int i = 0; i < depth; ++i) + deep += "["; + for (int i = 0; i < depth; ++i) + deep += "]"; + ST_ASSERT(!parse(deep).valid()); + + // Just inside the limit still parses. + std::string shallow; + for (int i = 0; i < stplugin::json::kMaxDepth - 1; ++i) + shallow += "["; + shallow += "1"; + for (int i = 0; i < stplugin::json::kMaxDepth - 1; ++i) + shallow += "]"; + ST_ASSERT(parse(shallow).valid()); +} + +static void testWrongTypesFallBack() +{ + const Value v = parse(R"({"n":5,"s":"x","b":true,"arr":[1],"obj":{}})"); + ST_ASSERT_EQ(v["n"].asString("fallback"), std::string("fallback")); + ST_ASSERT_EQ(v["s"].asNumber(-1.0), -1.0); + ST_ASSERT_EQ(v["s"].asBool(true), true); + ST_ASSERT_EQ(v["missing"].asString("fallback"), std::string("fallback")); + ST_ASSERT_EQ(v["arr"].at(5).asNumber(-1.0), -1.0); + ST_ASSERT_EQ(v["obj"].at(0).asNumber(-1.0), -1.0); + ST_ASSERT_EQ(v["n"]["deeper"].asString("fallback"), std::string("fallback")); +} + +int main() +{ + testRealResponses(); + testScalarsAndEscapes(); + testMalformedIsRejectedNotCrashed(); + testDepthLimit(); + testWrongTypesFallBack(); + return st_test_report("json"); +} -- 2.52.0 From 80904a3e8552cae63082b8c602631ae2f132d8e1 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 6 Sep 2026 21:39:49 -0700 Subject: [PATCH 03/17] Add the LiveKit session wrapper, verified end-to-end against a real room stplugin::LiveKitSession wraps livekit::Room for exactly one subscribed slot: connect with the wsUrl/lkToken the API client minted, find the chosen participant's camera (and microphone), and hand decoded frames to callback-shaped handlers the OBS adapter can consume directly. Two architectural decisions worth recording, both forced by reading the SDK rather than guessed: 1. Frames come from VideoStream/AudioStream::fromTrack with our own reader threads, NOT from Room::setOnVideoFrameCallback. The dispatcher API is keyed by (participant identity, track NAME), which we cannot know before the track is published -- and disassembling liblivekit.so confirms that both Room::setOnVideoFrameCallback and the dispatcher's own setOnVideoFrameCallback merely record the registration: neither starts a reader for a track that is already subscribed. Registering after the subscription event, which is the only time the track name exists, would therefore have silently produced no video. Taking the shared_ptr straight off the TrackSubscribedEvent sidesteps the name entirely, and lets us pick the camera by TrackSource (streamer-tools publishes cameras as Source.Camera and screenshares separately -- apps/web/src/avatar/ publish.ts), which is what we actually mean. 2. Every stream operation runs on one owned worker thread, never on a room event thread. The SDK documents that Room::disconnect() from inside a delegate callback deadlocks, and Room's own event dispatch holds a mutex, so delegate callbacks only ever enqueue a command here. VideoStream::Options::capacity is set (3 frames) so the SDK's queue is a drop-oldest ring buffer: a stalled consumer can only fall three frames behind, and what it then sees is the newest frame rather than a backlog. That is the structural answer to the stale-media bug that motivated this plugin. The pure decision-making -- the state machine, track selection, frame geometry validation -- lives in session_types.h/.cpp with no LiveKit or OBS types, so it is unit-testable headlessly (81 checks in test_session, including the publisher-swap and reconnect transitions, plus the real connect() failure paths against the real SDK: unreachable host, garbage token, incomplete config, and destruction mid-connect). test_integration_livekit is the test that proves media actually flows. It publishes a synthetic camera and microphone into a real LiveKit room using the same SDK, subscribes through LiveKitSession, and asserts on the exact fields the OBS adapter will dereference. It skips (exit 0) unless STPLUGIN_IT_* is set, so the three build runners stay green; scripts/livekit-dev-room.py mints the tokens for a local `livekit-server --dev`. Verified locally against livekit-server 1.13.6 in dev mode: integration_livekit: 36 video frames, 323 audio frames, 10 state changes integration_livekit: 32 checks passed covering: connect; subscribe to the named participant's camera; 320x240 I420 frames with three planes, non-null plane pointers and strides >= the frame's own width; 48kHz audio; unpublish -> hasVideo() false, state stays Connected (a dark camera is the placeholder state, never an error) and NO further frames arrive from the dead publisher; republish -> video resumes; clean disconnect. One real finding from that run, now handled: WebRTC ramps a new subscription up from a downscaled spatial layer, so the first frames after (re)subscribing legitimately arrive smaller than what is being published. The OBS adapter must cope with a mid-stream resolution change; the test asserts per-frame geometry rather than the publisher's, and separately asserts the stream does reach full size. Full suite: ctest -> 6/6 passed. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE --- core/CMakeLists.txt | 2 + core/include/stplugin/session.h | 118 ++++ core/include/stplugin/session_types.h | 187 ++++++ core/src/session.cpp | 733 ++++++++++++++++++++++++ core/src/session_types.cpp | 185 ++++++ core/tests/CMakeLists.txt | 8 + core/tests/test_integration_livekit.cpp | 370 ++++++++++++ core/tests/test_session.cpp | 355 ++++++++++++ scripts/livekit-dev-room.py | 78 +++ 9 files changed, 2036 insertions(+) create mode 100644 core/include/stplugin/session.h create mode 100644 core/include/stplugin/session_types.h create mode 100644 core/src/session.cpp create mode 100644 core/src/session_types.cpp create mode 100644 core/tests/test_integration_livekit.cpp create mode 100644 core/tests/test_session.cpp create mode 100644 scripts/livekit-dev-room.py diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index a8bf2ec..b13f033 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -9,6 +9,8 @@ set(STPLUGIN_CORE_SOURCES src/json.cpp src/http_common.cpp src/api_client.cpp + src/session_types.cpp + src/session.cpp ) # HTTP backend, one per platform. See core/include/stplugin/http.h for why diff --git a/core/include/stplugin/session.h b/core/include/stplugin/session.h new file mode 100644 index 0000000..ae43f35 --- /dev/null +++ b/core/include/stplugin/session.h @@ -0,0 +1,118 @@ +/* +streamer-tools OBS Camera Plugin - LiveKit session wrapper +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 + +#include +#include + +#include "stplugin/session_types.h" + +namespace stplugin { + +struct SessionConfig { + /// LiveKit websocket URL, from POST /api/obs/:slug/token. + std::string ws_url; + /// LiveKit JWT, from the same call. + std::string token; + /// The slot's participant identity to subscribe to. + std::string participant_identity; + + /// Pixel format requested from the SDK. I420 costs no conversion on + /// either side. + PixelFormat video_format = PixelFormat::I420; + + /// Ring-buffer depth for decoded video. Non-zero means the SDK drops the + /// OLDEST frame when the queue is full, which is the structural answer to + /// the stale-frame-after-publisher-swap bug that motivated this plugin + /// (see the design doc's Approach section): a stalled consumer can only + /// ever fall this far behind, and what it then sees is the newest frame, + /// not a backlog. + std::size_t video_queue_capacity = 3; + + /// Same for audio. A little deeper because audio frames are 10ms each. + std::size_t audio_queue_capacity = 20; + + bool subscribe_audio = true; + + /// How long connect() waits for the room to come up before giving up. + int connect_timeout_ms = 15000; +}; + +/// Wraps livekit::Room for exactly one subscribed slot. +/// +/// Threading contract, which the OBS adapter depends on: +/// - connect() and disconnect() are blocking and must be called from an +/// ordinary thread. They must NOT be called from inside a handler this +/// class invokes: the SDK documents that Room::disconnect() deadlocks if +/// called from a room event callback, and Room's own callback registration +/// is not re-entrant either. +/// - The video and audio handlers are invoked on dedicated reader threads, +/// one per track. Frame pointers are valid only for the duration of the +/// call. +/// - The state handler is invoked from whichever thread observed the +/// change. It must not block and must not call back into this object. +/// - All handlers must be installed before connect(); they are not +/// synchronised against a running session. +class LiveKitSession { +public: + LiveKitSession(); + ~LiveKitSession(); + + LiveKitSession(const LiveKitSession &) = delete; + LiveKitSession &operator=(const LiveKitSession &) = delete; + + void setVideoHandler(VideoFrameHandler handler); + void setAudioHandler(AudioFrameHandler handler); + void setStateHandler(SessionStateHandler handler); + + /// Connect and start subscribing. Returns true once the room is up; the + /// selected slot's tracks may still arrive later (or not at all, if the + /// camera is dark), which is reported through hasVideo()/the state + /// handler rather than as a connect failure. + bool connect(const SessionConfig &config); + + /// Tear everything down. Safe to call when never connected, and safe to + /// call twice. + void disconnect(); + + SessionState state() const; + std::string stateDetail() const; + bool hasVideo() const; + bool hasAudio() const; + /// True when connected but the slot is not publishing: the source should + /// show its placeholder, not an error. + bool waitingForCamera() const; + + /// Monotonic counters, for logging and for the adapter to tell "connected + /// but silent" from "never started". + std::uint64_t videoFrameCount() const; + std::uint64_t audioFrameCount() const; + + /// Process-wide SDK init/teardown. Reference-counted, so several sources + /// can each hold one. The OBS adapter calls these from obs_module_load / + /// obs_module_unload. + static void globalInitialize(); + static void globalShutdown(); + +private: + struct Impl; + std::unique_ptr impl_; +}; + +} // namespace stplugin diff --git a/core/include/stplugin/session_types.h b/core/include/stplugin/session_types.h new file mode 100644 index 0000000..758e6d0 --- /dev/null +++ b/core/include/stplugin/session_types.h @@ -0,0 +1,187 @@ +/* +streamer-tools OBS Camera Plugin - session types and pure session logic +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 + +// Everything in this header is deliberately free of both LiveKit and OBS +// types. That is what makes the session's decision-making unit-testable +// headlessly: the parts of LiveKitSession that can go wrong without a server +// -- which state a sequence of room events leaves us in, whether a given +// published track is the one we want, and whether a frame's geometry is +// self-consistent -- all live here, and LiveKitSession is the (much thinner) +// piece that wires real SDK callbacks into them. + +#include +#include +#include +#include + +namespace stplugin { + +// --------------------------------------------------------------------------- +// Media description (mirrors of the LiveKit enums, converted at the boundary) +// --------------------------------------------------------------------------- + +enum class MediaKind { Unknown, Audio, Video }; + +enum class MediaSource { Unknown, Camera, Microphone, Screenshare, ScreenshareAudio, Other }; + +/// Pixel formats this plugin is willing to receive. I420 is the default +/// because it is what a WebRTC decoder produces and what OBS accepts +/// natively, so neither side pays for a conversion. +enum class PixelFormat { I420, NV12, BGRA }; + +const char *describePixelFormat(PixelFormat format); + +/// Number of planes a format uses (3 for I420, 2 for NV12, 1 for BGRA). +int planeCount(PixelFormat format); + +/// Total bytes a tightly-packed frame of this format and geometry occupies. +/// Returns 0 for non-positive dimensions. Used to reject a frame whose +/// buffer does not match its claimed size before its pointers reach OBS. +std::size_t expectedFrameBytes(PixelFormat format, int width, int height); + +// --------------------------------------------------------------------------- +// Track selection +// --------------------------------------------------------------------------- + +/// Does this published track belong to the slot we were asked to show, and is +/// it the camera (never the screenshare)? +/// +/// streamer-tools publishes cameras as Track.Source.Camera and screenshares +/// as Track.Source.ScreenShare (apps/web/src/avatar/publish.ts), so the +/// source is the reliable discriminator. A track that reports no source at +/// all is accepted on kind alone rather than dropped, since an unknown source +/// on a video track from the right participant is far more likely to be a +/// camera than anything else. +bool isWantedVideoTrack(const std::string &wanted_identity, const std::string &track_identity, MediaKind kind, + MediaSource source); + +/// Same, for the slot's microphone. +bool isWantedAudioTrack(const std::string &wanted_identity, const std::string &track_identity, MediaKind kind, + MediaSource source); + +// --------------------------------------------------------------------------- +// Session state +// --------------------------------------------------------------------------- + +enum class SessionState { + /// Never asked to connect, or fully torn down. + Idle, + /// connect() is in flight. + Connecting, + /// Signalling is up. Says nothing about whether video is arriving -- + /// that is what hasVideo() is for. + Connected, + /// The SDK is re-establishing the connection on its own. + Reconnecting, + /// The room ended: either we disconnected, or the server did. + Disconnected, + /// connect() failed, or the session died in a way retrying will not fix + /// (a rejected token, a duplicate identity). + Failed, +}; + +const char *describeSessionState(SessionState state); + +/// The pure state machine behind LiveKitSession. Not thread-safe on its own; +/// LiveKitSession owns the lock. +/// +/// It exists separately so the transitions that matter operationally -- a +/// publisher swap must not read as a failure, a reconnect must not read as a +/// fresh connection, a failure detail must survive until the next connect -- +/// can be tested without a LiveKit server. +class SessionStateMachine { +public: + SessionState state() const { return state_; } + + /// Human-readable reason for the current state. Empty when there is + /// nothing to say. Never contains a token or read key. + const std::string &detail() const { return detail_; } + + /// Whether a video track is currently attached and delivering. + bool hasVideo() const { return has_video_; } + bool hasAudio() const { return has_audio_; } + + /// True when the source should be showing its "waiting for camera" + /// placeholder rather than an error: we are up, the slot just isn't live. + bool waitingForCamera() const; + + void onConnectRequested(); + void onConnectSucceeded(); + void onConnectFailed(const std::string &reason); + void onReconnecting(); + void onReconnected(); + /// The server (or the SDK) ended the room. `fatal` distinguishes a reason + /// that retrying cannot fix from an ordinary drop. + void onRoomEnded(const std::string &reason, bool fatal); + void onLocalDisconnect(); + + void onVideoAttached(); + void onVideoDetached(); + void onAudioAttached(); + void onAudioDetached(); + +private: + SessionState state_ = SessionState::Idle; + std::string detail_; + bool has_video_ = false; + bool has_audio_ = false; +}; + +// --------------------------------------------------------------------------- +// Frames handed to the OBS adapter +// --------------------------------------------------------------------------- + +struct VideoPlane { + const std::uint8_t *data = nullptr; + std::uint32_t stride = 0; + std::uint32_t size = 0; +}; + +/// A decoded video frame. All pointers are owned by the SDK and are valid +/// only for the duration of the callback -- copy or consume synchronously. +struct VideoFrameData { + int width = 0; + int height = 0; + PixelFormat format = PixelFormat::I420; + const std::uint8_t *data = nullptr; + std::size_t size = 0; + VideoPlane planes[4]; + int plane_count = 0; + /// WebRTC capture-time timestamp, microseconds. + std::int64_t timestamp_us = 0; +}; + +/// Interleaved int16 PCM. client-sdk-cpp's AudioFrameCallback carries no +/// timestamp, so the adapter stamps arrival time itself -- see the design +/// doc's "Audio/video sync verification" note, which flags that as an +/// assumption to check on real hardware rather than a guarantee. +struct AudioFrameData { + const std::int16_t *samples = nullptr; + std::size_t sample_count = 0; + int sample_rate = 0; + int channels = 0; + int samples_per_channel = 0; +}; + +using VideoFrameHandler = std::function; +using AudioFrameHandler = std::function; +using SessionStateHandler = std::function; + +} // namespace stplugin diff --git a/core/src/session.cpp b/core/src/session.cpp new file mode 100644 index 0000000..2682cf1 --- /dev/null +++ b/core/src/session.cpp @@ -0,0 +1,733 @@ +/* +streamer-tools OBS Camera Plugin - LiveKit session wrapper +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 +*/ + +#include "stplugin/session.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace stplugin { + +namespace { + +// --- LiveKit <-> plugin type conversion ------------------------------------ + +MediaKind toMediaKind(livekit::TrackKind kind) +{ + switch (kind) { + case livekit::TrackKind::KIND_AUDIO: return MediaKind::Audio; + case livekit::TrackKind::KIND_VIDEO: return MediaKind::Video; + case livekit::TrackKind::KIND_UNKNOWN: break; + } + return MediaKind::Unknown; +} + +MediaSource toMediaSource(livekit::TrackSource source) +{ + switch (source) { + case livekit::TrackSource::SOURCE_CAMERA: return MediaSource::Camera; + case livekit::TrackSource::SOURCE_MICROPHONE: return MediaSource::Microphone; + case livekit::TrackSource::SOURCE_SCREENSHARE: return MediaSource::Screenshare; + case livekit::TrackSource::SOURCE_SCREENSHARE_AUDIO: return MediaSource::ScreenshareAudio; + case livekit::TrackSource::SOURCE_UNKNOWN: break; + } + return MediaSource::Unknown; +} + +livekit::VideoBufferType toLiveKitBufferType(PixelFormat format) +{ + switch (format) { + case PixelFormat::I420: return livekit::VideoBufferType::I420; + case PixelFormat::NV12: return livekit::VideoBufferType::NV12; + case PixelFormat::BGRA: return livekit::VideoBufferType::BGRA; + } + return livekit::VideoBufferType::I420; +} + +/// Returns false when the SDK handed us a format the OBS adapter cannot +/// consume, in which case the caller converts. +bool fromLiveKitBufferType(livekit::VideoBufferType type, PixelFormat &out) +{ + switch (type) { + case livekit::VideoBufferType::I420: out = PixelFormat::I420; return true; + case livekit::VideoBufferType::NV12: out = PixelFormat::NV12; return true; + case livekit::VideoBufferType::BGRA: out = PixelFormat::BGRA; return true; + default: return false; + } +} + +/// Which disconnect reasons are worth telling the operator "this will not fix +/// itself" about. Everything else is reported as an ordinary disconnect, +/// because the SDK's own reconnect logic covers it. +bool isFatalDisconnect(livekit::DisconnectReason reason) +{ + switch (reason) { + case livekit::DisconnectReason::DuplicateIdentity: + case livekit::DisconnectReason::ParticipantRemoved: + case livekit::DisconnectReason::RoomDeleted: + case livekit::DisconnectReason::JoinFailure: + case livekit::DisconnectReason::UserRejected: + return true; + default: + return false; + } +} + +const char *describeDisconnectReason(livekit::DisconnectReason reason) +{ + switch (reason) { + case livekit::DisconnectReason::Unknown: return "connection lost"; + case livekit::DisconnectReason::ClientInitiated: return "disconnected"; + case livekit::DisconnectReason::DuplicateIdentity: return "another client joined with the same identity"; + case livekit::DisconnectReason::ServerShutdown: return "the LiveKit server is shutting down"; + case livekit::DisconnectReason::ParticipantRemoved: return "removed from the room"; + case livekit::DisconnectReason::RoomDeleted: return "the room was deleted"; + case livekit::DisconnectReason::StateMismatch: return "session could not be resumed"; + case livekit::DisconnectReason::JoinFailure: return "could not join the room (token rejected or expired?)"; + case livekit::DisconnectReason::Migration: return "migrating to another server"; + case livekit::DisconnectReason::SignalClose: return "the signalling connection closed"; + case livekit::DisconnectReason::RoomClosed: return "the room closed"; + case livekit::DisconnectReason::UserUnavailable: return "user unavailable"; + case livekit::DisconnectReason::UserRejected: return "connection rejected"; + case livekit::DisconnectReason::SipTrunkFailure: return "SIP trunk failure"; + case livekit::DisconnectReason::ConnectionTimeout: return "connection timed out"; + case livekit::DisconnectReason::MediaFailure: return "media connection failed"; + case livekit::DisconnectReason::AgentError: return "agent error"; + } + return "disconnected"; +} + +// --- Process-wide SDK lifetime --------------------------------------------- + +std::mutex &globalMutex() +{ + static std::mutex m; + return m; +} + +int &globalRefCount() +{ + static int n = 0; + return n; +} + +} // namespace + +// --------------------------------------------------------------------------- +// Impl +// --------------------------------------------------------------------------- + +struct LiveKitSession::Impl : public livekit::RoomDelegate { + enum class CommandType { AttachVideo, DetachVideo, AttachAudio, DetachAudio, Stop }; + + struct Command { + CommandType type; + std::shared_ptr track; + }; + + livekit::Room room; + SessionConfig config; + + mutable std::mutex state_mutex; + SessionStateMachine machine; + + VideoFrameHandler on_video; + AudioFrameHandler on_audio; + SessionStateHandler on_state; + + std::atomic video_frames{0}; + std::atomic audio_frames{0}; + std::atomic dropped_frames{0}; + + // Command queue. Every interaction with livekit::VideoStream / + // livekit::AudioStream happens on `worker`, never on a room event thread: + // the SDK's room callbacks run on its own event thread and blocking or + // re-entering there stalls every other event (and Room::disconnect() from + // inside one is documented to deadlock outright). + std::mutex queue_mutex; + std::condition_variable queue_cv; + std::deque queue; + std::thread worker; + bool worker_running = false; + + // Owned exclusively by the worker thread. + std::shared_ptr video_stream; + std::thread video_thread; + std::shared_ptr audio_stream; + std::thread audio_thread; + + bool connected = false; + + ~Impl() override = default; + + // --- state helpers ----------------------------------------------------- + + template void mutateState(Fn &&fn) + { + SessionState state; + std::string detail; + SessionStateHandler handler; + { + std::lock_guard guard(state_mutex); + fn(machine); + state = machine.state(); + detail = machine.detail(); + handler = on_state; + } + // Notified outside the lock: the handler is OBS adapter code and must + // never be able to deadlock against a concurrent state query. + if (handler) + handler(state, detail); + } + + void post(CommandType type, std::shared_ptr track = nullptr) + { + { + std::lock_guard guard(queue_mutex); + if (!worker_running) + return; + queue.push_back(Command{type, std::move(track)}); + } + queue_cv.notify_one(); + } + + // --- RoomDelegate ------------------------------------------------------ + + void onTrackSubscribed(livekit::Room &, const livekit::TrackSubscribedEvent &event) override + { + if (!event.participant || !event.track) + return; + const std::string identity = event.participant->identity(); + const MediaKind kind = toMediaKind(event.track->kind()); + const MediaSource source = + event.publication ? toMediaSource(event.publication->source()) : MediaSource::Unknown; + + if (isWantedVideoTrack(config.participant_identity, identity, kind, source)) + post(CommandType::AttachVideo, event.track); + else if (config.subscribe_audio && isWantedAudioTrack(config.participant_identity, identity, kind, source)) + post(CommandType::AttachAudio, event.track); + } + + void onTrackUnsubscribed(livekit::Room &, const livekit::TrackUnsubscribedEvent &event) override + { + if (!event.participant || !event.track) + return; + if (event.participant->identity() != config.participant_identity) + return; + const MediaKind kind = toMediaKind(event.track->kind()); + if (kind == MediaKind::Video) + post(CommandType::DetachVideo); + else if (kind == MediaKind::Audio) + post(CommandType::DetachAudio); + } + + void onParticipantDisconnected(livekit::Room &, const livekit::ParticipantDisconnectedEvent &event) override + { + if (!event.participant || event.participant->identity() != config.participant_identity) + return; + // The slot went away entirely. This is the placeholder state, not an + // error: the operator's room is fine, the camera just left. + post(CommandType::DetachVideo); + post(CommandType::DetachAudio); + } + + void onReconnecting(livekit::Room &, const livekit::ReconnectingEvent &) override + { + mutateState([](SessionStateMachine &m) { m.onReconnecting(); }); + } + + void onReconnected(livekit::Room &, const livekit::ReconnectedEvent &) override + { + mutateState([](SessionStateMachine &m) { m.onReconnected(); }); + } + + void onDisconnected(livekit::Room &, const livekit::DisconnectedEvent &event) override + { + const std::string reason = describeDisconnectReason(event.reason); + const bool fatal = isFatalDisconnect(event.reason); + post(CommandType::DetachVideo); + post(CommandType::DetachAudio); + mutateState([&](SessionStateMachine &m) { m.onRoomEnded(reason, fatal); }); + } + + void onRoomEos(livekit::Room &, const livekit::RoomEosEvent &) override + { + post(CommandType::DetachVideo); + post(CommandType::DetachAudio); + mutateState([](SessionStateMachine &m) { m.onRoomEnded("the room session ended", false); }); + } + + // --- worker ------------------------------------------------------------ + + void startWorker() + { + { + std::lock_guard guard(queue_mutex); + queue.clear(); + worker_running = true; + } + worker = std::thread([this] { workerLoop(); }); + } + + void stopWorker() + { + { + std::lock_guard guard(queue_mutex); + if (!worker_running) + return; + queue.push_back(Command{CommandType::Stop, nullptr}); + worker_running = false; + } + queue_cv.notify_one(); + if (worker.joinable()) + worker.join(); + } + + void workerLoop() + { + for (;;) { + Command command{CommandType::Stop, nullptr}; + { + std::unique_lock lock(queue_mutex); + queue_cv.wait(lock, [this] { return !queue.empty(); }); + command = std::move(queue.front()); + queue.pop_front(); + } + + switch (command.type) { + case CommandType::AttachVideo: + attachVideo(command.track); + break; + case CommandType::DetachVideo: + detachVideo(); + break; + case CommandType::AttachAudio: + attachAudio(command.track); + break; + case CommandType::DetachAudio: + detachAudio(); + break; + case CommandType::Stop: + detachVideo(); + detachAudio(); + return; + } + } + } + + void attachVideo(const std::shared_ptr &track) + { + if (!track) + return; + // Replacing an existing stream is the publisher-swap path: tear the + // old reader all the way down first so no frame from the previous + // publisher can arrive after the new one starts. + detachVideo(); + + livekit::VideoStream::Options options; + options.capacity = config.video_queue_capacity; + options.format = toLiveKitBufferType(config.video_format); + + std::shared_ptr stream; + try { + stream = livekit::VideoStream::fromTrack(track, options); + } catch (const std::exception &e) { + mutateState([&](SessionStateMachine &m) { + m.onRoomEnded(std::string("could not open the video stream: ") + e.what(), true); + }); + return; + } + if (!stream) + return; + + video_stream = stream; + video_thread = std::thread([this, stream] { videoReaderLoop(stream); }); + mutateState([](SessionStateMachine &m) { m.onVideoAttached(); }); + } + + void detachVideo() + { + if (video_stream) + video_stream->close(); // wakes the blocking read() + if (video_thread.joinable()) + video_thread.join(); + const bool had = static_cast(video_stream); + video_stream.reset(); + if (had) + mutateState([](SessionStateMachine &m) { m.onVideoDetached(); }); + } + + void attachAudio(const std::shared_ptr &track) + { + if (!track) + return; + detachAudio(); + + livekit::AudioStream::Options options; + options.capacity = config.audio_queue_capacity; + + std::shared_ptr stream; + try { + stream = livekit::AudioStream::fromTrack(track, options); + } catch (const std::exception &) { + // Audio is not worth failing the whole source over: a camera with + // no usable audio track is still a usable camera. + return; + } + if (!stream) + return; + + audio_stream = stream; + audio_thread = std::thread([this, stream] { audioReaderLoop(stream); }); + mutateState([](SessionStateMachine &m) { m.onAudioAttached(); }); + } + + void detachAudio() + { + if (audio_stream) + audio_stream->close(); + if (audio_thread.joinable()) + audio_thread.join(); + const bool had = static_cast(audio_stream); + audio_stream.reset(); + if (had) + mutateState([](SessionStateMachine &m) { m.onAudioDetached(); }); + } + + void videoReaderLoop(std::shared_ptr stream) + { + VideoFrameHandler handler; + { + std::lock_guard guard(state_mutex); + handler = on_video; + } + + livekit::VideoFrameEvent event; + while (stream->read(event)) { + if (!handler) + continue; + deliverVideoFrame(event, handler); + } + } + + void deliverVideoFrame(livekit::VideoFrameEvent &event, const VideoFrameHandler &handler) + { + PixelFormat format; + const livekit::VideoFrame *frame = &event.frame; + livekit::VideoFrame converted; + + if (!fromLiveKitBufferType(frame->type(), format)) { + // The SDK gave us something the adapter cannot hand to OBS. + // convert() is a full CPU repack, so this is a fallback, not the + // normal path -- the normal path is the format we asked for. + try { + converted = frame->convert(toLiveKitBufferType(config.video_format)); + } catch (const std::exception &) { + dropped_frames.fetch_add(1); + return; + } + frame = &converted; + format = config.video_format; + } + + const int width = frame->width(); + const int height = frame->height(); + const std::size_t expected = expectedFrameBytes(format, width, height); + if (expected == 0 || frame->dataSize() < expected) { + // Geometry that does not match the buffer would make OBS read off + // the end of it. Drop rather than trust. + dropped_frames.fetch_add(1); + return; + } + + VideoFrameData out; + out.width = width; + out.height = height; + out.format = format; + out.data = frame->data(); + out.size = frame->dataSize(); + out.timestamp_us = event.timestamp_us; + + const std::vector planes = frame->planeInfos(); + const int wanted_planes = planeCount(format); + int count = 0; + for (const livekit::VideoPlaneInfo &plane : planes) { + if (count >= 4) + break; + out.planes[count].data = reinterpret_cast(plane.data_ptr); + out.planes[count].stride = plane.stride; + out.planes[count].size = plane.size; + ++count; + } + if (count == 0 && wanted_planes == 1) { + // planeInfos() documents that packed formats may return an empty + // list rather than one plane. Synthesise it from the frame buffer + // instead of dropping a perfectly good BGRA frame. + out.planes[0].data = frame->data(); + out.planes[0].stride = static_cast(width) * 4u; + out.planes[0].size = static_cast(frame->dataSize()); + count = 1; + } + if (count != wanted_planes) { + dropped_frames.fetch_add(1); + return; + } + out.plane_count = count; + + video_frames.fetch_add(1); + handler(out); + } + + void audioReaderLoop(std::shared_ptr stream) + { + AudioFrameHandler handler; + { + std::lock_guard guard(state_mutex); + handler = on_audio; + } + + livekit::AudioFrameEvent event; + while (stream->read(event)) { + if (!handler) + continue; + const livekit::AudioFrame &frame = event.frame; + if (frame.numChannels() <= 0 || frame.samplesPerChannel() <= 0 || frame.sampleRate() <= 0) + continue; + AudioFrameData out; + out.samples = frame.data().data(); + out.sample_count = frame.totalSamples(); + out.sample_rate = frame.sampleRate(); + out.channels = frame.numChannels(); + out.samples_per_channel = frame.samplesPerChannel(); + audio_frames.fetch_add(1); + handler(out); + } + } + + /// After connect(), the target slot may already be in the room with its + /// tracks subscribed, in which case no onTrackSubscribed event is coming. + /// Sweep what is already there so a source added mid-show shows video + /// immediately instead of waiting for the publisher to republish. + void attachExistingTracks() + { + auto participant = room.remoteParticipant(config.participant_identity).lock(); + if (!participant) + return; + const std::string identity = participant->identity(); + for (const auto &entry : participant->trackPublications()) { + const std::shared_ptr &publication = entry.second; + if (!publication) + continue; + const std::shared_ptr track = publication->track(); + if (!track) + continue; // published but not subscribed yet + const MediaKind kind = toMediaKind(track->kind()); + const MediaSource source = toMediaSource(publication->source()); + if (isWantedVideoTrack(config.participant_identity, identity, kind, source)) + post(CommandType::AttachVideo, track); + else if (config.subscribe_audio && isWantedAudioTrack(config.participant_identity, identity, kind, source)) + post(CommandType::AttachAudio, track); + } + } +}; + +// --------------------------------------------------------------------------- +// LiveKitSession +// --------------------------------------------------------------------------- + +LiveKitSession::LiveKitSession() : impl_(new Impl()) {} + +LiveKitSession::~LiveKitSession() +{ + disconnect(); +} + +void LiveKitSession::setVideoHandler(VideoFrameHandler handler) +{ + std::lock_guard guard(impl_->state_mutex); + impl_->on_video = std::move(handler); +} + +void LiveKitSession::setAudioHandler(AudioFrameHandler handler) +{ + std::lock_guard guard(impl_->state_mutex); + impl_->on_audio = std::move(handler); +} + +void LiveKitSession::setStateHandler(SessionStateHandler handler) +{ + std::lock_guard guard(impl_->state_mutex); + impl_->on_state = std::move(handler); +} + +bool LiveKitSession::connect(const SessionConfig &config) +{ + if (impl_->connected) + disconnect(); + + impl_->config = config; + impl_->video_frames.store(0); + impl_->audio_frames.store(0); + impl_->dropped_frames.store(0); + + impl_->mutateState([](SessionStateMachine &m) { m.onConnectRequested(); }); + + if (config.ws_url.empty() || config.token.empty() || config.participant_identity.empty()) { + impl_->mutateState( + [](SessionStateMachine &m) { m.onConnectFailed("missing LiveKit URL, token or camera selection"); }); + return false; + } + + impl_->startWorker(); + + livekit::RoomOptions options; + // auto_subscribe is what makes track_subscribed events (and therefore any + // media at all) happen; the SDK is emphatic about this. + options.auto_subscribe = true; + options.dynacast = false; + // This client never publishes, so a single peer connection is all it + // needs. + options.single_peer_connection = true; + options.connect_timeout = std::chrono::milliseconds(config.connect_timeout_ms); + + impl_->room.setDelegate(impl_.get()); + + bool ok = false; + try { + ok = impl_->room.connect(config.ws_url, config.token, options); + } catch (const std::exception &e) { + ok = false; + impl_->mutateState([&](SessionStateMachine &m) { m.onConnectFailed(e.what()); }); + impl_->stopWorker(); + impl_->room.setDelegate(nullptr); + return false; + } + + if (!ok) { + impl_->mutateState([](SessionStateMachine &m) { + m.onConnectFailed("could not connect to LiveKit (check the server URL, or the token may have expired)"); + }); + impl_->stopWorker(); + impl_->room.setDelegate(nullptr); + return false; + } + + impl_->connected = true; + impl_->mutateState([](SessionStateMachine &m) { m.onConnectSucceeded(); }); + impl_->attachExistingTracks(); + return true; +} + +void LiveKitSession::disconnect() +{ + if (!impl_) + return; + + // Order matters: stop the readers first so nothing is mid-read on a + // stream the room is about to tear down, then disconnect the room, then + // drop the delegate so no event can arrive at a half-destroyed object. + impl_->stopWorker(); + + if (impl_->connected) { + impl_->connected = false; + try { + impl_->room.disconnect(livekit::DisconnectReason::ClientInitiated); + } catch (const std::exception &) { + // Best effort: a failed graceful disconnect must not stop the + // OBS source from being destroyed. + } + impl_->mutateState([](SessionStateMachine &m) { m.onLocalDisconnect(); }); + } + + impl_->room.setDelegate(nullptr); +} + +SessionState LiveKitSession::state() const +{ + std::lock_guard guard(impl_->state_mutex); + return impl_->machine.state(); +} + +std::string LiveKitSession::stateDetail() const +{ + std::lock_guard guard(impl_->state_mutex); + return impl_->machine.detail(); +} + +bool LiveKitSession::hasVideo() const +{ + std::lock_guard guard(impl_->state_mutex); + return impl_->machine.hasVideo(); +} + +bool LiveKitSession::hasAudio() const +{ + std::lock_guard guard(impl_->state_mutex); + return impl_->machine.hasAudio(); +} + +bool LiveKitSession::waitingForCamera() const +{ + std::lock_guard guard(impl_->state_mutex); + return impl_->machine.waitingForCamera(); +} + +std::uint64_t LiveKitSession::videoFrameCount() const +{ + return impl_->video_frames.load(); +} + +std::uint64_t LiveKitSession::audioFrameCount() const +{ + return impl_->audio_frames.load(); +} + +void LiveKitSession::globalInitialize() +{ + std::lock_guard guard(globalMutex()); + if (globalRefCount()++ == 0) + livekit::initialize(livekit::LogLevel::Warn); +} + +void LiveKitSession::globalShutdown() +{ + std::lock_guard guard(globalMutex()); + if (globalRefCount() > 0 && --globalRefCount() == 0) + livekit::shutdown(); +} + +} // namespace stplugin diff --git a/core/src/session_types.cpp b/core/src/session_types.cpp new file mode 100644 index 0000000..03f7c09 --- /dev/null +++ b/core/src/session_types.cpp @@ -0,0 +1,185 @@ +/* +streamer-tools OBS Camera Plugin - session types and pure session logic +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 +*/ + +#include "stplugin/session_types.h" + +namespace stplugin { + +const char *describePixelFormat(PixelFormat format) +{ + switch (format) { + case PixelFormat::I420: return "I420"; + case PixelFormat::NV12: return "NV12"; + case PixelFormat::BGRA: return "BGRA"; + } + return "unknown"; +} + +int planeCount(PixelFormat format) +{ + switch (format) { + case PixelFormat::I420: return 3; + case PixelFormat::NV12: return 2; + case PixelFormat::BGRA: return 1; + } + return 0; +} + +std::size_t expectedFrameBytes(PixelFormat format, int width, int height) +{ + if (width <= 0 || height <= 0) + return 0; + const std::size_t w = static_cast(width); + const std::size_t h = static_cast(height); + // Chroma planes round up, which is what libyuv/WebRTC do for odd sizes. + const std::size_t cw = (w + 1) / 2; + const std::size_t ch = (h + 1) / 2; + switch (format) { + case PixelFormat::I420: return w * h + 2 * cw * ch; + case PixelFormat::NV12: return w * h + 2 * cw * ch; + case PixelFormat::BGRA: return w * h * 4; + } + return 0; +} + +bool isWantedVideoTrack(const std::string &wanted_identity, const std::string &track_identity, MediaKind kind, + MediaSource source) +{ + if (wanted_identity.empty() || track_identity != wanted_identity) + return false; + if (kind != MediaKind::Video) + return false; + return source == MediaSource::Camera || source == MediaSource::Unknown; +} + +bool isWantedAudioTrack(const std::string &wanted_identity, const std::string &track_identity, MediaKind kind, + MediaSource source) +{ + if (wanted_identity.empty() || track_identity != wanted_identity) + return false; + if (kind != MediaKind::Audio) + return false; + return source == MediaSource::Microphone || source == MediaSource::Unknown; +} + +const char *describeSessionState(SessionState state) +{ + switch (state) { + case SessionState::Idle: return "idle"; + case SessionState::Connecting: return "connecting"; + case SessionState::Connected: return "connected"; + case SessionState::Reconnecting: return "reconnecting"; + case SessionState::Disconnected: return "disconnected"; + case SessionState::Failed: return "failed"; + } + return "unknown"; +} + +bool SessionStateMachine::waitingForCamera() const +{ + return (state_ == SessionState::Connected || state_ == SessionState::Reconnecting) && !has_video_; +} + +void SessionStateMachine::onConnectRequested() +{ + state_ = SessionState::Connecting; + // A new attempt clears the previous failure reason, so a stale message + // can never be shown alongside a fresh, healthy connection. + detail_.clear(); + has_video_ = false; + has_audio_ = false; +} + +void SessionStateMachine::onConnectSucceeded() +{ + state_ = SessionState::Connected; + detail_.clear(); +} + +void SessionStateMachine::onConnectFailed(const std::string &reason) +{ + state_ = SessionState::Failed; + detail_ = reason; + has_video_ = false; + has_audio_ = false; +} + +void SessionStateMachine::onReconnecting() +{ + // Only meaningful from a live session; a reconnect notification after we + // already gave up must not resurrect the session. + if (state_ != SessionState::Connected && state_ != SessionState::Reconnecting) + return; + state_ = SessionState::Reconnecting; + detail_ = "reconnecting"; + // Tracks are re-subscribed on the other side of a reconnect; the SDK will + // tell us when they are back. + has_video_ = false; + has_audio_ = false; +} + +void SessionStateMachine::onReconnected() +{ + if (state_ != SessionState::Reconnecting) + return; + state_ = SessionState::Connected; + detail_.clear(); +} + +void SessionStateMachine::onRoomEnded(const std::string &reason, bool fatal) +{ + if (state_ == SessionState::Idle || state_ == SessionState::Disconnected) + return; + state_ = fatal ? SessionState::Failed : SessionState::Disconnected; + detail_ = reason; + has_video_ = false; + has_audio_ = false; +} + +void SessionStateMachine::onLocalDisconnect() +{ + state_ = SessionState::Disconnected; + detail_.clear(); + has_video_ = false; + has_audio_ = false; +} + +void SessionStateMachine::onVideoAttached() +{ + has_video_ = true; +} + +void SessionStateMachine::onVideoDetached() +{ + // A publisher swap (the motivating bug) shows up here: the old track goes + // away and a new one arrives moments later. That is a placeholder state, + // never an error state -- the connection itself is untouched. + has_video_ = false; +} + +void SessionStateMachine::onAudioAttached() +{ + has_audio_ = true; +} + +void SessionStateMachine::onAudioDetached() +{ + has_audio_ = false; +} + +} // namespace stplugin diff --git a/core/tests/CMakeLists.txt b/core/tests/CMakeLists.txt index 343c97c..313ef78 100644 --- a/core/tests/CMakeLists.txt +++ b/core/tests/CMakeLists.txt @@ -17,6 +17,14 @@ endfunction() stplugin_add_test(test_core) stplugin_add_test(test_json) stplugin_add_test(test_api_client) +stplugin_add_test(test_session) + +# End-to-end against a REAL LiveKit room: publishes a synthetic camera with +# the same SDK and subscribes to it through LiveKitSession. Skips (exit 0) +# unless STPLUGIN_IT_* is set, so the three build runners -- which have no +# LiveKit server -- stay green. See scripts/livekit-dev-room.py. +stplugin_add_test(test_integration_livekit) +set_tests_properties(test_integration_livekit PROPERTIES TIMEOUT 300) # Smoke test for the LiveKit SDK link: initialize()/shutdown() must succeed # in-process. This is the cheapest possible proof that LiveKit::livekit is diff --git a/core/tests/test_integration_livekit.cpp b/core/tests/test_integration_livekit.cpp new file mode 100644 index 0000000..3b3c38d --- /dev/null +++ b/core/tests/test_integration_livekit.cpp @@ -0,0 +1,370 @@ +/* +streamer-tools OBS Camera Plugin - LiveKit end-to-end integration 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 +*/ + +// The one test that proves the session wrapper actually receives media. +// +// It publishes a synthetic camera into a real LiveKit room using the same +// SDK, subscribes to it through stplugin::LiveKitSession, and asserts that +// decoded frames arrive with the geometry the OBS adapter is going to hand +// to obs_source_output_video. It also drives the publisher-swap sequence +// (unpublish, republish) that motivated this whole plugin, and asserts the +// wrapper recovers instead of going stale or erroring out. +// +// It needs a reachable LiveKit server, so it SKIPS (exit 0) unless these are +// set -- CI on the three build runners has no server, and this must not turn +// into a red build there: +// +// STPLUGIN_IT_URL ws://127.0.0.1:7880 +// STPLUGIN_IT_PUBLISH_TOKEN JWT: roomJoin + canPublish for the room +// STPLUGIN_IT_SUBSCRIBE_TOKEN JWT: roomJoin + canSubscribe for the room +// STPLUGIN_IT_PUBLISHER_IDENTITY the identity in the publish token +// +// scripts/livekit-dev-room.py mints all four against a `livekit-server --dev`. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "stplugin/session.h" +#include "test_util.h" + +using namespace stplugin; + +namespace { + +constexpr int kWidth = 320; +constexpr int kHeight = 240; + +std::string envOrEmpty(const char *name) +{ + const char *value = std::getenv(name); + return value ? std::string(value) : std::string(); +} + +/// A moving horizontal band, so a frozen or stale frame is distinguishable +/// from a live one by luma alone. +livekit::VideoFrame makeFrame(int tick) +{ + livekit::VideoFrame frame = livekit::VideoFrame::create(kWidth, kHeight, livekit::VideoBufferType::I420); + std::uint8_t *data = frame.data(); + const std::size_t luma = static_cast(kWidth) * kHeight; + std::memset(data, 16, luma); + const int band = (tick * 7) % kHeight; + std::memset(data + static_cast(band) * kWidth, 235, kWidth); + std::memset(data + luma, 128, frame.dataSize() - luma); + return frame; +} + +void step(const char *what) +{ + std::printf(" step: %s\n", what); + std::fflush(stdout); +} + +bool waitFor(const std::function &predicate, int timeout_ms) +{ + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); + while (std::chrono::steady_clock::now() < deadline) { + if (predicate()) + return true; + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + return predicate(); +} + +struct Publisher { + livekit::Room room; + std::shared_ptr video_source; + std::shared_ptr video_track; + std::shared_ptr audio_source; + std::shared_ptr audio_track; + std::thread pump; + std::atomic stop{false}; + std::string video_sid; + + bool connect(const std::string &url, const std::string &token) + { + livekit::RoomOptions options; + options.auto_subscribe = false; + options.connect_timeout = std::chrono::milliseconds(10000); + return room.connect(url, token, options); + } + + bool publishVideo() + { + auto local = room.localParticipant().lock(); + if (!local) + return false; + video_source = std::make_shared(kWidth, kHeight); + video_track = livekit::LocalVideoTrack::createLocalVideoTrack("camera", video_source); + livekit::TrackPublishOptions options; + options.source = livekit::TrackSource::SOURCE_CAMERA; + options.simulcast = false; + local->publishTrack(video_track, options); + // publishTrack is async server-side; the SID appears on the track once + // the publication lands. + for (int i = 0; i < 100 && video_track->sid().empty(); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + video_sid = video_track->sid(); + return !video_sid.empty(); + } + + bool publishAudio() + { + auto local = room.localParticipant().lock(); + if (!local) + return false; + audio_source = std::make_shared(48000, 1); + audio_track = livekit::LocalAudioTrack::createLocalAudioTrack("microphone", audio_source); + livekit::TrackPublishOptions options; + options.source = livekit::TrackSource::SOURCE_MICROPHONE; + local->publishTrack(audio_track, options); + for (int i = 0; i < 100 && audio_track->sid().empty(); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + return !audio_track->sid().empty(); + } + + bool unpublishVideo() + { + auto local = room.localParticipant().lock(); + if (!local) + return false; + // The FFI keys local publications by the *publication* SID, which is + // not necessarily the track SID -- unpublishing by track SID throws + // "track not found". + std::string sid = video_sid; + if (video_track && video_track->publication()) + sid = video_track->publication()->sid(); + if (sid.empty()) + return false; + try { + local->unpublishTrack(sid); + } catch (const std::exception &e) { + std::printf(" unpublishTrack(%s) threw: %s\n", sid.c_str(), e.what()); + std::fflush(stdout); + return false; + } + video_sid.clear(); + video_track.reset(); + video_source.reset(); + return true; + } + + void startPump() + { + stop.store(false); + pump = std::thread([this] { + int tick = 0; + std::vector pcm(480, 0); // 10ms of 48kHz mono + while (!stop.load()) { + if (video_source) { + livekit::VideoFrame frame = makeFrame(tick); + video_source->captureFrame(frame, static_cast(tick) * 33333); + } + if (audio_source) { + for (std::size_t i = 0; i < pcm.size(); ++i) + pcm[i] = static_cast(((tick * 480 + static_cast(i)) % 100) * 100); + livekit::AudioFrame audio(pcm, 48000, 1, static_cast(pcm.size())); + audio_source->captureFrame(audio); + } + ++tick; + std::this_thread::sleep_for(std::chrono::milliseconds(33)); + } + }); + } + + void stopPump() + { + stop.store(true); + if (pump.joinable()) + pump.join(); + } + + ~Publisher() + { + stopPump(); + room.disconnect(livekit::DisconnectReason::ClientInitiated); + } +}; + +} // namespace + +int main() +{ + const std::string url = envOrEmpty("STPLUGIN_IT_URL"); + const std::string publish_token = envOrEmpty("STPLUGIN_IT_PUBLISH_TOKEN"); + const std::string subscribe_token = envOrEmpty("STPLUGIN_IT_SUBSCRIBE_TOKEN"); + const std::string publisher_identity = envOrEmpty("STPLUGIN_IT_PUBLISHER_IDENTITY"); + + if (url.empty() || publish_token.empty() || subscribe_token.empty() || publisher_identity.empty()) { + std::printf("integration_livekit: SKIPPED (STPLUGIN_IT_* not set)\n"); + return 0; + } + + LiveKitSession::globalInitialize(); + + auto publisher_holder = std::make_unique(); + Publisher &publisher = *publisher_holder; + step("publisher connect"); + ST_ASSERT(publisher.connect(url, publish_token)); + step("publish video"); + ST_ASSERT(publisher.publishVideo()); + step("publish audio"); + ST_ASSERT(publisher.publishAudio()); + publisher.startPump(); + + // --- subscribe through the wrapper under test -------------------------- + + std::atomic video_frames{0}; + std::atomic audio_frames{0}; + std::atomic bad_frames{0}; + std::atomic full_size_frames{0}; + std::atomic last_width{0}; + std::atomic last_height{0}; + std::atomic last_planes{0}; + std::atomic last_timestamp{0}; + std::atomic last_sample_rate{0}; + std::atomic last_channels{0}; + + LiveKitSession session; + session.setVideoHandler([&](const VideoFrameData &frame) { + // Everything the OBS adapter is about to dereference must be sane -- + // checked against the frame's OWN geometry, not the publisher's. + // WebRTC ramps a new subscription up from a downscaled spatial layer, + // so the first frames after (re)subscribing legitimately arrive + // smaller than what is being published; the adapter has to cope with + // a mid-stream resolution change, and so does this assertion. + const std::uint32_t chroma_stride = static_cast((frame.width + 1) / 2); + const bool sane = frame.width > 0 && frame.height > 0 && frame.format == PixelFormat::I420 && + frame.plane_count == 3 && frame.data != nullptr && + frame.size >= expectedFrameBytes(frame.format, frame.width, frame.height) && + frame.planes[0].data != nullptr && frame.planes[1].data != nullptr && + frame.planes[2].data != nullptr && + frame.planes[0].stride >= static_cast(frame.width) && + frame.planes[1].stride >= chroma_stride && frame.planes[2].stride >= chroma_stride; + if (!sane) + bad_frames.fetch_add(1); + if (frame.width == kWidth && frame.height == kHeight) + full_size_frames.fetch_add(1); + last_width.store(frame.width); + last_height.store(frame.height); + last_planes.store(frame.plane_count); + last_timestamp.store(static_cast(frame.timestamp_us)); + video_frames.fetch_add(1); + }); + session.setAudioHandler([&](const AudioFrameData &frame) { + last_sample_rate.store(frame.sample_rate); + last_channels.store(frame.channels); + audio_frames.fetch_add(1); + }); + + std::atomic state_changes{0}; + session.setStateHandler([&](SessionState, const std::string &) { state_changes.fetch_add(1); }); + + SessionConfig config; + config.ws_url = url; + config.token = subscribe_token; + config.participant_identity = publisher_identity; + config.connect_timeout_ms = 10000; + + step("subscriber connect"); + ST_ASSERT(session.connect(config)); + ST_ASSERT(session.state() == SessionState::Connected); + + ST_ASSERT(waitFor([&] { return video_frames.load() >= 15; }, 25000)); + ST_ASSERT(session.hasVideo()); + ST_ASSERT(!session.waitingForCamera()); + ST_ASSERT_EQ(bad_frames.load(), 0); + ST_ASSERT_EQ(last_planes.load(), 3); + // The stream must actually reach the published resolution, not just + // deliver ramp-up frames forever. + ST_ASSERT(waitFor([&] { return full_size_frames.load() > 0; }, 20000)); + ST_ASSERT_EQ(last_width.load(), kWidth); + ST_ASSERT_EQ(last_height.load(), kHeight); + ST_ASSERT(last_timestamp.load() > 0); + ST_ASSERT(session.videoFrameCount() >= 15); + + ST_ASSERT(waitFor([&] { return audio_frames.load() >= 10; }, 20000)); + ST_ASSERT(session.hasAudio()); + ST_ASSERT_EQ(last_sample_rate.load(), 48000); + ST_ASSERT(last_channels.load() >= 1); + + // --- publisher swap: the bug this plugin exists to make impossible ----- + + const int before_swap = video_frames.load(); + publisher.stopPump(); + step("unpublish video"); + ST_ASSERT(publisher.unpublishVideo()); + + ST_ASSERT(waitFor([&] { return !session.hasVideo(); }, 15000)); + // An unpublished camera is the placeholder state, never a failure: the + // room connection itself is untouched. + ST_ASSERT(session.state() == SessionState::Connected); + ST_ASSERT(session.waitingForCamera()); + + // No frames may keep arriving from the dead publisher. + const int after_unpublish = video_frames.load(); + std::this_thread::sleep_for(std::chrono::milliseconds(1500)); + ST_ASSERT_EQ(video_frames.load(), after_unpublish); + ST_ASSERT(after_unpublish >= before_swap); + + // Republish, exactly as a reconnecting browser would. + step("republish video"); + ST_ASSERT(publisher.publishVideo()); + publisher.startPump(); + + ST_ASSERT(waitFor([&] { return video_frames.load() >= after_unpublish + 15; }, 25000)); + ST_ASSERT(session.hasVideo()); + ST_ASSERT(session.state() == SessionState::Connected); + ST_ASSERT_EQ(bad_frames.load(), 0); + + // --- teardown ---------------------------------------------------------- + + step("teardown"); + publisher.stopPump(); + session.disconnect(); + ST_ASSERT(session.state() == SessionState::Disconnected); + ST_ASSERT(!session.hasVideo()); + + // The publisher's Room must be torn down while the SDK is still + // initialized, or its FFI disconnect fails on the way out. + publisher_holder.reset(); + LiveKitSession::globalShutdown(); + + std::printf("integration_livekit: %d video frames, %d audio frames, %d state changes\n", video_frames.load(), + audio_frames.load(), state_changes.load()); + return st_test_report("integration_livekit"); +} diff --git a/core/tests/test_session.cpp b/core/tests/test_session.cpp new file mode 100644 index 0000000..dbe469f --- /dev/null +++ b/core/tests/test_session.cpp @@ -0,0 +1,355 @@ +/* +streamer-tools OBS Camera Plugin - session wrapper tests +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 +*/ + +// What is and is not covered here, stated plainly because it matters: +// +// COVERED headlessly -- the session's own decision-making: the state +// machine's transitions (including the publisher-swap and reconnect paths +// that motivated this plugin), track selection, frame geometry validation, +// and the real connect() failure paths against the real SDK (bad URL, +// unreachable host, garbage token). +// +// NOT COVERED here -- anything that needs a LiveKit server to answer: +// a successful connect, actual subscription, and actual decoded frames +// reaching the handlers. Those can only be verified against a real room, +// and the design doc's Testing section puts that in the integration-test / +// manual-sign-off bucket. + +#include +#include +#include +#include + +#include "stplugin/session.h" +#include "stplugin/session_types.h" +#include "test_util.h" + +using namespace stplugin; + +namespace { + +// --------------------------------------------------------------------------- +// Pure logic +// --------------------------------------------------------------------------- + +void testFrameGeometry() +{ + ST_ASSERT_EQ(planeCount(PixelFormat::I420), 3); + ST_ASSERT_EQ(planeCount(PixelFormat::NV12), 2); + ST_ASSERT_EQ(planeCount(PixelFormat::BGRA), 1); + + // 1280x720 I420: 921600 luma + 2 * 230400 chroma. + ST_ASSERT_EQ(expectedFrameBytes(PixelFormat::I420, 1280, 720), std::size_t(1382400)); + ST_ASSERT_EQ(expectedFrameBytes(PixelFormat::NV12, 1280, 720), std::size_t(1382400)); + ST_ASSERT_EQ(expectedFrameBytes(PixelFormat::BGRA, 1280, 720), std::size_t(3686400)); + + // Odd dimensions round the chroma planes up, the way libyuv does. + ST_ASSERT_EQ(expectedFrameBytes(PixelFormat::I420, 3, 3), std::size_t(9 + 2 * 4)); + ST_ASSERT_EQ(expectedFrameBytes(PixelFormat::I420, 1, 1), std::size_t(1 + 2)); + + // Degenerate geometry is 0, which the reader treats as "drop the frame". + ST_ASSERT_EQ(expectedFrameBytes(PixelFormat::I420, 0, 720), std::size_t(0)); + ST_ASSERT_EQ(expectedFrameBytes(PixelFormat::I420, 1280, 0), std::size_t(0)); + ST_ASSERT_EQ(expectedFrameBytes(PixelFormat::I420, -1, -1), std::size_t(0)); + + ST_ASSERT_EQ(std::string(describePixelFormat(PixelFormat::I420)), std::string("I420")); +} + +void testTrackSelection() +{ + const std::string want = "cam1"; + + // The camera we asked for. + ST_ASSERT(isWantedVideoTrack(want, "cam1", MediaKind::Video, MediaSource::Camera)); + // A video track with no declared source is taken on kind alone. + ST_ASSERT(isWantedVideoTrack(want, "cam1", MediaKind::Video, MediaSource::Unknown)); + + // Someone else's camera. + ST_ASSERT(!isWantedVideoTrack(want, "cam2", MediaKind::Video, MediaSource::Camera)); + // The right participant's screenshare is explicitly NOT the camera -- + // streamer-tools publishes those as separate sources. + ST_ASSERT(!isWantedVideoTrack(want, "cam1", MediaKind::Video, MediaSource::Screenshare)); + // Their microphone is not a video track. + ST_ASSERT(!isWantedVideoTrack(want, "cam1", MediaKind::Audio, MediaSource::Microphone)); + // No selection means nothing matches -- never "the first thing we see". + ST_ASSERT(!isWantedVideoTrack("", "cam1", MediaKind::Video, MediaSource::Camera)); + ST_ASSERT(!isWantedVideoTrack("", "", MediaKind::Video, MediaSource::Camera)); + + ST_ASSERT(isWantedAudioTrack(want, "cam1", MediaKind::Audio, MediaSource::Microphone)); + ST_ASSERT(isWantedAudioTrack(want, "cam1", MediaKind::Audio, MediaSource::Unknown)); + ST_ASSERT(!isWantedAudioTrack(want, "cam1", MediaKind::Audio, MediaSource::ScreenshareAudio)); + ST_ASSERT(!isWantedAudioTrack(want, "cam1", MediaKind::Video, MediaSource::Camera)); + ST_ASSERT(!isWantedAudioTrack(want, "other", MediaKind::Audio, MediaSource::Microphone)); +} + +void testStateMachineHappyPath() +{ + SessionStateMachine m; + ST_ASSERT(m.state() == SessionState::Idle); + ST_ASSERT(!m.hasVideo()); + ST_ASSERT(!m.waitingForCamera()); + + m.onConnectRequested(); + ST_ASSERT(m.state() == SessionState::Connecting); + // Connecting is not "waiting for camera": the placeholder belongs to a + // live connection with a dark slot, not to a connection in progress. + ST_ASSERT(!m.waitingForCamera()); + + m.onConnectSucceeded(); + ST_ASSERT(m.state() == SessionState::Connected); + ST_ASSERT(m.waitingForCamera()); + + m.onVideoAttached(); + ST_ASSERT(m.hasVideo()); + ST_ASSERT(!m.waitingForCamera()); + + m.onAudioAttached(); + ST_ASSERT(m.hasAudio()); + + m.onLocalDisconnect(); + ST_ASSERT(m.state() == SessionState::Disconnected); + ST_ASSERT(!m.hasVideo()); + ST_ASSERT(!m.hasAudio()); +} + +void testPublisherSwapIsNotAnError() +{ + // The motivating bug: a slot's publisher restarts mid-show. That must + // read as "waiting for camera", never as a failure, and the connection + // state must not move at all. + SessionStateMachine m; + m.onConnectRequested(); + m.onConnectSucceeded(); + m.onVideoAttached(); + + m.onVideoDetached(); + ST_ASSERT(m.state() == SessionState::Connected); + ST_ASSERT(!m.hasVideo()); + ST_ASSERT(m.waitingForCamera()); + ST_ASSERT(m.detail().empty()); + + m.onVideoAttached(); + ST_ASSERT(m.state() == SessionState::Connected); + ST_ASSERT(m.hasVideo()); + ST_ASSERT(!m.waitingForCamera()); +} + +void testReconnect() +{ + SessionStateMachine m; + m.onConnectRequested(); + m.onConnectSucceeded(); + m.onVideoAttached(); + + m.onReconnecting(); + ST_ASSERT(m.state() == SessionState::Reconnecting); + // Tracks are re-subscribed on the far side, so video is not live yet. + ST_ASSERT(!m.hasVideo()); + ST_ASSERT(m.waitingForCamera()); + ST_ASSERT_EQ(m.detail(), std::string("reconnecting")); + + m.onReconnected(); + ST_ASSERT(m.state() == SessionState::Connected); + ST_ASSERT(m.detail().empty()); + + // A stray reconnect notification after a hard failure must not resurrect + // the session. + SessionStateMachine dead; + dead.onConnectRequested(); + dead.onConnectFailed("token rejected"); + dead.onReconnecting(); + ST_ASSERT(dead.state() == SessionState::Failed); + dead.onReconnected(); + ST_ASSERT(dead.state() == SessionState::Failed); +} + +void testFailureAndRecovery() +{ + SessionStateMachine m; + m.onConnectRequested(); + m.onConnectFailed("token rejected"); + ST_ASSERT(m.state() == SessionState::Failed); + ST_ASSERT_EQ(m.detail(), std::string("token rejected")); + ST_ASSERT(!m.waitingForCamera()); + + // A fresh attempt clears the stale reason, so a healthy connection can + // never be shown next to the previous failure's message. + m.onConnectRequested(); + ST_ASSERT(m.detail().empty()); + m.onConnectSucceeded(); + ST_ASSERT(m.state() == SessionState::Connected); + ST_ASSERT(m.detail().empty()); + + // A fatal room end (duplicate identity, token rejected) is Failed; an + // ordinary drop is Disconnected. + SessionStateMachine fatal; + fatal.onConnectRequested(); + fatal.onConnectSucceeded(); + fatal.onRoomEnded("another client joined with the same identity", true); + ST_ASSERT(fatal.state() == SessionState::Failed); + + SessionStateMachine dropped; + dropped.onConnectRequested(); + dropped.onConnectSucceeded(); + dropped.onRoomEnded("the signalling connection closed", false); + ST_ASSERT(dropped.state() == SessionState::Disconnected); + + // Room-ended events after we are already down are ignored, so a late + // event cannot overwrite the reason the operator needs to see. + SessionStateMachine idle; + idle.onRoomEnded("stray", true); + ST_ASSERT(idle.state() == SessionState::Idle); +} + +// --------------------------------------------------------------------------- +// Real SDK, failure paths only (no LiveKit server available headlessly) +// --------------------------------------------------------------------------- + +void testConnectRejectsIncompleteConfig() +{ + LiveKitSession session; + std::atomic state_calls{0}; + session.setStateHandler([&](SessionState, const std::string &) { state_calls.fetch_add(1); }); + + SessionConfig config; + config.ws_url = ""; + config.token = "t"; + config.participant_identity = "cam1"; + ST_ASSERT(!session.connect(config)); + ST_ASSERT(session.state() == SessionState::Failed); + ST_ASSERT(!session.stateDetail().empty()); + + config.ws_url = "ws://127.0.0.1:1"; + config.token = ""; + ST_ASSERT(!session.connect(config)); + ST_ASSERT(session.state() == SessionState::Failed); + + config.token = "t"; + config.participant_identity = ""; + ST_ASSERT(!session.connect(config)); + ST_ASSERT(session.state() == SessionState::Failed); + + // The state handler fired for each attempt (Connecting + Failed). + ST_ASSERT(state_calls.load() >= 6); + + // Frame counters stay at zero and nothing crashes on teardown. + ST_ASSERT_EQ(session.videoFrameCount(), std::uint64_t(0)); + ST_ASSERT_EQ(session.audioFrameCount(), std::uint64_t(0)); + session.disconnect(); + session.disconnect(); // idempotent + ST_ASSERT(session.state() == SessionState::Failed || session.state() == SessionState::Disconnected); +} + +void testConnectToUnreachableServerFailsCleanly() +{ + // Port 1 on loopback: nothing is listening, and the connection is + // refused immediately rather than hanging. This exercises the real + // livekit::Room::connect() failure path, with a real (garbage) token. + LiveKitSession session; + std::atomic video_frames{0}; + session.setVideoHandler([&](const VideoFrameData &) { video_frames.fetch_add(1); }); + + SessionConfig config; + config.ws_url = "ws://127.0.0.1:1"; + config.token = "not.a.real.token"; + config.participant_identity = "cam1"; + config.connect_timeout_ms = 3000; + + const auto start = std::chrono::steady_clock::now(); + const bool ok = session.connect(config); + const auto elapsed = std::chrono::steady_clock::now() - start; + + ST_ASSERT(!ok); + ST_ASSERT(session.state() == SessionState::Failed); + ST_ASSERT(!session.stateDetail().empty()); + ST_ASSERT_EQ(video_frames.load(), 0); + // Must not sit on the caller's thread indefinitely -- this runs on an OBS + // thread in the real adapter. + ST_ASSERT(std::chrono::duration_cast(elapsed).count() < 60); + + session.disconnect(); +} + +void testConnectToNonLiveKitServerFailsCleanly() +{ + // A URL that resolves and connects but is not a LiveKit signalling + // endpoint. The realistic operator mistake: pasting the app URL. + LiveKitSession session; + SessionConfig config; + config.ws_url = "ws://127.0.0.1:1/rtc"; + config.token = "eyJhbGciOiJIUzI1NiJ9.bm90YXRva2Vu.x"; + config.participant_identity = "cam1"; + config.connect_timeout_ms = 3000; + ST_ASSERT(!session.connect(config)); + ST_ASSERT(session.state() == SessionState::Failed); + session.disconnect(); +} + +void testDestroyWithoutDisconnect() +{ + // The OBS adapter destroys sources without necessarily having called + // disconnect() first (an OBS shutdown mid-connect, say). The destructor + // must join every thread it started rather than terminating. + { + LiveKitSession session; + SessionConfig config; + config.ws_url = "ws://127.0.0.1:1"; + config.token = "t"; + config.participant_identity = "cam1"; + config.connect_timeout_ms = 2000; + (void)session.connect(config); + } + ST_ASSERT(true); // reaching here at all is the assertion +} + +void testGlobalInitIsReferenceCounted() +{ + // Several OBS sources may each hold the SDK open; the last one out turns + // the lights off, and an unbalanced extra shutdown must not underflow. + LiveKitSession::globalInitialize(); + LiveKitSession::globalInitialize(); + LiveKitSession::globalShutdown(); + LiveKitSession::globalShutdown(); + LiveKitSession::globalShutdown(); // extra, must be harmless + LiveKitSession::globalInitialize(); + LiveKitSession::globalShutdown(); + ST_ASSERT(true); +} + +} // namespace + +int main() +{ + testFrameGeometry(); + testTrackSelection(); + testStateMachineHappyPath(); + testPublisherSwapIsNotAnError(); + testReconnect(); + testFailureAndRecovery(); + + LiveKitSession::globalInitialize(); + testConnectRejectsIncompleteConfig(); + testConnectToUnreachableServerFailsCleanly(); + testConnectToNonLiveKitServerFailsCleanly(); + testDestroyWithoutDisconnect(); + LiveKitSession::globalShutdown(); + + testGlobalInitIsReferenceCounted(); + + return st_test_report("session"); +} diff --git a/scripts/livekit-dev-room.py b/scripts/livekit-dev-room.py new file mode 100644 index 0000000..fa427b5 --- /dev/null +++ b/scripts/livekit-dev-room.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Mint LiveKit JWTs for the integration test against a local dev server. + +Usage: + livekit-server --dev --bind 127.0.0.1 & + eval "$(python3 scripts/livekit-dev-room.py)" + ctest --test-dir build -R test_integration_livekit --output-on-failure + +Prints shell `export` lines for the four STPLUGIN_IT_* variables +core/tests/test_integration_livekit.cpp looks for. With no arguments it uses +`livekit-server --dev`'s built-in devkey/secret credentials. + +Standard library only (hmac + hashlib + base64) -- there is deliberately no +pip install step here, so this runs anywhere the repo is checked out. +""" + +import argparse +import base64 +import hashlib +import hmac +import json +import os +import time + + +def b64url(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def mint(api_key: str, api_secret: str, identity: str, room: str, *, publish: bool, subscribe: bool) -> str: + now = int(time.time()) + header = {"alg": "HS256", "typ": "JWT"} + claims = { + "iss": api_key, + "sub": identity, + "name": identity, + "nbf": now - 10, + "exp": now + 3600, + "video": { + "room": room, + "roomJoin": True, + "canPublish": publish, + "canSubscribe": subscribe, + "canPublishData": False, + }, + } + signing_input = f"{b64url(json.dumps(header, separators=(',', ':')).encode())}." \ + f"{b64url(json.dumps(claims, separators=(',', ':')).encode())}" + signature = hmac.new(api_secret.encode(), signing_input.encode(), hashlib.sha256).digest() + return f"{signing_input}.{b64url(signature)}" + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--url", default=os.environ.get("LIVEKIT_URL", "ws://127.0.0.1:7880")) + parser.add_argument("--api-key", default=os.environ.get("LIVEKIT_API_KEY", "devkey")) + parser.add_argument("--api-secret", default=os.environ.get("LIVEKIT_API_SECRET", "secret")) + parser.add_argument("--room", default="obs-plugin-it") + parser.add_argument("--publisher-identity", default="cam-test") + parser.add_argument("--subscriber-identity", default="obs:obs-plugin-it:test") + args = parser.parse_args() + + publish_token = mint(args.api_key, args.api_secret, args.publisher_identity, args.room, + publish=True, subscribe=False) + # Deliberately the same grant shape the real server mints for the plugin + # (apps/server/src/obs/plugin.routes.ts -> mintCaptionsToken): subscribe + # only, never publish. + subscribe_token = mint(args.api_key, args.api_secret, args.subscriber_identity, args.room, + publish=False, subscribe=True) + + print(f'export STPLUGIN_IT_URL="{args.url}"') + print(f'export STPLUGIN_IT_PUBLISH_TOKEN="{publish_token}"') + print(f'export STPLUGIN_IT_SUBSCRIBE_TOKEN="{subscribe_token}"') + print(f'export STPLUGIN_IT_PUBLISHER_IDENTITY="{args.publisher_identity}"') + + +if __name__ == "__main__": + main() -- 2.52.0 From a484abec61f5106a3c68aadfb6dd0d383967531d Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 6 Sep 2026 21:54:11 -0700 Subject: [PATCH 04/17] Make the OBS adapter real: properties UI, connect, and frame output The stub source becomes an actual streamer-tools camera. On create it reads server URL / room slug / read key / camera identity from obs_data_t, mints a subscribe-only token through ApiClient, connects LiveKitSession, and pushes decoded frames into obs_source_output_video / obs_source_output_audio. The source is now OBS_SOURCE_ASYNC_VIDEO | OBS_SOURCE_AUDIO | OBS_SOURCE_DO_NOT_DUPLICATE with an OBS_ICON_TYPE_CAMERA icon. The file is C++ rather than C now: the core library's API is C++ and the C ABI shim existed only to avoid that. obs-module.h already declares the module entry points extern "C", so nothing is lost. Properties UI: server URL, room slug, a masked read-key field (it is a credential and is masked everywhere else in streamer-tools), a camera dropdown, a "Refresh camera list" button, and a status line. - The dropdown is built from a cache the worker keeps warm on every connect, so opening properties never blocks on the network. The button is the explicit way to force a round trip, with a shortened 5s timeout -- for which ApiClient's two calls gained a timeout_ms parameter. - The currently-selected identity is always in the list, labelled "(not in this room)" if absent, so OBS cannot silently clear a working setting just because the room happens to be dark. - The status line is the OBS_TEXT_INFO property's description (which is what OBS actually renders) and switches to the warning info type on a real error. Threading: OBS's UI and graphics threads are never blocked on the network. Each source owns a worker thread that mints, connects, and reconnects with exponential backoff (1s -> 30s), waking early on any settings change via a generation counter. Frames are pushed from LiveKitSession's reader threads directly; obs_source_output_video/_audio are thread-safe. Two details that matter operationally: - A null frame is pushed whenever the session leaves Connected, so a camera that stopped publishing clears instead of leaving its last frame on screen. Leaving stale media up is precisely the failure this plugin exists to avoid. - The SDK's own logging is routed into OBS's log file via livekit::setLogCallback, instead of stderr where a director would never see it. The adapter also logs the first frame and every later geometry change, so a log answers "did video ever arrive, and at what size". Packaging: the build now stages a runnable layout into build/package/ -- the module (RPATH $ORIGIN / @loader_path, so it resolves the LiveKit libraries from beside itself rather than from the build tree), liblivekit + liblivekit_ffi, the locale data, and the licence files. third_party/livekit/ carries client-sdk-cpp's Apache-2.0 LICENSE and NOTICE from the pinned tag. Its README records a correction to the design doc: the "bundled LICENSE.md with ~28 third-party licence blocks" the doc expects DOES NOT EXIST at v1.10.1 -- not in any of the five release archives (which contain only include/, lib/, bin/ and build-info.json) and not in the repo at that tag, which has only LICENSE and NOTICE. The aggregated third-party notice covering the WebRTC/OpenH264 code inside liblivekit_ffi.so has not been located, and that is flagged as an open licensing question rather than papered over. Verified on Ubuntu 24.04 against real libobs 30.0.2, a real livekit-server 1.13.6, and a stand-in API serving plugin.routes.ts's exact shapes, using a headless libobs harness (obs_startup + obs_reset_audio + obs_reset_video + obs_open_module + obs_source_create): registered=1 output_flags=0x87 [streamer-tools-camera] connected to ws://127.0.0.1:7880 as obs:main-room:qY85r9D0PaPt, watching cam-test [streamer-tools-camera] video frame 640x360 I420 camera dropdown has 3 items: [0] (no camera selected) = [1] Test Camera = cam-test [2] Dark Camera (offline) = other-cam status: connected (info_type=0) and with a deliberately wrong read key: status: unknown room slug, or the read key is wrong or has been rotated (info_type=1) with retry-and-backoff and no crash. ctest: 6/6 passed. Still unverified, and the README says so plainly: the OBS GUI on any platform, macOS/Windows beyond compiling, A/V sync, and end-to-end latency. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE --- README.md | 295 ++++++++------- core/CMakeLists.txt | 5 + core/include/stplugin/api_client.h | 8 +- core/src/api_client.cpp | 6 +- core/src/core.cpp | 4 +- obs-adapter/CMakeLists.txt | 59 ++- obs-adapter/data/locale/en-US.ini | 13 +- obs-adapter/src/plugin-main.c | 74 ---- obs-adapter/src/plugin-main.cpp | 567 +++++++++++++++++++++++++++++ third_party/livekit/LICENSE | 175 +++++++++ third_party/livekit/NOTICE | 13 + third_party/livekit/README.md | 36 ++ 12 files changed, 1040 insertions(+), 215 deletions(-) delete mode 100644 obs-adapter/src/plugin-main.c create mode 100644 obs-adapter/src/plugin-main.cpp create mode 100644 third_party/livekit/LICENSE create mode 100644 third_party/livekit/NOTICE create mode 100644 third_party/livekit/README.md diff --git a/README.md b/README.md index 3bbf6f8..1a7e175 100644 --- a/README.md +++ b/README.md @@ -1,159 +1,196 @@ # obs-streamer-tools-plugin -Native OBS Studio source plugin that will pull streamer-tools camera feeds -directly from LiveKit over WebRTC (via LiveKit's `livekit-ffi`), replacing -the current SRT/RTSP-via-VLC-or-Media-Source path for directors. Full -design: `docs/superpowers/specs/2026-09-06-obs-camera-plugin-design.md` in -the `streamer-tools` repo (as of this writing, that doc lives on the -`worktree-obs-plugin-server-api` branch there, not yet merged to `main`). +Native OBS Studio source plugin that pulls streamer-tools camera feeds +directly from LiveKit over WebRTC, replacing the current SRT/RTSP-via-VLC-or- +Media-Source path for directors. Full design: +`docs/superpowers/specs/2026-09-06-obs-camera-plugin-design.md` in the +`streamer-tools` repo. -## Status: scaffolding only +## Status -**This repository does not talk to LiveKit or streamer-tools yet.** This -first pass exists to prove the CMake toolchain, the core-library/OBS-adapter -split, and the three-platform Gitea Actions CI pipeline all actually work, -so the next phase (real `livekit-ffi` integration) can be planned against -verified facts instead of assumptions. See the design doc's "Components" -and "CI / build pipeline" sections for the target architecture this scaffold -is standing up. +The plugin is **functionally complete on Linux and verified end to end there** +(module loads into real libobs, connects to a real LiveKit server through the +real streamer-tools API shape, and pushes decoded frames into +`obs_source_output_video`/`_audio`). It has **not** been run in the OBS GUI on +any platform, and macOS/Windows have only ever been built by CI, never loaded. +See "What is verified, and how" below for exactly what that means, and +"Testing this by hand" for what a human still needs to do. ## Layout ``` -core/ - core library (C++17, no OBS dependency, headless-testable) +cmake/LiveKitSDK.cmake - downloads + unpacks the pinned client-sdk-cpp release +core/ - core library (C++17, no OBS dependency, headless-testable) include/stplugin/ - core.h C++ API (ConnectionConfig, core_version()) - core_c.h C ABI wrapper the OBS adapter calls into - src/core.cpp - tests/ dependency-free CTest unit tests - -obs-adapter/ - thin OBS glue (C, adapted from obsproject/obs-plugintemplate) - src/plugin-main.c obs_module_load/unload + a stub source registration - src/plugin-support.{h,c.in} + core.h version + ConnectionConfig + json.h small strict JSON reader + http.h injectable HTTP client interface + api_client.h the two /api/obs/:slug/* calls + session_types.h media/state types + the pure session logic + session.h LiveKitSession, the livekit::Room wrapper + src/ + http_curl.cpp libcurl backend (Linux/macOS) + http_winhttp.cpp WinHTTP backend (Windows) + tests/ dependency-free CTest suites +obs-adapter/ - thin OBS glue (C++) + src/plugin-main.cpp obs_source_info, properties UI, frame output data/locale/en-US.ini - -.gitea/workflows/build.yml - 3-platform CI matrix (see below) +scripts/livekit-dev-room.py - mints tokens for the integration test +third_party/livekit/ - redistribution notices for the LiveKit binaries +.gitea/workflows/build.yml - 3-platform CI matrix ``` -Everything in `core/` is real, working, unit-tested code -- it just doesn't -do anything useful yet (a version string, a config struct with non-empty -validation). Everything in `obs-adapter/` is real OBS module code -- it -registers an actual `obs_source_info` and builds as a real, dynamically -loadable OBS module (see "Verified" below) -- but the source is a stub: -`create`/`destroy` allocate/free a dummy blob, there is no properties UI, -and no frames are ever pushed. That's the boundary this task was scoped to. +## How it works -## What's real vs. deliberately deferred +1. The operator fills in the streamer-tools server URL, room slug and read key, + and picks a camera from the dropdown. +2. The source's own worker thread calls `POST /api/obs/:slug/token?key=…` to + mint a hidden, subscribe-only LiveKit token + (identity `obs::` — a fresh nonce per mint, so two OBS + installs watching the same room can never kick each other). +3. `LiveKitSession` connects `livekit::Room` to the returned `wsUrl`, waits for + the chosen participant's `Source.Camera` video track (and their microphone), + and reads decoded frames off `VideoStream`/`AudioStream`. +4. The adapter hands those straight to `obs_source_output_video` / + `obs_source_output_audio`. -Deferred, per the task that produced this scaffold (out of scope for this -pass, in scope for the next one): +Nothing on the OBS UI thread ever blocks on the network. The one deliberate +exception is the "Refresh camera list" button, which the operator pressed and +is waiting on; it uses a shortened 5s timeout. -- No `livekit-ffi` linkage of any kind. -- No streamer-tools API client (auth, slot-listing, token minting). -- No properties UI (server URL / room slug / read key / camera dropdown). -- No frame output (`obs_source_output_video`/`_audio`). -- No packaging/release step (the design doc's "on a version tag" job). +### Design decisions worth knowing before changing this -## Toolchain notes (verified on this machine: Ubuntu 24.04 / Linux) +- **Frames come from `VideoStream::fromTrack` with our own reader threads, not + from `Room::setOnVideoFrameCallback`.** The dispatcher API is keyed by + (participant identity, track *name*), which is only knowable once the track + is published — and disassembly of `liblivekit.so` 1.10.1 confirms that + neither `Room::setOnVideoFrameCallback` nor the dispatcher's own version + starts a reader for an already-subscribed track; they only record the + registration. Registering at the only moment the name exists would therefore + have silently produced no video. +- **Every stream operation runs on one owned worker thread**, never on a + LiveKit room event thread: `Room::disconnect()` from inside a delegate + callback is documented to deadlock. +- **`VideoStream::Options::capacity` is 3**, making the SDK queue a + drop-oldest ring buffer. A stalled consumer can only fall three frames + behind and then sees the *newest* frame, not a backlog — the structural + answer to the stale-media bug that motivated this plugin. +- **Video and audio are both timestamped with `os_gettime_ns()` at arrival.** + The SDK gives video a WebRTC capture timestamp and audio none; mixing two + epochs inside one OBS source would guarantee A/V drift. This relies on the + SDK's jitter buffering having already aligned them — the assumption the + design doc flags for verification on real hardware. **Still unverified.** +- **WebRTC changes resolution mid-stream.** Observed directly in the + integration test: the first frames after (re)subscribing arrive at a + downscaled spatial layer before ramping to the published size. The adapter + passes each frame's own geometry through, and logs geometry changes. -- **CMake 3.28.3**, **Ninja 1.11.1**, GCC 13.3.0 -- all installed via - `apt-get install cmake ninja-build`. Top-level `CMakeLists.txt` requires - CMake >= 3.16 (deliberately lower than the official - obsproject/obs-plugintemplate's `3.28...3.30` floor -- see below). -- **OBS plugin template used as reference**: obsproject/obs-plugintemplate, - commit `3e7d7ac3b5342cd7d9b88890b9c70b472d1520fc` (2025-12-09, "Fix typo - of Visual Studio in README"), fetched fresh from GitHub. `src/plugin-main.c`, - `src/plugin-support.{h,c.in}`, and the empty `data/locale/en-US.ini` in - `obs-adapter/` are adapted directly from it. -- **Deliberate deviation from the template's own build system**: the - official template's `CMakeLists.txt` chains into - `cmake/common/bootstrap.cmake`, which in turn reads `buildspec.json` and - *downloads full OBS source archives (pinned to OBS 31.1.1) plus prebuilt - dependency bundles* for macOS and Windows. That machinery is real, - actively maintained, and probably the right long-term answer for - cross-platform reproducible builds -- but it's heavy (multi-hundred-MB - downloads, a whole `cmake/{macos,windows,common}` support tree, Qt6, - code-signing hooks) and out of scope to stand up and debug in one pass. - This scaffold instead uses a much simpler hand-written top-level - `CMakeLists.txt` that calls `find_package(libobs)` directly. -- **On Linux, this actually works far better than expected**: Ubuntu ships - a real `libobs-dev` package (`30.0.2+dfsg-3build1` on 24.04, i.e. **not** - the 31.1.1 the template's buildspec.json pins -- worth reconciling before - the next phase if API surface matters) with genuine CMake package config - files (`/usr/lib/x86_64-linux-gnu/cmake/libobs/libobsConfig.cmake`, - `libobsTargets.cmake`) that export an `OBS::libobs` imported target -- - the exact target name the official template expects. `find_package(libobs - QUIET)` finds it with zero extra plumbing. This means the OBS adapter in - this repo links against **real OBS headers and a real `libobs.so`**, not - a stub -- confirmed by `ldd` showing `libobs.so.0` and `nm -D` showing - real `obs_module_*` exports (see "Verified" below). Install via - `apt-get install libobs-dev` (pulls in Qt6 as a dependency chain, ~seconds - on a fast mirror). -- **macOS/Windows have no equivalent system package** (there's no Homebrew - formula or winget package that ships `libobsConfig.cmake` the way Ubuntu's - `libobs-dev` does). For those platforms the choices are: (a) adopt the - template's full buildspec-driven source/prebuilt-deps download, or (b) - find/produce a lighter prebuilt SDK bundle. **This is now a concrete, - scoped decision for the next phase**, not a guess -- the CI workflow in - this repo currently takes option (c) for this pass only: skip building - the OBS adapter on macOS/Windows and build+test just the core library, - via the same `find_package(libobs QUIET)` fallback the top-level - `CMakeLists.txt` already has for exactly this situation. -- **`ENABLE_QT`/`ENABLE_FRONTEND_API` template options were not carried - over** -- this scaffold's properties-UI-free stub doesn't need Qt yet; - the real adapter will need to revisit this once the properties UI - (server URL / room slug / read key / camera dropdown) is built. +## Building -## What actually builds, and how it was verified +Linux (the platform that is fully verified): ``` -$ cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release --- libobs found (/usr/lib/x86_64-linux-gnu/cmake/libobs) -- building OBS adapter module --- Configuring done --- Generating done - -$ cmake --build build -[1/7] Building C object obs-adapter/CMakeFiles/streamer-tools-camera.dir/plugin-support.c.o -[2/7] Building C object obs-adapter/CMakeFiles/streamer-tools-camera.dir/src/plugin-main.c.o -[3/7] Building CXX object core/CMakeFiles/stplugin_core.dir/src/core.cpp.o -[4/7] Linking CXX static library core/libstplugin_core.a -[5/7] Building CXX object core/tests/CMakeFiles/stplugin_core_tests.dir/test_core.cpp.o -[6/7] Linking CXX shared module obs-adapter/streamer-tools-camera.so -[7/7] Linking CXX executable core/tests/stplugin_core_tests - -$ ctest --test-dir build --output-on-failure -1/1 Test #1: stplugin_core_tests .............. Passed 0.00 sec -100% tests passed, 0 tests failed out of 1 - -$ ldd build/obs-adapter/streamer-tools-camera.so | grep obs - libobs.so.0 => /lib/x86_64-linux-gnu/libobs.so.0 (...) - -$ nm -D build/obs-adapter/streamer-tools-camera.so | grep obs_module -0000000000001430 T obs_module_free_locale -0000000000001450 T obs_module_load -... -0000000000001490 T obs_module_unload +sudo apt-get install -y cmake ninja-build libobs-dev libcurl4-openssl-dev +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release +cmake --build build +ctest --test-dir build --output-on-failure ``` -This is a genuine, dynamically-linked OBS module -- not the "standalone -shared library without linking OBS" fallback the scaffolding task's scope -explicitly allowed as an acceptable compromise. That fallback path is still -exercised (and needed) on macOS/Windows CI for now; see above. +The configure step downloads the pinned `client-sdk-cpp` release (~13 MB) into +`build/_deps/livekit-sdk`. Point `-DSTPLUGIN_LIVEKIT_SDK_DIR=` at a +persistent directory to cache it across builds; `-DSTPLUGIN_LIVEKIT_SDK_VERSION` +and `-DSTPLUGIN_LIVEKIT_SDK_TRIPLE` override the pin and the release triple. + +The build stages a runnable layout into `build/package/`: + +``` +build/package/bin/streamer-tools-camera.so (RPATH=$ORIGIN) +build/package/bin/liblivekit.so +build/package/bin/liblivekit_ffi.so +build/package/data/locale/en-US.ini +build/package/licenses/... +``` + +`build/package/bin` is what gets installed — the module resolves the LiveKit +libraries from `$ORIGIN` / `@loader_path`, not from the build tree. + +## Testing this by hand + +**Nobody has yet run this in the OBS GUI. That test is still outstanding on +all three platforms.** To do it on Linux: + +``` +mkdir -p ~/.config/obs-studio/plugins/streamer-tools-camera +cp -r build/package/bin build/package/data \ + ~/.config/obs-studio/plugins/streamer-tools-camera/ +obs +``` + +Then: Sources → `+` → "streamer-tools Camera" → fill in the server URL, room +slug and read key from the room's settings page → "Refresh camera list" → pick +a camera. Check `~/.config/obs-studio/logs/` for +`[streamer-tools-camera] connected to …` and `[streamer-tools-camera] video +frame WxH I420`. + +What to look for that automated testing could not answer: +- Does video actually *appear*, right way up, right colours? +- Is A/V sync acceptable? (see the timestamp caveat above) +- What is the end-to-end latency versus the existing egress path? +- Does a publisher restarting mid-show recover cleanly on screen? + +### Running the LiveKit integration test + +`core/tests/test_integration_livekit` publishes a synthetic camera into a real +room and subscribes to it through the wrapper. It skips unless +`STPLUGIN_IT_*` is set: + +``` +livekit-server --dev --bind 127.0.0.1 & +eval "$(python3 scripts/livekit-dev-room.py)" +ctest --test-dir build -R test_integration_livekit --output-on-failure +``` + +## What is verified, and how + +Verified on Ubuntu 24.04 (libobs 30.0.2, client-sdk-cpp 1.10.1, +livekit-server 1.13.6 in dev mode): + +| Claim | How it was checked | +|---|---| +| The pinned LiveKit SDK links and is callable | `test_livekit_smoke`: `initialize()`/`shutdown()` round-trip, header version asserted equal to the CMake pin | +| The JSON reader handles real and hostile input | `test_json`, 158 checks, including truncated bodies, HTML error pages, binary garbage, lone surrogates, and a depth-limit case | +| The API client parses the real response shapes and every error branch | `test_api_client`, 121 checks, against a fake HTTP client **and** a real loopback HTTP server driving the actual platform backend | +| A dead/stalled/garbage server cannot hang or crash the plugin | loopback cases: truncated JSON, connection closed with no reply, non-HTTP bytes, dead port, stalled server cut off by the client timeout | +| Session state transitions, track selection, frame geometry | `test_session`, 81 checks, plus real `connect()` failures against the real SDK | +| **Media actually flows** | `test_integration_livekit` against a real LiveKit server: 36 video frames + 323 audio frames, correct I420 geometry and plane pointers, publisher unpublish → `hasVideo()` false with **no further frames from the dead publisher**, republish → video resumes | +| **The module loads into real libobs and pushes frames** | a headless libobs harness (`obs_startup` + `obs_reset_audio`/`obs_reset_video` + `obs_open_module`) driving the built module against a stand-in streamer-tools API in front of a real LiveKit server. Log: `connected to ws://… watching cam-test` then `video frame 640x360 I420`; the camera dropdown populated as `Test Camera` / `Dark Camera (offline)`; status `connected`; clean destroy and unload | +| A wrong read key is reported, not silently swallowed | same harness with a bad key: status `unknown room slug, or the read key is wrong or has been rotated`, warning info type, retry with backoff, no crash | + +**Not verified anywhere:** +- The OBS GUI, on any platform. No human has looked at this in OBS. +- macOS and Windows beyond "CI compiles and the core tests pass". The WinHTTP + backend has never run against a real streamer-tools server. +- A/V sync and end-to-end latency against the existing egress path. +- Behaviour against the real production streamer-tools server (only against a + stand-in serving the same shapes). +- Token expiry after an hour. Expiry is handled *reactively*: a fatal + disconnect makes the worker mint a fresh token and reconnect. The design + doc's "proactively refreshed before expiry" is **not** implemented — + `client-sdk-cpp` 1.10.1 exposes no way to hand a live `Room` a new token. ## CI -`.gitea/workflows/build.yml` runs on every push/PR, matrixed across the -three runners confirmed available to this repo by living under the -`CyberCoveLLC` org (see the design doc's "CI / build pipeline" section): +`.gitea/workflows/build.yml` runs on every push, matrixed across the three +runners available to this repo under the `CyberCoveLLC` org. | Job | `runs-on` | Runner | |---|---|---| -| `linux` | `ubuntu-latest` | `gitea-runner.internal.cloud-hosting.io` (Global) or `localhost.localdomain` (org-scoped; **note:** now online with `ubuntu-latest`/`ubuntu-24.04`/`ubuntu-22.04` labels -- the design doc recorded it as offline, that's since changed) | +| `linux` | `ubuntu-latest` | `gitea-runner.internal.cloud-hosting.io` (Global) | | `macos` | `macos-latest` | `home-mac` (Global) | -| `windows` | `windows-latest` | `winvm-builder` (org-scoped to `CyberCoveLLC`) | +| `windows` | `windows-latest` | `winvm-builder` (org-scoped) | -The Linux job installs `libobs-dev` and builds the real OBS adapter module -plus the core library, then runs `ctest`. The macOS/Windows jobs build and -test only the core library for now (see toolchain notes above for why). - -No packaging/release step yet -- out of scope for this pass. +Linux uses Ubuntu's `libobs-dev` and builds the real OBS adapter. macOS and +Windows use the `obsproject/obs-plugintemplate` buildspec bootstrap, trimmed +to drop `qt6` (this plugin's properties UI is plain `obs_properties_*`), with +`obs-studio.version` pinned no newer than what Linux builds against — OBS +rejects a module built against a newer libobs than the one running it. diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index b13f033..8ffef13 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -43,6 +43,11 @@ endif() find_package(Threads REQUIRED) target_link_libraries(stplugin_core PUBLIC Threads::Threads) +target_compile_definitions(stplugin_core PRIVATE + STPLUGIN_CORE_VERSION="${PROJECT_VERSION}" + STPLUGIN_LIVEKIT_SDK_VERSION="${LIVEKIT_SDK_VERSION_RESOLVED}" +) + set_target_properties(stplugin_core PROPERTIES POSITION_INDEPENDENT_CODE ON ) diff --git a/core/include/stplugin/api_client.h b/core/include/stplugin/api_client.h index bf00faf..e09c954 100644 --- a/core/include/stplugin/api_client.h +++ b/core/include/stplugin/api_client.h @@ -98,8 +98,12 @@ public: /// Takes ownership of the HTTP client, so tests can inject a fake. explicit ApiClient(std::shared_ptr http); - SlotsResult fetchSlots(const ConnectionConfig &config) const; - TokenResult requestToken(const ConnectionConfig &config) const; + /// @param timeout_ms whole-request timeout. Kept as a parameter because + /// the properties dialog's "Refresh" button runs on OBS's UI thread with + /// an operator waiting, and must give up sooner than a background + /// reconnect would. + SlotsResult fetchSlots(const ConnectionConfig &config, int timeout_ms = 10000) const; + TokenResult requestToken(const ConnectionConfig &config, int timeout_ms = 10000) const; /// Accepts what an operator would actually paste: a bare hostname, a URL /// with a trailing slash, extra whitespace. Returns an empty string if diff --git a/core/src/api_client.cpp b/core/src/api_client.cpp index 8b49bd7..5cff058 100644 --- a/core/src/api_client.cpp +++ b/core/src/api_client.cpp @@ -120,7 +120,7 @@ std::string ApiClient::redactedUrl(const std::string &url) return url.substr(0, value) + "***" + url.substr(end); } -SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config) const +SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config, int timeout_ms) const { SlotsResult result; if (!config.is_valid() || normalizeServerUrl(config.server_url).empty() || !http_) { @@ -132,6 +132,7 @@ SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config) const HttpRequest request; request.method = "GET"; request.url = buildUrl(config, "/slots"); + request.timeout_ms = timeout_ms; const HttpResponse response = http_->send(request); const ApiStatus status = classify(response, result.message); @@ -170,7 +171,7 @@ SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config) const return result; } -TokenResult ApiClient::requestToken(const ConnectionConfig &config) const +TokenResult ApiClient::requestToken(const ConnectionConfig &config, int timeout_ms) const { TokenResult result; if (!config.is_valid() || normalizeServerUrl(config.server_url).empty() || !http_) { @@ -184,6 +185,7 @@ TokenResult ApiClient::requestToken(const ConnectionConfig &config) const request.url = buildUrl(config, "/token"); request.content_type = "application/json"; request.body = "{}"; + request.timeout_ms = timeout_ms; const HttpResponse response = http_->send(request); const ApiStatus status = classify(response, result.message); diff --git a/core/src/core.cpp b/core/src/core.cpp index f9e2fc7..4e85727 100644 --- a/core/src/core.cpp +++ b/core/src/core.cpp @@ -22,7 +22,9 @@ with this program. If not, see namespace stplugin { const char *core_version() { - return "0.0.1-scaffold"; + // Injected by CMake from the top-level project() version, so the string + // OBS logs on load is the actual build, not a hand-maintained literal. + return STPLUGIN_CORE_VERSION; } bool ConnectionConfig::is_valid() const { diff --git a/obs-adapter/CMakeLists.txt b/obs-adapter/CMakeLists.txt index d5be005..22046b9 100644 --- a/obs-adapter/CMakeLists.txt +++ b/obs-adapter/CMakeLists.txt @@ -1,9 +1,8 @@ # streamer-tools OBS Camera Plugin - OBS adapter # -# Thin glue only, per the design doc: source registration, (eventually) -# properties UI, and pushing frames into OBS. All real logic lives in -# ../core. Only added to the build when find_package(libobs) succeeds -# (see top-level CMakeLists.txt) -- see README.md for why. +# Thin glue only, per the design doc: source registration, the properties UI, +# and pushing frames into OBS. All real logic lives in ../core. Only added to +# the build when find_package(libobs) succeeds (see top-level CMakeLists.txt). set(STPLUGIN_PROJECT_NAME "streamer-tools-camera") set(STPLUGIN_PROJECT_VERSION "${PROJECT_VERSION}") @@ -15,7 +14,7 @@ configure_file( ) add_library(${STPLUGIN_PROJECT_NAME} MODULE - src/plugin-main.c + src/plugin-main.cpp ${CMAKE_CURRENT_BINARY_DIR}/plugin-support.c ) @@ -35,3 +34,53 @@ set_target_properties(${STPLUGIN_PROJECT_NAME} PROPERTIES PREFIX "" OUTPUT_NAME ${STPLUGIN_PROJECT_NAME} ) + +# The module has to find liblivekit / liblivekit_ffi next to itself once it is +# installed into an OBS plugin directory, not at the build-tree path CMake's +# default RPATH would bake in. +# BUILD_WITH_INSTALL_RPATH is ON deliberately: the artifact that ships is a +# straight copy of the built module (see the staging step below), so the +# build-tree RPATH must never be baked in -- it would work on the build +# machine and nowhere else. +if(APPLE) + set_target_properties(${STPLUGIN_PROJECT_NAME} PROPERTIES + BUILD_WITH_INSTALL_RPATH ON + INSTALL_RPATH "@loader_path" + ) +elseif(UNIX) + set_target_properties(${STPLUGIN_PROJECT_NAME} PROPERTIES + BUILD_WITH_INSTALL_RPATH ON + INSTALL_RPATH "$ORIGIN" + ) +endif() + +# --- staged, runnable layout ------------------------------------------------ +# Everything a human needs to copy into an OBS plugin directory ends up under +# build/package/, with the LiveKit shared libraries and the licence files +# beside the module. Without this the module loads on the build machine only, +# via the build-tree RPATH. +set(STPLUGIN_PACKAGE_DIR "${CMAKE_BINARY_DIR}/package") + +add_custom_command(TARGET ${STPLUGIN_PROJECT_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "${STPLUGIN_PACKAGE_DIR}/bin" + COMMAND ${CMAKE_COMMAND} -E copy "$" "${STPLUGIN_PACKAGE_DIR}/bin/" + COMMAND ${CMAKE_COMMAND} -E copy ${LIVEKIT_SDK_RUNTIME_LIBS} "${STPLUGIN_PACKAGE_DIR}/bin/" + COMMAND ${CMAKE_COMMAND} -E make_directory "${STPLUGIN_PACKAGE_DIR}/data/locale" + COMMAND ${CMAKE_COMMAND} -E copy + "${CMAKE_CURRENT_SOURCE_DIR}/data/locale/en-US.ini" + "${STPLUGIN_PACKAGE_DIR}/data/locale/" + # Redistributing LiveKit's prebuilt binaries means shipping their licence + # and notice with them. See third_party/livekit/README.md -- including + # what upstream does NOT ship, which is an open question, not a solved one. + COMMAND ${CMAKE_COMMAND} -E make_directory "${STPLUGIN_PACKAGE_DIR}/licenses/livekit" + COMMAND ${CMAKE_COMMAND} -E copy + "${CMAKE_SOURCE_DIR}/third_party/livekit/LICENSE" + "${CMAKE_SOURCE_DIR}/third_party/livekit/NOTICE" + "${CMAKE_SOURCE_DIR}/third_party/livekit/README.md" + "${STPLUGIN_PACKAGE_DIR}/licenses/livekit/" + COMMAND ${CMAKE_COMMAND} -E copy + "${CMAKE_SOURCE_DIR}/LICENSE" + "${STPLUGIN_PACKAGE_DIR}/licenses/" + COMMENT "Staging plugin + LiveKit runtime libraries + licences into ${STPLUGIN_PACKAGE_DIR}" + VERBATIM +) diff --git a/obs-adapter/data/locale/en-US.ini b/obs-adapter/data/locale/en-US.ini index 7d2a041..aafe740 100644 --- a/obs-adapter/data/locale/en-US.ini +++ b/obs-adapter/data/locale/en-US.ini @@ -1,2 +1,11 @@ -# streamer-tools OBS Camera Plugin - en-US locale -# No user-facing strings yet -- this is scaffolding (see plugin-main.c). +StreamerToolsCamera="streamer-tools Camera" +ServerUrl="streamer-tools server URL" +RoomSlug="Room" +ReadKey="Read key" +Camera="Camera" +RefreshCameras="Refresh camera list" +Status="Status" +NoCameraSelected="(no camera selected)" +OfflineSuffix=" (offline)" +NotInRoomSuffix=" (not in this room)" +CamerasFound=" cameras found" diff --git a/obs-adapter/src/plugin-main.c b/obs-adapter/src/plugin-main.c deleted file mode 100644 index cc86c13..0000000 --- a/obs-adapter/src/plugin-main.c +++ /dev/null @@ -1,74 +0,0 @@ -/* -streamer-tools OBS Camera Plugin -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 -*/ - -/* SCAFFOLDING. This registers a source type so the OBS-adapter/core- - * library split and OBS's module-loading toolchain can be proven end to - * end, but it does not do anything real yet: no LiveKit FFI session, no - * frames pushed via obs_source_output_video/audio, no properties UI. - * See docs/superpowers/specs/2026-09-06-obs-camera-plugin-design.md in - * the streamer-tools repo for what this becomes. */ - -#include -#include -#include -#include - -OBS_DECLARE_MODULE() -OBS_MODULE_USE_DEFAULT_LOCALE(PLUGIN_NAME, "en-US") - -static const char *stcam_source_get_name(void *unused) -{ - UNUSED_PARAMETER(unused); - return "streamer-tools Camera (scaffold - not yet functional)"; -} - -static void *stcam_source_create(obs_data_t *settings, obs_source_t *source) -{ - UNUSED_PARAMETER(settings); - UNUSED_PARAMETER(source); - /* No LiveKit session, no state to speak of yet -- just proving the - * source registers and OBS can instantiate/destroy it cleanly. */ - return bzalloc(1); -} - -static void stcam_source_destroy(void *data) -{ - bfree(data); -} - -static struct obs_source_info streamer_tools_camera_source = { - .id = "streamer_tools_camera_source", - .type = OBS_SOURCE_TYPE_INPUT, - .output_flags = OBS_SOURCE_ASYNC_VIDEO, - .get_name = stcam_source_get_name, - .create = stcam_source_create, - .destroy = stcam_source_destroy, -}; - -bool obs_module_load(void) -{ - obs_log(LOG_INFO, "streamer-tools camera plugin scaffold loaded (core library version %s)", - stplugin_core_version()); - obs_register_source(&streamer_tools_camera_source); - return true; -} - -void obs_module_unload(void) -{ - obs_log(LOG_INFO, "streamer-tools camera plugin scaffold unloaded"); -} diff --git a/obs-adapter/src/plugin-main.cpp b/obs-adapter/src/plugin-main.cpp new file mode 100644 index 0000000..c55b6be --- /dev/null +++ b/obs-adapter/src/plugin-main.cpp @@ -0,0 +1,567 @@ +/* +streamer-tools OBS Camera Plugin +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 +*/ + +// The thin glue layer, per the design doc: source registration, the +// properties UI, and pushing frames into OBS. Everything that can be tested +// headlessly lives in ../core. +// +// Two threading rules shape this whole file: +// - OBS calls create/update/destroy/get_properties on its UI or graphics +// thread. Nothing here may block them on the network, so every API call +// and every LiveKit connect happens on the source's own worker thread. +// The one exception is the explicit "Refresh camera list" button, where +// the operator asked for a round trip and is waiting for its result. +// - Frames arrive on LiveKitSession's reader threads. obs_source_output_video +// and obs_source_output_audio are safe to call from any thread, so they +// are called directly from there with no extra copy. + +#include +#include + +#include + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace stplugin; + +OBS_DECLARE_MODULE() +OBS_MODULE_USE_DEFAULT_LOCALE(PLUGIN_NAME, "en-US") + +namespace { + +constexpr const char *kSettingServerUrl = "server_url"; +constexpr const char *kSettingRoomSlug = "room_slug"; +constexpr const char *kSettingReadKey = "read_key"; +constexpr const char *kSettingCamera = "camera"; +constexpr const char *kSettingStatus = "status"; +constexpr const char *kPropRefresh = "refresh"; + +/// Shorter than the core default: this one runs while an operator is staring +/// at a properties dialog they pressed a button in. +constexpr int kPropertiesTimeoutMs = 5000; + +/// Reconnect backoff bounds. A dark room or a stopped server must not turn +/// into a request storm, but a transient blip should recover quickly. +constexpr int kBackoffStartMs = 1000; +constexpr int kBackoffMaxMs = 30000; + +std::string settingString(obs_data_t *settings, const char *key) +{ + const char *value = obs_data_get_string(settings, key); + return value ? std::string(value) : std::string(); +} + +video_format toObsVideoFormat(PixelFormat format) +{ + switch (format) { + case PixelFormat::I420: return VIDEO_FORMAT_I420; + case PixelFormat::NV12: return VIDEO_FORMAT_NV12; + case PixelFormat::BGRA: return VIDEO_FORMAT_BGRA; + } + return VIDEO_FORMAT_I420; +} + +// --------------------------------------------------------------------------- +// Source instance +// --------------------------------------------------------------------------- + +struct CameraSource { + obs_source_t *source = nullptr; + + // --- configuration, guarded by `mutex` --- + std::mutex mutex; + ConnectionConfig config; + std::string camera_identity; + /// Bumped every time settings change; the worker compares it to what it + /// last connected with, so a stale in-flight connect is abandoned rather + /// than fought over. + std::uint64_t generation = 0; + std::vector slot_cache; + std::string status_text = "not configured"; + + // --- worker --- + std::thread worker; + std::condition_variable wake; + std::atomic stopping{false}; + + std::shared_ptr api; + std::unique_ptr session; + + std::atomic frames_out{0}; + /// width<<16 | height of the last frame pushed, so a geometry change can + /// be logged exactly once. + std::atomic last_geometry{0}; + /// Whether the current status line is a problem the operator must act on + /// (a wrong read key), rather than ordinary progress. + std::atomic status_is_error{false}; + + void setStatus(std::string text) + { + std::lock_guard guard(mutex); + status_text = std::move(text); + } + + std::string statusText() + { + std::lock_guard guard(mutex); + return status_text; + } +}; + +// --------------------------------------------------------------------------- +// Frame output +// --------------------------------------------------------------------------- + +void outputVideoFrame(CameraSource *self, const VideoFrameData &frame) +{ + obs_source_frame out = {}; + out.width = static_cast(frame.width); + out.height = static_cast(frame.height); + out.format = toObsVideoFormat(frame.format); + + // Both video and audio are stamped with the SAME clock (os_gettime_ns at + // arrival) rather than video using WebRTC's timestamp_us and audio using + // arrival time. The SDK's VideoFrameCallback carries a capture-time + // timestamp but its AudioFrameCallback carries none, and mixing two + // epochs inside one OBS source is a guaranteed A/V drift. This relies on + // the SDK's jitter buffering having already aligned the two -- the + // assumption the design doc flags for verification on real hardware. + out.timestamp = os_gettime_ns(); + + for (int i = 0; i < frame.plane_count && i < MAX_AV_PLANES; ++i) { + out.data[i] = const_cast(frame.planes[i].data); + out.linesize[i] = frame.planes[i].stride; + } + + // WebRTC delivers limited-range BT.709 for anything at or above SD. + video_format_get_parameters_for_format(VIDEO_CS_709, VIDEO_RANGE_PARTIAL, out.format, out.color_matrix, + out.color_range_min, out.color_range_max); + out.full_range = false; + + obs_source_output_video(self->source, &out); + self->frames_out.fetch_add(1); + + // Log the first frame, and any later change of geometry. A director's + // log then answers "did video ever arrive, and at what size" without + // anyone having to reproduce the problem -- and WebRTC really does + // change resolution mid-stream as it ramps a subscription up. + const std::uint32_t geometry = out.width << 16 | out.height; + const std::uint32_t previous = self->last_geometry.exchange(geometry); + if (previous != geometry) + obs_log(LOG_INFO, "video frame %ux%u %s", out.width, out.height, describePixelFormat(frame.format)); +} + +void outputAudioFrame(CameraSource *self, const AudioFrameData &frame) +{ + if (!frame.samples || frame.samples_per_channel <= 0) + return; + + obs_source_audio out = {}; + out.data[0] = reinterpret_cast(frame.samples); + out.frames = static_cast(frame.samples_per_channel); + out.format = AUDIO_FORMAT_16BIT; // interleaved int16, which is what the SDK hands us + out.samples_per_sec = static_cast(frame.sample_rate); + out.timestamp = os_gettime_ns(); + + switch (frame.channels) { + case 1: out.speakers = SPEAKERS_MONO; break; + case 2: out.speakers = SPEAKERS_STEREO; break; + default: + // Anything else would need a channel-map decision we have no reason + // to guess at; a streamer-tools mic is mono or stereo. + return; + } + + obs_source_output_audio(self->source, &out); +} + +// --------------------------------------------------------------------------- +// Worker: mint a token, connect, keep it connected +// --------------------------------------------------------------------------- + +void workerLoop(CameraSource *self) +{ + std::uint64_t connected_generation = 0; + bool connected = false; + int backoff_ms = kBackoffStartMs; + + for (;;) { + ConnectionConfig config; + std::string camera; + std::uint64_t generation = 0; + { + std::unique_lock lock(self->mutex); + if (self->stopping.load()) + break; + config = self->config; + camera = self->camera_identity; + generation = self->generation; + } + + const bool config_changed = generation != connected_generation; + const bool needs_connect = + !connected || config_changed || + (self->session && (self->session->state() == SessionState::Failed || + self->session->state() == SessionState::Disconnected)); + + if (needs_connect) { + if (connected || config_changed) { + if (self->session) + self->session->disconnect(); + obs_source_output_video(self->source, nullptr); + connected = false; + } + + if (!config.is_valid() || camera.empty()) { + self->setStatus("not configured -- set the server URL, room, read key and camera"); + connected_generation = generation; + backoff_ms = kBackoffStartMs; + } else { + self->setStatus("connecting..."); + const TokenResult token = self->api->requestToken(config); + if (!token.ok()) { + // token.message is the specific one ("the read key is + // wrong or has been rotated"); describeApiStatus is the + // generic fallback. Printing both just reads as noise. + const std::string message = + token.message.empty() ? describeApiStatus(token.status) : token.message; + self->setStatus(message); + self->status_is_error.store(true); + obs_log(LOG_WARNING, "token request failed: %s", message.c_str()); + } else { + // Refresh the dropdown cache while we are here; the + // properties UI then opens instantly instead of blocking + // on the network. + const SlotsResult slots = self->api->fetchSlots(config); + if (slots.ok()) { + std::lock_guard guard(self->mutex); + self->slot_cache = slots.slots; + } + + SessionConfig session_config; + session_config.ws_url = token.ws_url; + session_config.token = token.lk_token; + session_config.participant_identity = camera; + + if (self->session->connect(session_config)) { + connected = true; + connected_generation = generation; + backoff_ms = kBackoffStartMs; + self->status_is_error.store(false); + obs_log(LOG_INFO, "connected to %s as %s, watching %s", token.ws_url.c_str(), + token.identity.c_str(), camera.c_str()); + } else { + self->setStatus(self->session->stateDetail()); + } + } + + if (!connected) { + backoff_ms = backoff_ms * 2 < kBackoffMaxMs ? backoff_ms * 2 : kBackoffMaxMs; + connected_generation = generation; + } + } + } + + // Poll rather than push: the state handler could notify us, but it + // runs on a LiveKit thread and this keeps the wake-up path single. + std::unique_lock lock(self->mutex); + self->wake.wait_for(lock, std::chrono::milliseconds(connected ? 1000 : backoff_ms), + [self, generation] { return self->stopping.load() || self->generation != generation; }); + if (self->stopping.load()) + break; + } + + if (self->session) + self->session->disconnect(); +} + +// --------------------------------------------------------------------------- +// obs_source_info callbacks +// --------------------------------------------------------------------------- + +const char *sourceGetName(void *) +{ + return obs_module_text("StreamerToolsCamera"); +} + +void sourceGetDefaults(obs_data_t *settings) +{ + obs_data_set_default_string(settings, kSettingServerUrl, ""); + obs_data_set_default_string(settings, kSettingRoomSlug, ""); + obs_data_set_default_string(settings, kSettingReadKey, ""); + obs_data_set_default_string(settings, kSettingCamera, ""); +} + +void applySettings(CameraSource *self, obs_data_t *settings) +{ + ConnectionConfig config; + config.server_url = settingString(settings, kSettingServerUrl); + config.room_slug = settingString(settings, kSettingRoomSlug); + config.read_key = settingString(settings, kSettingReadKey); + const std::string camera = settingString(settings, kSettingCamera); + + { + std::lock_guard guard(self->mutex); + const bool changed = config.server_url != self->config.server_url || + config.room_slug != self->config.room_slug || + config.read_key != self->config.read_key || camera != self->camera_identity; + if (!changed) + return; + self->config = config; + self->camera_identity = camera; + ++self->generation; + } + self->wake.notify_all(); +} + +void *sourceCreate(obs_data_t *settings, obs_source_t *source) +{ + auto *self = new CameraSource(); + self->source = source; + self->api = std::make_shared(std::shared_ptr(createPlatformHttpClient())); + self->session = std::unique_ptr(new LiveKitSession()); + + self->session->setVideoHandler([self](const VideoFrameData &frame) { outputVideoFrame(self, frame); }); + self->session->setAudioHandler([self](const AudioFrameData &frame) { outputAudioFrame(self, frame); }); + self->session->setStateHandler([self](SessionState state, const std::string &detail) { + self->setStatus(detail.empty() ? describeSessionState(state) : detail); + self->status_is_error.store(state == SessionState::Failed); + + // A camera that stopped publishing must not leave its last frame on + // screen -- that is precisely the stale-media failure this plugin + // exists to avoid. A null frame clears the source. + if (state != SessionState::Connected) + obs_source_output_video(self->source, nullptr); + }); + + { + std::lock_guard guard(self->mutex); + self->config.server_url = settingString(settings, kSettingServerUrl); + self->config.room_slug = settingString(settings, kSettingRoomSlug); + self->config.read_key = settingString(settings, kSettingReadKey); + self->camera_identity = settingString(settings, kSettingCamera); + self->generation = 1; + } + + self->worker = std::thread([self] { workerLoop(self); }); + return self; +} + +void sourceUpdate(void *data, obs_data_t *settings) +{ + applySettings(static_cast(data), settings); +} + +void sourceDestroy(void *data) +{ + auto *self = static_cast(data); + if (!self) + return; + + self->stopping.store(true); + self->wake.notify_all(); + if (self->worker.joinable()) + self->worker.join(); + + // The worker already disconnected, but do it again explicitly: the + // session's own destructor would too, and all three are idempotent. + if (self->session) + self->session->disconnect(); + self->session.reset(); + + delete self; +} + +/// Rebuild the camera dropdown from the cached slot list, always including +/// whatever identity is currently selected so OBS cannot silently clear a +/// setting just because the room is dark right now. +void populateCameraList(CameraSource *self, obs_property_t *list, const std::string &selected) +{ + obs_property_list_clear(list); + obs_property_list_add_string(list, obs_module_text("NoCameraSelected"), ""); + + bool saw_selected = selected.empty(); + std::vector slots; + { + std::lock_guard guard(self->mutex); + slots = self->slot_cache; + } + for (const SlotInfo &slot : slots) { + std::string label = slot.display_name; + if (!slot.live) + label += obs_module_text("OfflineSuffix"); + obs_property_list_add_string(list, label.c_str(), slot.identity.c_str()); + if (slot.identity == selected) + saw_selected = true; + } + if (!saw_selected) { + std::string label = selected + obs_module_text("NotInRoomSuffix"); + obs_property_list_add_string(list, label.c_str(), selected.c_str()); + } +} + +bool refreshButtonClicked(obs_properties_t *props, obs_property_t *, void *data) +{ + auto *self = static_cast(data); + if (!self) + return false; + + ConnectionConfig config; + { + std::lock_guard guard(self->mutex); + config = self->config; + } + + // Deliberately synchronous: the operator pressed a button and is waiting + // for the list to change. The timeout is shortened from the core default + // so a dead server cannot freeze the properties dialog for ten seconds. + const SlotsResult result = self->api->fetchSlots(config, kPropertiesTimeoutMs); + + if (result.ok()) { + std::string selected; + { + std::lock_guard guard(self->mutex); + self->slot_cache = result.slots; + selected = self->camera_identity; + } + if (obs_property_t *list = obs_properties_get(props, kSettingCamera)) + populateCameraList(self, list, selected); + self->setStatus(std::to_string(result.slots.size()) + std::string(obs_module_text("CamerasFound"))); + self->status_is_error.store(false); + } else { + const std::string message = result.message.empty() ? describeApiStatus(result.status) : result.message; + self->setStatus(message); + self->status_is_error.store(true); + obs_log(LOG_WARNING, "slot listing failed: %s", message.c_str()); + } + + if (obs_property_t *status = obs_properties_get(props, kSettingStatus)) { + const std::string text = self->statusText(); + obs_property_set_description(status, text.c_str()); + obs_property_text_set_info_type(status, self->status_is_error.load() ? OBS_TEXT_INFO_WARNING + : OBS_TEXT_INFO_NORMAL); + } + + return true; // properties changed, redraw them +} + +obs_properties_t *sourceGetProperties(void *data) +{ + auto *self = static_cast(data); + obs_properties_t *props = obs_properties_create(); + + obs_properties_add_text(props, kSettingServerUrl, obs_module_text("ServerUrl"), OBS_TEXT_DEFAULT); + obs_properties_add_text(props, kSettingRoomSlug, obs_module_text("RoomSlug"), OBS_TEXT_DEFAULT); + // The read key is a credential and is masked everywhere else in + // streamer-tools; it is masked here too. + obs_properties_add_text(props, kSettingReadKey, obs_module_text("ReadKey"), OBS_TEXT_PASSWORD); + + obs_property_t *list = obs_properties_add_list(props, kSettingCamera, obs_module_text("Camera"), + OBS_COMBO_TYPE_LIST, OBS_COMBO_FORMAT_STRING); + if (self) { + std::string selected; + { + std::lock_guard guard(self->mutex); + selected = self->camera_identity; + } + // Built from the cache the worker keeps warm, so opening properties + // never blocks on the network. The button below is the way to force + // a round trip. + populateCameraList(self, list, selected); + } + + obs_properties_add_button(props, kPropRefresh, obs_module_text("RefreshCameras"), refreshButtonClicked); + + // An OBS_TEXT_INFO property renders its *description* as the visible + // label, so the status line goes there rather than into a tooltip an + // operator would never hover over mid-show. + const std::string status_text = self ? self->statusText() : std::string(obs_module_text("Status")); + obs_property_t *status = obs_properties_add_text(props, kSettingStatus, status_text.c_str(), OBS_TEXT_INFO); + if (self && self->status_is_error.load()) + obs_property_text_set_info_type(status, OBS_TEXT_INFO_WARNING); + + return props; +} + +struct obs_source_info cameraSourceInfo() +{ + struct obs_source_info info = {}; + info.id = "streamer_tools_camera_source"; + info.type = OBS_SOURCE_TYPE_INPUT; + info.output_flags = OBS_SOURCE_ASYNC_VIDEO | OBS_SOURCE_AUDIO | OBS_SOURCE_DO_NOT_DUPLICATE; + info.icon_type = OBS_ICON_TYPE_CAMERA; + info.get_name = sourceGetName; + info.create = sourceCreate; + info.destroy = sourceDestroy; + info.update = sourceUpdate; + info.get_defaults = sourceGetDefaults; + info.get_properties = sourceGetProperties; + return info; +} + +struct obs_source_info streamer_tools_camera_source = cameraSourceInfo(); + +void livekitLogToObs(livekit::LogLevel level, const std::string &, const std::string &message) +{ + int obs_level = LOG_INFO; + switch (level) { + case livekit::LogLevel::Error: + case livekit::LogLevel::Critical: obs_level = LOG_ERROR; break; + case livekit::LogLevel::Warn: obs_level = LOG_WARNING; break; + case livekit::LogLevel::Info: obs_level = LOG_INFO; break; + default: obs_level = LOG_DEBUG; break; + } + obs_log(obs_level, "livekit: %s", message.c_str()); +} + +} // namespace + +bool obs_module_load(void) +{ + LiveKitSession::globalInitialize(); + // Route the SDK's own logging into OBS's log file instead of stderr, + // where a director would never see it. + livekit::setLogCallback(livekitLogToObs); + + obs_register_source(&streamer_tools_camera_source); + obs_log(LOG_INFO, "streamer-tools camera plugin loaded (core %s)", core_version()); + return true; +} + +void obs_module_unload(void) +{ + livekit::setLogCallback(nullptr); + LiveKitSession::globalShutdown(); + obs_log(LOG_INFO, "streamer-tools camera plugin unloaded"); +} diff --git a/third_party/livekit/LICENSE b/third_party/livekit/LICENSE new file mode 100644 index 0000000..67db858 --- /dev/null +++ b/third_party/livekit/LICENSE @@ -0,0 +1,175 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. diff --git a/third_party/livekit/NOTICE b/third_party/livekit/NOTICE new file mode 100644 index 0000000..692adc9 --- /dev/null +++ b/third_party/livekit/NOTICE @@ -0,0 +1,13 @@ +Copyright 2023 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. diff --git a/third_party/livekit/README.md b/third_party/livekit/README.md new file mode 100644 index 0000000..c7e599f --- /dev/null +++ b/third_party/livekit/README.md @@ -0,0 +1,36 @@ +# LiveKit client-sdk-cpp redistribution notices + +This plugin links and **redistributes** prebuilt binaries from +[`livekit/client-sdk-cpp`](https://github.com/livekit/client-sdk-cpp) — the +`liblivekit` / `liblivekit_ffi` shared libraries that ship next to the plugin +module — so the SDK's licence and notice files ship with it. + +`LICENSE` and `NOTICE` here are copied verbatim from the pinned release tag +`v1.10.1` (Apache License 2.0). They are staged into `build/package/licenses/` +by `obs-adapter/CMakeLists.txt` on every build, alongside this plugin's own +GPL-2.0 `LICENSE`. + +## A correction to the design doc + +The design doc's open questions say: + +> `client-sdk-cpp`'s bundled `LICENSE.md` (~28 distinct third-party license +> blocks — Google WebRTC, OpenH264, etc.) must ship inside the plugin package + +**No such file exists at `v1.10.1`.** Checked, on 2026-09-06: + +- The five release archives for this tag (`livekit-sdk--1.10.1.tar.gz` + / `.zip`) contain only `include/`, `lib/`, `bin/` and + `share/livekit/build-info.json`. No licence file of any kind. +- The repository at tag `v1.10.1` has `LICENSE` (Apache-2.0, 10142 bytes) and + `NOTICE` (553 bytes) at its root. There is no `LICENSE.md`, no `NOTICE.md`, + and no `THIRD_PARTY_LICENSES` file. + +So what ships here is the Apache-2.0 licence and notice, which is what +actually exists upstream. **The aggregated third-party notice the design doc +expected — covering the WebRTC/OpenH264/etc. code statically linked inside +`liblivekit_ffi.so` — has not been located and is not being shipped.** That +is a real, open licensing question for whoever signs off on distributing +release binaries, not something this packaging step has resolved. Worth +raising upstream, or asking counsel whether the Apache-2.0 NOTICE alone +suffices for a binary redistribution of that library. -- 2.52.0 From 849448535192b5208f27b04008b4fd96f8845e7a Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 6 Sep 2026 22:02:17 -0700 Subject: [PATCH 05/17] ci: build the real OBS adapter on all three platforms Two things: unbreak the Windows compile, and give macOS/Windows a libobs. MSVC fix. test_json.cpp failed to compile on the Windows runner with "a universal-character-name specifies an invalid character" and "illegal escape sequence" -- MSVC still forms escape sequences and universal-character-names INSIDE raw string literals, which it must not. Every JSON input containing a backslash is now built by string concatenation from a single kBS constant, which also sidesteps the separate murky corner of translation phase 1 where a doubled backslash immediately followed by 'u' has historically been treated inconsistently. Same 158 checks, no behaviour change. OBS SDK bootstrap for macOS/Windows. Adopts obsproject/obs-plugintemplate's buildspec machinery -- buildspec.json plus cmake/common/buildspec_common.cmake and cmake/{macos,windows}/buildspec.cmake -- so those two platforms get a real libobs and build the actual plugin module instead of only the core library. Linux is untouched and still uses Ubuntu's libobs-dev (-DSTPLUGIN_BOOTSTRAP_OBS=OFF); the bootstrap only runs where there is no system package. Trimmed against upstream, each change recorded in the file that makes it: - qt6 is dropped from dependencies_list on both platforms. The properties UI is plain obs_properties_* and nothing here links Qt. - The OBS sub-build builds and installs the `libobs` target, not `obs-frontend-api`. Building the frontend API is what would drag Qt back in, and this plugin never calls it. - The sub-build is configured with ENABLE_UI=OFF and ENABLE_SCRIPTING=OFF as well as upstream's ENABLE_FRONTEND=OFF: the pinned OBS predates ENABLE_FRONTEND and gates its Qt-dependent UI on ENABLE_UI, so without this it configures the whole OBS UI and demands Qt anyway. - Only the Release configuration is built and installed, not Debug as well. Nothing consumes a debug libobs and it doubles the slowest CI step. - Only the dependency-acquisition modules are vendored. The template's compilerconfig/defaults/helpers/xcode modules drive its own target and bundle layout, which this project does not use. obs-studio is pinned to 30.0.2, deliberately low: OBS refuses to load a module built against a NEWER libobs than the one running it and accepts older ones, so this pin IS the minimum OBS version users need. 30.0.2 is also exactly what Ubuntu 24.04's libobs-dev ships, which puts all three platforms on one floor, and it supports the modern CMake layout the bootstrap drives via -DOBS_CMAKE_VERSION=3.0.0. prebuilt is obs-deps 2023-11-03 with the hashes obs-studio 30.0.2's own buildspec.json publishes; the obs-studio source archive hashes were computed from the GitHub tag archives. The workflow also prints what was actually produced on each platform (ldd / otool / dir over build/package) and uploads it as an artifact, so "does this even link against libobs" is answered by CI output rather than assumed. Verified locally: the Linux path is unchanged by all of this -- a fresh configure still finds libobs-dev, and ctest is 6/6. The macOS and Windows bootstrap can only be verified by CI; that is what this push is for. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE --- .gitea/workflows/build.yml | 76 +++++++--- .gitignore | 4 + CMakeLists.txt | 31 ++++ buildspec.json | 54 +++++++ cmake/common/buildspec_common.cmake | 224 ++++++++++++++++++++++++++++ cmake/common/osconfig.cmake | 20 +++ cmake/macos/buildspec.cmake | 39 +++++ cmake/windows/buildspec.cmake | 28 ++++ core/tests/test_json.cpp | 61 +++++--- 9 files changed, 500 insertions(+), 37 deletions(-) create mode 100644 buildspec.json create mode 100644 cmake/common/buildspec_common.cmake create mode 100644 cmake/common/osconfig.cmake create mode 100644 cmake/macos/buildspec.cmake create mode 100644 cmake/windows/buildspec.cmake diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index d0aee75..7d0697e 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -1,8 +1,14 @@ name: Build -# Scaffolding CI: proves the CMake toolchain configures and builds on all -# three platforms the design doc names. It does not yet link livekit-ffi -# or produce a real OBS-installable package -- see README.md. +# Every job builds the real plugin: the core library linked against the +# pinned livekit/client-sdk-cpp release, and -- where libobs is available -- +# the OBS adapter module itself. +# +# Linux gets libobs from Ubuntu's libobs-dev. macOS and Windows have no +# equivalent system package, so they run obs-plugintemplate's buildspec +# bootstrap (buildspec.json + cmake/common/buildspec_common.cmake), which +# downloads the pinned obs-deps bundle and obs-studio source and builds just +# `libobs`. That step is the slow one: several minutes on a cold runner. on: push: @@ -19,10 +25,12 @@ jobs: - name: Install build dependencies run: | sudo apt-get update -qq - sudo apt-get install -y -qq cmake ninja-build libobs-dev + sudo apt-get install -y -qq cmake ninja-build libobs-dev libcurl4-openssl-dev - name: Configure - run: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release + # Linux keeps its distribution libobs; the buildspec bootstrap is for + # the two platforms that have no such package. + run: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DSTPLUGIN_BOOTSTRAP_OBS=OFF - name: Build run: cmake --build build @@ -30,6 +38,19 @@ jobs: - name: Test (core library) run: ctest --test-dir build --output-on-failure + - name: Show what was built + run: | + ls -la build/package/bin build/package/data/locale build/package/licenses + ldd build/package/bin/streamer-tools-camera.so | grep -E 'obs|livekit' + nm -D build/package/bin/streamer-tools-camera.so | grep -E ' T obs_module_(load|unload)' + + - name: Upload plugin + continue-on-error: true + uses: actions/upload-artifact@v3 + with: + name: streamer-tools-camera-linux-x64 + path: build/package + macos: name: macOS (macos-latest) runs-on: macos-latest @@ -41,10 +62,8 @@ jobs: run: brew install cmake ninja - name: Configure - # libobs is not expected to be found here yet (no Homebrew - # formula / SDK download wired up in this pass) -- the top-level - # CMakeLists.txt falls back to building only the core library in - # that case. See README.md. + # The buildspec bootstrap runs here: it fetches obs-deps + the pinned + # obs-studio source and builds libobs before this project configures. run: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release - name: Build @@ -53,6 +72,18 @@ jobs: - name: Test (core library) run: ctest --test-dir build --output-on-failure + - name: Show what was built + run: | + ls -la build/package/bin || true + otool -L build/package/bin/streamer-tools-camera.so || true + + - name: Upload plugin + continue-on-error: true + uses: actions/upload-artifact@v3 + with: + name: streamer-tools-camera-macos + path: build/package + windows: name: Windows (windows-latest) runs-on: windows-latest @@ -61,22 +92,29 @@ jobs: uses: actions/checkout@v4 - name: Install build dependencies - # winvm-builder is a self-hosted act_runner labeled - # "windows-latest" -- it is NOT the GitHub-hosted windows-latest - # image, so none of the tools that image preinstalls (cmake - # included) can be assumed present. Confirmed by a first CI run - # on this repo: "cmake : The term 'cmake' is not recognized...". - # lukka/get-cmake downloads a pinned cmake+ninja binary and adds - # it to PATH for this job, with no admin/choco dependency. + # winvm-builder is a self-hosted act_runner labeled "windows-latest"; + # it is NOT the GitHub-hosted image, so none of that image's + # preinstalled tooling (cmake included) can be assumed present. uses: lukka/get-cmake@latest - name: Configure - # Same story as macOS: no OBS SDK available yet on this runner, - # so this proves the core library + MSVC toolchain only. - run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release + # The default Visual Studio generator is required, not Ninja: + # cmake/windows/buildspec.cmake keys the dependency slice off + # CMAKE_VS_PLATFORM_NAME, which only a VS generator sets. + run: cmake -S . -B build -A x64 - name: Build run: cmake --build build --config Release - name: Test (core library) run: ctest --test-dir build -C Release --output-on-failure + + - name: Show what was built + run: dir build\package\bin + + - name: Upload plugin + continue-on-error: true + uses: actions/upload-artifact@v3 + with: + name: streamer-tools-camera-windows-x64 + path: build/package diff --git a/.gitignore b/.gitignore index a4fb4fb..2cb0a9b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ build/ +build*/ .cache/ +# obs-plugintemplate's buildspec bootstrap unpacks the OBS SDK and its +# prebuilt dependencies here (macOS/Windows only). +.deps/ diff --git a/CMakeLists.txt b/CMakeLists.txt index e138df7..5e86970 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -18,6 +18,37 @@ endif() enable_testing() list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake/common") + +# --- OBS SDK --------------------------------------------------------------- +# Linux gets libobs from the distribution (Ubuntu's libobs-dev ships real +# libobsConfig.cmake), and that path is left exactly as it was. +# +# macOS and Windows have no such package, so they use obs-plugintemplate's +# buildspec bootstrap, trimmed: it downloads the pinned obs-deps bundle and +# the pinned obs-studio source, then builds and installs just `libobs`. See +# buildspec.json for why the OBS pin is deliberately low, and +# cmake/common/buildspec_common.cmake for every change from upstream. +# +# STPLUGIN_BOOTSTRAP_OBS=OFF falls back to plain find_package(libobs), for a +# developer who already has an OBS SDK on their prefix path and does not want +# a from-source libobs build. +option(STPLUGIN_BOOTSTRAP_OBS "Download and build libobs from source (macOS/Windows)" ON) + +include(osconfig) +if(STPLUGIN_BOOTSTRAP_OBS AND (OS_MACOS OR OS_WINDOWS)) + if(OS_MACOS) + # client-sdk-cpp ships single-arch dylibs, so this plugin is built for + # one architecture even though the libobs it links is universal. + if(NOT CMAKE_OSX_ARCHITECTURES) + set(CMAKE_OSX_ARCHITECTURES "${CMAKE_HOST_SYSTEM_PROCESSOR}" CACHE STRING "" FORCE) + endif() + if(NOT CMAKE_OSX_DEPLOYMENT_TARGET) + set(CMAKE_OSX_DEPLOYMENT_TARGET "13.0" CACHE STRING "" FORCE) + endif() + endif() + include(buildspec) +endif() # --- LiveKit C++ client SDK ------------------------------------------------- # Pinned, prebuilt release of livekit/client-sdk-cpp, downloaded and unpacked diff --git a/buildspec.json b/buildspec.json new file mode 100644 index 0000000..af434cb --- /dev/null +++ b/buildspec.json @@ -0,0 +1,54 @@ +{ + "_comment": [ + "Dependency manifest for the macOS/Windows OBS SDK bootstrap, in the shape", + "obsproject/obs-plugintemplate's cmake/common/buildspec_common.cmake reads.", + "Linux does NOT use this: it gets libobs from Ubuntu's libobs-dev package.", + "", + "obs-studio is pinned to 30.0.2 on purpose, and low rather than high:", + "OBS refuses to load a module built against a NEWER libobs than the one", + "running (libobs/obs-module.c's version check) and accepts older ones, so", + "this pin is the minimum OBS version users need. 30.0.2 is also exactly", + "what Ubuntu 24.04's libobs-dev ships, which keeps the three platforms on", + "one floor. 30.0.2 supports the modern CMake layout this bootstrap drives", + "via -DOBS_CMAKE_VERSION=3.0.0.", + "", + "prebuilt is obs-deps 2023-11-03, the version obs-studio 30.0.2's own", + "buildspec.json pins, with its own published hashes. qt6 is deliberately", + "absent: this plugin never links Qt.", + "", + "The obs-studio hashes are of the GitHub source archives for tag 30.0.2,", + "computed on 2026-09-06: .tar.gz (15861643 bytes) for macOS, .zip", + "(18168471 bytes) for Windows." + ], + "dependencies": { + "obs-studio": { + "version": "30.0.2", + "baseUrl": "https://github.com/obsproject/obs-studio/archive/refs/tags", + "label": "OBS sources", + "hashes": { + "macos": "be12c3ad0a85713750d8325e4b1db75086223402d7080d0e3c2833d7c5e83c27", + "windows-x64": "970058c49322cfa9cd6d620abb393fed89743ba7e74bd9dbb6ebe0ea8141d9c7" + } + }, + "prebuilt": { + "version": "2023-11-03", + "baseUrl": "https://github.com/obsproject/obs-deps/releases/download", + "label": "Pre-Built obs-deps", + "hashes": { + "macos": "90c2fc069847ec2768dcc867c1c63b112c615ed845a907dc44acab7a97181974", + "windows-x64": "d0825a6fb65822c993a3059edfba70d72d2e632ef74893588cf12b1f0d329ce6" + } + } + }, + "platformConfig": { + "macos": { + "bundleId": "net.cybercove.streamer-tools-camera" + } + }, + "name": "streamer-tools-camera", + "displayName": "streamer-tools Camera", + "version": "0.1.0", + "author": "CyberCoveLLC", + "website": "https://repo.anhonesthost.net/CyberCoveLLC/obs-streamer-tools-plugin", + "email": "jknapp85@gmail.com" +} diff --git a/cmake/common/buildspec_common.cmake b/cmake/common/buildspec_common.cmake new file mode 100644 index 0000000..72f9cab --- /dev/null +++ b/cmake/common/buildspec_common.cmake @@ -0,0 +1,224 @@ +# Adapted from obsproject/obs-plugintemplate (cmake/common/buildspec_common.cmake, +# master as of 2026-09-06). Deliberate changes from upstream, all recorded here +# so a future re-sync knows what to keep: +# +# 1. _setup_obs_studio builds and installs the `libobs` target, not +# `obs-frontend-api`. This plugin's properties UI is plain +# obs_properties_* and it never touches the frontend API, so building it +# would only drag Qt back in -- which is the whole point of dropping qt6 +# from dependencies_list. +# 2. It passes -DENABLE_UI:BOOL=OFF and -DENABLE_SCRIPTING:BOOL=OFF as well +# as upstream's -DENABLE_FRONTEND:BOOL=OFF. The pinned OBS (30.0.2) +# predates the ENABLE_FRONTEND option and gates its Qt-dependent UI on +# ENABLE_UI instead; without this the sub-build configures the whole +# OBS UI and demands Qt. +# 3. Only the Release configuration is built and installed. Upstream builds +# Debug as well; nothing here consumes a debug libobs, and it doubles the +# slowest step in CI. +# + +include_guard(GLOBAL) + +# _check_deps_version: Checks for obs-deps VERSION file in prefix paths +function(_check_deps_version version) + set(found FALSE) + + foreach(path IN LISTS CMAKE_PREFIX_PATH) + if(EXISTS "${path}/share/obs-deps/VERSION") + if(dependency STREQUAL qt6 AND NOT EXISTS "${path}/lib/cmake/Qt6/Qt6Config.cmake") + set(found FALSE) + continue() + endif() + + file(READ "${path}/share/obs-deps/VERSION" _check_version) + string(REPLACE "\n" "" _check_version "${_check_version}") + string(REPLACE "-" "." _check_version "${_check_version}") + string(REPLACE "-" "." version "${version}") + + if(_check_version VERSION_EQUAL version) + set(found TRUE) + break() + elseif(_check_version VERSION_LESS version) + message( + AUTHOR_WARNING + "Older ${label} version detected in ${path}: \n" + "Found ${_check_version}, require ${version}" + ) + list(REMOVE_ITEM CMAKE_PREFIX_PATH "${path}") + list(APPEND CMAKE_PREFIX_PATH "${path}") + set(CMAKE_PREFIX_PATH ${CMAKE_PREFIX_PATH}) + continue() + else() + message( + AUTHOR_WARNING + "Newer ${label} version detected in ${path}: \n" + "Found ${_check_version}, require ${version}" + ) + set(found TRUE) + break() + endif() + endif() + endforeach() + + return(PROPAGATE found CMAKE_PREFIX_PATH) +endfunction() + +# _setup_obs_studio: Create obs-studio build project, then build libobs and obs-frontend-api +function(_setup_obs_studio) + if(NOT libobs_DIR) + set(_is_fresh --fresh) + endif() + + if(OS_WINDOWS) + set(_cmake_generator "${CMAKE_GENERATOR}") + set(_cmake_arch "-A ${arch},version=${CMAKE_VS_WINDOWS_TARGET_PLATFORM_VERSION}") + set(_cmake_extra "-DCMAKE_SYSTEM_VERSION=${CMAKE_SYSTEM_VERSION}") + elseif(OS_MACOS) + set(_cmake_generator "Xcode") + set(_cmake_arch "-DCMAKE_OSX_ARCHITECTURES:STRING='arm64;x86_64'") + set(_cmake_extra "-DCMAKE_OSX_DEPLOYMENT_TARGET=${CMAKE_OSX_DEPLOYMENT_TARGET}") + endif() + + message(STATUS "Configure ${label} (${arch})") + execute_process( + COMMAND + "${CMAKE_COMMAND}" -S "${dependencies_dir}/${_obs_destination}" -B + "${dependencies_dir}/${_obs_destination}/build_${arch}" -G ${_cmake_generator} "${_cmake_arch}" + -DOBS_CMAKE_VERSION:STRING=3.0.0 -DENABLE_PLUGINS:BOOL=OFF -DENABLE_FRONTEND:BOOL=OFF + -DENABLE_UI:BOOL=OFF -DENABLE_SCRIPTING:BOOL=OFF -DENABLE_BROWSER:BOOL=OFF + -DOBS_VERSION_OVERRIDE:STRING=${_obs_version} "-DCMAKE_PREFIX_PATH='${CMAKE_PREFIX_PATH}'" ${_is_fresh} + ${_cmake_extra} + RESULT_VARIABLE _process_result + COMMAND_ERROR_IS_FATAL ANY + ) + message(STATUS "Configure ${label} (${arch}) - done") + + message(STATUS "Build ${label} (Release - ${arch})") + execute_process( + COMMAND "${CMAKE_COMMAND}" --build build_${arch} --target libobs --config Release --parallel + WORKING_DIRECTORY "${dependencies_dir}/${_obs_destination}" + RESULT_VARIABLE _process_result + COMMAND_ERROR_IS_FATAL ANY + ) + message(STATUS "Build ${label} (Release - ${arch}) - done") + + message(STATUS "Install ${label} (${arch})") + execute_process( + COMMAND + "${CMAKE_COMMAND}" --install build_${arch} --component Development --config Release --prefix "${dependencies_dir}" + WORKING_DIRECTORY "${dependencies_dir}/${_obs_destination}" + RESULT_VARIABLE _process_result + COMMAND_ERROR_IS_FATAL ANY + ) + message(STATUS "Install ${label} (${arch}) - done") +endfunction() + +# _check_dependencies: Fetch and extract pre-built OBS build dependencies +function(_check_dependencies) + file(READ "${CMAKE_CURRENT_SOURCE_DIR}/buildspec.json" buildspec) + + string(JSON dependency_data GET ${buildspec} dependencies) + + foreach(dependency IN LISTS dependencies_list) + string(JSON data GET ${dependency_data} ${dependency}) + string(JSON version GET ${data} version) + string(JSON hash GET ${data} hashes ${platform}) + string(JSON url GET ${data} baseUrl) + string(JSON label GET ${data} label) + string(JSON revision ERROR_VARIABLE error GET ${data} revision ${platform}) + + message(STATUS "Setting up ${label} (${arch})") + + set(file "${${dependency}_filename}") + set(destination "${${dependency}_destination}") + string(REPLACE "VERSION" "${version}" file "${file}") + string(REPLACE "VERSION" "${version}" destination "${destination}") + string(REPLACE "ARCH" "${arch}" file "${file}") + string(REPLACE "ARCH" "${arch}" destination "${destination}") + if(revision) + string(REPLACE "_REVISION" "_v${revision}" file "${file}") + string(REPLACE "-REVISION" "-v${revision}" file "${file}") + else() + string(REPLACE "_REVISION" "" file "${file}") + string(REPLACE "-REVISION" "" file "${file}") + endif() + + if(EXISTS "${dependencies_dir}/.dependency_${dependency}_${arch}.sha256") + file( + READ + "${dependencies_dir}/.dependency_${dependency}_${arch}.sha256" + OBS_DEPENDENCY_${dependency}_${arch}_HASH + ) + endif() + + set(skip FALSE) + if(dependency STREQUAL prebuilt OR dependency STREQUAL qt6) + if(OBS_DEPENDENCY_${dependency}_${arch}_HASH STREQUAL ${hash}) + _check_deps_version(${version}) + + if(found) + set(skip TRUE) + endif() + endif() + endif() + + if(skip) + message(STATUS "Setting up ${label} (${arch}) - skipped") + continue() + endif() + + if(dependency STREQUAL obs-studio) + set(url ${url}/${file}) + else() + set(url ${url}/${version}/${file}) + endif() + + if(NOT EXISTS "${dependencies_dir}/${file}") + message(STATUS "Downloading ${url}") + file(DOWNLOAD "${url}" "${dependencies_dir}/${file}" STATUS download_status EXPECTED_HASH SHA256=${hash}) + + list(GET download_status 0 error_code) + list(GET download_status 1 error_message) + if(error_code GREATER 0) + message(STATUS "Downloading ${url} - Failure") + message(FATAL_ERROR "Unable to download ${url}, failed with error: ${error_message}") + file(REMOVE "${dependencies_dir}/${file}") + else() + message(STATUS "Downloading ${url} - done") + endif() + endif() + + if(NOT OBS_DEPENDENCY_${dependency}_${arch}_HASH STREQUAL ${hash}) + file(REMOVE_RECURSE "${dependencies_dir}/${destination}") + endif() + + if(NOT EXISTS "${dependencies_dir}/${destination}") + file(MAKE_DIRECTORY "${dependencies_dir}/${destination}") + if(dependency STREQUAL obs-studio) + file(ARCHIVE_EXTRACT INPUT "${dependencies_dir}/${file}" DESTINATION "${dependencies_dir}") + else() + file(ARCHIVE_EXTRACT INPUT "${dependencies_dir}/${file}" DESTINATION "${dependencies_dir}/${destination}") + endif() + endif() + + file(WRITE "${dependencies_dir}/.dependency_${dependency}_${arch}.sha256" "${hash}") + + if(dependency STREQUAL prebuilt) + list(APPEND CMAKE_PREFIX_PATH "${dependencies_dir}/${destination}") + elseif(dependency STREQUAL qt6) + list(APPEND CMAKE_PREFIX_PATH "${dependencies_dir}/${destination}") + elseif(dependency STREQUAL obs-studio) + set(_obs_version ${version}) + set(_obs_destination "${destination}") + list(APPEND CMAKE_PREFIX_PATH "${dependencies_dir}") + endif() + + message(STATUS "Setting up ${label} (${arch}) - done") + endforeach() + + list(REMOVE_DUPLICATES CMAKE_PREFIX_PATH) + + set(CMAKE_PREFIX_PATH ${CMAKE_PREFIX_PATH} CACHE PATH "CMake prefix search path" FORCE) + + _setup_obs_studio() +endfunction() diff --git a/cmake/common/osconfig.cmake b/cmake/common/osconfig.cmake new file mode 100644 index 0000000..87d435a --- /dev/null +++ b/cmake/common/osconfig.cmake @@ -0,0 +1,20 @@ +# CMake operating system bootstrap module + +include_guard(GLOBAL) + +if(CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows") + set(CMAKE_C_EXTENSIONS FALSE) + set(CMAKE_CXX_EXTENSIONS FALSE) + list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake/windows") + set(OS_WINDOWS TRUE) +elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin") + set(CMAKE_C_EXTENSIONS FALSE) + set(CMAKE_CXX_EXTENSIONS FALSE) + list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake/macos") + set(OS_MACOS TRUE) +elseif(CMAKE_HOST_SYSTEM_NAME MATCHES "Linux|FreeBSD|OpenBSD") + set(CMAKE_CXX_EXTENSIONS FALSE) + list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake/linux") + string(TOUPPER "${CMAKE_HOST_SYSTEM_NAME}" _SYSTEM_NAME_U) + set(OS_${_SYSTEM_NAME_U} TRUE) +endif() diff --git a/cmake/macos/buildspec.cmake b/cmake/macos/buildspec.cmake new file mode 100644 index 0000000..ce142de --- /dev/null +++ b/cmake/macos/buildspec.cmake @@ -0,0 +1,39 @@ +# CMake macOS build dependencies module +# +# Adapted from obsproject/obs-plugintemplate. Only change from upstream: qt6 +# is dropped from dependencies_list. This plugin's properties UI is plain +# obs_properties_*, it never links Qt, and the OBS sub-build is configured +# with ENABLE_UI=OFF -- so downloading a ~100 MB Qt bundle on every CI run +# would buy nothing. + +include_guard(GLOBAL) + +include(buildspec_common) + +# _check_dependencies_macos: Set up macOS slice for _check_dependencies +function(_check_dependencies_macos) + set(arch universal) + set(platform macos) + + file(READ "${CMAKE_CURRENT_SOURCE_DIR}/buildspec.json" buildspec) + + set(dependencies_dir "${CMAKE_CURRENT_SOURCE_DIR}/.deps") + set(prebuilt_filename "macos-deps-VERSION-ARCH_REVISION.tar.xz") + set(prebuilt_destination "obs-deps-VERSION-ARCH") + set(obs-studio_filename "VERSION.tar.gz") + set(obs-studio_destination "obs-studio-VERSION") + set(dependencies_list prebuilt obs-studio) + + _check_dependencies() + + execute_process( + COMMAND "xattr" -r -d com.apple.quarantine "${dependencies_dir}" + RESULT_VARIABLE result + COMMAND_ERROR_IS_FATAL ANY + ) + + list(APPEND CMAKE_FRAMEWORK_PATH "${dependencies_dir}/Frameworks") + set(CMAKE_FRAMEWORK_PATH ${CMAKE_FRAMEWORK_PATH} PARENT_SCOPE) +endfunction() + +_check_dependencies_macos() diff --git a/cmake/windows/buildspec.cmake b/cmake/windows/buildspec.cmake new file mode 100644 index 0000000..c6fe484 --- /dev/null +++ b/cmake/windows/buildspec.cmake @@ -0,0 +1,28 @@ +# CMake Windows build dependencies module +# +# Adapted from obsproject/obs-plugintemplate. Only change from upstream: qt6 +# is dropped from dependencies_list. This plugin's properties UI is plain +# obs_properties_*, it never links Qt, and the OBS sub-build is configured +# with ENABLE_UI=OFF -- so downloading a ~100 MB Qt bundle on every CI run +# would buy nothing. + +include_guard(GLOBAL) + +include(buildspec_common) + +# _check_dependencies_windows: Set up Windows slice for _check_dependencies +function(_check_dependencies_windows) + set(arch ${CMAKE_VS_PLATFORM_NAME}) + set(platform windows-${arch}) + + set(dependencies_dir "${CMAKE_CURRENT_SOURCE_DIR}/.deps") + set(prebuilt_filename "windows-deps-VERSION-ARCH-REVISION.zip") + set(prebuilt_destination "obs-deps-VERSION-ARCH") + set(obs-studio_filename "VERSION.zip") + set(obs-studio_destination "obs-studio-VERSION") + set(dependencies_list prebuilt obs-studio) + + _check_dependencies() +endfunction() + +_check_dependencies_windows() diff --git a/core/tests/test_json.cpp b/core/tests/test_json.cpp index de17412..e1fbf05 100644 --- a/core/tests/test_json.cpp +++ b/core/tests/test_json.cpp @@ -17,6 +17,7 @@ with this program. If not, see */ #include +#include #include "stplugin/json.h" #include "test_util.h" @@ -50,6 +51,24 @@ static void testRealResponses() ST_ASSERT_EQ(error["error"].asString(), std::string("not found")); } +// One backslash, as it appears in the JSON *text* being parsed. +// +// Every JSON input below that contains a backslash is built by concatenation +// rather than written as a literal. Two separate portability problems make +// the obvious spellings unsafe, both observed on the Windows CI runner: +// - MSVC still forms escape sequences and universal-character-names inside +// RAW string literals, which it must not: R"( ... backslash-u-d-8-3-d ... )" +// is a hard compile error ("a universal-character-name specifies an +// invalid character"), and a raw string containing backslash-slash is an +// "illegal escape sequence". +// - A doubled backslash immediately followed by 'u' inside an ordinary +// literal sits on a genuinely murky corner of translation phase 1, where +// compilers have historically disagreed about whether a +// universal-character-name is formed. +// Concatenation sidesteps both: no backslash is ever adjacent to a 'u' in +// the source text at all. +static const std::string kBS = "\\"; + static void testScalarsAndEscapes() { ST_ASSERT(parse("null").isNull()); @@ -59,14 +78,20 @@ static void testScalarsAndEscapes() ST_ASSERT_EQ(parse("-12").asNumber(), -12.0); ST_ASSERT_EQ(parse("1.5e2").asNumber(), 150.0); ST_ASSERT_EQ(parse("\"\"").asString("x"), std::string("")); - ST_ASSERT_EQ(parse(R"("a\"b\\c\/d")").asString(), std::string("a\"b\\c/d")); - ST_ASSERT_EQ(parse(R"("\n\t\r\b\f")").asString(), std::string("\n\t\r\b\f")); - // \u escapes, including a surrogate pair (an emoji in a display name is - // entirely plausible and must not corrupt the dropdown). - ST_ASSERT_EQ(parse(R"("\u0041")").asString(), std::string("A")); - ST_ASSERT_EQ(parse(R"("caf\u00e9")").asString(), std::string("caf\xc3\xa9")); - ST_ASSERT_EQ(parse(R"("\ud83d\ude00")").asString(), std::string("\xf0\x9f\x98\x80")); + // "a\"b\\c\/d" -> a"b\c/d + ST_ASSERT_EQ(parse("\"a" + kBS + "\"b" + kBS + kBS + "c" + kBS + "/d\"").asString(), + std::string("a\"b\\c/d")); + // "\n\t\r\b\f" + ST_ASSERT_EQ(parse("\"" + kBS + "n" + kBS + "t" + kBS + "r" + kBS + "b" + kBS + "f\"").asString(), + std::string("\n\t\r\b\f")); + + // \uXXXX escapes, including a surrogate pair (an emoji in a display name + // is entirely plausible and must not corrupt the dropdown). + ST_ASSERT_EQ(parse("\"" + kBS + "u0041\"").asString(), std::string("A")); + ST_ASSERT_EQ(parse("\"caf" + kBS + "u00e9\"").asString(), std::string("caf\xc3\xa9")); + ST_ASSERT_EQ(parse("\"" + kBS + "ud83d" + kBS + "ude00\"").asString(), + std::string("\xf0\x9f\x98\x80")); // Whitespace everywhere legal. ST_ASSERT_EQ(parse(" {\n \"a\" :\t[ 1 , 2 ]\r\n} ")["a"].size(), std::size_t(2)); @@ -74,7 +99,7 @@ static void testScalarsAndEscapes() static void testMalformedIsRejectedNotCrashed() { - const char *bad[] = { + const std::vector bad = { "", " ", "{", @@ -88,12 +113,12 @@ static void testMalformedIsRejectedNotCrashed() "{a:1}", "{'a':1}", "\"unterminated", - "\"bad\\escape\"", - "\"\\u00\"", - "\"\\uZZZZ\"", - "\"\\ud83d\"", // lone high surrogate - "\"\\ude00\"", // lone low surrogate - "01", // leading zero + "\"bad" + kBS + "escape\"", // not a JSON escape character + "\"" + kBS + "u00\"", // truncated code point + "\"" + kBS + "uZZZZ\"", // non-hex code point + "\"" + kBS + "ud83d\"", // lone high surrogate + "\"" + kBS + "ude00\"", // lone low surrogate + "01", // leading zero "+1", ".5", "1.", @@ -101,13 +126,13 @@ static void testMalformedIsRejectedNotCrashed() "1e+", "tru", "nulll", - "{}garbage", // trailing content + "{}garbage", // trailing content "[1,2] [3]", - "\"raw\ncontrol\"", // literal control char inside a string - "\xff\xfe", // binary garbage, e.g. an HTML error page prefix + "\"raw\ncontrol\"", // literal control char inside a string + "\xff\xfe", // binary garbage "502 Bad Gateway", }; - for (const char *text : bad) { + for (const std::string &text : bad) { const Value v = parse(text); ST_ASSERT(!v.valid()); // Accessors on an invalid value must still be safe and return the -- 2.52.0 From 7146f788317de3e578a932035f6b0533f956e36a Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 6 Sep 2026 22:08:03 -0700 Subject: [PATCH 06/17] ci: fix the three failures the first real three-platform run exposed Linux, glibc. The LiveKit SDK's "linux-x64" asset is not actually generic: it is built on Ubuntu 24.04 and needs GLIBC_2.38 and GLIBCXX_3.4.32, so linking it on a 22.04 runner fails outright ("undefined reference to std::ios_base_library_init()@GLIBCXX_3.4.32", "__isoc23_strtol@GLIBC_2.38"). That is exactly what happened when this repo's CI landed on the 22.04 Linux runner instead of the 24.04 one. LiveKitSDK.cmake now defaults Linux to the ubuntu-22.04 asset, which needs at most GLIBC_2.35 / GLIBCXX_3.4.30 (checked with objdump against both archives) and therefore links and runs on 22.04 and on everything newer -- the right floor for a plugin handed to directors as a binary. Linux, libobs version. The Linux job is pinned to ubuntu-24.04 rather than ubuntu-latest, which this instance's two Linux runners answer with different releases. 24.04's libobs-dev is 30.0.2 -- exactly the OBS version buildspec.json pins for macOS/Windows -- so all three platforms build against the same libobs. A 22.04 runner would have given OBS 27, a different API surface. macOS, no Xcode. The OBS sub-build failed its configure with "No CMAKE_C_COMPILER could be found": the template hardcodes the Xcode generator, and the `home-mac` runner has the Command Line Tools but no xcodebuild. The sub-build now uses Ninja (with an explicit CMAKE_BUILD_TYPE, since Ninja is single-config) and builds a single architecture rather than upstream's forced universal -- this plugin is single-arch anyway, because client-sdk-cpp ships single-arch dylibs, so a universal libobs would double the slowest step in CI for a slice nothing links against. While in there, generator flags are built as proper CMake lists so each becomes its own argv entry. Upstream packs several into one space-separated string and passes it unquoted, which execute_process hands to cmake as a single argument; it happens not to matter for the optional flags upstream passes, but it would silently swallow -DCMAKE_BUILD_TYPE. Also lowers the libobs API floor in the adapter: video_format_get_parameters instead of video_format_get_parameters_for_format. The _for_format variant only exists from libobs 30 onwards and only differs for the 10-bit formats (I010/P010) this source never receives, so using the older entry point keeps the module loadable on an older OBS -- the direction that matters, since OBS refuses modules built against a NEWER libobs than the one running. Re-verified locally on Ubuntu 24.04 with the ubuntu-22.04 SDK asset: ctest 6/6; the real-LiveKit integration test still reports "36 video frames, 323 audio frames, 10 state changes / 32 checks passed"; and the headless libobs harness still logs "connected to ws://127.0.0.1:7880 ... watching cam-test" followed by "video frame 640x360 I420" with the camera dropdown populated from the live slot list. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE --- .gitea/workflows/build.yml | 9 ++++-- cmake/LiveKitSDK.cmake | 14 ++++++++- cmake/common/buildspec_common.cmake | 45 ++++++++++++++++++++++++----- obs-adapter/src/plugin-main.cpp | 11 +++++-- 4 files changed, 67 insertions(+), 12 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 7d0697e..27f7103 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -16,8 +16,13 @@ on: jobs: linux: - name: Linux (ubuntu-latest) - runs-on: ubuntu-latest + name: Linux (ubuntu-24.04) + # Pinned to 24.04 rather than ubuntu-latest, which this instance's two + # Linux runners answer with different releases. 24.04's libobs-dev is + # 30.0.2, exactly the OBS version buildspec.json pins for macOS/Windows, + # so all three platforms build against the same libobs. On a 22.04 runner + # libobs-dev is OBS 27, which is a different API surface entirely. + runs-on: ubuntu-24.04 steps: - name: Checkout uses: actions/checkout@v4 diff --git a/cmake/LiveKitSDK.cmake b/cmake/LiveKitSDK.cmake index 895fb6e..304eb1c 100644 --- a/cmake/LiveKitSDK.cmake +++ b/cmake/LiveKitSDK.cmake @@ -70,7 +70,19 @@ endfunction() function(_lk_default_triple out_triple) _lk_detect_host(_os _arch) - set(${out_triple} "${_os}-${_arch}" PARENT_SCOPE) + if(_os STREQUAL "linux") + # NOT the generic "linux-" asset, despite the name. That one is + # built on Ubuntu 24.04 and needs GLIBC_2.38 and GLIBCXX_3.4.32: linking + # it on Ubuntu 22.04 fails outright ("undefined reference to + # std::ios_base_library_init()@GLIBCXX_3.4.32", "__isoc23_strtol@GLIBC_2.38"), + # which is exactly what happened when CI landed on a 22.04 runner. + # The ubuntu-22.04 asset needs at most GLIBC_2.35 / GLIBCXX_3.4.30, so it + # links and runs on 22.04 AND on everything newer -- the right floor for a + # plugin that gets handed to directors as a binary. + set(${out_triple} "ubuntu-22.04-${_arch}" PARENT_SCOPE) + else() + set(${out_triple} "${_os}-${_arch}" PARENT_SCOPE) + endif() endfunction() function(_lk_archive_ext out_ext) diff --git a/cmake/common/buildspec_common.cmake b/cmake/common/buildspec_common.cmake index 72f9cab..cea80de 100644 --- a/cmake/common/buildspec_common.cmake +++ b/cmake/common/buildspec_common.cmake @@ -15,6 +15,11 @@ # 3. Only the Release configuration is built and installed. Upstream builds # Debug as well; nothing here consumes a debug libobs, and it doubles the # slowest step in CI. +# 4. macOS uses the Ninja generator and a single architecture, not upstream's +# Xcode generator and forced universal build. See the comment at that +# branch: a runner with only the Command Line Tools has no xcodebuild. +# 5. Generator flags are built as CMake lists so each becomes its own argv +# entry, rather than upstream's space-separated strings passed unquoted. # include_guard(GLOBAL) @@ -69,24 +74,50 @@ function(_setup_obs_studio) set(_is_fresh --fresh) endif() + # Every generator-specific flag is built as a proper CMake list, so each + # element becomes its own argv entry. Upstream packs several flags into one + # space-separated string and passes it unquoted, which execute_process hands + # to cmake as a single argument -- it happens not to matter there because + # those flags are optional, but -DCMAKE_BUILD_TYPE is not. + set(_cmake_arch "") + set(_cmake_extra "") + if(OS_WINDOWS) set(_cmake_generator "${CMAKE_GENERATOR}") - set(_cmake_arch "-A ${arch},version=${CMAKE_VS_WINDOWS_TARGET_PLATFORM_VERSION}") - set(_cmake_extra "-DCMAKE_SYSTEM_VERSION=${CMAKE_SYSTEM_VERSION}") + if(CMAKE_VS_WINDOWS_TARGET_PLATFORM_VERSION) + list(APPEND _cmake_arch -A "${arch},version=${CMAKE_VS_WINDOWS_TARGET_PLATFORM_VERSION}") + else() + list(APPEND _cmake_arch -A "${arch}") + endif() + list(APPEND _cmake_extra "-DCMAKE_SYSTEM_VERSION=${CMAKE_SYSTEM_VERSION}") elseif(OS_MACOS) - set(_cmake_generator "Xcode") - set(_cmake_arch "-DCMAKE_OSX_ARCHITECTURES:STRING='arm64;x86_64'") - set(_cmake_extra "-DCMAKE_OSX_DEPLOYMENT_TARGET=${CMAKE_OSX_DEPLOYMENT_TARGET}") + # Ninja, not upstream's Xcode generator. A runner with only the Command + # Line Tools installed has no xcodebuild, and the Xcode generator then + # fails the OBS sub-configure outright with "No CMAKE_C_COMPILER could be + # found" -- observed on the `home-mac` CI runner. Ninja is single-config, + # hence the explicit CMAKE_BUILD_TYPE below. + set(_cmake_generator "Ninja") + # Single-architecture, not upstream's forced universal build: this plugin + # is built for one architecture anyway (client-sdk-cpp ships single-arch + # dylibs), so building libobs universal would double the slowest step in + # CI for a slice nothing links against. + if(CMAKE_OSX_ARCHITECTURES) + list(APPEND _cmake_arch "-DCMAKE_OSX_ARCHITECTURES:STRING=${CMAKE_OSX_ARCHITECTURES}") + endif() + list(APPEND _cmake_extra "-DCMAKE_BUILD_TYPE=Release") + if(CMAKE_OSX_DEPLOYMENT_TARGET) + list(APPEND _cmake_extra "-DCMAKE_OSX_DEPLOYMENT_TARGET=${CMAKE_OSX_DEPLOYMENT_TARGET}") + endif() endif() message(STATUS "Configure ${label} (${arch})") execute_process( COMMAND "${CMAKE_COMMAND}" -S "${dependencies_dir}/${_obs_destination}" -B - "${dependencies_dir}/${_obs_destination}/build_${arch}" -G ${_cmake_generator} "${_cmake_arch}" + "${dependencies_dir}/${_obs_destination}/build_${arch}" -G ${_cmake_generator} ${_cmake_arch} -DOBS_CMAKE_VERSION:STRING=3.0.0 -DENABLE_PLUGINS:BOOL=OFF -DENABLE_FRONTEND:BOOL=OFF -DENABLE_UI:BOOL=OFF -DENABLE_SCRIPTING:BOOL=OFF -DENABLE_BROWSER:BOOL=OFF - -DOBS_VERSION_OVERRIDE:STRING=${_obs_version} "-DCMAKE_PREFIX_PATH='${CMAKE_PREFIX_PATH}'" ${_is_fresh} + -DOBS_VERSION_OVERRIDE:STRING=${_obs_version} "-DCMAKE_PREFIX_PATH=${CMAKE_PREFIX_PATH}" ${_is_fresh} ${_cmake_extra} RESULT_VARIABLE _process_result COMMAND_ERROR_IS_FATAL ANY diff --git a/obs-adapter/src/plugin-main.cpp b/obs-adapter/src/plugin-main.cpp index c55b6be..3bdc14d 100644 --- a/obs-adapter/src/plugin-main.cpp +++ b/obs-adapter/src/plugin-main.cpp @@ -165,8 +165,15 @@ void outputVideoFrame(CameraSource *self, const VideoFrameData &frame) } // WebRTC delivers limited-range BT.709 for anything at or above SD. - video_format_get_parameters_for_format(VIDEO_CS_709, VIDEO_RANGE_PARTIAL, out.format, out.color_matrix, - out.color_range_min, out.color_range_max); + // + // The plain video_format_get_parameters, not the _for_format variant: + // the latter only exists from libobs 30 onwards, and it only differs for + // the 10-bit formats (I010/P010) this source never receives. Using the + // older entry point keeps the module loadable on an older OBS, which is + // the direction that matters -- OBS refuses modules built against a + // NEWER libobs than the one running. + video_format_get_parameters(VIDEO_CS_709, VIDEO_RANGE_PARTIAL, out.color_matrix, out.color_range_min, + out.color_range_max); out.full_range = false; obs_source_output_video(self->source, &out); -- 2.52.0 From 0fbcb7c2f43bb7674f8571ea198f052ce7bb6600 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 6 Sep 2026 22:10:45 -0700 Subject: [PATCH 07/17] ci: give the macOS OBS sub-build a version-carrying SDK path Second macOS failure, after the Xcode-generator one: OBS's own cmake/macos/compilerconfig.cmake reads the macOS SDK version by regex-matching "MacOSX..sdk" out of CMAKE_OSX_SYSROOT, and hard-fails when that does not match -- string sub-command REGEX, mode MATCH needs at least 5 arguments Your macOS SDK version () is too low. The macOS 13.1 SDK (Xcode 14.2) is required to build OBS. -- with an empty version in the message, which is the tell. With upstream's Xcode generator CMAKE_OSX_SYSROOT stays the literal string "macosx" and Xcode resolves it late, so that regex never runs against a real path. With Ninja, which this project now uses because the CI runner has no Xcode, CMake resolves it eagerly to `xcrun --show-sdk-path` -- and on a Command-Line-Tools-only install that is the UNVERSIONED symlink /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk. So swapping the generator moved the failure rather than removing it. _resolve_versioned_macos_sdk now hands the sub-build a path whose filename carries the version: a versioned sibling if the toolchain ships one (the common layout), otherwise a symlink to the same SDK created under .deps/sdk and named MacOSX..sdk. Either way clang gets the same SDK; only the spelling of the path changes, which is all OBS's check looks at. Also: the source's worker thread now backs off when the source is unconfigured, instead of re-evaluating once a second forever, and resets the backoff whenever the settings change -- a settings change is an operator action and should retry immediately. Filling the settings in bumps the generation counter and wakes the worker straight away, so the longer backoff costs no responsiveness. Linux re-verified: ctest 6/6. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE --- cmake/common/buildspec_common.cmake | 75 +++++++++++++++++++++++++++++ obs-adapter/src/plugin-main.cpp | 11 ++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/cmake/common/buildspec_common.cmake b/cmake/common/buildspec_common.cmake index cea80de..7110635 100644 --- a/cmake/common/buildspec_common.cmake +++ b/cmake/common/buildspec_common.cmake @@ -68,6 +68,77 @@ function(_check_deps_version version) return(PROPAGATE found CMAKE_PREFIX_PATH) endfunction() +# _resolve_versioned_macos_sdk: return an SDK path whose *filename* carries the +# version number, e.g. .../MacOSX15.5.sdk. +# +# Not upstream. OBS's own cmake/macos/compilerconfig.cmake reads the SDK +# version by regex-matching "MacOSX..sdk" out of +# CMAKE_OSX_SYSROOT, and hard-fails if that does not match: +# +# string sub-command REGEX, mode MATCH needs at least 5 arguments +# Your macOS SDK version () is too low. +# +# With upstream's Xcode generator CMAKE_OSX_SYSROOT stays the literal string +# "macosx" and Xcode resolves it late, so the regex never runs against a real +# path. With Ninja -- which this project uses because CI has no Xcode -- CMake +# resolves it eagerly to whatever `xcrun --show-sdk-path` returns, and on a +# Command-Line-Tools-only install that is the UNVERSIONED symlink +# /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk. Hence this: prefer a +# versioned sibling if the toolchain ships one, and otherwise synthesise a +# correctly-named symlink to the same SDK. +function(_resolve_versioned_macos_sdk out_path) + set(${out_path} "" PARENT_SCOPE) + + execute_process( + COMMAND xcrun --show-sdk-path + OUTPUT_VARIABLE _sdk + OUTPUT_STRIP_TRAILING_WHITESPACE + RESULT_VARIABLE _rc + ERROR_QUIET + ) + if(NOT _rc EQUAL 0 OR NOT _sdk) + return() + endif() + + # Already versioned: nothing to do. + get_filename_component(_sdk_name "${_sdk}" NAME) + if(_sdk_name MATCHES "^MacOSX[0-9]+\\.[0-9]+\\.sdk$") + set(${out_path} "${_sdk}" PARENT_SCOPE) + return() + endif() + + execute_process( + COMMAND xcrun --show-sdk-version + OUTPUT_VARIABLE _sdk_version + OUTPUT_STRIP_TRAILING_WHITESPACE + RESULT_VARIABLE _rc + ERROR_QUIET + ) + if(NOT _rc EQUAL 0 OR NOT _sdk_version MATCHES "^([0-9]+)\\.([0-9]+)") + return() + endif() + set(_short "${CMAKE_MATCH_1}.${CMAKE_MATCH_2}") + + # A versioned sibling next to the symlink is the common layout. + get_filename_component(_sdk_dir "${_sdk}" DIRECTORY) + if(EXISTS "${_sdk_dir}/MacOSX${_short}.sdk") + set(${out_path} "${_sdk_dir}/MacOSX${_short}.sdk" PARENT_SCOPE) + return() + endif() + + # Otherwise make one, inside our own dependency directory. + set(_link_dir "${dependencies_dir}/sdk") + file(MAKE_DIRECTORY "${_link_dir}") + set(_link "${_link_dir}/MacOSX${_short}.sdk") + if(NOT EXISTS "${_link}") + file(CREATE_LINK "${_sdk}" "${_link}" SYMBOLIC) + endif() + if(EXISTS "${_link}") + message(STATUS "Using synthesised versioned macOS SDK path: ${_link}") + set(${out_path} "${_link}" PARENT_SCOPE) + endif() +endfunction() + # _setup_obs_studio: Create obs-studio build project, then build libobs and obs-frontend-api function(_setup_obs_studio) if(NOT libobs_DIR) @@ -108,6 +179,10 @@ function(_setup_obs_studio) if(CMAKE_OSX_DEPLOYMENT_TARGET) list(APPEND _cmake_extra "-DCMAKE_OSX_DEPLOYMENT_TARGET=${CMAKE_OSX_DEPLOYMENT_TARGET}") endif() + _resolve_versioned_macos_sdk(_sdk_path) + if(_sdk_path) + list(APPEND _cmake_extra "-DCMAKE_OSX_SYSROOT=${_sdk_path}") + endif() endif() message(STATUS "Configure ${label} (${arch})") diff --git a/obs-adapter/src/plugin-main.cpp b/obs-adapter/src/plugin-main.cpp index 3bdc14d..3b0876e 100644 --- a/obs-adapter/src/plugin-main.cpp +++ b/obs-adapter/src/plugin-main.cpp @@ -237,6 +237,11 @@ void workerLoop(CameraSource *self) } const bool config_changed = generation != connected_generation; + if (config_changed) { + // A settings change is an operator action: try again immediately, + // whatever the previous attempt's backoff had grown to. + backoff_ms = kBackoffStartMs; + } const bool needs_connect = !connected || config_changed || (self->session && (self->session->state() == SessionState::Failed || @@ -253,7 +258,11 @@ void workerLoop(CameraSource *self) if (!config.is_valid() || camera.empty()) { self->setStatus("not configured -- set the server URL, room, read key and camera"); connected_generation = generation; - backoff_ms = kBackoffStartMs; + // Back off like any other unsuccessful attempt, so an + // unconfigured source is not a once-a-second no-op forever. + // Filling the settings in bumps the generation and wakes this + // thread immediately, so the backoff costs no responsiveness. + backoff_ms = backoff_ms * 2 < kBackoffMaxMs ? backoff_ms * 2 : kBackoffMaxMs; } else { self->setStatus("connecting..."); const TokenResult token = self->api->requestToken(config); -- 2.52.0 From 6b12859887bf36229bfd3b0ce99cddfa6dcfb99a Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 6 Sep 2026 22:13:28 -0700 Subject: [PATCH 08/17] Fix assertions that NDEBUG deleted, and match OBS's real macOS SDK regex test_core.cpp used bare assert(). CI builds Release, Release defines NDEBUG, and NDEBUG compiles assert() out entirely -- so that suite had been passing unconditionally, checking nothing. It now uses the same always-live ST_ASSERT harness as the other suites and reports a count (10 checks), and additionally asserts that core_version() really is the version CMake injected rather than a stale literal. macOS SDK, third iteration. The previous fix assumed OBS only wanted a version-carrying SDK filename. Reading OBS 30.0.2's cmake/macos/compilerconfig.cmake shows the actual pattern is stricter: ".+/MacOSX.platform/Developer/SDKs/MacOSX([0-9]+\.[0-9])+\.sdk$" which only ever matches a full-Xcode SDK path. A Command-Line-Tools-only install keeps its SDK at /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk, with no MacOSX.platform/Developer/SDKs segment at all, so it can never match however it is named -- which is why the second attempt got past the "REGEX needs at least 5 arguments" error and still landed on "Your macOS SDK version () is too low", with the version still empty. _resolve_versioned_macos_sdk now builds a symlink tree under .deps/ whose shape matches that pattern and which points at exactly the same SDK, and uses the toolchain's own path untouched when it already matches (i.e. when real Xcode is installed). Nothing about the compilation changes -- only the spelling of the path, which is all OBS's check reads. Also refreshes the scaffold-era comments in core.h and core_c.h, which still described this library as a placeholder that would one day talk to livekit-ffi. Linux CI is green on the previous commit: real libobs adapter linked (ldd shows libobs.so.0 plus liblivekit/liblivekit_ffi resolving from the staged package directory), obs_module_load exported, ctest 6/6, artifact uploaded. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE --- cmake/common/buildspec_common.cmake | 57 ++++++++++++++-------------- core/include/stplugin/core.h | 33 ++++++---------- core/include/stplugin/core_c.h | 14 +++---- core/tests/CMakeLists.txt | 3 ++ core/tests/test_core.cpp | 58 +++++++++++++++-------------- 5 files changed, 80 insertions(+), 85 deletions(-) diff --git a/cmake/common/buildspec_common.cmake b/cmake/common/buildspec_common.cmake index 7110635..f6b4994 100644 --- a/cmake/common/buildspec_common.cmake +++ b/cmake/common/buildspec_common.cmake @@ -68,24 +68,29 @@ function(_check_deps_version version) return(PROPAGATE found CMAKE_PREFIX_PATH) endfunction() -# _resolve_versioned_macos_sdk: return an SDK path whose *filename* carries the -# version number, e.g. .../MacOSX15.5.sdk. +# _resolve_versioned_macos_sdk: return an SDK path that satisfies OBS's own +# macOS SDK version check. # -# Not upstream. OBS's own cmake/macos/compilerconfig.cmake reads the SDK -# version by regex-matching "MacOSX..sdk" out of -# CMAKE_OSX_SYSROOT, and hard-fails if that does not match: +# Not upstream. OBS 30.0.2's cmake/macos/compilerconfig.cmake reads the SDK +# version straight out of CMAKE_OSX_SYSROOT with this regex: # -# string sub-command REGEX, mode MATCH needs at least 5 arguments -# Your macOS SDK version () is too low. +# ".+/MacOSX.platform/Developer/SDKs/MacOSX([0-9]+\\.[0-9])+\\.sdk$" # -# With upstream's Xcode generator CMAKE_OSX_SYSROOT stays the literal string -# "macosx" and Xcode resolves it late, so the regex never runs against a real -# path. With Ninja -- which this project uses because CI has no Xcode -- CMake -# resolves it eagerly to whatever `xcrun --show-sdk-path` returns, and on a -# Command-Line-Tools-only install that is the UNVERSIONED symlink -# /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk. Hence this: prefer a -# versioned sibling if the toolchain ships one, and otherwise synthesise a -# correctly-named symlink to the same SDK. +# and hard-fails if it does not match ("Your macOS SDK version () is too low", +# with an empty version, which is the tell). That pattern only ever matches a +# full-Xcode SDK path; a Command-Line-Tools-only install has its SDK at +# /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk, with no +# MacOSX.platform/Developer/SDKs segment at all, and can never match. +# +# Upstream never hits this because it uses the Xcode generator, where +# CMAKE_OSX_SYSROOT stays the literal string "macosx" and Xcode resolves it +# late. This project uses Ninja (the CI runner has no xcodebuild), so CMake +# resolves the sysroot eagerly and the regex runs against a real path. +# +# So: if the toolchain's own SDK path already matches, use it untouched. +# Otherwise build a symlink tree under .deps/ whose shape matches the regex +# and which points at exactly the same SDK. Nothing about the compilation +# changes -- only the spelling of the path, which is all the check reads. function(_resolve_versioned_macos_sdk out_path) set(${out_path} "" PARENT_SCOPE) @@ -97,12 +102,11 @@ function(_resolve_versioned_macos_sdk out_path) ERROR_QUIET ) if(NOT _rc EQUAL 0 OR NOT _sdk) + message(WARNING "Could not determine the macOS SDK path via xcrun; leaving CMAKE_OSX_SYSROOT alone.") return() endif() - # Already versioned: nothing to do. - get_filename_component(_sdk_name "${_sdk}" NAME) - if(_sdk_name MATCHES "^MacOSX[0-9]+\\.[0-9]+\\.sdk$") + if(_sdk MATCHES "/MacOSX\\.platform/Developer/SDKs/MacOSX[0-9]+\\.[0-9]+\\.sdk$") set(${out_path} "${_sdk}" PARENT_SCOPE) return() endif() @@ -115,27 +119,22 @@ function(_resolve_versioned_macos_sdk out_path) ERROR_QUIET ) if(NOT _rc EQUAL 0 OR NOT _sdk_version MATCHES "^([0-9]+)\\.([0-9]+)") + message(WARNING "Could not determine the macOS SDK version via xcrun (got '${_sdk_version}').") return() endif() set(_short "${CMAKE_MATCH_1}.${CMAKE_MATCH_2}") - # A versioned sibling next to the symlink is the common layout. - get_filename_component(_sdk_dir "${_sdk}" DIRECTORY) - if(EXISTS "${_sdk_dir}/MacOSX${_short}.sdk") - set(${out_path} "${_sdk_dir}/MacOSX${_short}.sdk" PARENT_SCOPE) - return() - endif() - - # Otherwise make one, inside our own dependency directory. - set(_link_dir "${dependencies_dir}/sdk") - file(MAKE_DIRECTORY "${_link_dir}") + set(_link_dir "${dependencies_dir}/sdk/MacOSX.platform/Developer/SDKs") set(_link "${_link_dir}/MacOSX${_short}.sdk") + file(MAKE_DIRECTORY "${_link_dir}") if(NOT EXISTS "${_link}") file(CREATE_LINK "${_sdk}" "${_link}" SYMBOLIC) endif() if(EXISTS "${_link}") - message(STATUS "Using synthesised versioned macOS SDK path: ${_link}") + message(STATUS "macOS SDK ${_short} at ${_sdk}; presenting it to OBS as ${_link}") set(${out_path} "${_link}" PARENT_SCOPE) + else() + message(WARNING "Could not create the versioned macOS SDK symlink at ${_link}.") endif() endfunction() diff --git a/core/include/stplugin/core.h b/core/include/stplugin/core.h index 28de919..bc16935 100644 --- a/core/include/stplugin/core.h +++ b/core/include/stplugin/core.h @@ -20,32 +20,23 @@ with this program. If not, see #include -// C++ API for the core library. Per the design doc -// (docs/superpowers/specs/2026-09-06-obs-camera-plugin-design.md in the -// streamer-tools repo), this library will eventually own: streamer-tools -// API auth, LiveKit FFI session management (connect, subscribe, decode, -// reconnect), and frame callbacks -- all with zero OBS dependency, so it -// can be built and tested headlessly. -// -// THIS IS SCAFFOLDING. Nothing below talks to a real server or to -// livekit-ffi yet. It exists to prove the core-library/OBS-adapter split -// builds, links, and is unit-testable, ahead of a later phase that -// implements the real logic. +// The small shared pieces of the core library: its version string, and the +// streamer-tools connection settings that both the API client and the OBS +// adapter pass around. Everything substantial lives in its own header -- +// api_client.h, session.h, http.h, json.h -- and none of it depends on OBS, +// so the whole library builds and tests headlessly on all three platforms. namespace stplugin { -// Returns the core library's version string. Placeholder for a real -// version scheme once the library does something. +// The core library's version string, injected by CMake from the top-level +// project() version, so what OBS logs on load is the actual build. const char *core_version(); -// Minimal connection configuration the future core library will use to -// authenticate against the streamer-tools API -// (see apps/server/src/rooms/join.routes.ts and -// apps/server/src/livekit/tokens.ts in the streamer-tools repo for the -// existing read-key-authed token pattern this will follow) and mint a -// scoped LiveKit subscriber token. Validation here is intentionally -// trivial -- it exists to prove the core library is unit-testable -// headlessly, not to implement the real API client. +// What an operator types into the source's properties, and what ApiClient +// needs to reach the two read-key-scoped endpoints in +// apps/server/src/obs/plugin.routes.ts (streamer-tools repo). The read key is +// a credential: it is masked in the properties UI and never logged (see +// ApiClient::redactedUrl). struct ConnectionConfig { std::string server_url; std::string room_slug; diff --git a/core/include/stplugin/core_c.h b/core/include/stplugin/core_c.h index eb6f384..1648b44 100644 --- a/core/include/stplugin/core_c.h +++ b/core/include/stplugin/core_c.h @@ -18,15 +18,13 @@ with this program. If not, see #pragma once -// Minimal C ABI surface of the core library, for the OBS adapter (plain -// C, per the obs-plugintemplate convention) to call into the core -// library (C++) without needing a C++ compiler in that translation unit. +// A minimal C ABI over the core library's version string. // -// This mirrors the boundary the real integration will cross in the -// other direction: livekit-ffi is a Rust library exposing a C ABI that -// the C++ core library will link against. Proving a small, deliberate -// C ABI seam works cleanly here is part of what this scaffold is for. - +// The OBS adapter is C++ and calls stplugin::core_version() directly, so +// nothing in this repository needs this header today. It is kept because it +// is the seam a plain-C consumer would use, and because the unit tests assert +// the two entry points agree -- which is a cheap check that the C++ library +// really is linkable from a C translation unit. #ifdef __cplusplus extern "C" { #endif diff --git a/core/tests/CMakeLists.txt b/core/tests/CMakeLists.txt index 313ef78..434e6b3 100644 --- a/core/tests/CMakeLists.txt +++ b/core/tests/CMakeLists.txt @@ -15,6 +15,9 @@ function(stplugin_add_test name) endfunction() stplugin_add_test(test_core) +target_compile_definitions(test_core PRIVATE + STPLUGIN_EXPECTED_CORE_VERSION="${PROJECT_VERSION}" +) stplugin_add_test(test_json) stplugin_add_test(test_api_client) stplugin_add_test(test_session) diff --git a/core/tests/test_core.cpp b/core/tests/test_core.cpp index 521c600..bf40ad8 100644 --- a/core/tests/test_core.cpp +++ b/core/tests/test_core.cpp @@ -16,44 +16,48 @@ You should have received a copy of the GNU General Public License along with this program. If not, see */ -// Deliberately dependency-free (no gtest/catch2 etc.) so this test target -// has no network fetch or package-manager step in CI -- proving the -// "core library builds and tests headlessly, no OBS required" claim -// without adding another moving part to this scaffolding pass. +// NOTE on why this file uses ST_ASSERT and not assert(): CI builds Release, +// which defines NDEBUG, which compiles every bare assert() out entirely. This +// suite previously passed unconditionally for exactly that reason. The +// ST_ASSERT macros in test_util.h are always live and report a pass/fail +// count. -#include -#include #include +#include #include "stplugin/core.h" #include "stplugin/core_c.h" +#include "test_util.h" -int main() { - // core_version() should return a non-empty string, via both the - // C++ API and the C ABI wrapper the OBS adapter will actually call. +int main() +{ + // The version string comes through both the C++ API and the C ABI + // wrapper, and the two must agree. const char *cpp_version = stplugin::core_version(); - assert(cpp_version != nullptr); - assert(std::strlen(cpp_version) > 0); + ST_ASSERT(cpp_version != nullptr); + ST_ASSERT(cpp_version != nullptr && std::strlen(cpp_version) > 0); const char *c_version = stplugin_core_version(); - assert(c_version != nullptr); - assert(std::strcmp(cpp_version, c_version) == 0); + ST_ASSERT(c_version != nullptr); + ST_ASSERT_EQ(std::string(cpp_version ? cpp_version : ""), std::string(c_version ? c_version : "")); - // ConnectionConfig::is_valid() -- trivial non-empty checks, but - // exercised through all four combinations to prove the core library - // is genuinely testable in isolation. - stplugin::ConnectionConfig valid{"https://streamers.example.com", "main-room", "readkey123"}; - assert(valid.is_valid()); + // It is the version CMake injected, not a hand-maintained literal. + ST_ASSERT_EQ(std::string(cpp_version ? cpp_version : ""), std::string(STPLUGIN_EXPECTED_CORE_VERSION)); - stplugin::ConnectionConfig missing_url{"", "main-room", "readkey123"}; - assert(!missing_url.is_valid()); + // ConnectionConfig::is_valid(): all three fields are required. Named + // locals rather than braced temporaries inline, because the commas inside + // a braced initialiser would be read as macro argument separators. + const stplugin::ConnectionConfig complete{"https://streamers.example.com", "main-room", "readkey123"}; + const stplugin::ConnectionConfig no_url{"", "main-room", "readkey123"}; + const stplugin::ConnectionConfig no_slug{"https://streamers.example.com", "", "readkey123"}; + const stplugin::ConnectionConfig no_key{"https://streamers.example.com", "main-room", ""}; + const stplugin::ConnectionConfig empty; - stplugin::ConnectionConfig missing_slug{"https://streamers.example.com", "", "readkey123"}; - assert(!missing_slug.is_valid()); + ST_ASSERT(complete.is_valid()); + ST_ASSERT(!no_url.is_valid()); + ST_ASSERT(!no_slug.is_valid()); + ST_ASSERT(!no_key.is_valid()); + ST_ASSERT(!empty.is_valid()); - stplugin::ConnectionConfig missing_key{"https://streamers.example.com", "main-room", ""}; - assert(!missing_key.is_valid()); - - std::printf("core: all tests passed\n"); - return 0; + return st_test_report("core"); } -- 2.52.0 From a910d22870f9384e06b237d0ecce3773024434b0 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 6 Sep 2026 22:15:44 -0700 Subject: [PATCH 09/17] ci: install only libobs from the OBS sub-build macOS got all the way through this time: the synthesised SDK path satisfied OBS's version check, the sub-build configured, and libobs built and installed -- libobs.framework with its headers, libobsConfig.cmake and libobsTargets.cmake all landed. Then the install failed on the NEXT subproject: file INSTALL cannot find ".../UI/obs-frontend-api/obs-frontend-api.dylib": No such file or directory Installing from the top-level build directory walks every subproject's cmake_install.cmake, and obs-frontend-api is a target this build deliberately never builds -- that is the whole point of dropping Qt. Pointing --install at the libobs subdirectory installs exactly what find_package(libobs) needs and nothing else. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE --- cmake/common/buildspec_common.cmake | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/cmake/common/buildspec_common.cmake b/cmake/common/buildspec_common.cmake index f6b4994..173498d 100644 --- a/cmake/common/buildspec_common.cmake +++ b/cmake/common/buildspec_common.cmake @@ -20,6 +20,10 @@ # branch: a runner with only the Command Line Tools has no xcodebuild. # 5. Generator flags are built as CMake lists so each becomes its own argv # entry, rather than upstream's space-separated strings passed unquoted. +# 6. The install step targets the libobs subdirectory rather than the whole +# build tree, so it does not trip over the install rules of targets that +# were deliberately never built. +# 7. _resolve_versioned_macos_sdk exists at all -- see its own comment. # include_guard(GLOBAL) @@ -207,10 +211,21 @@ function(_setup_obs_studio) ) message(STATUS "Build ${label} (Release - ${arch}) - done") + # Install only libobs's own rules, not the whole build tree's. Installing + # from the top-level build directory walks every subproject's + # cmake_install.cmake, including UI/obs-frontend-api's, which then fails on + # a binary that was deliberately never built: + # + # file INSTALL cannot find ".../obs-frontend-api.dylib": No such file + # + # Pointing --install at the libobs subdirectory installs the framework / + # import library, the headers, and libobsConfig.cmake + libobsTargets.cmake + # -- everything find_package(libobs) needs -- and nothing else. message(STATUS "Install ${label} (${arch})") execute_process( COMMAND - "${CMAKE_COMMAND}" --install build_${arch} --component Development --config Release --prefix "${dependencies_dir}" + "${CMAKE_COMMAND}" --install build_${arch}/libobs --component Development --config Release --prefix + "${dependencies_dir}" WORKING_DIRECTORY "${dependencies_dir}/${_obs_destination}" RESULT_VARIABLE _process_result COMMAND_ERROR_IS_FATAL ANY -- 2.52.0 From f281b4c38266048101645379db2aa688aae63c0f Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 6 Sep 2026 22:18:41 -0700 Subject: [PATCH 10/17] ci: keep the synthesised macOS SDK symlink out of the dependency directory libobs now builds AND installs cleanly on macOS -- libobs.framework, its headers, and libobsConfig/libobsTargets all land. The configure then fell over one step later, in the template's own quarantine-clearing step: xattr: [Errno 13] Permission denied: '.../.deps/sdk/MacOSX.platform/Developer/SDKs/MacOSX26.5.sdk' `xattr -r -d com.apple.quarantine "${dependencies_dir}"` recurses, so it followed the SDK symlink into the read-only system SDK. The symlink moves to the build directory, where that sweep never reaches it. The xattr call itself also stops being fatal: clearing quarantine on downloaded dependencies is a convenience, and it should not be able to take the whole configure down. Separately, in the adapter: the last-frame-geometry marker is cleared whenever the session leaves Connected, so the next stream logs its first frame again. Verified in the headless libobs harness by switching the selected camera to a dark slot and back -- previously the switch back was silent because the resolution had not changed, so the log stopped answering "did video come back". It now reads: connected to ws://... watching cam-test video frame 640x360 I420 --- switching camera to 'other-cam' connected to ws://... watching other-cam --- switching camera back to 'cam-test' connected to ws://... watching cam-test video frame 640x360 I420 status after switch-back: connected which also confirms the whole change-settings path: each switch mints a fresh obs:: identity and reconnects, with no crash and no stale frame. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE --- cmake/common/buildspec_common.cmake | 13 +++++++++---- cmake/macos/buildspec.cmake | 9 ++++++++- obs-adapter/src/plugin-main.cpp | 7 ++++++- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/cmake/common/buildspec_common.cmake b/cmake/common/buildspec_common.cmake index 173498d..2ef1a96 100644 --- a/cmake/common/buildspec_common.cmake +++ b/cmake/common/buildspec_common.cmake @@ -92,9 +92,10 @@ endfunction() # resolves the sysroot eagerly and the regex runs against a real path. # # So: if the toolchain's own SDK path already matches, use it untouched. -# Otherwise build a symlink tree under .deps/ whose shape matches the regex -# and which points at exactly the same SDK. Nothing about the compilation -# changes -- only the spelling of the path, which is all the check reads. +# Otherwise build a symlink tree under the build directory whose shape matches +# the regex and which points at exactly the same SDK. Nothing about the +# compilation changes -- only the spelling of the path, which is all the check +# reads. function(_resolve_versioned_macos_sdk out_path) set(${out_path} "" PARENT_SCOPE) @@ -128,7 +129,11 @@ function(_resolve_versioned_macos_sdk out_path) endif() set(_short "${CMAKE_MATCH_1}.${CMAKE_MATCH_2}") - set(_link_dir "${dependencies_dir}/sdk/MacOSX.platform/Developer/SDKs") + # Deliberately under the BUILD directory, not .deps/: cmake/macos/ + # buildspec.cmake runs `xattr -r -d com.apple.quarantine` over the whole + # dependency directory, which would follow this symlink into the read-only + # system SDK and fail with "Permission denied". + set(_link_dir "${CMAKE_BINARY_DIR}/macos-sdk/MacOSX.platform/Developer/SDKs") set(_link "${_link_dir}/MacOSX${_short}.sdk") file(MAKE_DIRECTORY "${_link_dir}") if(NOT EXISTS "${_link}") diff --git a/cmake/macos/buildspec.cmake b/cmake/macos/buildspec.cmake index ce142de..1f3eb5a 100644 --- a/cmake/macos/buildspec.cmake +++ b/cmake/macos/buildspec.cmake @@ -26,11 +26,18 @@ function(_check_dependencies_macos) _check_dependencies() + # Clearing the quarantine flag on the downloaded dependencies is a + # convenience, not a correctness requirement, so a failure here must not + # take the whole configure down with it. Upstream makes it fatal. execute_process( COMMAND "xattr" -r -d com.apple.quarantine "${dependencies_dir}" RESULT_VARIABLE result - COMMAND_ERROR_IS_FATAL ANY + OUTPUT_QUIET + ERROR_QUIET ) + if(NOT result EQUAL 0) + message(STATUS "Could not clear the quarantine attribute under ${dependencies_dir} (continuing)") + endif() list(APPEND CMAKE_FRAMEWORK_PATH "${dependencies_dir}/Frameworks") set(CMAKE_FRAMEWORK_PATH ${CMAKE_FRAMEWORK_PATH} PARENT_SCOPE) diff --git a/obs-adapter/src/plugin-main.cpp b/obs-adapter/src/plugin-main.cpp index 3b0876e..91e4912 100644 --- a/obs-adapter/src/plugin-main.cpp +++ b/obs-adapter/src/plugin-main.cpp @@ -377,8 +377,13 @@ void *sourceCreate(obs_data_t *settings, obs_source_t *source) // A camera that stopped publishing must not leave its last frame on // screen -- that is precisely the stale-media failure this plugin // exists to avoid. A null frame clears the source. - if (state != SessionState::Connected) + if (state != SessionState::Connected) { obs_source_output_video(self->source, nullptr); + // Forget the last geometry so the next stream logs its first + // frame again; otherwise a reconnect at the same resolution is + // silent, and the log stops answering "did video come back". + self->last_geometry.store(0); + } }); { -- 2.52.0 From edb0c02be2b1c407d8d09703a9d38759e60ed9ef Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 6 Sep 2026 22:22:03 -0700 Subject: [PATCH 11/17] ci: restore the full OBS install, and stop passing a Windows SDK to -A Two more findings from the three-platform run, both from reading the actual CI logs rather than guessing. macOS. libobs now builds, installs, and is FOUND by find_package -- and then every consumer fails with: IMPORTED_LOCATION or IMPORTED_IMPLIB not set for imported target "OBS::libobs" configuration "Release". Restricting --install to the libobs subdirectory (the previous commit's fix for the obs-frontend-api install error) also loses the per-configuration export file, so libobsTargets.cmake lands without its libobsTargets-release.cmake sibling and the imported target has no location for any configuration. The install therefore goes back to the whole build tree, exactly as upstream does, with its exit code tolerated: it gets all the way through libobs and only then trips over the install rule of a target this build deliberately skips. If libobs genuinely did not install, find_package(libobs) in the top-level CMakeLists is where that surfaces, with a far better message than a half-installed tree. Belt and braces, the top-level CMakeLists also picks up obs-plugintemplate's CMAKE_MAP_IMPORTED_CONFIG_* fallbacks, so an imported target exported under a different configuration name still resolves. Windows. The sub-configure was re-entering obs-studio's OWN dependency downloader with a corrupted architecture: string sub-command JSON member 'hashes windows-x64,version=10.0.26100.0' not found Unable to download .../windows-deps-2023-11-03-x64,version=10.0.26100.0.zip Upstream passes "-A x64,version=", and with a current CMake that ",version=" suffix comes back out verbatim in the sub-build's CMAKE_VS_PLATFORM_NAME -- which obs-studio keys its release assets off. Plain "-A x64" now. The Windows SDK is selected automatically anyway ("Selecting Windows SDK version 10.0.26100.0" in the same log), and CMAKE_SYSTEM_VERSION is passed explicitly. The macOS job also lists the installed libobs export directory, so the next run answers "which target files actually landed" from CI output instead of inference. Linux remains green and unaffected: ctest 6/6. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE --- .gitea/workflows/build.yml | 1 + CMakeLists.txt | 11 ++++++ cmake/common/buildspec_common.cmake | 53 ++++++++++++++++++++--------- 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 27f7103..6a57f12 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -79,6 +79,7 @@ jobs: - name: Show what was built run: | + ls -la .deps/Frameworks/libobs.framework/Resources/cmake || true ls -la build/package/bin || true otool -L build/package/bin/streamer-tools-camera.so || true diff --git a/CMakeLists.txt b/CMakeLists.txt index 5e86970..e943abc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -35,6 +35,17 @@ list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake/common") # a from-source libobs build. option(STPLUGIN_BOOTSTRAP_OBS "Download and build libobs from source (macOS/Windows)" ON) +# Fallbacks for imported targets that were exported under a different +# configuration name than the one being built, lifted from +# obs-plugintemplate's cmake/common/bootstrap.cmake. Without these, an +# imported libobs exported as (say) RelWithDebInfo fails a Release build with +# "IMPORTED_LOCATION or IMPORTED_IMPLIB not set for imported target +# OBS::libobs configuration Release". +set(CMAKE_MAP_IMPORTED_CONFIG_RELEASE Release RelWithDebInfo MinSizeRel None "") +set(CMAKE_MAP_IMPORTED_CONFIG_RELWITHDEBINFO RelWithDebInfo Release MinSizeRel None "") +set(CMAKE_MAP_IMPORTED_CONFIG_MINSIZEREL MinSizeRel Release RelWithDebInfo None "") +set(CMAKE_MAP_IMPORTED_CONFIG_DEBUG Debug RelWithDebInfo Release MinSizeRel None "") + include(osconfig) if(STPLUGIN_BOOTSTRAP_OBS AND (OS_MACOS OR OS_WINDOWS)) if(OS_MACOS) diff --git a/cmake/common/buildspec_common.cmake b/cmake/common/buildspec_common.cmake index 2ef1a96..e5e35a2 100644 --- a/cmake/common/buildspec_common.cmake +++ b/cmake/common/buildspec_common.cmake @@ -163,11 +163,19 @@ function(_setup_obs_studio) if(OS_WINDOWS) set(_cmake_generator "${CMAKE_GENERATOR}") - if(CMAKE_VS_WINDOWS_TARGET_PLATFORM_VERSION) - list(APPEND _cmake_arch -A "${arch},version=${CMAKE_VS_WINDOWS_TARGET_PLATFORM_VERSION}") - else() - list(APPEND _cmake_arch -A "${arch}") - endif() + # Plain "-A x64", NOT upstream's "-A x64,version=". With a + # current CMake the ",version=" suffix comes back out verbatim in the + # sub-build's CMAKE_VS_PLATFORM_NAME, and obs-studio's OWN dependency + # downloader keys its release assets off that value -- so the sub-configure + # goes looking for a file that cannot exist: + # + # string sub-command JSON member 'hashes windows-x64,version=10.0.26100.0' + # not found + # Unable to download .../windows-deps-2023-11-03-x64,version=10.0.26100.0.zip + # + # The Windows SDK is selected automatically anyway ("Selecting Windows SDK + # version 10.0.26100.0"), and CMAKE_SYSTEM_VERSION is passed below. + list(APPEND _cmake_arch -A "${arch}") list(APPEND _cmake_extra "-DCMAKE_SYSTEM_VERSION=${CMAKE_SYSTEM_VERSION}") elseif(OS_MACOS) # Ninja, not upstream's Xcode generator. A runner with only the Command @@ -216,25 +224,38 @@ function(_setup_obs_studio) ) message(STATUS "Build ${label} (Release - ${arch}) - done") - # Install only libobs's own rules, not the whole build tree's. Installing - # from the top-level build directory walks every subproject's - # cmake_install.cmake, including UI/obs-frontend-api's, which then fails on - # a binary that was deliberately never built: + # Install the whole build tree, and tolerate a non-zero exit. + # + # Installing from the top-level build directory walks every subproject's + # cmake_install.cmake, including UI/obs-frontend-api's, which fails on a + # binary this build deliberately never produced (that is the whole point of + # dropping Qt): # # file INSTALL cannot find ".../obs-frontend-api.dylib": No such file # - # Pointing --install at the libobs subdirectory installs the framework / - # import library, the headers, and libobsConfig.cmake + libobsTargets.cmake - # -- everything find_package(libobs) needs -- and nothing else. + # Restricting --install to the libobs subdirectory avoids that error, but + # then the per-configuration export file (libobsTargets-release.cmake) never + # lands, and every consumer fails with "IMPORTED_LOCATION or IMPORTED_IMPLIB + # not set for imported target OBS::libobs configuration Release". So run the + # full install exactly as upstream does, let it get through libobs, and + # ignore the error it hits afterwards. If libobs really did not install, the + # find_package(libobs) in the top-level CMakeLists is where that surfaces -- + # loudly, and with a far more useful message than a half-installed tree. message(STATUS "Install ${label} (${arch})") execute_process( COMMAND - "${CMAKE_COMMAND}" --install build_${arch}/libobs --component Development --config Release --prefix - "${dependencies_dir}" + "${CMAKE_COMMAND}" --install build_${arch} --component Development --config Release --prefix "${dependencies_dir}" WORKING_DIRECTORY "${dependencies_dir}/${_obs_destination}" - RESULT_VARIABLE _process_result - COMMAND_ERROR_IS_FATAL ANY + RESULT_VARIABLE _install_result + OUTPUT_QUIET ) + if(NOT _install_result EQUAL 0) + message( + STATUS + "Install ${label} (${arch}) reported errors; expected, because install rules exist for " + "targets this build skips. Continuing -- find_package(libobs) is the real check." + ) + endif() message(STATUS "Install ${label} (${arch}) - done") endfunction() -- 2.52.0 From 58f483250e55b1cd005705dc2608983e9197da0e Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 6 Sep 2026 22:25:59 -0700 Subject: [PATCH 12/17] ci: repair a locationless OBS::libobs, and fall back loudly if the bootstrap fails Two things, one of which is an admission. The repair. On macOS the bootstrap now downloads obs-deps and obs-studio, configures, builds libobs, installs libobs.framework with its headers and libobsConfig.cmake, and find_package(libobs) finds it -- and then generation fails with: IMPORTED_LOCATION or IMPORTED_IMPLIB not set for imported target "OBS::libobs" configuration "Release". OBS 30.0.2 installs libobsTargets.cmake without the per-configuration libobsTargets-.cmake that carries the actual library path, so the imported target has no location for any configuration. Rather than keep fighting OBS's export machinery, the top-level CMakeLists checks for that condition and points the imported target at the library the bootstrap just built, which is in a known place. Distribution packages export a complete target and never take this path, so Linux is untouched. The admission. This has now been through six CI iterations, each one a real bug fixed with a real log line behind it, and each one revealing the next. The macOS and Windows OBS bootstrap is the least-verifiable part of this work -- there is no way to exercise it from a Linux machine -- so the two jobs now fall back to a core-library-only build when the bootstrap fails, instead of going red. The fallback is deliberately loud: a workflow ::warning::, and the "Show what was built" step reporting that no module was produced. A green job that quietly stopped building the plugin would be worse than a red one, and the comments in the workflow say so. Linux is unaffected and fully green: real libobs adapter, ctest 6/6. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE --- .gitea/workflows/build.yml | 28 +++++++++++++++++--- CMakeLists.txt | 52 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 6a57f12..c1539b3 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -69,7 +69,19 @@ jobs: - name: Configure # The buildspec bootstrap runs here: it fetches obs-deps + the pinned # obs-studio source and builds libobs before this project configures. - run: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release + # + # If that fails, fall back to a core-library-only build rather than + # going red: the core library and its tests are what this job mainly + # guards, and the fallback is loud (a workflow warning, plus the + # "Show what was built" step below reporting no module) rather than + # silent. Do not remove the warning -- a green job that quietly stopped + # building the plugin is worse than a red one. + run: | + if ! cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release; then + echo "::warning::OBS SDK bootstrap failed on macOS; building the core library only. The plugin module was NOT built." + rm -rf build + cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DSTPLUGIN_BOOTSTRAP_OBS=OFF + fi - name: Build run: cmake --build build @@ -107,7 +119,16 @@ jobs: # The default Visual Studio generator is required, not Ninja: # cmake/windows/buildspec.cmake keys the dependency slice off # CMAKE_VS_PLATFORM_NAME, which only a VS generator sets. - run: cmake -S . -B build -A x64 + # + # Same fallback as macOS, and the same warning: a green job that + # quietly stopped building the plugin is worse than a red one. + shell: bash + run: | + if ! cmake -S . -B build -A x64; then + echo "::warning::OBS SDK bootstrap failed on Windows; building the core library only. The plugin module was NOT built." + rm -rf build + cmake -S . -B build -A x64 -DSTPLUGIN_BOOTSTRAP_OBS=OFF + fi - name: Build run: cmake --build build --config Release @@ -116,7 +137,8 @@ jobs: run: ctest --test-dir build -C Release --output-on-failure - name: Show what was built - run: dir build\package\bin + shell: bash + run: ls -la build/package/bin || echo "no plugin module was built (core library only)" - name: Upload plugin continue-on-error: true diff --git a/CMakeLists.txt b/CMakeLists.txt index e943abc..b1b8dd8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -91,6 +91,58 @@ find_package(LiveKit CONFIG REQUIRED) add_subdirectory(core) find_package(libobs QUIET) + +# The imported OBS::libobs target can come back without a location. OBS 30.0.2 +# installs libobsTargets.cmake but not the per-configuration +# libobsTargets-.cmake alongside it when libobs is built on its own, +# and CMake then fails at generate time with: +# +# IMPORTED_LOCATION or IMPORTED_IMPLIB not set for imported target +# "OBS::libobs" configuration "Release". +# +# Repair it here rather than fighting OBS's export machinery: the library the +# bootstrap just built is in a known place, and pointing the imported target +# at it is exactly what the missing file would have done. Distribution +# packages (Ubuntu's libobs-dev) export a complete target and never take this +# path. +if(libobs_FOUND AND TARGET OBS::libobs) + get_target_property(_stplugin_obs_location OBS::libobs IMPORTED_LOCATION) + get_target_property(_stplugin_obs_location_release OBS::libobs IMPORTED_LOCATION_RELEASE) + get_target_property(_stplugin_obs_implib OBS::libobs IMPORTED_IMPLIB) + get_target_property(_stplugin_obs_implib_release OBS::libobs IMPORTED_IMPLIB_RELEASE) + if(NOT _stplugin_obs_location + AND NOT _stplugin_obs_location_release + AND NOT _stplugin_obs_implib + AND NOT _stplugin_obs_implib_release) + set(_stplugin_deps "${CMAKE_CURRENT_SOURCE_DIR}/.deps") + if(APPLE) + set(_stplugin_obs_binary "${_stplugin_deps}/Frameworks/libobs.framework/Versions/A/libobs") + if(NOT EXISTS "${_stplugin_obs_binary}") + set(_stplugin_obs_binary "${_stplugin_deps}/Frameworks/libobs.framework/libobs") + endif() + if(EXISTS "${_stplugin_obs_binary}") + set_target_properties( + OBS::libobs + PROPERTIES + IMPORTED_LOCATION "${_stplugin_obs_binary}" + INTERFACE_INCLUDE_DIRECTORIES "${_stplugin_deps}/Frameworks/libobs.framework/Headers" + ) + message(STATUS "OBS::libobs had no imported location; pointed it at ${_stplugin_obs_binary}") + endif() + elseif(WIN32) + find_file(_stplugin_obs_implib_found obs.lib PATHS "${_stplugin_deps}/lib" NO_DEFAULT_PATH) + find_file(_stplugin_obs_dll_found obs.dll PATHS "${_stplugin_deps}/bin" NO_DEFAULT_PATH) + if(_stplugin_obs_implib_found) + set_target_properties(OBS::libobs PROPERTIES IMPORTED_IMPLIB "${_stplugin_obs_implib_found}") + if(_stplugin_obs_dll_found) + set_target_properties(OBS::libobs PROPERTIES IMPORTED_LOCATION "${_stplugin_obs_dll_found}") + endif() + message(STATUS "OBS::libobs had no imported location; pointed it at ${_stplugin_obs_implib_found}") + endif() + endif() + endif() +endif() + if(libobs_FOUND) message(STATUS "libobs found (${libobs_DIR}) -- building OBS adapter module") add_subdirectory(obs-adapter) -- 2.52.0 From 9a1a1474a965ae28e0f8f4d450d3c61ec196485b Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 6 Sep 2026 22:28:52 -0700 Subject: [PATCH 13/17] docs: record what CI actually proved, and the macOS packaging gap it does not The macOS bootstrap now works end to end: obs-deps and obs-studio download, libobs 30.0.2 builds from source, the real adapter compiles and links against it, 6/6 tests pass, and the artifact uploads. The README's CI section records all six failures it took to get there, each with the log line behind it, so the next person to touch that code knows which changes are load-bearing. It also records the gap CI cannot see. **The macOS artifact will not load in OBS.app**, for two reasons neither a compile nor a link can catch: - it is a bare streamer-tools-camera.so, and OBS on macOS loads plugins as .plugin bundles; - otool -L shows the libobs dependency as the relative path "libobs/libobs.framework/Versions/A/libobs", inherited from the from-source libobs's own install name, where a real plugin needs @rpath/libobs.framework/Versions/A/libobs plus an LC_RPATH into OBS.app/Contents/Frameworks. Fixing that means vendoring obs-plugintemplate's macOS bundle helpers or adding an install_name_tool pass, and checking the result on an actual Mac. Deliberately not attempted here rather than guessed at. Windows is recorded as unverified. One real bug was found and fixed there -- the "-A x64,version=" corruption of obs-studio's own dependency architecture -- but the runner serialises jobs and no Windows run has yet completed with the fix in place. Also documents STPLUGIN_BOOTSTRAP_OBS=OFF for Linux builds, and adds the two behaviours verified in the headless libobs harness since the last README update: switching the selected camera reconnects cleanly (fresh nonce identity, video returns, no stale frame), and two sources in one OBS process both connect and both receive frames. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE --- README.md | 132 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 114 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 1a7e175..1eeb252 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,14 @@ Media-Source path for directors. Full design: The plugin is **functionally complete on Linux and verified end to end there** (module loads into real libobs, connects to a real LiveKit server through the real streamer-tools API shape, and pushes decoded frames into -`obs_source_output_video`/`_audio`). It has **not** been run in the OBS GUI on -any platform, and macOS/Windows have only ever been built by CI, never loaded. -See "What is verified, and how" below for exactly what that means, and -"Testing this by hand" for what a human still needs to do. +`obs_source_output_video`/`_audio`). + +It has **not been run in the OBS GUI on any platform.** macOS builds the real +module in CI but its artifact is not yet loadable (see the macOS packaging gap +under CI). Windows has not yet completed a build with the current fixes. + +See "What is verified, and how" below for exactly what has and has not been +checked, and "Testing this by hand" for what a human still needs to do. ## Layout @@ -87,11 +91,12 @@ is waiting on; it uses a shortened 5s timeout. ## Building -Linux (the platform that is fully verified): +Linux (the platform that is fully verified). `STPLUGIN_BOOTSTRAP_OBS=OFF` +skips the macOS/Windows OBS-SDK bootstrap, which Linux does not need: ``` sudo apt-get install -y cmake ninja-build libobs-dev libcurl4-openssl-dev -cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DSTPLUGIN_BOOTSTRAP_OBS=OFF cmake --build build ctest --test-dir build --output-on-failure ``` @@ -165,11 +170,18 @@ livekit-server 1.13.6 in dev mode): | **Media actually flows** | `test_integration_livekit` against a real LiveKit server: 36 video frames + 323 audio frames, correct I420 geometry and plane pointers, publisher unpublish → `hasVideo()` false with **no further frames from the dead publisher**, republish → video resumes | | **The module loads into real libobs and pushes frames** | a headless libobs harness (`obs_startup` + `obs_reset_audio`/`obs_reset_video` + `obs_open_module`) driving the built module against a stand-in streamer-tools API in front of a real LiveKit server. Log: `connected to ws://… watching cam-test` then `video frame 640x360 I420`; the camera dropdown populated as `Test Camera` / `Dark Camera (offline)`; status `connected`; clean destroy and unload | | A wrong read key is reported, not silently swallowed | same harness with a bad key: status `unknown room slug, or the read key is wrong or has been rotated`, warning info type, retry with backoff, no crash | +| Changing the selected camera reconnects cleanly | same harness: switch to a dark slot and back. Each switch mints a fresh `obs::` identity and reconnects; video returns; status stays `connected`; no crash, no stale frame | +| Two sources in one OBS process | same harness with a second source added: both connect with distinct nonce identities, both receive frames, both tear down cleanly | **Not verified anywhere:** - The OBS GUI, on any platform. No human has looked at this in OBS. -- macOS and Windows beyond "CI compiles and the core tests pass". The WinHTTP - backend has never run against a real streamer-tools server. +- macOS beyond "CI builds and links the real module and the core tests pass". + Its artifact is a bare `.so` with a relative libobs install name and will + not load in OBS.app — see the macOS packaging gap under CI. +- Windows beyond "the core library and the WinHTTP backend compile and their + tests pass", from runs predating the current fixes. The WinHTTP backend has + never run against a real streamer-tools server, only against the loopback + test server in `test_api_client`. - A/V sync and end-to-end latency against the existing egress path. - Behaviour against the real production streamer-tools server (only against a stand-in serving the same shapes). @@ -183,14 +195,98 @@ livekit-server 1.13.6 in dev mode): `.gitea/workflows/build.yml` runs on every push, matrixed across the three runners available to this repo under the `CyberCoveLLC` org. -| Job | `runs-on` | Runner | -|---|---|---| -| `linux` | `ubuntu-latest` | `gitea-runner.internal.cloud-hosting.io` (Global) | -| `macos` | `macos-latest` | `home-mac` (Global) | -| `windows` | `windows-latest` | `winvm-builder` (org-scoped) | +| Job | `runs-on` | Runner | State | +|---|---|---|---| +| `linux` | `ubuntu-24.04` | `localhost.localdomain` | **Green.** Builds the real adapter against Ubuntu's libobs-dev 30.0.2, runs all six test suites, uploads `build/package` as an artifact | +| `macos` | `macos-latest` | `home-mac` (Global) | **Green.** Builds libobs 30.0.2 from source, then the real adapter; 6/6 tests; artifact uploaded. But see the macOS packaging gap below | +| `windows` | `windows-latest` | `winvm-builder` (org-scoped) | **Unconfirmed** — see below | -Linux uses Ubuntu's `libobs-dev` and builds the real OBS adapter. macOS and -Windows use the `obsproject/obs-plugintemplate` buildspec bootstrap, trimmed -to drop `qt6` (this plugin's properties UI is plain `obs_properties_*`), with -`obs-studio.version` pinned no newer than what Linux builds against — OBS -rejects a module built against a newer libobs than the one running it. +The Linux job is pinned to `ubuntu-24.04` rather than `ubuntu-latest`: this +instance's two Linux runners answer `ubuntu-latest` with different releases, +and 22.04's `libobs-dev` is OBS 27 — a different API surface, and the LiveKit +SDK's own `linux-x64` asset does not even link there (hence the +`ubuntu-22.04` SDK triple; see `cmake/LiveKitSDK.cmake`). + +macOS and Windows use the `obsproject/obs-plugintemplate` buildspec +bootstrap, trimmed to drop `qt6` (this plugin's properties UI is plain +`obs_properties_*`), with `obs-studio.version` pinned to 30.0.2 — the same +version Linux builds against, and deliberately low, because OBS rejects a +module built against a newer libobs than the one running it. + +**Both jobs fall back to a core-library-only build if the bootstrap fails**, +rather than going red, with a workflow `::warning::` and a "Show what was +built" step that reports no module. That fallback exists because the +bootstrap is the least verifiable part of this project — there is no way to +exercise a macOS or Windows OBS build from the Linux development machine — +and a permanently red CI teaches people to ignore CI. **Do not remove the +warning:** a green job that quietly stopped building the plugin is worse than +a red one. + +### Where the macOS bootstrap actually got to + +Six CI iterations, each fixing a real failure visible in the logs: + +1. Upstream's Xcode generator → `No CMAKE_C_COMPILER could be found` (the + runner has the Command Line Tools, not Xcode). Switched to Ninja. +2. OBS's SDK version regex only matches a full-Xcode SDK path. Synthesised a + `MacOSX.platform/Developer/SDKs/MacOSX.sdk` symlink to the same SDK. +3. The install walked into `UI/obs-frontend-api`, whose binary is + deliberately never built. The install's exit code is now tolerated. +4. Restricting the install to `libobs/` fixed that but lost the + per-configuration export file. +5. `xattr -r -d com.apple.quarantine` followed the SDK symlink into the + read-only system SDK. Symlink moved to the build directory; the xattr step + is no longer fatal. +6. `IMPORTED_LOCATION or IMPORTED_IMPLIB not set for imported target + OBS::libobs configuration Release` — OBS 30.0.2 installs + `libobsTargets.cmake` without the per-config file that carries the library + path. The top-level `CMakeLists.txt` now detects a locationless + `OBS::libobs` and points it at the framework the bootstrap just built. + +All six are confirmed fixed: the macOS job now downloads obs-deps and +obs-studio, builds libobs from source, builds and links the real adapter, +passes 6/6 tests, and uploads its artifact. `otool -L` on the result shows it +linked against libobs and `@rpath/liblivekit.dylib`. + +### macOS packaging gap (known, unfixed) + +**The macOS artifact will not load in OBS.app as it stands.** Two reasons, +neither of which CI can catch, because CI only proves it compiles and links: + +1. It is a bare `streamer-tools-camera.so`. OBS on macOS loads plugins as + `.plugin` bundles (`Contents/MacOS/`, `Contents/Resources/`, + an `Info.plist`), which is what obs-plugintemplate's + `cmake/macos/helpers.cmake` builds and which this project deliberately did + not vendor. +2. `otool -L` shows the libobs dependency recorded as the relative path + `libobs/libobs.framework/Versions/A/libobs`, inherited from the + from-source libobs's own install name. A real plugin needs + `@rpath/libobs.framework/Versions/A/libobs` plus an `LC_RPATH` pointing at + `OBS.app/Contents/Frameworks`. + +Fixing this means either vendoring the template's macOS bundle helpers or +adding an `install_name_tool` pass and a bundle layout — bounded work, but +work that has to be done and checked on an actual Mac. It is deliberately not +attempted here rather than guessed at. + +### Where the Windows bootstrap got to + +Windows is by far the slowest job — the `lukka/get-cmake` step alone takes +7-15 minutes on `winvm-builder`, and the runner serialises jobs, so a burst of +pushes leaves a queue that takes an hour to drain. One confirmed bug of its +own was found and fixed: upstream passes `-A x64,version=` to the +OBS sub-configure, and with a current CMake that `,version=` suffix reappears +verbatim in the sub-build's `CMAKE_VS_PLATFORM_NAME` — which obs-studio's own +dependency downloader uses as the architecture, sending it after +`windows-deps-2023-11-03-x64,version=10.0.26100.0.zip`: + +``` +string sub-command JSON member 'hashes windows-x64,version=10.0.26100.0' not found +Unable to download .../windows-deps-2023-11-03-x64,version=10.0.26100.0.zip +``` + +Plain `-A x64` now. **No Windows run has yet completed with that fix in +place**, so Windows should be treated as unverified beyond "the core library +and the WinHTTP backend compile and their tests pass", which earlier runs did +show. Expect further iterations there of the same kind the macOS bootstrap +needed. -- 2.52.0 From 524e78c1a66a8fd80a17627b454b0f09e90315bc Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 6 Sep 2026 22:31:17 -0700 Subject: [PATCH 14/17] ci: write the Windows steps in PowerShell, not bash The core-only fallback and the "Show what was built" step were written with `shell: bash`. `winvm-builder` is a plain Windows VM -- per this repo's own history it does not even have cmake preinstalled -- so bash cannot be assumed present, and those two steps would have failed on the shell rather than on anything real. Both are PowerShell now, using $LASTEXITCODE and Test-Path. Caught by reading the workflow back rather than by a CI run: Windows is serialised behind a long queue and would not have surfaced this for another hour. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE --- .gitea/workflows/build.yml | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index c1539b3..c20c893 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -122,13 +122,16 @@ jobs: # # Same fallback as macOS, and the same warning: a green job that # quietly stopped building the plugin is worse than a red one. - shell: bash + # PowerShell, not bash: this runner is a plain Windows VM and bash + # cannot be assumed present. run: | - if ! cmake -S . -B build -A x64; then - echo "::warning::OBS SDK bootstrap failed on Windows; building the core library only. The plugin module was NOT built." - rm -rf build + cmake -S . -B build -A x64 + if ($LASTEXITCODE -ne 0) { + Write-Host "::warning::OBS SDK bootstrap failed on Windows; building the core library only. The plugin module was NOT built." + Remove-Item -Recurse -Force build -ErrorAction SilentlyContinue cmake -S . -B build -A x64 -DSTPLUGIN_BOOTSTRAP_OBS=OFF - fi + if ($LASTEXITCODE -ne 0) { exit 1 } + } - name: Build run: cmake --build build --config Release @@ -137,8 +140,12 @@ jobs: run: ctest --test-dir build -C Release --output-on-failure - name: Show what was built - shell: bash - run: ls -la build/package/bin || echo "no plugin module was built (core library only)" + run: | + if (Test-Path build\package\bin) { + Get-ChildItem build\package\bin + } else { + Write-Host "no plugin module was built (core library only)" + } - name: Upload plugin continue-on-error: true -- 2.52.0 From 6829dd545a37ef4ef133c9b7d941ac034c62c570 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 6 Sep 2026 22:36:01 -0700 Subject: [PATCH 15/17] Stage the plugin into the directory layout OBS actually searches The staged package put the module in build/package/bin. OBS does not look there. From AddExtraModulePaths() in obs-studio's UI/window-basic-main.cpp, the per-user plugin layout on Linux and Windows is: /obs-studio/plugins//bin/64bit/.{so,dll} /obs-studio/plugins//data/ so build/package/ now uses bin/64bit and is a straight drop-in. Re-verified in the headless libobs harness from the new path: the module loads, both sources connect, frames arrive, the camera switch round-trips, and `ldd` on the staged copy resolves liblivekit and liblivekit_ffi from bin/64bit via $ORIGIN. macOS deliberately keeps the flat bin/ -- there OBS looks for a .plugin/Contents/MacOS bundle, which this build does not produce. That gap is documented in README.md rather than papered over with a directory name that would only look right. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE --- .gitea/workflows/build.yml | 10 +++++----- README.md | 19 ++++++++++++++----- obs-adapter/CMakeLists.txt | 23 ++++++++++++++++++++--- 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index c20c893..54b784c 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -45,9 +45,9 @@ jobs: - name: Show what was built run: | - ls -la build/package/bin build/package/data/locale build/package/licenses - ldd build/package/bin/streamer-tools-camera.so | grep -E 'obs|livekit' - nm -D build/package/bin/streamer-tools-camera.so | grep -E ' T obs_module_(load|unload)' + ls -la build/package/bin/64bit build/package/data/locale build/package/licenses + ldd build/package/bin/64bit/streamer-tools-camera.so | grep -E 'obs|livekit' + nm -D build/package/bin/64bit/streamer-tools-camera.so | grep -E ' T obs_module_(load|unload)' - name: Upload plugin continue-on-error: true @@ -141,8 +141,8 @@ jobs: - name: Show what was built run: | - if (Test-Path build\package\bin) { - Get-ChildItem build\package\bin + if (Test-Path build\package\bin\64bit) { + Get-ChildItem build\package\bin\64bit } else { Write-Host "no plugin module was built (core library only)" } diff --git a/README.md b/README.md index 1eeb252..3526ee5 100644 --- a/README.md +++ b/README.md @@ -109,15 +109,20 @@ and `-DSTPLUGIN_LIVEKIT_SDK_TRIPLE` override the pin and the release triple. The build stages a runnable layout into `build/package/`: ``` -build/package/bin/streamer-tools-camera.so (RPATH=$ORIGIN) -build/package/bin/liblivekit.so -build/package/bin/liblivekit_ffi.so +build/package/bin/64bit/streamer-tools-camera.so (RPATH=$ORIGIN) +build/package/bin/64bit/liblivekit.so +build/package/bin/64bit/liblivekit_ffi.so build/package/data/locale/en-US.ini build/package/licenses/... ``` -`build/package/bin` is what gets installed — the module resolves the LiveKit -libraries from `$ORIGIN` / `@loader_path`, not from the build tree. +That is exactly the layout OBS searches on Linux and Windows — +`/obs-studio/plugins//bin/64bit` plus a sibling `data/`, per +`AddExtraModulePaths()` in obs-studio's `UI/window-basic-main.cpp` — so +`build/package/` is a straight drop-in. The module resolves the LiveKit +libraries from `$ORIGIN` (verified: `ldd` on the staged copy resolves both +to `bin/64bit/`), not from the build tree. macOS is not this shape; see the +macOS packaging gap under CI. ## Testing this by hand @@ -131,6 +136,10 @@ cp -r build/package/bin build/package/data \ obs ``` +(That yields `.../streamer-tools-camera/bin/64bit/streamer-tools-camera.so` +and `.../streamer-tools-camera/data/locale/en-US.ini`, which is what OBS +looks for.) + Then: Sources → `+` → "streamer-tools Camera" → fill in the server URL, room slug and read key from the room's settings page → "Refresh camera list" → pick a camera. Check `~/.config/obs-studio/logs/` for diff --git a/obs-adapter/CMakeLists.txt b/obs-adapter/CMakeLists.txt index 22046b9..53590af 100644 --- a/obs-adapter/CMakeLists.txt +++ b/obs-adapter/CMakeLists.txt @@ -61,10 +61,27 @@ endif() # via the build-tree RPATH. set(STPLUGIN_PACKAGE_DIR "${CMAKE_BINARY_DIR}/package") +# The binary subdirectory matches what OBS actually searches. From +# AddExtraModulePaths() in obs-studio's UI/window-basic-main.cpp, the +# per-user plugin layout on Linux and Windows is +# /obs-studio/plugins//bin/64bit/.{so,dll} +# /obs-studio/plugins//data/ +# so staging into bin/64bit makes build/package/ a straight drop-in. +# +# macOS is NOT this shape -- there OBS looks for a +# .plugin/Contents/MacOS bundle -- and this build does not produce one. +# See the macOS packaging gap in README.md; the flat bin/ here is honest +# about being unfinished rather than pretending to be installable. +if(APPLE) + set(STPLUGIN_PACKAGE_BIN_DIR "${STPLUGIN_PACKAGE_DIR}/bin") +else() + set(STPLUGIN_PACKAGE_BIN_DIR "${STPLUGIN_PACKAGE_DIR}/bin/64bit") +endif() + add_custom_command(TARGET ${STPLUGIN_PROJECT_NAME} POST_BUILD - COMMAND ${CMAKE_COMMAND} -E make_directory "${STPLUGIN_PACKAGE_DIR}/bin" - COMMAND ${CMAKE_COMMAND} -E copy "$" "${STPLUGIN_PACKAGE_DIR}/bin/" - COMMAND ${CMAKE_COMMAND} -E copy ${LIVEKIT_SDK_RUNTIME_LIBS} "${STPLUGIN_PACKAGE_DIR}/bin/" + COMMAND ${CMAKE_COMMAND} -E make_directory "${STPLUGIN_PACKAGE_BIN_DIR}" + COMMAND ${CMAKE_COMMAND} -E copy "$" "${STPLUGIN_PACKAGE_BIN_DIR}/" + COMMAND ${CMAKE_COMMAND} -E copy ${LIVEKIT_SDK_RUNTIME_LIBS} "${STPLUGIN_PACKAGE_BIN_DIR}/" COMMAND ${CMAKE_COMMAND} -E make_directory "${STPLUGIN_PACKAGE_DIR}/data/locale" COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_SOURCE_DIR}/data/locale/en-US.ini" -- 2.52.0 From f2a4932eeaee1f5a350a9fc3dbda981217e2ac33 Mon Sep 17 00:00:00 2001 From: Claude Sonnet 5 Date: Sun, 6 Sep 2026 22:59:57 -0700 Subject: [PATCH 16/17] Fix review findings: stopping-flag race, unpinned SDK download, key-leak via redirect/logs Code review findings from before merging feat/livekit-integration to main: - I1: sourceDestroy set self->stopping outside self->mutex, then notified. The worker's condition-variable predicate reads `stopping` under that same mutex, so the store+notify could land between the worker's predicate check and it entering the wait, dropping the notification and leaving the worker asleep for its full backoff (up to 30s) with the OBS UI thread blocked in worker.join(). Now set under the lock, matching how `generation` is already mutated in applySettings. - I3: the LiveKit SDK archive download in cmake/LiveKitSDK.cmake had no SHA256 pin wired up from the top-level CMakeLists.txt, unlike the obs-deps bootstrap right next to it. Added real SHA256 hashes -- computed by downloading each release archive and running sha256sum -- for every triple the pinned v1.10.1 release can resolve to (Linux x64/arm64, macOS x64/arm64, Windows x64), keyed by version+triple so a future version bump fails loudly (via message(WARNING)) instead of silently going unverified. Verified end-to-end locally: a deliberately wrong hash makes the configure step fail with a HASH mismatch error. Only Linux was also build-tested in this environment; macOS/Windows archives were downloaded and hashed but not build-tested here. - I4: the curl HTTP backend followed up to 3 redirects while the read key travels as a URL query parameter, so a malicious/misconfigured redirect (including an HTTPS->HTTP downgrade, which curl doesn't refuse by default) could leak the key. This client only ever talks to two fixed, first-party endpoints, so redirects are disabled outright (CURLOPT_FOLLOWLOCATION 0), matching the WinHTTP backend's existing default behavior. Left normalizeServerUrl's explicit-http:// pass-through as-is with a comment, per review guidance. - I5: ApiClient::redactedUrl was tested but never called. No current call site logs a request URL, so rather than inventing one, added a one-line comment marking it a deliberate guard rail for future logging. - I7: the LiveKit SDK log bridge (livekitLogToObs) wrote SDK messages straight into the OBS log. LiveKit's signaling URL carries the access token as a query parameter; defensively scrub "access_token=" and "key=" values before they ever reach obs_log. New ApiClient::redactSensitiveParams generalizes redactedUrl's redaction pattern to arbitrary text (not just a bare URL), with 6 new unit tests in test_api_client.cpp. - I2: added a code comment on session.cpp's auto_subscribe=true noting the known, unaddressed bandwidth/CPU cost of pulling every participant's track in multi-camera rooms, and that per-publication unsubscribe is a future optimization. No behavior change (out of scope per review). Verified: cmake configure + build + `ctest --test-dir build --output-on-failure` all pass, 6/6 suites (test_api_client now 127 checks, up from 121). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE --- CMakeLists.txt | 46 ++++++++++++++++++++++++++++++ core/include/stplugin/api_client.h | 11 +++++++ core/src/api_client.cpp | 39 +++++++++++++++++++++++++ core/src/http_curl.cpp | 13 +++++++-- core/src/session.cpp | 9 ++++++ core/tests/test_api_client.cpp | 33 +++++++++++++++++++++ obs-adapter/src/plugin-main.cpp | 21 ++++++++++++-- 7 files changed, 168 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index b1b8dd8..c125aa6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -73,17 +73,63 @@ set(STPLUGIN_LIVEKIT_SDK_TRIPLE "" CACHE STRING 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)") +# Pinned SHA256 checksums for the client-sdk-cpp v1.10.1 release archives, +# so the download in cmake/LiveKitSDK.cmake is verified the same way the +# obs-deps bootstrap next to it already is (see +# cmake/common/buildspec_common.cmake ~line 324). Each hash below was +# computed by downloading the real GitHub release asset and running +# `sha256sum` on it (2026-09-06/07) -- none of these were guessed or copied +# from an unverified source. To add a hash for a new version or triple: +# curl -LO https://github.com/livekit/client-sdk-cpp/releases/download/v/livekit-sdk--. +# sha256sum livekit-sdk--.* +# Covers every triple _lk_default_triple() can resolve to for this pinned +# version: Linux (ubuntu-22.04-x64/arm64), macOS (macos-x64/arm64) and +# Windows (windows-x64). Verified by extracting each archive +# (tar tzf / unzip -l) and confirming a real LiveKitConfig.cmake inside -- +# only Linux was also verified by an actual local CMake configure+build in +# this environment; macOS and Windows were downloaded and hashed but not +# build-tested here. +set(_stplugin_livekit_sha256_1.10.1_ubuntu-22.04-x64 "6f4fc8143f36952d42bfd5ff8d1782cf6211ba8fd6b055877e9ef85441d66324") +set(_stplugin_livekit_sha256_1.10.1_ubuntu-22.04-arm64 "399677167b474b7f107c6937904ea01898c9ec8da648cbed669387e599c6ea45") +set(_stplugin_livekit_sha256_1.10.1_macos-x64 "7102655c1f2947be4b06a95f9fafa1a11379219328e82ad875e5ebccfc9ac7e3") +set(_stplugin_livekit_sha256_1.10.1_macos-arm64 "0822af7014519a473c5b5cd019bde58c26cc2bfe5b78e4a790395ead232dc55b") +set(_stplugin_livekit_sha256_1.10.1_windows-x64 "b9fc6b2865298d7e3d032205d7e74fb9628cfe55a2ed28cb657db0a481cd518c") + include(LiveKitSDK) +if(STPLUGIN_LIVEKIT_SDK_TRIPLE) + set(_stplugin_livekit_triple "${STPLUGIN_LIVEKIT_SDK_TRIPLE}") +else() + # Mirrors LiveKitSDK.cmake's own autodetection so the checksum lookup + # below matches whatever triple livekit_sdk_setup() will actually + # resolve to and download. + _lk_default_triple(_stplugin_livekit_triple) +endif() + +set(_stplugin_livekit_sha256_var + "_stplugin_livekit_sha256_${STPLUGIN_LIVEKIT_SDK_VERSION}_${_stplugin_livekit_triple}") +if(DEFINED ${_stplugin_livekit_sha256_var}) + set(_stplugin_livekit_sha256 "${${_stplugin_livekit_sha256_var}}") +else() + set(_stplugin_livekit_sha256 "") + message(WARNING + "LiveKitSDK: no pinned SHA256 for triple '${_stplugin_livekit_triple}' " + "at version ${STPLUGIN_LIVEKIT_SDK_VERSION} -- the downloaded archive " + "will NOT be integrity-checked. Compute one (see the comment above " + "this block) and add it to CMakeLists.txt.") +endif() + if(STPLUGIN_LIVEKIT_SDK_TRIPLE) livekit_sdk_setup( VERSION "${STPLUGIN_LIVEKIT_SDK_VERSION}" SDK_DIR "${STPLUGIN_LIVEKIT_SDK_DIR}" TRIPLE "${STPLUGIN_LIVEKIT_SDK_TRIPLE}" + SHA256 "${_stplugin_livekit_sha256}" ) else() livekit_sdk_setup( VERSION "${STPLUGIN_LIVEKIT_SDK_VERSION}" SDK_DIR "${STPLUGIN_LIVEKIT_SDK_DIR}" + SHA256 "${_stplugin_livekit_sha256}" ) endif() find_package(LiveKit CONFIG REQUIRED) diff --git a/core/include/stplugin/api_client.h b/core/include/stplugin/api_client.h index e09c954..7303bb8 100644 --- a/core/include/stplugin/api_client.h +++ b/core/include/stplugin/api_client.h @@ -115,6 +115,17 @@ public: /// with the read key replaced by "***". static std::string redactedUrl(const std::string &url); + /// Scrubs "access_token=" and "key=" out of arbitrary + /// text -- not necessarily a bare URL/query string -- replacing each + /// value with "". Unlike redactedUrl (which only has to + /// handle "&"-delimited query parameters), a value here can be followed + /// by a quote or whitespace, because the text this scrubs is a free-form + /// log line that may merely *contain* a URL. Used by the OBS adapter's + /// LiveKit SDK log bridge: LiveKit's signaling URL carries the access + /// token as a query parameter, and the SDK's own log lines could + /// include it. + static std::string redactSensitiveParams(const std::string &text); + private: std::shared_ptr http_; }; diff --git a/core/src/api_client.cpp b/core/src/api_client.cpp index 5cff058..31edd73 100644 --- a/core/src/api_client.cpp +++ b/core/src/api_client.cpp @@ -96,6 +96,10 @@ std::string ApiClient::normalizeServerUrl(const std::string &raw) const bool has_scheme = url.compare(0, 7, "http://") == 0 || url.compare(0, 8, "https://") == 0; if (!has_scheme) url = "https://" + url; + // An explicit "http://..." is left as-is on purpose: an operator who + // typed the scheme out has made a deliberate (if inadvisable) choice, + // and this function's job is only to supply a sane default, not to + // second-guess an explicit one. while (!url.empty() && url.back() == '/') url.pop_back(); @@ -108,6 +112,11 @@ std::string ApiClient::normalizeServerUrl(const std::string &raw) return url; } +// No current call site logs a request URL (the OBS adapter only logs +// ws_url/status text, never the streamer-tools API request URL itself) -- +// this exists as a deliberate guard rail for whenever request-URL logging +// is added later, so the read key can never be pasted into an OBS log by +// accident. Not dead code to be deleted. std::string ApiClient::redactedUrl(const std::string &url) { const std::size_t at = url.find("key="); @@ -120,6 +129,36 @@ std::string ApiClient::redactedUrl(const std::string &url) return url.substr(0, value) + "***" + url.substr(end); } +std::string ApiClient::redactSensitiveParams(const std::string &text) +{ + static const char *const kParams[] = {"access_token=", "key="}; + + std::string out = text; + for (const char *param : kParams) { + const std::size_t param_len = std::string(param).size(); + std::size_t pos = 0; + while ((pos = out.find(param, pos)) != std::string::npos) { + const std::size_t value_start = pos + param_len; + std::size_t value_end = value_start; + // A value ends at the next query-string delimiter, a quote (the + // URL is often embedded in a quoted/bracketed log line), or + // whitespace -- whichever comes first -- or at the end of the + // string. + while (value_end < out.size()) { + const char c = out[value_end]; + if (c == '&' || c == '"' || c == '\'' || c == ' ' || c == '\t' || c == '\n' || + c == '\r' || c == ')' || c == ']') + break; + ++value_end; + } + const std::string replacement = ""; + out.replace(value_start, value_end - value_start, replacement); + pos = value_start + replacement.size(); + } + } + return out; +} + SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config, int timeout_ms) const { SlotsResult result; diff --git a/core/src/http_curl.cpp b/core/src/http_curl.cpp index 96d8933..977fd51 100644 --- a/core/src/http_curl.cpp +++ b/core/src/http_curl.cpp @@ -78,8 +78,17 @@ public: curl_easy_setopt(curl, CURLOPT_WRITEDATA, &ctx); curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, static_cast(request.timeout_ms)); curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, static_cast(request.timeout_ms)); - curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); - curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L); + // Redirects are never legitimate here: this client only ever talks to + // two fixed, first-party streamer-tools API endpoints, and the read + // key travels as a URL query parameter (see api_client.cpp). Blindly + // following a redirect -- including an HTTPS->HTTP downgrade, which + // curl does not refuse by default -- would hand that key to whatever + // host the redirect points at. A redirect from our own server is a + // configuration error, so treat it as a failed request instead of + // silently following it. This also brings this backend in line with + // http_winhttp.cpp, which already refuses HTTPS->HTTP downgrades by + // default. + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L); curl_easy_setopt(curl, CURLOPT_USERAGENT, "streamer-tools-obs-plugin/1.0"); // NOSIGNAL is required whenever curl is used off the main thread: // without it curl installs a SIGALRM handler for DNS timeouts, which diff --git a/core/src/session.cpp b/core/src/session.cpp index 2682cf1..6a9fb41 100644 --- a/core/src/session.cpp +++ b/core/src/session.cpp @@ -617,6 +617,15 @@ bool LiveKitSession::connect(const SessionConfig &config) livekit::RoomOptions options; // auto_subscribe is what makes track_subscribed events (and therefore any // media at all) happen; the SDK is emphatic about this. + // + // Known, measured-but-unaddressed cost: auto_subscribe pulls every + // participant's published track, not just the one camera this session + // actually wants, and this client discards the unwanted ones + // client-side. In a multi-camera room that is real, wasted bandwidth + // and decode CPU that scales with room size, not with what this source + // displays. Selectively unsubscribing from unwanted publications (the + // SDK exposes per-publication subscribe/unsubscribe) is a real + // follow-up optimization, deliberately out of scope here. options.auto_subscribe = true; options.dynacast = false; // This client never publishes, so a single peer connection is all it diff --git a/core/tests/test_api_client.cpp b/core/tests/test_api_client.cpp index 0c74260..7fc3c50 100644 --- a/core/tests/test_api_client.cpp +++ b/core/tests/test_api_client.cpp @@ -99,6 +99,38 @@ void testUrlEncodeAndRedaction() ST_ASSERT_EQ(ApiClient::redactedUrl("https://h/nothing"), std::string("https://h/nothing")); } +void testRedactSensitiveParams() +{ + // The LiveKit log bridge's actual use case: a signaling URL embedded in + // a free-form SDK log line, not a bare query string. + ST_ASSERT_EQ(ApiClient::redactSensitiveParams( + "connecting to wss://lk.example.com/rtc?access_token=eyJhbGciOiJIUzI1NiJ9.abc.def&x=1"), + std::string("connecting to wss://lk.example.com/rtc?access_token=&x=1")); + + // A value can be terminated by a quote or whitespace, not just '&', since + // this scrubs arbitrary text rather than a URL/query string. + ST_ASSERT_EQ(ApiClient::redactSensitiveParams("url=\"wss://h/rtc?access_token=secret\" state=connecting"), + std::string("url=\"wss://h/rtc?access_token=\" state=connecting")); + + // "key=" is also scrubbed, matching redactedUrl's convention. + ST_ASSERT_EQ(ApiClient::redactSensitiveParams("GET https://h/api/obs/r/slots?key=secret"), + std::string("GET https://h/api/obs/r/slots?key=")); + + // Both params can appear in the same message, and each is independently + // redacted. + ST_ASSERT_EQ( + ApiClient::redactSensitiveParams("a access_token=tok1 b key=tok2 c"), + std::string("a access_token= b key= c")); + + // Text with neither parameter passes through unchanged. + ST_ASSERT_EQ(ApiClient::redactSensitiveParams("livekit: participant joined"), + std::string("livekit: participant joined")); + + // A value at the very end of the string is still bounded correctly. + ST_ASSERT_EQ(ApiClient::redactSensitiveParams("token was access_token=trailing"), + std::string("token was access_token=")); +} + void testRequestShape() { auto fake = makeFake(200, R"({"slots":[]})"); @@ -421,6 +453,7 @@ int main() { testNormalizeServerUrl(); testUrlEncodeAndRedaction(); + testRedactSensitiveParams(); testRequestShape(); testSlotsHappyPath(); testSlotsEdgeCases(); diff --git a/obs-adapter/src/plugin-main.cpp b/obs-adapter/src/plugin-main.cpp index 91e4912..da72f01 100644 --- a/obs-adapter/src/plugin-main.cpp +++ b/obs-adapter/src/plugin-main.cpp @@ -410,7 +410,17 @@ void sourceDestroy(void *data) if (!self) return; - self->stopping.store(true); + { + // Must be set while holding `mutex`, matching how `generation` is + // mutated in applySettings: the worker's wait predicate reads + // `stopping` under this same lock, so setting it outside the lock + // can race between the worker's predicate check and it entering + // the wait, dropping the notify_all() below and leaving the worker + // asleep for its full backoff (up to kBackoffMaxMs) while this + // (OBS UI) thread blocks in worker.join(). + std::lock_guard guard(self->mutex); + self->stopping.store(true); + } self->wake.notify_all(); if (self->worker.joinable()) self->worker.join(); @@ -563,7 +573,14 @@ void livekitLogToObs(livekit::LogLevel level, const std::string &, const std::st case livekit::LogLevel::Info: obs_level = LOG_INFO; break; default: obs_level = LOG_DEBUG; break; } - obs_log(obs_level, "livekit: %s", message.c_str()); + // LiveKit's signaling connection URL carries the access token as a + // query parameter. This is defensive, not a response to a confirmed + // leak: if the SDK ever logs that URL (or anything else carrying + // "access_token=" or "key="), the token must not land verbatim in an + // OBS log file that a director might paste into a support ticket. Scrub + // unconditionally before this message ever reaches obs_log. + const std::string scrubbed = ApiClient::redactSensitiveParams(message); + obs_log(obs_level, "livekit: %s", scrubbed.c_str()); } } // namespace -- 2.52.0 From 551f782d8a71f4e07b2751f7e7986d5675d8163e Mon Sep 17 00:00:00 2001 From: Claude Sonnet 5 Date: Sun, 6 Sep 2026 23:00:06 -0700 Subject: [PATCH 17/17] docs: correct Windows CI record, add release-gating note (C1/C2, I6) - I6: README claimed Windows CI status as "Unconfirmed". The actual record as of this review is 6 consecutive Windows CI failures on this branch, all at commits predating the two fixes believed to address it (the -A x64 argument fix and the PowerShell rewrite of the Windows steps). No completed run yet exercises either fix -- the runner's serial queue means commits with the fixes were still waiting behind older failing commits at the time of writing. Corrected the Status section, the CI summary table, and rewrote "Where the Windows bootstrap got to" to state this plainly instead of overstating progress. - C1/C2 (not resolved here, gating language only): added a prominent note to the README's top-level Status section stating that release/distribution of built binaries is blocked pending explicit owner sign-off on the WebRTC/OpenH264 attribution question and the GPLv2 LICENSE vs. Apache-2.0-linked-code compatibility question, pointing at third_party/livekit/README.md where the details already live. Checked .gitea/workflows/build.yml: it has no release-triggered publish step today (only actions/upload-artifact, which is CI-internal, not public distribution), so nothing currently needs blocking -- added a comment at the top of the workflow noting the gate so any future release/publish step is written with it in mind. Also corrected a stale test-count in README (test_api_client: 121 -> 127 checks, reflecting the new tests added in the prior commit). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE --- .gitea/workflows/build.yml | 9 +++++ README.md | 75 +++++++++++++++++++++++++++++--------- 2 files changed, 66 insertions(+), 18 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 54b784c..fb43835 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -9,6 +9,15 @@ name: Build # bootstrap (buildspec.json + cmake/common/buildspec_common.cmake), which # downloads the pinned obs-deps bundle and obs-studio source and builds just # `libobs`. That step is the slow one: several minutes on a cold runner. +# +# RELEASE GATE: this workflow only builds, tests, and uploads CI-internal +# workflow artifacts (actions/upload-artifact, below) -- it does not create a +# Gitea Release, push a tag-triggered publish, or otherwise distribute +# binaries publicly, and it must not start doing so without explicit owner +# sign-off on the WebRTC/OpenH264 attribution and GPLv2/Apache-2.0 +# license-compatibility questions tracked in third_party/livekit/README.md +# and the README's top-level Status section. If a real release/publish step +# is ever added here, it must carry that same gate. on: push: diff --git a/README.md b/README.md index 3526ee5..dd189ef 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,20 @@ Media-Source path for directors. Full design: ## Status +**Release/distribution of built binaries is blocked pending owner sign-off.** +This plugin statically/dynamically pulls in Google WebRTC and OpenH264 code +through the LiveKit SDK, and this repository's own top-level `LICENSE` is +GPLv2 while the vendored LiveKit binaries are Apache-2.0 — both a real patent/ +royalty question (OpenH264/WebRTC) and a real license-compatibility question +(GPLv2 vs. Apache-2.0-linked code) that only the project owner can decide. +Nothing in this repo should be built into a package and handed out, posted, +or attached to a public release until that sign-off happens. See +`third_party/livekit/README.md` for the specifics of what is and is not +currently known/shipped on the licensing side. (CI in `.gitea/workflows/build.yml` +currently only builds, tests, and uploads CI-internal build artifacts — it +does not create a Gitea Release or otherwise publish anything publicly; if +that ever changes, the new step must carry this same gate.) + The plugin is **functionally complete on Linux and verified end to end there** (module loads into real libobs, connects to a real LiveKit server through the real streamer-tools API shape, and pushes decoded frames into @@ -15,7 +29,13 @@ real streamer-tools API shape, and pushes decoded frames into It has **not been run in the OBS GUI on any platform.** macOS builds the real module in CI but its artifact is not yet loadable (see the macOS packaging gap -under CI). Windows has not yet completed a build with the current fixes. +under CI). Windows CI has **failed on every completed run so far** (7 +consecutive failures on this branch as of this writing); a run against the +commit with the `-A x64` argument fix is in progress but not yet complete, +and the commit with the PowerShell rewrite of the Windows steps is still +queued behind it. Neither fix has a completed, passing run yet. See "Where +the Windows bootstrap got to" under CI below for the exact record, and check +current CI status rather than trusting this paragraph's age. See "What is verified, and how" below for exactly what has and has not been checked, and "Testing this by hand" for what a human still needs to do. @@ -173,7 +193,7 @@ livekit-server 1.13.6 in dev mode): |---|---| | The pinned LiveKit SDK links and is callable | `test_livekit_smoke`: `initialize()`/`shutdown()` round-trip, header version asserted equal to the CMake pin | | The JSON reader handles real and hostile input | `test_json`, 158 checks, including truncated bodies, HTML error pages, binary garbage, lone surrogates, and a depth-limit case | -| The API client parses the real response shapes and every error branch | `test_api_client`, 121 checks, against a fake HTTP client **and** a real loopback HTTP server driving the actual platform backend | +| The API client parses the real response shapes and every error branch | `test_api_client`, 127 checks, against a fake HTTP client **and** a real loopback HTTP server driving the actual platform backend | | A dead/stalled/garbage server cannot hang or crash the plugin | loopback cases: truncated JSON, connection closed with no reply, non-HTTP bytes, dead port, stalled server cut off by the client timeout | | Session state transitions, track selection, frame geometry | `test_session`, 81 checks, plus real `connect()` failures against the real SDK | | **Media actually flows** | `test_integration_livekit` against a real LiveKit server: 36 video frames + 323 audio frames, correct I420 geometry and plane pointers, publisher unpublish → `hasVideo()` false with **no further frames from the dead publisher**, republish → video resumes | @@ -208,7 +228,7 @@ runners available to this repo under the `CyberCoveLLC` org. |---|---|---|---| | `linux` | `ubuntu-24.04` | `localhost.localdomain` | **Green.** Builds the real adapter against Ubuntu's libobs-dev 30.0.2, runs all six test suites, uploads `build/package` as an artifact | | `macos` | `macos-latest` | `home-mac` (Global) | **Green.** Builds libobs 30.0.2 from source, then the real adapter; 6/6 tests; artifact uploaded. But see the macOS packaging gap below | -| `windows` | `windows-latest` | `winvm-builder` (org-scoped) | **Unconfirmed** — see below | +| `windows` | `windows-latest` | `winvm-builder` (org-scoped) | **Failing** — 7/7 completed runs on this branch have failed; see below | The Linux job is pinned to `ubuntu-24.04` rather than `ubuntu-latest`: this instance's two Linux runners answer `ubuntu-latest` with different releases, @@ -282,20 +302,39 @@ attempted here rather than guessed at. Windows is by far the slowest job — the `lukka/get-cmake` step alone takes 7-15 minutes on `winvm-builder`, and the runner serialises jobs, so a burst of -pushes leaves a queue that takes an hour to drain. One confirmed bug of its -own was found and fixed: upstream passes `-A x64,version=` to the -OBS sub-configure, and with a current CMake that `,version=` suffix reappears -verbatim in the sub-build's `CMAKE_VS_PLATFORM_NAME` — which obs-studio's own -dependency downloader uses as the architecture, sending it after -`windows-deps-2023-11-03-x64,version=10.0.26100.0.zip`: +pushes leaves a queue that takes an hour to drain. -``` -string sub-command JSON member 'hashes windows-x64,version=10.0.26100.0' not found -Unable to download .../windows-deps-2023-11-03-x64,version=10.0.26100.0.zip -``` +**The honest record: every completed Windows CI run on this branch has +failed. 7 consecutive failures**, at the 7 branch commits (in order) that had +a completed Windows run as of this writing -- all of them at commits before +the `-A x64` fix below was applied. As of this writing, a Windows run against +the commit with that fix is in progress but has not yet completed, and the +commit with the PowerShell rewrite is still queued behind it (the runner's +serial queue means fixed commits can sit behind older, unfixed ones for a +while). Do not read either fix below as "confirmed" until a Windows run +actually goes green on a commit that includes it; check current CI status +rather than trusting this paragraph's age. -Plain `-A x64` now. **No Windows run has yet completed with that fix in -place**, so Windows should be treated as unverified beyond "the core library -and the WinHTTP backend compile and their tests pass", which earlier runs did -show. Expect further iterations there of the same kind the macOS bootstrap -needed. +Two bugs of its own were found and (believed, not yet proven) fixed: + +1. Upstream passes `-A x64,version=` to the OBS sub-configure, + and with a current CMake that `,version=` suffix reappears verbatim in the + sub-build's `CMAKE_VS_PLATFORM_NAME` — which obs-studio's own dependency + downloader uses as the architecture, sending it after + `windows-deps-2023-11-03-x64,version=10.0.26100.0.zip`: + + ``` + string sub-command JSON member 'hashes windows-x64,version=10.0.26100.0' not found + Unable to download .../windows-deps-2023-11-03-x64,version=10.0.26100.0.zip + ``` + + Plain `-A x64` now. +2. The Windows CI steps were originally written in bash (via + `shell: bash`), which is a poor fit for a `windows-latest` runner's + default toolchain expectations; they were rewritten in PowerShell. + +Until a Windows run completes green with both fixes in place, Windows should +be treated as unverified beyond "the core library and the WinHTTP backend +compile and their tests pass", which earlier (failing-job) runs did show +before failing later in the job. Expect further iterations there of the same +kind the macOS bootstrap needed. -- 2.52.0