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