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}