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}