cargo_obs_build/
lib.rs

1use git::{fetch_latest_patch_release, fetch_release, ReleaseInfo};
2use lock::{acquire_lock, wait_for_lock};
3use log::{debug, info, warn};
4use metadata::fetch_latest_release_tag;
5use std::{
6    env,
7    fs::{self, File},
8    path::{Path, PathBuf},
9};
10use util::{copy_to_dir, delete_all_except};
11use walkdir::WalkDir;
12
13use lib_version::get_lib_obs_version;
14
15use download::download_binaries;
16use zip::ZipArchive;
17
18pub use metadata::get_meta_info;
19
20mod download;
21mod git;
22mod lib_version;
23mod lock;
24mod metadata;
25mod util;
26
27/// Check if we're running in a CI environment
28fn is_ci_environment() -> bool {
29    env::var("CI").is_ok()
30        || env::var("GITHUB_ACTIONS").is_ok()
31        || env::var("GITLAB_CI").is_ok()
32        || env::var("CIRCLECI").is_ok()
33        || env::var("TRAVIS").is_ok()
34        || env::var("JENKINS_URL").is_ok()
35        || env::var("BUILDKITE").is_ok()
36}
37
38/// Check and warn about CI environment configuration issues
39fn check_ci_environment(cache_dir: &Path) {
40    if !is_ci_environment() {
41        return;
42    }
43
44    let mut warnings = Vec::new();
45
46    // Check if GitHub token is set
47    if env::var("GITHUB_TOKEN").is_err() {
48        warnings.push(
49            "GITHUB_TOKEN environment variable not set in CI. \
50This may cause GitHub API rate limiting issues.",
51        );
52    }
53
54    // Check if cache directory exists
55    if !cache_dir.exists() {
56        warnings.push(
57            "OBS build cache directory does not exist. \
58Consider caching this directory in your CI configuration to speed up builds. \
59Ignore if this is the first run.",
60        );
61    }
62
63    if !warnings.is_empty() {
64        println!("cargo:warning=");
65        println!("cargo:warning=⚠️  CI Environment Configuration Issues Detected:");
66        for warning in warnings {
67            println!("cargo:warning=  - {}", warning);
68        }
69        println!("cargo:warning=");
70        println!("cargo:warning=For detailed setup instructions, see:");
71        println!("cargo:warning=https://github.com/libobs-rs/libobs-rs/blob/main/cargo-obs-build/CI_SETUP.md");
72        println!("cargo:warning=");
73    }
74}
75
76/// Configuration options for building OBS binaries
77#[derive(Debug, Clone)]
78pub struct ObsBuildConfig {
79    /// The directory the libobs binaries should be installed to (this is typically your `target/debug` or `target/release` directory)
80    pub out_dir: PathBuf,
81
82    /// The location where the OBS Studio binaries should be downloaded to. If this is set to None, it defaults to reading the `Cargo.toml` metadata. If no metadata is set, it defaults to `obs-build`.
83    pub cache_dir: Option<PathBuf>,
84
85    /// The GitHub repository to clone OBS Studio from, if not specified it defaults to `obsproject/obs-studio`
86    pub repo_id: Option<String>,
87
88    /// If this is specified, the specified zip file will be used instead of downloading the latest release
89    /// This is useful for testing purposes, but it is not recommended to use this in production
90    pub override_zip: Option<PathBuf>,
91
92    /// When this flag is active, the cache will be cleared and a new build will be started
93    pub rebuild: bool,
94
95    /// If the browser should be included in the build
96    pub browser: bool,
97
98    /// The tag of the OBS Studio release to build.
99    /// If none is specified, first the `Cargo.toml` metadata will be checked, if the version is not set it'll find the matching release for the libobs crate will be used.
100    /// Use `latest` for the latest obs release.
101    pub tag: Option<String>,
102
103    /// If the compatibility check should be skipped
104    pub skip_compatibility_check: bool,
105
106    /// If set, PDBs will be deleted after extraction to save space, saving disk space.
107    pub remove_pdbs: bool,
108}
109
110impl Default for ObsBuildConfig {
111    fn default() -> Self {
112        Self {
113            out_dir: PathBuf::from("obs-out"),
114            cache_dir: None,
115            repo_id: None,
116            override_zip: None,
117            rebuild: false,
118            browser: false,
119            tag: None,
120            skip_compatibility_check: false,
121            remove_pdbs: false,
122        }
123    }
124}
125
126/// Simple installation method for use in build scripts.
127///
128/// This automatically:
129/// - Determines the target directory from the OUT_DIR environment variable
130/// - Uses default cache directory ("obs-build") if none is specified in metadata
131/// - Auto-detects the OBS version from the libobs crate
132/// - Handles all caching and locking
133///
134/// # Example
135///
136/// ```rust,no_run
137/// cargo_obs_build::install().expect("Failed to install OBS binaries");
138/// ```
139///
140/// This is equivalent to calling `build_obs_binaries()` with default configuration
141/// and the out_dir set to `$OUT_DIR/../../obs-binaries`.
142pub fn install() -> anyhow::Result<()> {
143    use std::env;
144
145    let out_dir = env::var("OUT_DIR")
146        .map_err(|_| anyhow::anyhow!("OUT_DIR environment variable not set. This function should only be called from a build script."))?;
147
148    let target_dir = PathBuf::from(&out_dir);
149    let target_dir = target_dir
150        .parent()
151        .and_then(|p| p.parent())
152        .and_then(|p| p.parent())
153        .ok_or_else(|| anyhow::anyhow!("Failed to determine target directory from OUT_DIR"))?;
154
155    let config = ObsBuildConfig {
156        out_dir: target_dir.to_path_buf(),
157        ..Default::default()
158    };
159
160    build_obs_binaries(config)
161}
162
163/// Build and install OBS binaries according to the provided configuration
164///
165/// This is the main entry point for the library. It handles:
166/// - Version detection from the libobs crate
167/// - Downloading and extracting OBS binaries
168/// - Caching to avoid re-downloads
169/// - Locking to prevent concurrent builds
170/// - Copying binaries to the target directory
171pub fn build_obs_binaries(config: ObsBuildConfig) -> anyhow::Result<()> {
172    //TODO For build scripts, we should actually check the TARGET env var instead of just erroring out on linux, but I don't think anyone will be cross-compiling
173
174    if cfg!(target_os = "linux") {
175        // The case for the "install" subcommand is handled before calling this function
176        return Err(anyhow::anyhow!("Building OBS Studio from source is required on Linux. You can install binaries by running `cargo-obs-build install` separately before building your project."));
177    }
178
179    let ObsBuildConfig {
180        mut cache_dir,
181        repo_id,
182        out_dir,
183        rebuild,
184        browser,
185        mut tag,
186        override_zip,
187        skip_compatibility_check,
188        remove_pdbs,
189    } = config;
190
191    // Get metadata which may update cache_dir and tag
192    metadata::get_meta_info(&mut cache_dir, &mut tag)?;
193    let cache_dir = cache_dir.unwrap_or_else(|| PathBuf::from("obs-build"));
194
195    let mut obs_ver = None;
196    let repo_id = repo_id.unwrap_or_else(|| "obsproject/obs-studio".to_string());
197    if tag.is_none() {
198        obs_ver = Some(get_lib_obs_version()?);
199        let (major, minor, patch) = obs_ver.as_ref().unwrap();
200        let lib_tag = format!("{}.{}.{}", major, minor, patch);
201
202        // Check if a newer version of libobs (same major/minor, higher patch) exists in releases.
203        // If found, use that tag; otherwise fall back to the crate version tag.
204        match fetch_latest_patch_release(&repo_id, *major, *minor, &cache_dir) {
205            Ok(Some(found_tag)) => {
206                let parts: Vec<&str> = found_tag.trim_start_matches('v').split('.').collect();
207                let found_patch = parts
208                    .get(2)
209                    .and_then(|s| s.parse::<u32>().ok())
210                    .unwrap_or(0);
211                if found_patch > *patch {
212                    info!(
213                        "Found newer libobs binaries release {} (crate: {}). Using {}",
214                        found_tag, lib_tag, found_tag
215                    );
216                    tag = Some(found_tag);
217                } else {
218                    // no newer patch found -> use crate version
219                    tag = Some(lib_tag);
220                }
221            }
222            Ok(None) => {
223                // none found -> use crate version
224                tag = Some(lib_tag);
225            }
226            Err(e) => {
227                // On error, log debug and fall back to crate version
228                warn!("Failed to check for newer compatible libobs release: {}", e);
229                tag = Some(lib_tag);
230            }
231        }
232    }
233
234    let tag = tag.unwrap();
235    let target_out_dir = PathBuf::new().join(&out_dir);
236
237    // Check CI environment configuration AFTER we have the final cache_dir
238    check_ci_environment(&cache_dir);
239
240    let tag = if tag.trim() == "latest" {
241        fetch_latest_release_tag(&repo_id, &cache_dir)?
242    } else {
243        tag
244    };
245
246    if !skip_compatibility_check {
247        let (major, minor, patch) = if let Some(v) = obs_ver {
248            v
249        } else {
250            get_lib_obs_version()?
251        };
252
253        info!(
254            "Detected libobs crate version: {}.{}.{}",
255            major, minor, patch
256        );
257        let tag_parts: Vec<&str> = tag.trim_start_matches('v').split('.').collect();
258        let tag_parts = tag_parts
259            .iter()
260            .map(|e| e.parse::<u32>().unwrap_or(0))
261            .collect::<Vec<u32>>();
262
263        if tag_parts.len() < 3 {
264            info!("Warning: Could not determine libobs compatibility, tag does not have 3 parts");
265        } else {
266            let (tag_major, tag_minor, tag_patch) = (tag_parts[0], tag_parts[1], tag_parts[2]);
267            if major != tag_major || minor != tag_minor {
268                use log::warn;
269
270                warn!(
271                    "libobs (crate) version {}.{}.{} may not be compatible with libobs (binaries) {}.{}.{}",
272                    major, minor, patch, tag_major, tag_minor, tag_patch
273                );
274                warn!(
275                    "Set the `libobs-version` in `[workspace.metadata]` to {}.{}.{} to avoid runtime issues",
276                    major, minor, patch
277                );
278            } else {
279                info!(
280                    "libobs (crate) version {}.{}.{} should be compatible with libobs (binaries) {}.{}.{}",
281                    major, minor, patch, tag_major, tag_minor, tag_patch
282                );
283            }
284        }
285    }
286
287    let repo_dir = cache_dir.join(&tag);
288    let repo_exists = repo_dir.is_dir();
289
290    if !repo_exists {
291        fs::create_dir_all(&repo_dir)?;
292    }
293
294    let build_out = repo_dir.join("build_out");
295    let lock_file = cache_dir.join(format!("{}.lock", tag));
296    let success_file = repo_dir.join(".success");
297
298    wait_for_lock(&lock_file)?;
299
300    if !success_file.is_file() || rebuild {
301        let lock = acquire_lock(&lock_file)?;
302        if repo_exists || rebuild {
303            debug!("Cleaning up old build...");
304            delete_all_except(&repo_dir, None)?;
305        }
306
307        debug!("Fetching {} version of OBS Studio...", tag);
308
309        let release = fetch_release(&repo_id, &Some(tag.clone()), &cache_dir)?;
310        build_obs(release, &build_out, browser, remove_pdbs, override_zip)?;
311
312        File::create(&success_file)?;
313        drop(lock);
314    }
315
316    info!(
317        "Copying files from {} to {}",
318        build_out.display(),
319        target_out_dir.display()
320    );
321    copy_to_dir(&build_out, &target_out_dir, None)?;
322
323    info!("Done!");
324
325    Ok(())
326}
327
328fn build_obs(
329    release: ReleaseInfo,
330    build_out: &Path,
331    include_browser: bool,
332    remove_pdbs: bool,
333    override_zip: Option<PathBuf>,
334) -> anyhow::Result<()> {
335    fs::create_dir_all(build_out)?;
336
337    let obs_path = if let Some(e) = override_zip {
338        e
339    } else {
340        download_binaries(build_out, &release)?
341    };
342
343    let obs_archive = File::open(&obs_path)?;
344    let mut archive = ZipArchive::new(&obs_archive)?;
345
346    info!("Extracting OBS Studio binaries...");
347    archive.extract(build_out)?;
348    let bin_path = build_out.join("bin").join("64bit");
349    copy_to_dir(&bin_path, build_out, None)?;
350    fs::remove_dir_all(build_out.join("bin"))?;
351
352    clean_up_files(build_out, remove_pdbs, include_browser)?;
353
354    fs::remove_file(&obs_path)?;
355
356    Ok(())
357}
358
359fn clean_up_files(
360    build_out: &Path,
361    remove_pdbs: bool,
362    include_browser: bool,
363) -> anyhow::Result<()> {
364    let mut to_exclude = vec![
365        "obs64",
366        "frontend",
367        "obs-webrtc",
368        "obs-websocket",
369        "decklink",
370        "obs-scripting",
371        "qt6",
372        "qminimal",
373        "qwindows",
374        "imageformats",
375        "obs-studio",
376        "aja-output-ui",
377        "obs-vst",
378    ];
379
380    if remove_pdbs {
381        to_exclude.push(".pdb");
382    }
383
384    if !include_browser {
385        to_exclude.append(&mut vec![
386            "obs-browser",
387            "obs-browser-page",
388            "chrome_",
389            "resources",
390            "cef",
391            "snapshot",
392            "locales",
393        ]);
394    }
395
396    info!("Cleaning up unnecessary files...");
397    for entry in WalkDir::new(build_out).into_iter().flatten() {
398        let path = entry.path();
399        if to_exclude.iter().any(|e| {
400            path.file_name().is_some_and(|x| {
401                let x_l = x.to_string_lossy().to_lowercase();
402                x_l.contains(e) || x_l == *e
403            })
404        }) {
405            debug!("Deleting: {}", path.display());
406            if path.is_dir() {
407                fs::remove_dir_all(path)?;
408            } else {
409                fs::remove_file(path)?;
410            }
411        }
412    }
413
414    Ok(())
415}