Skip to main content

libobs_bootstrapper/
lib.rs

1#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
2// awaits and streams use unsafe internally, so I'm gonna check for unsafe blocks manually here.
3#![allow(unknown_lints, require_safety_comments_on_unsafe)]
4
5use std::{env, path::PathBuf, process};
6
7use async_stream::stream;
8use download::DownloadStatus;
9use extract::ExtractStatus;
10use futures_core::Stream;
11use futures_util::{StreamExt, pin_mut};
12use lazy_static::lazy_static;
13use libobs::{LIBOBS_API_MAJOR_VER, LIBOBS_API_MINOR_VER, LIBOBS_API_PATCH_VER};
14use tokio::{fs::File, io::AsyncWriteExt, process::Command};
15
16#[cfg_attr(coverage_nightly, coverage(off))]
17mod download;
18mod error;
19#[cfg_attr(coverage_nightly, coverage(off))]
20mod extract;
21#[cfg_attr(coverage_nightly, coverage(off))]
22mod github_types;
23mod options;
24pub mod status_handler;
25mod version;
26
27#[cfg(test)]
28mod download_tests;
29#[cfg(test)]
30mod options_tests;
31#[cfg(test)]
32mod version_tests;
33
34pub use error::ObsBootstrapError;
35
36pub use options::{ObsBootstrapperOptions, UpdateTargetMode};
37
38use crate::status_handler::{ObsBootstrapConsoleHandler, ObsBootstrapStatusHandler};
39
40pub enum BootstrapStatus {
41    /// Downloading status (first is progress from 0.0 to 1.0 and second is message)
42    Downloading(f32, String),
43
44    /// Extracting status (first is progress from 0.0 to 1.0 and second is message)
45    Extracting(f32, String),
46    Error(ObsBootstrapError),
47    /// The application must be restarted to use the new version of OBS.
48    /// This is because the obs.dll file is in use by the application and can not be replaced while running.
49    /// Therefore, the "updater" is spawned to watch for the application to exit and rename the "obs_new.dll" file to "obs.dll".
50    /// The updater will start the application again with the same arguments as the original application.
51    RestartRequired,
52}
53
54/// A struct for bootstrapping OBS Studio.
55///
56/// This struct provides functionality to download, extract, and set up OBS Studio
57/// for use with libobs-rs. It also handles updates to OBS when necessary.
58///
59/// If you want to use this bootstrapper to also install required OBS binaries at runtime,
60/// do the following:
61/// - Add a `obs.dll` file to your executable directory. This file will be replaced by the obs installer.
62///   Recommended to use is the dll dummy (found [here](https://github.com/sshcrack/libobs-builds/releases), make sure you use the correct OBS version)
63///   and rename it to `obs.dll`.
64/// - Call `ObsBootstrapper::bootstrap()` at the start of your application. Options must be configured. For more documentation look at the [tauri example app](https://github.com/libobs-rs/libobs-rs/tree/main/examples/tauri-app). This will download the latest version of OBS and extract it in the executable directory.
65/// - If BootstrapStatus::RestartRequired is returned, you'll need to restart your application. A updater process has been spawned to watch for the application to exit and rename the `obs_new.dll` file to `obs.dll`.
66/// - Exit the application. The updater process will wait for the application to exit and rename the `obs_new.dll` file to `obs.dll` and restart your application with the same arguments as before.
67///
68/// [Example project](https://github.com/libobs-rs/libobs-rs/tree/main/examples/download-at-runtime)
69pub struct ObsBootstrapper {}
70
71lazy_static! {
72    pub(crate) static ref LIBRARY_OBS_VERSION: String = format!(
73        "{}.{}.{}",
74        LIBOBS_API_MAJOR_VER, LIBOBS_API_MINOR_VER, LIBOBS_API_PATCH_VER
75    );
76}
77
78pub const UPDATER_SCRIPT: &str = include_str!("./updater.ps1");
79
80fn get_obs_dll_path() -> Result<PathBuf, ObsBootstrapError> {
81    let executable =
82        env::current_exe().map_err(|e| ObsBootstrapError::IoError("Getting current exe", e))?;
83    let obs_dll = executable
84        .parent()
85        .ok_or_else(|| {
86            ObsBootstrapError::IoError(
87                "Failed to get parent directory",
88                std::io::Error::from(std::io::ErrorKind::InvalidInput),
89            )
90        })?
91        .join("obs.dll");
92
93    Ok(obs_dll)
94}
95
96pub(crate) fn bootstrap(
97    options: &ObsBootstrapperOptions,
98) -> Result<Option<impl Stream<Item = BootstrapStatus>>, ObsBootstrapError> {
99    let repo = options.repository.to_string();
100
101    log::trace!("Checking for update...");
102    let installed = version::get_installed_version(&get_obs_dll_path()?)?;
103
104    let update = if options.update {
105        if let Some(installed_version) = &installed {
106            if !version::is_compatible_major(installed_version)? {
107                log::warn!(
108                    "Installed OBS major version ({}) does not match required major ({}); skipping automatic update.",
109                    installed_version,
110                    LIBOBS_API_MAJOR_VER
111                );
112                false
113            } else {
114                true
115            }
116        } else {
117            true
118        }
119    } else {
120        installed.is_none()
121    };
122
123    if !update {
124        log::debug!("No update needed.");
125        return Ok(None);
126    }
127
128    let options = options.clone();
129    Ok(Some(stream! {
130        let resolved_release = download::resolve_latest_compatible_release(&repo, options.update_target_mode).await;
131        if let Err(err) = resolved_release {
132            yield BootstrapStatus::Error(err);
133            return;
134        }
135
136        let resolved_release = resolved_release.unwrap();
137
138        if let Some(installed_version) = installed.as_deref() {
139            let should_update = version::should_update(installed_version, &resolved_release.version);
140            if let Err(err) = should_update {
141                yield BootstrapStatus::Error(err);
142                return;
143            }
144
145            if !should_update.unwrap() {
146                log::debug!(
147                    "No update needed; installed OBS version {} is up-to-date for compatible target {}.",
148                    installed_version,
149                    resolved_release.version
150                );
151                return;
152            }
153        }
154
155        log::debug!("Downloading OBS from {}", repo);
156        let download_stream = download::download_obs(&resolved_release).await;
157        if let Err(err) = download_stream {
158            yield BootstrapStatus::Error(err);
159            return;
160        }
161
162        let download_stream = download_stream.unwrap();
163        pin_mut!(download_stream);
164
165        let mut file = None;
166        while let Some(item) = download_stream.next().await {
167            match item {
168                DownloadStatus::Error(err) => {
169                    yield BootstrapStatus::Error(err);
170                    return;
171                }
172                DownloadStatus::Progress(progress, message) => {
173                    yield BootstrapStatus::Downloading(progress, message);
174                }
175                DownloadStatus::Done(path) => {
176                    file = Some(path)
177                }
178            }
179        }
180
181        let archive_file = file.ok_or(ObsBootstrapError::InvalidState);
182        if let Err(err) = archive_file {
183            yield BootstrapStatus::Error(err);
184            return;
185        }
186
187        log::debug!("Extracting OBS to {:?}", archive_file);
188        let archive_file = archive_file.unwrap();
189        let extract_stream = extract::extract_obs(&archive_file).await;
190        if let Err(err) = extract_stream {
191            yield BootstrapStatus::Error(err);
192            return;
193        }
194
195        let extract_stream = extract_stream.unwrap();
196        pin_mut!(extract_stream);
197
198        while let Some(item) = extract_stream.next().await {
199            match item {
200                ExtractStatus::Error(err) => {
201                    yield BootstrapStatus::Error(err);
202                    return;
203                }
204                ExtractStatus::Progress(progress, message) => {
205                    yield BootstrapStatus::Extracting(progress, message);
206                }
207            }
208        }
209
210        let r = spawn_updater(options).await;
211        if let Err(err) = r {
212            yield BootstrapStatus::Error(err);
213            return;
214        }
215
216        yield BootstrapStatus::RestartRequired;
217    }))
218}
219
220pub(crate) async fn spawn_updater(
221    options: ObsBootstrapperOptions,
222) -> Result<(), ObsBootstrapError> {
223    let pid = process::id();
224    let args = env::args().collect::<Vec<_>>();
225    // Skip the first argument which is the executable path
226    let args = args.into_iter().skip(1).collect::<Vec<_>>();
227
228    let updater_path = env::temp_dir().join("libobs_updater.ps1");
229    let mut updater_file = File::create(&updater_path)
230        .await
231        .map_err(|e| ObsBootstrapError::IoError("Creating updater script", e))?;
232
233    updater_file
234        .write_all(UPDATER_SCRIPT.as_bytes())
235        .await
236        .map_err(|e| ObsBootstrapError::IoError("Writing updater script", e))?;
237
238    let mut command = Command::new("powershell");
239    command
240        .arg("-ExecutionPolicy")
241        .arg("Bypass")
242        .arg("-NoProfile")
243        .arg("-WindowStyle")
244        .arg("Hidden")
245        .arg("-File")
246        .arg(updater_path)
247        .arg("-processPid")
248        .arg(pid.to_string())
249        .arg("-binary")
250        .arg(
251            env::current_exe()
252                .map_err(|e| ObsBootstrapError::IoError("Getting current exe", e))?
253                .to_string_lossy()
254                .to_string(),
255        );
256
257    if options.restart_after_update {
258        command.arg("-restart");
259    }
260
261    // Encode arguments as hex string (UTF-8, null-separated)
262    if !args.is_empty() {
263        let joined = args.join("\0");
264        let bytes = joined.as_bytes();
265        let hex_str = hex::encode(bytes);
266        command.arg("-argumentHex");
267        command.arg(hex_str);
268    }
269
270    command
271        .spawn()
272        .map_err(|e| ObsBootstrapError::IoError("Spawning updater process", e))?;
273
274    Ok(())
275}
276
277pub enum ObsBootstrapperResult {
278    /// No action was needed, OBS is already installed and up to date.
279    None,
280    /// The application must be restarted to complete the installation or update of OBS.
281    Restart,
282}
283
284/// A convenience type that exposes high-level helpers to detect, update and
285/// bootstrap an OBS installation.
286///
287/// The bootstrapper coordinates version checks and the streaming bootstrap
288/// process. It does not itself perform low-level network or extraction work;
289/// instead it delegates to internal modules (version checking and the
290/// bootstrap stream) and surfaces a simple API for callers.
291impl ObsBootstrapper {
292    /// Returns true if a valid OBS installation (as determined by locating the
293    /// OBS DLL and querying the installed version) is present on the system.
294    ///
295    /// # Returns
296    ///
297    /// - `Ok(true)` if an installed OBS version could be detected.
298    /// - `Ok(false)` if no installed OBS version was found.
299    ///
300    /// # Errors
301    ///
302    /// Returns an `Err(ObsBootstrapError)` if there was an error locating the OBS DLL or
303    /// reading the installed version information.
304    pub fn is_valid_installation() -> Result<bool, ObsBootstrapError> {
305        let installed = version::get_installed_version(&get_obs_dll_path()?)?;
306        if installed.is_none() {
307            log::trace!("No valid OBS installation found");
308            return Ok(false);
309        }
310
311        Ok(true)
312    }
313
314    /// Returns true when an update to OBS should be performed.
315    ///
316    /// The function first checks whether OBS is installed. If no installation
317    /// is found it treats that as an available update (returns `Ok(true)`).
318    /// Otherwise it consults the internal version logic to determine whether
319    /// the installed version should be updated.
320    ///
321    /// # Returns
322    ///
323    /// - `Ok(true)` when an update is recommended or when OBS is not installed.
324    /// - `Ok(false)` when the installed version is up-to-date.
325    ///
326    /// # Errors
327    ///
328    /// Returns an `Err(ObsBootstrapError)` if there was an error locating the OBS DLL or
329    /// determining the currently installed version or update necessity.
330    pub fn is_update_available() -> Result<bool, ObsBootstrapError> {
331        let installed = version::get_installed_version(&get_obs_dll_path()?)?;
332        if installed.is_none() {
333            log::trace!("No OBS installation found, treating as update available");
334            return Ok(true);
335        }
336
337        let installed = installed.unwrap();
338        if !version::is_compatible_major(&installed)? {
339            log::warn!(
340                "Installed OBS major version ({}) does not match required major ({}); not counting as update.",
341                installed,
342                LIBOBS_API_MAJOR_VER
343            );
344            return Ok(false);
345        }
346
347        Ok(true)
348    }
349
350    /// Bootstraps OBS using the provided options and a default console status
351    /// handler.
352    ///
353    /// This is a convenience wrapper around `bootstrap_with_handler` that
354    /// supplies an `ObsBootstrapConsoleHandler` as the status consumer.
355    ///
356    /// # Returns
357    ///
358    /// - `Ok(ObsBootstrapperResult::None)` if no action was necessary.
359    /// - `Ok(ObsBootstrapperResult::Restart)` if the bootstrap completed and a
360    ///   restart is required.
361    ///
362    /// # Errors
363    ///
364    /// Returns `Err(ObsBootstrapError)` for any failure that prevents the
365    /// bootstrap from completing (download failures, extraction failures,
366    /// general errors).
367    pub async fn bootstrap(
368        options: &options::ObsBootstrapperOptions,
369    ) -> Result<ObsBootstrapperResult, ObsBootstrapError> {
370        ObsBootstrapper::bootstrap_with_handler(
371            options,
372            Box::new(ObsBootstrapConsoleHandler::default()),
373        )
374        .await
375    }
376
377    /// Bootstraps OBS using the provided options and a custom status handler.
378    ///
379    /// The handler will receive progress updates as the bootstrap stream emits
380    /// statuses. The method drives the bootstrap stream to completion and maps
381    /// stream statuses into handler calls or final results:
382    ///
383    /// - `BootstrapStatus::Downloading(progress, message)` → calls
384    ///   `handler.handle_downloading(progress, message)`. Handler errors are
385    ///   mapped to `ObsBootstrapError::DownloadError`.
386    /// - `BootstrapStatus::Extracting(progress, message)` → calls
387    ///   `handler.handle_extraction(progress, message)`. Handler errors are
388    ///   mapped to `ObsBootstrapError::ExtractError`.
389    /// - `BootstrapStatus::Error(err)` → returns `Err(ObsBootstrapError::GeneralError(_))`.
390    /// - `BootstrapStatus::RestartRequired` → returns `Ok(ObsBootstrapperResult::Restart)`.
391    ///
392    /// If the underlying `bootstrap(options)` call returns `None` there is
393    /// nothing to do and the function returns `Ok(ObsBootstrapperResult::None)`.
394    ///
395    /// # Parameters
396    ///
397    /// - `options`: configuration that controls download/extraction behavior.
398    /// - `handler`: user-provided boxed trait object that receives progress
399    ///   notifications; it is called on each progress update and can fail.
400    ///
401    /// # Returns
402    ///
403    /// - `Ok(ObsBootstrapperResult::None)` when no work was required or the
404    ///   stream completed without requiring a restart.
405    /// - `Ok(ObsBootstrapperResult::Restart)` when the bootstrap succeeded and
406    ///   a restart is required.
407    ///
408    /// # Errors
409    ///
410    /// Returns `Err(ObsBootstrapError)` when:
411    /// - the bootstrap pipeline could not be started,
412    /// - the handler returns an error while handling a download or extraction
413    ///   update (mapped respectively to `DownloadError` / `ExtractError`),
414    /// - or when the bootstrap stream yields a general error.
415    pub async fn bootstrap_with_handler<E: Send + Sync + 'static + std::error::Error>(
416        options: &options::ObsBootstrapperOptions,
417        mut handler: Box<dyn ObsBootstrapStatusHandler<Error = E>>,
418    ) -> Result<ObsBootstrapperResult, ObsBootstrapError> {
419        let stream = bootstrap(options)?;
420
421        if let Some(stream) = stream {
422            pin_mut!(stream);
423
424            log::trace!("Waiting for bootstrapper to finish");
425            while let Some(item) = stream.next().await {
426                match item {
427                    BootstrapStatus::Downloading(progress, message) => {
428                        handler
429                            .handle_downloading(progress, message)
430                            .map_err(|e| ObsBootstrapError::Abort(Box::new(e)))?;
431                    }
432                    BootstrapStatus::Extracting(progress, message) => {
433                        handler
434                            .handle_extraction(progress, message)
435                            .map_err(|e| ObsBootstrapError::Abort(Box::new(e)))?;
436                    }
437                    BootstrapStatus::Error(err) => {
438                        return Err(err);
439                    }
440                    BootstrapStatus::RestartRequired => {
441                        return Ok(ObsBootstrapperResult::Restart);
442                    }
443                }
444            }
445        }
446
447        Ok(ObsBootstrapperResult::None)
448    }
449}