scratch_io/
lib.rs

1pub mod errors;
2mod extract;
3mod filesystem;
4mod game_files;
5mod heuristics;
6pub mod itch_api;
7pub mod itch_manifest;
8pub mod wharf;
9
10pub use crate::itch_api::ItchClient;
11use crate::itch_api::{types::*, *};
12
13use md5::{Digest, Md5};
14use reqwest::{Method, Response, header};
15use serde::{Deserialize, Serialize};
16use std::borrow::Cow;
17use std::path::{Path, PathBuf};
18use tokio::io::AsyncBufReadExt;
19use tokio::time::{Duration, Instant};
20
21// This isn't inside itch_types because it is not something that the itch API returns
22// These platforms are *interpreted* from the data provided by the API
23/// The different platforms a upload can be made for
24#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize, clap::ValueEnum)]
25pub enum GamePlatform {
26  Linux,
27  Windows,
28  OSX,
29  Android,
30  Web,
31  Flash,
32  Java,
33  UnityWebPlayer,
34}
35
36impl Upload {
37  #[must_use]
38  pub fn to_game_platforms(&self) -> Vec<GamePlatform> {
39    let mut platforms: Vec<GamePlatform> = Vec::new();
40
41    match self.r#type {
42      UploadType::Html => platforms.push(GamePlatform::Web),
43      UploadType::Flash => platforms.push(GamePlatform::Flash),
44      UploadType::Java => platforms.push(GamePlatform::Java),
45      UploadType::Unity => platforms.push(GamePlatform::UnityWebPlayer),
46      _ => (),
47    }
48
49    for t in &self.traits {
50      match t {
51        UploadTrait::PLinux => platforms.push(GamePlatform::Linux),
52        UploadTrait::PWindows => platforms.push(GamePlatform::Windows),
53        UploadTrait::POsx => platforms.push(GamePlatform::OSX),
54        UploadTrait::PAndroid => platforms.push(GamePlatform::Android),
55        UploadTrait::Demo => (),
56      }
57    }
58
59    platforms
60  }
61}
62
63pub enum DownloadStatus {
64  Warning(String),
65  StartingDownload { bytes_to_download: u64 },
66  DownloadProgress { downloaded_bytes: u64 },
67  Extract,
68}
69
70pub enum LaunchMethod {
71  AlternativeExecutable {
72    executable_path: PathBuf,
73  },
74  ManifestAction {
75    manifest_action_name: String,
76  },
77  Heuristics {
78    game_platform: GamePlatform,
79    game_title: String,
80  },
81}
82
83/// Some information about a installed upload
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85pub struct InstalledUpload {
86  pub upload_id: UploadID,
87  pub game_folder: PathBuf,
88  pub game_id: GameID,
89  pub game_title: String,
90}
91
92/// Hash a file into a MD5 hasher
93///
94/// # Arguments
95///
96/// * `readable` - Anything that implements [`tokio::io::AsyncRead`] to read the data from, could be a File
97///
98/// * `hasher` - A mutable reference to a MD5 hasher, which will be updated with the file data
99///
100/// # Returns
101///
102/// An error if something goes wrong
103async fn hash_readable_async(
104  readable: impl tokio::io::AsyncRead + Unpin,
105  hasher: &mut Md5,
106) -> Result<(), String> {
107  let mut br = tokio::io::BufReader::new(readable);
108
109  loop {
110    let buffer = filesystem::fill_buffer(&mut br).await?;
111
112    // If buffer is empty then BufReader has reached the EOF
113    if buffer.is_empty() {
114      break Ok(());
115    }
116
117    // Update the hasher
118    hasher.update(buffer);
119
120    // Marked the hashed bytes as read
121    let len = buffer.len();
122    br.consume(len);
123  }
124}
125
126/// Stream a reqwest [`Response`] into a [`tokio::fs::File`] async
127///
128/// # Arguments
129///
130/// * `response` - A file download response
131///
132/// * `file` - An opened [`tokio::fs::File`] with write access
133///
134/// * `md5_hash` - If provided, the hasher to update with the received data
135///
136/// * `progress_callback` - A closure called with the number of downloaded bytes at the moment
137///
138/// * `callback_interval` - The minimum time span between each `progress_callback` call
139///
140/// # Returns
141///
142/// The total downloaded bytes
143///
144/// An error if something goes wrong
145async fn stream_response_into_file(
146  response: Response,
147  file: &mut tokio::fs::File,
148  mut md5_hash: Option<&mut Md5>,
149  progress_callback: impl Fn(u64),
150  callback_interval: Duration,
151) -> Result<u64, String> {
152  // Prepare the download and the callback variables
153  let mut downloaded_bytes: u64 = 0;
154  let mut stream = response.bytes_stream();
155  let mut last_callback = Instant::now();
156
157  use futures_util::StreamExt;
158
159  // Save chunks to the file async
160  // Also, compute the MD5 hash while it is being downloaded
161  while let Some(chunk) = match stream.next().await {
162    None => Ok(None),
163    Some(result) => result
164      .map(Some)
165      .map_err(|e| format!("Couldn't read chunk from network!\n{e}")),
166  }? {
167    // Write the chunk to the file
168    filesystem::write_all(file, &chunk).await?;
169
170    // If the file has a MD5 hash, update the hasher
171    if let Some(hasher) = &mut md5_hash {
172      hasher.update(&chunk);
173    }
174
175    // Send a callback with the progress
176    downloaded_bytes += chunk.len() as u64;
177    if last_callback.elapsed() > callback_interval {
178      last_callback = Instant::now();
179      progress_callback(downloaded_bytes);
180    }
181  }
182
183  progress_callback(downloaded_bytes);
184
185  Ok(downloaded_bytes)
186}
187
188/// Download a file from an itch API URL
189///
190/// # Arguments
191///
192/// * `client` - An itch.io API client
193///
194/// * `url` - A itch.io API address to download the file from
195///
196/// * `file_path` - The path where the file will be placed
197///
198/// * `md5_hash` - A MD5 hash to check the file against. If none, don't verify the download
199///
200/// * `file_size_callback` - A clousure called with total size the downloaded file will have after the download
201///
202/// * `progress_callback` - A closure called with the number of downloaded bytes at the moment
203///
204/// * `callback_interval` - The minimum time span between each `progress_callback` call
205///
206/// # Returns
207///
208/// An error if something goes wrong
209async fn download_file(
210  client: &ItchClient,
211  url: &ItchApiUrl,
212  file_path: &Path,
213  md5_hash: Option<&str>,
214  file_size_callback: impl Fn(u64),
215  progress_callback: impl Fn(u64),
216  callback_interval: Duration,
217) -> Result<(), String> {
218  // Create the hasher variable
219  let mut md5_hash: Option<(Md5, &str)> = md5_hash.map(|s| (Md5::new(), s));
220
221  // The file will be downloaded to this file with the .part extension,
222  // and then the extension will be removed when the download ends
223  let partial_file_path: PathBuf = game_files::add_part_extension(file_path)?;
224
225  // If there already exists a file in file_path, then move it to partial_file_path
226  // This way, the file's length and its hash are verified
227  if filesystem::exists(file_path).await? {
228    filesystem::rename(file_path, &partial_file_path).await?;
229  }
230
231  // Open the file where the data is going to be downloaded
232  // Use the append option to ensure that the old download data isn't deleted
233  let mut file = filesystem::open_file(
234    &partial_file_path,
235    tokio::fs::OpenOptions::new()
236      .create(true)
237      .append(true)
238      .read(true),
239  )
240  .await?;
241
242  let mut downloaded_bytes: u64 = filesystem::read_file_metadata(&file).await?.len();
243
244  let file_response: Option<Response> = 'r: {
245    // Send a request for the whole file
246    let res = client
247      .itch_request(url, Method::GET, |b| b)
248      .await
249      .map_err(|e| e.to_string())?;
250
251    let download_size = res.content_length().ok_or_else(|| {
252      format!(
253        "Couldn't get content length!
254  URL: {url}"
255      )
256    })?;
257
258    file_size_callback(download_size);
259
260    // If the file is empty, then return the request for the whole file
261    if downloaded_bytes == 0 {
262      break 'r Some(res);
263    }
264    // If the file is exactly the size it should be, then return None so nothing more is downloaded
265    else if downloaded_bytes == download_size {
266      break 'r None;
267    }
268    // If the file is not empty, and smaller than the whole file, download the remaining file range
269    else if downloaded_bytes < download_size {
270      let part_res = client
271        .itch_request(url, Method::GET, |b| {
272          b.header(header::RANGE, format!("bytes={downloaded_bytes}-"))
273        })
274        .await
275        .map_err(|e| e.to_string())?;
276
277      match part_res.status() {
278        // 206 Partial Content code means the server will send the requested range
279        // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/206
280        reqwest::StatusCode::PARTIAL_CONTENT => break 'r Some(part_res),
281
282        // 200 OK code means the server doesn't support ranges
283        // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Range
284        // Don't break, so the fallback code is run instead and the whole file is downloaded
285        reqwest::StatusCode::OK => (),
286
287        // Any code other than 200 or 206 means that something went wrong
288        _ => {
289          return Err(format!(
290            "The HTTP server to download the file from didn't return HTTP code 200 nor 206, so exiting!
291  It returned code: {}
292  URL: {url}", part_res.status().as_str()));
293        }
294      }
295    }
296
297    // If we're here, that means one of two things:
298    //
299    // 1. The file is bigger than it should
300    // 2. The server doesn't support ranges
301    //
302    // In either case, the current file should be removed and downloaded again fully
303    downloaded_bytes = 0;
304    filesystem::set_file_len(&file, 0).await?;
305
306    Some(res)
307  };
308
309  // If a partial file was already downloaded, hash the old downloaded data
310  if let Some((ref mut hasher, _)) = md5_hash
311    && downloaded_bytes > 0
312  {
313    hash_readable_async(&mut file, hasher).await?;
314  }
315
316  // Stream the Response into the File
317  if let Some(res) = file_response {
318    stream_response_into_file(
319      res,
320      &mut file,
321      md5_hash.as_mut().map(|(h, _)| h),
322      |b| progress_callback(downloaded_bytes + b),
323      callback_interval,
324    )
325    .await?;
326  }
327
328  // If the hashes aren't equal, exit with an error
329  if let Some((hasher, hash)) = md5_hash {
330    let file_hash = format!("{:x}", hasher.finalize());
331
332    if !file_hash.eq_ignore_ascii_case(hash) {
333      return Err(format!("File verification failed! The file hash and the hash provided by the server are different.\n
334  File hash:   {file_hash}
335  Server hash: {hash}"
336      ));
337    }
338  }
339
340  // Sync the file to ensure all the data has been written
341  filesystem::file_sync_all(&file).await?;
342
343  // Move the downloaded file to its final destination
344  // This has to be the last call in this function because after it, the File is not longer valid
345  filesystem::rename(&partial_file_path, file_path).await?;
346
347  Ok(())
348}
349
350/// Find out which platforms a game's uploads are available in
351///
352/// # Arguments
353///
354/// * `uploads` - A list of a game's uploads
355///
356/// # Returns
357///
358/// A vector of tuples containing an upload ID and the [`GamePlatform`] in which it is available
359#[must_use]
360pub fn get_game_platforms(uploads: &[Upload]) -> Vec<(UploadID, GamePlatform)> {
361  let mut platforms: Vec<(UploadID, GamePlatform)> = Vec::new();
362
363  for u in uploads {
364    for p in u.to_game_platforms() {
365      platforms.push((u.id, p));
366    }
367  }
368
369  platforms
370}
371
372/// Download a game cover image from its game ID
373///
374/// The image will be a PNG. This is because the itch.io servers return that type of image
375///
376/// # Arguments
377///
378/// * `client` - An itch.io API client
379///
380/// * `game_id` - The ID of the game from which the cover will be downloaded
381///
382/// * `folder` - The game folder where the cover will be placed
383///
384/// * `cover_filename` - The new filename of the cover
385///
386/// * `force_download` - If true, download the cover image again, even if it already exists
387///
388/// # Returns
389///
390/// The path of the downloaded image, or None if the game doesn't have one
391///
392/// # Errors
393///
394/// If something goes wrong
395pub async fn download_game_cover(
396  client: &ItchClient,
397  game_id: GameID,
398  folder: &Path,
399  cover_filename: Option<&str>,
400  force_download: bool,
401) -> Result<Option<PathBuf>, String> {
402  // Get the game info from the server
403  let game = get_game_info(client, game_id)
404    .await
405    .map_err(|e| e.to_string())?;
406  // If the game doesn't have a cover, return
407  let Some(cover_url) = game.game_info.cover_url else {
408    return Ok(None);
409  };
410
411  // Create the folder where the file is going to be placed if it doesn't already exist
412  filesystem::create_dir(folder).await?;
413
414  // If the cover filename isn't set, set it to "cover"
415  let cover_filename = match cover_filename {
416    Some(f) => f,
417    None => game_files::COVER_IMAGE_DEFAULT_FILENAME,
418  };
419
420  let cover_path = folder.join(cover_filename);
421
422  // If the cover image already exists and the force variable is false, don't replace the original image
423  if !force_download && filesystem::exists(&cover_path).await? {
424    return Ok(Some(cover_path));
425  }
426
427  download_file(
428    client,
429    &ItchApiUrl::from_api_endpoint(ItchApiVersion::Other, cover_url),
430    &cover_path,
431    None,
432    |_| (),
433    |_| (),
434    Duration::MAX,
435  )
436  .await?;
437
438  Ok(Some(cover_path))
439}
440
441/// Download a game upload
442///
443/// # Arguments
444///
445/// * `client` - An itch.io API client
446///
447/// * `upload_id` - The ID of the upload which will be downloaded
448///
449/// * `game_folder` - The folder where the downloadeded game files will be placed
450///
451/// * `skip_hash_verification` - If true, don't check the downloaded upload integrity (insecure)
452///
453/// * `upload_info` - A closure which reports the upload and the game info before the download starts
454///
455/// * `progress_callback` - A closure which reports the download progress
456///
457/// * `callback_interval` - The minimum time span between each `progress_callback` call
458///
459/// # Returns
460///
461/// The installation info about the upload
462///
463/// # Errors
464///
465/// If something goes wrong
466pub async fn download_upload(
467  client: &ItchClient,
468  upload_id: UploadID,
469  game_folder: Option<&Path>,
470  skip_hash_verification: bool,
471  upload_info: impl FnOnce(&Upload, &Game),
472  progress_callback: impl Fn(DownloadStatus),
473  callback_interval: Duration,
474) -> Result<InstalledUpload, String> {
475  // --- DOWNLOAD PREPARATION ---
476
477  // Obtain information about the game and the upload that will be downloaeded
478  let upload: Upload = get_upload_info(client, upload_id)
479    .await
480    .map_err(|e| e.to_string())?;
481  let game: Game = get_game_info(client, upload.game_id)
482    .await
483    .map_err(|e| e.to_string())?;
484
485  // Send to the caller the game and the upload info
486  upload_info(&upload, &game);
487
488  // Set the game_folder and the file variables
489  // If the game_folder is unset, set it to ~/Games/{game_name}/
490  let game_folder = match game_folder {
491    Some(f) => f,
492    None => &game_files::get_game_folder(&game.game_info.title)?,
493  };
494
495  // upload_archive is the location where the upload will be downloaded
496  let upload_archive: PathBuf =
497    game_files::get_upload_archive_path(game_folder, upload_id, &upload.filename);
498
499  // Create the game folder if it doesn't already exist
500  filesystem::create_dir(game_folder).await?;
501
502  // Get the upload's hash
503  let hash: Option<&str> = upload.get_hash();
504
505  // --- DOWNLOAD ---
506
507  // Download the file
508  download_file(
509    client,
510    &ItchApiUrl::from_api_endpoint(ItchApiVersion::V2, format!("uploads/{upload_id}/download")),
511    &upload_archive,
512    // Only pass the hash if skip_hash_verification is false
513    hash.filter(|_| !skip_hash_verification),
514    |bytes| {
515      progress_callback(DownloadStatus::StartingDownload {
516        bytes_to_download: bytes,
517      });
518    },
519    |bytes| {
520      progress_callback(DownloadStatus::DownloadProgress {
521        downloaded_bytes: bytes,
522      });
523    },
524    callback_interval,
525  )
526  .await?;
527
528  // Print a warning if the upload doesn't have a hash in the server
529  // or the hash verification is skipped
530  if skip_hash_verification {
531    progress_callback(DownloadStatus::Warning(
532      "Skipping hash verification! The file integrity won't be checked!".to_string(),
533    ));
534  } else if hash.is_none() {
535    progress_callback(DownloadStatus::Warning(
536      "Missing MD5 hash. Couldn't verify the file integrity!".to_string(),
537    ));
538  }
539
540  // --- FILE EXTRACTION ---
541
542  progress_callback(DownloadStatus::Extract);
543
544  // The new upload_folder is game_folder + the upload id
545  let upload_folder: PathBuf = game_files::get_upload_folder(game_folder, upload_id);
546
547  // Extracts the downloaded archive (if it's an archive)
548  // game_files can be the path of an executable or the path to the extracted folder
549  extract::extract(&upload_archive, &upload_folder)
550    .await
551    .map_err(|e| e.to_string())?;
552
553  Ok(InstalledUpload {
554    upload_id,
555    // Get the absolute (canonical) form of the path
556    game_folder: filesystem::get_canonical_path(game_folder).await?,
557    game_id: game.game_info.id,
558    game_title: game.game_info.title,
559  })
560}
561
562/// Import an already installed upload
563///
564/// # Arguments
565///
566/// * `client` - An itch.io API client
567///
568/// * `upload_id` - The ID of the upload which will be imported
569///
570/// * `game_folder` - The folder where the game files are currectly placed
571///
572/// # Returns
573///
574/// The installation info about the upload
575///
576/// # Errors
577///
578/// If something goes wrong
579pub async fn import(
580  client: &ItchClient,
581  upload_id: UploadID,
582  game_folder: &Path,
583) -> Result<InstalledUpload, String> {
584  // Obtain information about the game and the upload that will be downloaeded
585  let upload: Upload = get_upload_info(client, upload_id)
586    .await
587    .map_err(|e| e.to_string())?;
588  let game: Game = get_game_info(client, upload.game_id)
589    .await
590    .map_err(|e| e.to_string())?;
591
592  Ok(InstalledUpload {
593    upload_id,
594    // Get the absolute (canonical) form of the path
595    game_folder: filesystem::get_canonical_path(game_folder).await?,
596    game_id: game.game_info.id,
597    game_title: game.game_info.title,
598  })
599}
600
601/// Remove partially downloaded game files from a cancelled download
602///
603/// # Arguments
604///
605/// * `client` - An itch.io API client
606///
607/// * `upload_id` - The ID of the upload whose download was canceled
608///
609/// * `game_folder` - The folder where the game files are currectly placed
610///
611/// # Returns
612///
613/// True if something was actually deleted
614///
615/// # Errors
616///
617/// If something goes wrong
618pub async fn remove_partial_download(
619  client: &ItchClient,
620  upload_id: UploadID,
621  game_folder: Option<&Path>,
622) -> Result<bool, String> {
623  // Obtain information about the game and the upload
624  let upload: Upload = get_upload_info(client, upload_id)
625    .await
626    .map_err(|e| e.to_string())?;
627  let game: Game = get_game_info(client, upload.game_id)
628    .await
629    .map_err(|e| e.to_string())?;
630
631  // If the game_folder is unset, set it to ~/Games/{game_name}/
632  let game_folder = match game_folder {
633    Some(f) => f,
634    None => &game_files::get_game_folder(&game.game_info.title)?,
635  };
636
637  // Vector of files and folders to be removed
638  let to_be_removed_folders: &[PathBuf] = &[
639    // **Do not remove the upload folder!**
640
641    // The upload partial folder
642    // Example: ~/Games/ExampleGame/123456.part/
643    game_files::add_part_extension(&game_files::get_upload_folder(game_folder, upload_id))?,
644  ];
645
646  let to_be_removed_files: &[PathBuf] = {
647    let upload_archive =
648      game_files::get_upload_archive_path(game_folder, upload_id, &upload.filename);
649
650    &[
651      // The upload partial archive
652      // Example: ~/Games/ExampleGame/123456-download-ArchiveName.zip.part
653      game_files::add_part_extension(&upload_archive)?,
654      // The upload downloaded archive
655      // Example: ~/Games/ExampleGame/123456-download-ArchiveName.zip
656      upload_archive,
657    ]
658  };
659
660  // Set this variable to true if some file or folder was deleted
661  let mut was_something_deleted: bool = false;
662
663  // Remove the partially downloaded files
664  for f in to_be_removed_files {
665    if filesystem::exists(f).await? {
666      filesystem::remove_file(f).await?;
667      was_something_deleted = true;
668    }
669  }
670
671  // Remove the partially downloaded folders
672  for f in to_be_removed_folders {
673    if filesystem::exists(f).await? {
674      game_files::remove_folder_safely(f).await?;
675      was_something_deleted = true;
676    }
677  }
678
679  // If the game folder is now useless, remove it
680  was_something_deleted |= game_files::remove_folder_if_empty(game_folder).await?;
681
682  Ok(was_something_deleted)
683}
684
685/// Remove an installed upload
686///
687/// # Arguments
688///
689/// * `upload_id` - The ID of upload which will be removed
690///
691/// * `game_folder` - The folder with the game files where the upload will be removed from
692///
693/// # Errors
694///
695/// If something goes wrong
696pub async fn remove(upload_id: UploadID, game_folder: &Path) -> Result<(), String> {
697  let upload_folder = game_files::get_upload_folder(game_folder, upload_id);
698
699  // If there isn't a upload_folder, or it is empty, that means the game
700  // has already been removed, so return Ok(())
701  if filesystem::is_folder_empty(&upload_folder).await? {
702    return Ok(());
703  }
704
705  game_files::remove_folder_safely(&upload_folder).await?;
706  // The upload folder has been removed
707
708  // If the game folder is empty, remove it
709  game_files::remove_folder_if_empty(game_folder).await?;
710
711  Ok(())
712}
713
714/// Move an installed upload to a new game folder
715///
716/// # Arguments
717///
718/// * `upload_id` - The ID of upload which will be moved
719///
720/// * `src_game_folder` - The folder where the game files are currently placed
721///
722/// * `dst_game_folder` - The folder where the game files will be moved to
723///
724/// # Returns
725///
726/// The new game folder in its absolute (canonical) form
727///
728/// # Errors
729///
730/// If something goes wrong
731pub async fn r#move(
732  upload_id: UploadID,
733  src_game_folder: &Path,
734  dst_game_folder: &Path,
735) -> Result<PathBuf, String> {
736  let src_upload_folder = game_files::get_upload_folder(src_game_folder, upload_id);
737
738  // If there isn't a src_upload_folder, exit with error
739  filesystem::ensure_is_dir(&src_upload_folder).await?;
740
741  let dst_upload_folder = game_files::get_upload_folder(dst_game_folder, upload_id);
742
743  // If there is a dst_upload_folder with contents, exit with error
744  filesystem::ensure_is_empty(&dst_upload_folder).await?;
745
746  // Move the upload folder
747  game_files::move_folder(&src_upload_folder, &dst_upload_folder).await?;
748
749  // If src_game_folder is empty, remove it
750  game_files::remove_folder_if_empty(src_game_folder).await?;
751
752  filesystem::get_canonical_path(dst_game_folder)
753    .await
754    .map_err(std::convert::Into::into)
755}
756
757/// Retrieve the itch manifest from an installed upload
758///
759/// # Arguments
760///
761/// * `upload_id` - The ID of upload from which the info will be retrieved
762///
763/// * `game_folder` - The folder with the game files where the upload folder is placed
764///
765/// # Returns
766///
767/// A [`Manifest`] struct with the manifest actions info, or None if the manifest isn't present
768///
769/// # Errors
770///
771/// If something goes wrong
772pub async fn get_upload_manifest(
773  upload_id: UploadID,
774  game_folder: &Path,
775) -> Result<Option<Manifest>, String> {
776  let upload_folder = game_files::get_upload_folder(game_folder, upload_id);
777
778  itch_manifest::read_manifest(&upload_folder).await
779}
780
781/// Launchs an installed upload
782///
783/// # Arguments
784///
785/// * `upload_id` - The ID of upload which will be launched
786///
787/// * `game_folder` - The folder where the game uploads are placed
788///
789/// * `launch_method` - The launch method to use to determine the upload executable file
790///
791/// * `wrapper` - A list of a wrapper and its options to run the upload executable with
792///
793/// * `game_arguments` - A list of arguments to launch the upload executable with
794///
795/// * `environment_variables` - A list of environment variables to be added to the upload executable process's environment
796///
797/// * `launch_start_callback` - A callback triggered just before the upload executable runs, providing information about what is about to be executed
798///
799/// # Errors
800///
801/// If something goes wrong
802pub async fn launch(
803  upload_id: UploadID,
804  game_folder: &Path,
805  launch_method: LaunchMethod,
806  wrapper: &[String],
807  game_arguments: &[String],
808  environment_variables: &[(String, String)],
809  launch_start_callback: impl FnOnce(&Path, &tokio::process::Command),
810) -> Result<(), String> {
811  let upload_folder: PathBuf = game_files::get_upload_folder(game_folder, upload_id);
812
813  // Determine the upload executable and its launch arguments from the function arguments, manifest, or heuristics.
814  let (upload_executable, game_arguments): (PathBuf, Cow<[String]>) = match launch_method {
815    // 1. If the launch method is an alternative executable, then that executable with the arguments provided to the function
816    LaunchMethod::AlternativeExecutable { executable_path } => {
817      (executable_path, Cow::Borrowed(game_arguments))
818    }
819    // 2. If the launch method is a manifest action, use its executable
820    LaunchMethod::ManifestAction {
821      manifest_action_name,
822    } => {
823      let ma = itch_manifest::launch_action(&upload_folder, Some(&manifest_action_name))
824        .await?
825        .ok_or_else(|| {
826          format!(
827            "The provided launch action doesn't exist in the manifest: {manifest_action_name}"
828          )
829        })?;
830      (
831        ma.get_canonical_path(&upload_folder).await?,
832        // a) If the function's game arguments are empty, use the ones from the manifest
833        if game_arguments.is_empty() {
834          Cow::Owned(ma.args.unwrap_or_default())
835        }
836        // b) Otherwise, use the provided ones
837        else {
838          Cow::Borrowed(game_arguments)
839        },
840      )
841    }
842    // 3. Otherwise, if the launch method are the heuristics, use them to locate the executable
843    LaunchMethod::Heuristics {
844      game_platform,
845      game_title,
846    } => {
847      // But first, check if the game has a manifest with a "play" action, and use it if possible
848      let mao = itch_manifest::launch_action(&upload_folder, None).await?;
849
850      match mao {
851        // If the manifest has a "play" action, launch from it
852        Some(ma) => (
853          ma.get_canonical_path(&upload_folder).await?,
854          // a) If the function's game arguments are empty, use the ones from the manifest
855          if game_arguments.is_empty() {
856            Cow::Owned(ma.args.unwrap_or_default())
857          }
858          // b) Otherwise, use the provided ones
859          else {
860            Cow::Borrowed(game_arguments)
861          },
862        ),
863        // Else, now use the heuristics to determine the executable, with the function's game arguments
864        None => (
865          heuristics::get_game_executable(&upload_folder, game_platform, game_title).await?,
866          Cow::Borrowed(game_arguments),
867        ),
868      }
869    }
870  };
871
872  let upload_executable = filesystem::get_canonical_path(&upload_executable).await?;
873
874  // Make the file executable
875  filesystem::make_executable(&upload_executable).await?;
876
877  // Create the tokio process
878  let mut game_process = {
879    let mut wrapper_iter = wrapper.iter();
880    match wrapper_iter.next() {
881      // If it doesn't have a wrapper, just run the executable
882      None => tokio::process::Command::new(&upload_executable),
883      Some(w) => {
884        // If the game has a wrapper, then run the wrapper with its
885        // arguments and add the game executable as the last argument
886        let mut gp = tokio::process::Command::new(w);
887        gp.args(wrapper_iter).arg(&upload_executable);
888        gp
889      }
890    }
891  };
892
893  // Add the working directory, the game arguments and the environment variables
894  game_process
895    .current_dir(&upload_folder)
896    .args(&*game_arguments)
897    .envs(environment_variables.iter().map(|(k, v)| (k, v)));
898
899  launch_start_callback(&upload_executable, &game_process);
900
901  let mut child = filesystem::spawn_command(&mut game_process)?;
902  filesystem::wait_child(&mut child).await?;
903
904  Ok(())
905}
906
907/// Get the url to a itch.io web game
908///
909/// # Arguments
910///
911/// * `upload_id` - The ID of the html upload
912///
913/// # Returns
914///
915/// The web game URL
916#[must_use]
917pub fn get_web_game_url(upload_id: UploadID) -> String {
918  format!("https://html-classic.itch.zone/html/{upload_id}/index.html")
919}