From e6142da8b559521a0dc67cd678c11641fefd6add Mon Sep 17 00:00:00 2001 From: Michael Faille Date: Sun, 19 Apr 2026 16:32:45 -0700 Subject: [PATCH 1/3] Add headless Linux build path --- Cargo.toml | 2 +- examples/local_audio/Cargo.toml | 2 +- examples/local_video/Cargo.toml | 2 +- livekit/Cargo.toml | 3 ++- webrtc-sys/build.rs | 35 +++++++++++++++++++++------------ 5 files changed, 27 insertions(+), 17 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9345c5f9d..dbcf3b5be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,7 +46,7 @@ license = "Apache-2.0" [workspace.dependencies] device-info = { version = "0.1.1", path = "device-info" } imgproc = { version = "0.3.19", path = "imgproc" } -libwebrtc = { version = "0.3.34", path = "libwebrtc" } +libwebrtc = { version = "0.3.34", path = "libwebrtc", default-features = false } livekit = { version = "0.7.42", path = "livekit" } livekit-api = { version = "0.4.24", path = "livekit-api" } livekit-ffi = { version = "0.12.60", path = "livekit-ffi" } diff --git a/examples/local_audio/Cargo.toml b/examples/local_audio/Cargo.toml index 200f528b3..6d65b9cd8 100644 --- a/examples/local_audio/Cargo.toml +++ b/examples/local_audio/Cargo.toml @@ -9,7 +9,7 @@ tokio = { workspace = true, features = ["full"] } env_logger = { workspace = true } livekit = { workspace = true, features = ["rustls-tls-native-roots"] } livekit-api = { workspace = true, features = ["rustls-tls-native-roots"] } -libwebrtc = { workspace = true } +libwebrtc = { workspace = true, features = ["glib-main-loop"] } log = { workspace = true } cpal = "0.15" anyhow = { workspace = true } diff --git a/examples/local_video/Cargo.toml b/examples/local_video/Cargo.toml index 82be9f3f5..03cbeb00e 100644 --- a/examples/local_video/Cargo.toml +++ b/examples/local_video/Cargo.toml @@ -26,7 +26,7 @@ required-features = ["desktop"] tokio = { workspace = true, features = ["full", "parking_lot"] } livekit = { workspace = true, features = ["rustls-tls-native-roots"] } webrtc-sys = { workspace = true } -libwebrtc = { workspace = true } +libwebrtc = { workspace = true, features = ["glib-main-loop"] } livekit-api = { workspace = true } yuv-sys = { workspace = true, features = ["jpeg"] } futures = { workspace = true } diff --git a/livekit/Cargo.toml b/livekit/Cargo.toml index 307d2d7ed..79c2fd656 100644 --- a/livekit/Cargo.toml +++ b/livekit/Cargo.toml @@ -8,11 +8,12 @@ repository.workspace = true [features] # By default ws TLS is not enabled -default = ["tokio"] +default = ["tokio", "glib-main-loop"] async = ["livekit-api/signal-client-async"] tokio = ["livekit-api/signal-client-tokio"] dispatcher = ["livekit-api/signal-client-dispatcher"] +glib-main-loop = ["libwebrtc/glib-main-loop"] # Note that the following features only change the behavior of tokio-tungstenite. diff --git a/webrtc-sys/build.rs b/webrtc-sys/build.rs index 4535fc5e4..a7ee2b564 100644 --- a/webrtc-sys/build.rs +++ b/webrtc-sys/build.rs @@ -16,6 +16,10 @@ use std::path::Path; use std::path::PathBuf; use std::{env, path, process::Command}; +fn lk_headless() -> bool { + env::var("LK_HEADLESS").map(|v| v == "1" || v.eq_ignore_ascii_case("true")).unwrap_or(false) +} + fn main() { if env::var("DOCS_RS").is_ok() { return; @@ -23,10 +27,13 @@ fn main() { let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap(); let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap(); - let is_desktop = target_os == "linux" || target_os == "windows" || target_os == "macos"; + let headless_linux = target_os == "linux" && lk_headless(); + let is_desktop = + (target_os == "linux" || target_os == "windows" || target_os == "macos") && !headless_linux; println!("cargo:rerun-if-env-changed=LK_DEBUG_WEBRTC"); println!("cargo:rerun-if-env-changed=LK_CUSTOM_WEBRTC"); + println!("cargo:rerun-if-env-changed=LK_HEADLESS"); let mut rust_files = vec![ "src/peer_connection.rs", @@ -171,20 +178,22 @@ fn main() { println!("cargo:rustc-link-lib=dylib=pthread"); println!("cargo:rustc-link-lib=dylib=m"); - // In order to avoid any ABI mismatches we use the sysroot's headers. - add_gio_headers(&mut builder); + if !headless_linux { + // In order to avoid any ABI mismatches we use the sysroot's headers. + add_gio_headers(&mut builder); - for lib_name in ["glib-2.0", "gobject-2.0", "gio-2.0"] { - pkg_config::probe_library(lib_name).unwrap(); - } + for lib_name in ["glib-2.0", "gobject-2.0", "gio-2.0"] { + pkg_config::probe_library(lib_name).unwrap(); + } - add_lazy_load_so( - &mut builder, - "desktop_capturer", - ["drm", "gbm", "X11", "Xfixes", "Xdamage", "Xrandr", "Xcomposite", "Xext"] - .map(String::from) - .to_vec(), - ); + add_lazy_load_so( + &mut builder, + "desktop_capturer", + ["drm", "gbm", "X11", "Xfixes", "Xdamage", "Xrandr", "Xcomposite", "Xext"] + .map(String::from) + .to_vec(), + ); + } let x86 = target_arch == "x86_64" || target_arch == "i686"; let arm = target_arch == "aarch64" || target_arch.contains("arm"); From d7246bf74cb60bc8c65a6be2815b54f9ae803f38 Mon Sep 17 00:00:00 2001 From: Michael Faille Date: Sun, 19 Apr 2026 18:02:20 -0700 Subject: [PATCH 2/3] Add changeset for headless Linux build path --- .changeset/headless_linux_build_path.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/headless_linux_build_path.md diff --git a/.changeset/headless_linux_build_path.md b/.changeset/headless_linux_build_path.md new file mode 100644 index 000000000..f80a748dc --- /dev/null +++ b/.changeset/headless_linux_build_path.md @@ -0,0 +1,8 @@ +--- +livekit: patch +webrtc-sys: patch +libwebrtc: patch +livekit-ffi: patch +--- + +Add headless Linux build path - #1024 (@mikefaille) From be88852bf76500c06df89d585b04b418390f1a62 Mon Sep 17 00:00:00 2001 From: Michael Faille Date: Tue, 26 May 2026 14:08:55 -0700 Subject: [PATCH 3/3] refactor(headless): refine headless Linux build configuration and add C++ stubs --- .../fix_headless_livekit_feature_wiring.md | 5 + README.md | 6 + docs/HEADLESS.md | 113 ++++++++++++++++++ libwebrtc/Cargo.toml | 2 + livekit-ffi/Cargo.toml | 6 +- livekit/Cargo.toml | 2 + webrtc-sys/Cargo.toml | 2 + webrtc-sys/build.rs | 17 +-- webrtc-sys/include/livekit/desktop_capturer.h | 40 +++++++ webrtc-sys/src/desktop_capturer.cpp | 14 ++- 10 files changed, 196 insertions(+), 11 deletions(-) create mode 100644 .changeset/fix_headless_livekit_feature_wiring.md create mode 100644 docs/HEADLESS.md diff --git a/.changeset/fix_headless_livekit_feature_wiring.md b/.changeset/fix_headless_livekit_feature_wiring.md new file mode 100644 index 000000000..14ec07f99 --- /dev/null +++ b/.changeset/fix_headless_livekit_feature_wiring.md @@ -0,0 +1,5 @@ +--- +livekit: patch +--- + +Disable libwebrtc default features in workspace dependency wiring so `livekit --no-default-features -F tokio` no longer re-enables `glib-main-loop` transitively. diff --git a/README.md b/README.md index 29e0a8b27..19ff5f64a 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,12 @@ When building on MacOS, `-ObjC` linker flag is needed. LiveKit's WebRTC implemen ## Environment variables +### `LK_HEADLESS` + +For server/headless Linux deployments where desktop capturing dependencies (such as X11, GLib, GIO, DRM, and GBM) are not present, set `LK_HEADLESS=1` to compile without those native library requirements. The C++ desktop capturer will be replaced by stubs at build time, and `DesktopCapturer::new()` will return `None`. + +See [HEADLESS.md](docs/HEADLESS.md) for more details. + ### `LIVEKIT_PREFERRED_HW_ENCODER` On Linux builds that include support for multiple hardware video encoders (NVENC and VAAPI), this variable selects which one is preferred when both are available at runtime. Accepted values: diff --git a/docs/HEADLESS.md b/docs/HEADLESS.md new file mode 100644 index 000000000..832670f02 --- /dev/null +++ b/docs/HEADLESS.md @@ -0,0 +1,113 @@ +# Headless Linux Build Support + +This document details how to compile and run the LiveKit Rust SDK on headless Linux environments (such as server deployments, Docker containers, or CI environments) where desktop-related libraries (e.g., X11, GLib, GIO, DRM, and GBM) are not installed. + +--- + +## Overview + +By default, the SDK compiles with native desktop capture support (`glib-main-loop`). On Linux, this requires GLib event loops and X11/Wayland libraries. For server-side or containerized usage, these desktop dependencies are unnecessary and complicate builds. + +We provide a **unified hybrid configuration** offering two parallel mechanisms to compile without these dependencies: + +1. **Option A: Cargo Features** (Declarative configuration within your Cargo dependency tree) +2. **Option B: Environment Variables** (Direct shell or CI/CD environment overrides) + +Regardless of the option chosen, **the public Rust API surface remains identical**. `DesktopCapturer::new()` compiles successfully but returns `None` at runtime, ensuring your downstream code doesn't need to be gated by custom `#[cfg]` attributes. + +--- + +## Comparison: Option A vs. Option B + +| Dimension | Option A: Cargo Features | Option B: Environment Variable | +| :--- | :--- | :--- | +| **Configuration Style** | Declarative in `Cargo.toml`. | Shell environment (`export LK_HEADLESS=1`). | +| **Scope** | Targets a specific crate or workspace package. | Global override across the entire build invocation. | +| **Workspace Ergonomics** | Fragile if some crates transitively unify default features. | Highly robust for workspace builds and containerized builds. | +| **Caching Integration** | Automatic compile cache invalidation by Cargo. | Enabled via `cargo:rerun-if-env-changed` in `build.rs`. | +| **Best Used For** | Purely headless applications declaring their own dependencies. | CI/CD pipelines, Dockerfiles, and heterogeneous workspaces. | + +--- + +## Option A: Cargo Features (`headless`) + +To configure a headless build declaratively, disable the default features of the `livekit` crate and opt in to `headless` and `tokio`: + +```toml +[dependencies] +livekit = { version = "0.7", default-features = false, features = ["tokio", "headless"] } +``` + +### Option A: FAQ + +#### Q1: Why do I need `default-features = false`? +By default, the `livekit` crate includes the `glib-main-loop` feature to provide desktop capturing integration out of the box. You must disable default features to prevent this feature from pulling in GLib. + +#### Q2: What happens if a third-party dependency pulls in default features of `livekit`? +Due to Cargo's feature unification, if any dependency in your tree enables default features for `livekit`, the `glib-main-loop` feature will be unified and enabled globally. In this case, Option A will fail, and you must use **Option B** to force a headless compile. + +#### Q3: Do I need `resolver = "2"` in my workspace? +Yes. Cargo's resolver version 2 is required to prevent build-dependencies (like `build.rs` dependencies) from unifying their features with target dependencies. The root `Cargo.toml` in the SDK is already configured with `resolver = "2"`. + +--- + +## Option B: Environment Variable (`LK_HEADLESS=1`) + +To force a headless build globally without editing `Cargo.toml` files, set the environment variable: + +```bash +LK_HEADLESS=1 cargo build --release +``` + +Or inject it into a Dockerfile: + +```dockerfile +ENV LK_HEADLESS=1 +RUN cargo build --release +``` + +### Option B: FAQ + +#### Q1: How does `LK_HEADLESS=1` bypass Cargo feature unification? +Even if the `glib-main-loop` Cargo feature is transitively enabled, the `build.rs` script of `webrtc-sys` checks the `LK_HEADLESS` environment variable. If set to `1` or `true`, the build script completely bypasses probing for GLib/GIO/X11/DRM/GBM packages. + +#### Q2: Can this cause runtime linker errors? +No. Because `build.rs` skips registering these library linkages, the binary will not link against X11, GLib, or DRM libraries. + +#### Q3: Does this affect hardware accelerated video codecs (CUDA / VAAPI)? +No. Hardware acceleration (such as NVidia CUDA/NVCodec or Intel VAAPI) is preserved on headless builds since they do not require desktop environments. They continue to load their respective drivers dynamically via dlopen at runtime. + +#### Q4: How is compile caching affected when changing `LK_HEADLESS`? +The build script prints `cargo:rerun-if-env-changed=LK_HEADLESS`. Cargo automatically invalidates the compile cache and rebuilds the native bindings if you switch the environment variable between builds. + +--- + +## What Changes in the API? + +The Rust API surface is kept completely uniform. You do **not** need to wrap your screensharing logic in `#[cfg]` attributes: + +```rust +use livekit::webrtc::desktop_capturer::{DesktopCapturer, DesktopCapturerOptions, DesktopCaptureSourceType}; + +// This compiles on both desktop and headless targets +let options = DesktopCapturerOptions { + source_type: DesktopCaptureSourceType::Screen, + include_cursor: true, + allow_sck_system_picker: false, +}; + +// Returns Some(capturer) on desktop; returns None on headless Linux/servers +if let Some(capturer) = DesktopCapturer::new(options) { + println!("Desktop capturer started successfully!"); +} else { + println!("Running in a headless/server environment (desktop capturer disabled)."); +} +``` + +--- + +## How It Works Under the Hood + +1. **Feature Wiring**: The `headless` Cargo feature is forwarded from `livekit` -> `libwebrtc` -> `webrtc-sys`. +2. **Build Script Bypassing**: If `LK_HEADLESS=1` or `CARGO_FEATURE_HEADLESS` is detected, `webrtc-sys/build.rs` skips GLib/GIO/X11 pkg-config lookups and defines the preprocessor macro `-DLK_HEADLESS=1` for the C++ compiler. +3. **C++ Stubbing**: Inside `desktop_capturer.cpp`, all methods accessing WebRTC's desktop capture systems are enclosed in `#ifndef LK_HEADLESS` guards. In headless mode, the factory returns `nullptr`, and all operations become safe no-ops. diff --git a/libwebrtc/Cargo.toml b/libwebrtc/Cargo.toml index 325bcd450..03baf1bb1 100644 --- a/libwebrtc/Cargo.toml +++ b/libwebrtc/Cargo.toml @@ -14,6 +14,8 @@ default = [ "glib-main-loop" ] # event loop running in your application, for example if you are using the # GTK or GStreamer Rust bindings, disable this feature. glib-main-loop = [ "dep:glib" ] +# Disable native desktop capture dependencies (X11, GLib, GIO, DRM, GBM) for headless/server builds +headless = [ "webrtc-sys/headless" ] [dependencies] log = { workspace = true } diff --git a/livekit-ffi/Cargo.toml b/livekit-ffi/Cargo.toml index 5829c5b90..7e5af464e 100644 --- a/livekit-ffi/Cargo.toml +++ b/livekit-ffi/Cargo.toml @@ -9,18 +9,20 @@ readme = "README.md" publish = false [features] -default = ["rustls-tls-native-roots"] +default = ["rustls-tls-native-roots", "livekit/tokio", "livekit/glib-main-loop"] native-tls = ["livekit/native-tls"] native-tls-vendored = ["livekit/native-tls-vendored"] rustls-tls-native-roots = ["livekit/rustls-tls-native-roots"] rustls-tls-webpki-roots = ["livekit/rustls-tls-webpki-roots"] __rustls-tls = ["livekit/__rustls-tls"] +# Disable native desktop capture dependencies (X11, GLib, GIO, DRM, GBM) for headless/server builds +headless = ["livekit/headless", "webrtc-sys/headless"] # Enable tokio-console to debug tasks tracing = ["tokio/tracing", "console-subscriber"] [dependencies] -livekit = { workspace = true } +livekit = { workspace = true, default-features = false } webrtc-sys = { workspace = true } soxr-sys = { workspace = true } imgproc = { workspace = true } diff --git a/livekit/Cargo.toml b/livekit/Cargo.toml index 79c2fd656..10f48b5a5 100644 --- a/livekit/Cargo.toml +++ b/livekit/Cargo.toml @@ -14,6 +14,8 @@ async = ["livekit-api/signal-client-async"] tokio = ["livekit-api/signal-client-tokio"] dispatcher = ["livekit-api/signal-client-dispatcher"] glib-main-loop = ["libwebrtc/glib-main-loop"] +# Disable native desktop capture dependencies (X11, GLib, GIO, DRM, GBM) for headless/server builds +headless = ["libwebrtc/headless"] # Note that the following features only change the behavior of tokio-tungstenite. diff --git a/webrtc-sys/Cargo.toml b/webrtc-sys/Cargo.toml index 966883710..3b29ec6d7 100644 --- a/webrtc-sys/Cargo.toml +++ b/webrtc-sys/Cargo.toml @@ -9,6 +9,8 @@ repository.workspace = true [features] default = [] +# Disable native desktop capture dependencies (X11, GLib, GIO, DRM, GBM) for headless/server builds +headless = [] [dependencies] cxx = "1.0" diff --git a/webrtc-sys/build.rs b/webrtc-sys/build.rs index a7ee2b564..647e7e383 100644 --- a/webrtc-sys/build.rs +++ b/webrtc-sys/build.rs @@ -16,10 +16,6 @@ use std::path::Path; use std::path::PathBuf; use std::{env, path, process::Command}; -fn lk_headless() -> bool { - env::var("LK_HEADLESS").map(|v| v == "1" || v.eq_ignore_ascii_case("true")).unwrap_or(false) -} - fn main() { if env::var("DOCS_RS").is_ok() { return; @@ -27,9 +23,11 @@ fn main() { let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap(); let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap(); - let headless_linux = target_os == "linux" && lk_headless(); - let is_desktop = - (target_os == "linux" || target_os == "windows" || target_os == "macos") && !headless_linux; + let is_headless = target_os == "linux" + && (env::var("LK_HEADLESS").as_deref() == Ok("1") + || env::var("LK_HEADLESS").as_deref() == Ok("true") + || env::var("CARGO_FEATURE_HEADLESS").is_ok()); + let is_desktop = target_os == "linux" || target_os == "windows" || target_os == "macos"; println!("cargo:rerun-if-env-changed=LK_DEBUG_WEBRTC"); println!("cargo:rerun-if-env-changed=LK_CUSTOM_WEBRTC"); @@ -126,6 +124,9 @@ fn main() { webrtc_include.join("sdk/objc/base"), ]); builder.define("WEBRTC_APM_DEBUG_DUMP", "0"); + if is_headless { + builder.define("LK_HEADLESS", "1"); + } println!("cargo:rustc-link-search=native={}", webrtc_lib.to_str().unwrap()); @@ -178,7 +179,7 @@ fn main() { println!("cargo:rustc-link-lib=dylib=pthread"); println!("cargo:rustc-link-lib=dylib=m"); - if !headless_linux { + if !is_headless { // In order to avoid any ABI mismatches we use the sysroot's headers. add_gio_headers(&mut builder); diff --git a/webrtc-sys/include/livekit/desktop_capturer.h b/webrtc-sys/include/livekit/desktop_capturer.h index aac111c4e..ab7b587b3 100644 --- a/webrtc-sys/include/livekit/desktop_capturer.h +++ b/webrtc-sys/include/livekit/desktop_capturer.h @@ -17,9 +17,41 @@ #pragma once #include +#ifndef LK_HEADLESS #include "modules/desktop_capture/desktop_capturer.h" +#else +#include +#include +namespace webrtc { +class DesktopFrame { + public: + struct Size { int32_t width() const { return 0; } int32_t height() const { return 0; } }; + struct Rect { int32_t left() const { return 0; } int32_t top() const { return 0; } }; + Size size() const { return {}; } + Rect rect() const { return {}; } + int32_t stride() const { return 0; } + const uint8_t* data() const { return nullptr; } +}; +class DesktopCapturer { + public: + enum class Result { SUCCESS, ERROR_TEMPORARY, ERROR_PERMANENT }; + struct Source { + int64_t id; + std::string title; + }; + typedef std::vector SourceList; + class Callback { + public: + virtual ~Callback() = default; + virtual void OnCaptureResult(Result result, std::unique_ptr frame) = 0; + }; +}; +} +#endif + #include "rust/cxx.h" + namespace livekit_ffi { class DesktopFrame; class DesktopCapturer; @@ -40,9 +72,17 @@ class DesktopCapturer : public webrtc::DesktopCapturer::Callback { std::unique_ptr frame) final; rust::Vec get_source_list() const; +#ifdef LK_HEADLESS + bool select_source(uint64_t id) const { return false; } +#else bool select_source(uint64_t id) const { return capturer->SelectSource(id); } +#endif void start(rust::Box callback); +#ifdef LK_HEADLESS + void capture_frame() const {} +#else void capture_frame() const { capturer->CaptureFrame(); } +#endif private: std::unique_ptr capturer; diff --git a/webrtc-sys/src/desktop_capturer.cpp b/webrtc-sys/src/desktop_capturer.cpp index 8b5af3c86..5d1e7f727 100644 --- a/webrtc-sys/src/desktop_capturer.cpp +++ b/webrtc-sys/src/desktop_capturer.cpp @@ -16,7 +16,9 @@ #include "livekit/desktop_capturer.h" +#ifndef LK_HEADLESS #include "modules/desktop_capture/desktop_capture_options.h" +#endif using SourceList = webrtc::DesktopCapturer::SourceList; @@ -24,6 +26,9 @@ namespace livekit_ffi { std::unique_ptr new_desktop_capturer( DesktopCapturerOptions options) { +#ifdef LK_HEADLESS + return nullptr; +#else webrtc::DesktopCaptureOptions webrtc_options = webrtc::DesktopCaptureOptions::CreateDefault(); #if defined(WEBRTC_MAC) && !defined(WEBRTC_IOS) @@ -72,17 +77,21 @@ std::unique_ptr new_desktop_capturer( return nullptr; } return std::make_unique(std::move(capturer)); +#endif } void DesktopCapturer::start( rust::Box callback) { +#ifndef LK_HEADLESS this->callback = std::move(callback); capturer->Start(this); +#endif } void DesktopCapturer::OnCaptureResult( webrtc::DesktopCapturer::Result result, std::unique_ptr frame) { +#ifndef LK_HEADLESS CaptureResult ret_result = CaptureResult::ErrorPermanent; switch (result) { case webrtc::DesktopCapturer::Result::SUCCESS: @@ -101,18 +110,21 @@ void DesktopCapturer::OnCaptureResult( (*callback)->on_capture_result( ret_result, std::make_unique(std::move(frame))); } +#endif } rust::Vec DesktopCapturer::get_source_list() const { + rust::Vec source_list{}; +#ifndef LK_HEADLESS SourceList list{}; bool res = capturer->GetSourceList(&list); - rust::Vec source_list{}; if (res) { for (auto& source : list) { source_list.push_back(Source{static_cast(source.id), source.title, source.display_id}); } } +#endif return source_list; } } // namespace livekit_ffi \ No newline at end of file