libobs_bootstrapper/
lib.rs

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