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}