diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index d0aee75..fb43835 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -1,8 +1,23 @@ 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. +# +# 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: @@ -10,8 +25,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 @@ -19,10 +39,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 +52,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/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 + 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,11 +76,21 @@ 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. - run: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release + # The buildspec bootstrap runs here: it fetches obs-deps + the pinned + # obs-studio source and builds libobs before this project configures. + # + # 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 @@ -53,6 +98,19 @@ jobs: - name: Test (core library) run: ctest --test-dir build --output-on-failure + - 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 + + - 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 +119,46 @@ 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. + # + # Same fallback as macOS, and the same warning: a green job that + # quietly stopped building the plugin is worse than a red one. + # PowerShell, not bash: this runner is a plain Windows VM and bash + # cannot be assumed present. + run: | + 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 + if ($LASTEXITCODE -ne 0) { exit 1 } + } - 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: | + if (Test-Path build\package\bin\64bit) { + Get-ChildItem build\package\bin\64bit + } else { + Write-Host "no plugin module was built (core library only)" + } + + - 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 2be358a..c125aa6 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,23 +11,184 @@ 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") +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) + +# 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) + # 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 +# 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)") + +# 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) 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) diff --git a/README.md b/README.md index 3bbf6f8..dd189ef 100644 --- a/README.md +++ b/README.md @@ -1,159 +1,340 @@ # 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. +**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 +`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 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. ## 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). `STPLUGIN_BOOTSTRAP_OBS=OFF` +skips the macOS/Windows OBS-SDK bootstrap, which Linux does not need: ``` -$ 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 -DSTPLUGIN_BOOTSTRAP_OBS=OFF +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/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/... +``` + +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 + +**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 +``` + +(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 +`[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`, 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 | +| **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 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). +- 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) | -| `macos` | `macos-latest` | `home-mac` (Global) | -| `windows` | `windows-latest` | `winvm-builder` (org-scoped to `CyberCoveLLC`) | +| 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) | **Failing** — 7/7 completed runs on this branch have failed; see below | -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). +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`). -No packaging/release step yet -- out of scope for this pass. +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. + +**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. + +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. 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/LiveKitSDK.cmake b/cmake/LiveKitSDK.cmake new file mode 100644 index 0000000..304eb1c --- /dev/null +++ b/cmake/LiveKitSDK.cmake @@ -0,0 +1,221 @@ +# 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) + 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) + 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/cmake/common/buildspec_common.cmake b/cmake/common/buildspec_common.cmake new file mode 100644 index 0000000..e5e35a2 --- /dev/null +++ b/cmake/common/buildspec_common.cmake @@ -0,0 +1,370 @@ +# 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. +# 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. +# 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) + +# _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() + +# _resolve_versioned_macos_sdk: return an SDK path that satisfies OBS's own +# macOS SDK version check. +# +# Not upstream. OBS 30.0.2's cmake/macos/compilerconfig.cmake reads the SDK +# version straight out of CMAKE_OSX_SYSROOT with this regex: +# +# ".+/MacOSX.platform/Developer/SDKs/MacOSX([0-9]+\\.[0-9])+\\.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 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) + + 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) + message(WARNING "Could not determine the macOS SDK path via xcrun; leaving CMAKE_OSX_SYSROOT alone.") + return() + endif() + + if(_sdk MATCHES "/MacOSX\\.platform/Developer/SDKs/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]+)") + message(WARNING "Could not determine the macOS SDK version via xcrun (got '${_sdk_version}').") + return() + endif() + set(_short "${CMAKE_MATCH_1}.${CMAKE_MATCH_2}") + + # 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}") + file(CREATE_LINK "${_sdk}" "${_link}" SYMBOLIC) + endif() + if(EXISTS "${_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() + +# _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() + + # 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}") + # 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 + # 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() + _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})") + 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") + + # 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 + # + # 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} --component Development --config Release --prefix "${dependencies_dir}" + WORKING_DIRECTORY "${dependencies_dir}/${_obs_destination}" + 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() + +# _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..1f3eb5a --- /dev/null +++ b/cmake/macos/buildspec.cmake @@ -0,0 +1,46 @@ +# 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() + + # 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 + 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) +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/CMakeLists.txt b/core/CMakeLists.txt index 618c517..8ffef13 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -4,15 +4,50 @@ # 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 + src/session_types.cpp + src/session.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 ) +target_link_libraries(stplugin_core + PUBLIC + 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) + +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 new file mode 100644 index 0000000..7303bb8 --- /dev/null +++ b/core/include/stplugin/api_client.h @@ -0,0 +1,133 @@ +/* +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); + + /// @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 + /// 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); + + /// 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_; +}; + +} // namespace stplugin 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/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/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/api_client.cpp b/core/src/api_client.cpp new file mode 100644 index 0000000..31edd73 --- /dev/null +++ b/core/src/api_client.cpp @@ -0,0 +1,252 @@ +/* +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; + // 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(); + + // "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; +} + +// 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="); + 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); +} + +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; + 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"); + request.timeout_ms = timeout_ms; + + 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, int timeout_ms) 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 = "{}"; + request.timeout_ms = timeout_ms; + + 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/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/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..977fd51 --- /dev/null +++ b/core/src/http_curl.cpp @@ -0,0 +1,146 @@ +/* +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)); + // 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 + // 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/src/session.cpp b/core/src/session.cpp new file mode 100644 index 0000000..6a9fb41 --- /dev/null +++ b/core/src/session.cpp @@ -0,0 +1,742 @@ +/* +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. + // + // 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 + // 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 c517532..434e6b3 100644 --- a/core/tests/CMakeLists.txt +++ b/core/tests/CMakeLists.txt @@ -1,7 +1,39 @@ -add_executable(stplugin_core_tests - test_core.cpp +# 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) +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) -target_link_libraries(stplugin_core_tests PRIVATE stplugin_core) +# 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) -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). +stplugin_add_test(test_livekit_smoke) +target_compile_definitions(test_livekit_smoke PRIVATE + STPLUGIN_EXPECTED_LIVEKIT_VERSION="${LIVEKIT_SDK_VERSION_RESOLVED}" +) 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..7fc3c50 --- /dev/null +++ b/core/tests/test_api_client.cpp @@ -0,0 +1,467 @@ +/* +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 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":[]})"); + 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(); + testRedactSensitiveParams(); + testRequestShape(); + testSlotsHappyPath(); + testSlotsEdgeCases(); + testTokenHappyPath(); + testHttpErrorStatuses(); + testMalformedSuccessBodies(); + testNetworkErrorAndInvalidConfig(); + testPlatformBackendAgainstLoopback(); + testPlatformBackendTimeout(); + return st_test_report("api_client"); +} 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"); } 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_json.cpp b/core/tests/test_json.cpp new file mode 100644 index 0000000..e1fbf05 --- /dev/null +++ b/core/tests/test_json.cpp @@ -0,0 +1,188 @@ +/* +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 + +#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")); +} + +// 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()); + 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("")); + + // "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)); +} + +static void testMalformedIsRejectedNotCrashed() +{ + const std::vector bad = { + "", + " ", + "{", + "}", + "[", + "[1,", + "[1,]", + "{\"a\"}", + "{\"a\":}", + "{\"a\":1,}", + "{a:1}", + "{'a':1}", + "\"unterminated", + "\"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.", + "1e", + "1e+", + "tru", + "nulll", + "{}garbage", // trailing content + "[1,2] [3]", + "\"raw\ncontrol\"", // literal control char inside a string + "\xff\xfe", // binary garbage + "502 Bad Gateway", + }; + 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 + // 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"); +} 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_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/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; +} diff --git a/obs-adapter/CMakeLists.txt b/obs-adapter/CMakeLists.txt index d5be005..53590af 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,70 @@ 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") + +# 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_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" + "${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..da72f01 --- /dev/null +++ b/obs-adapter/src/plugin-main.cpp @@ -0,0 +1,605 @@ +/* +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. + // + // 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); + 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; + 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 || + 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; + // 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); + 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); + // 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); + } + }); + + { + 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; + + { + // 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(); + + // 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; + } + // 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 + +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/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() 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.