scratch_io/
lib.rs

1use tokio::io::AsyncWriteExt;
2use tokio::time::{Instant, Duration};
3use futures_util::StreamExt;
4use md5::{Md5, Digest};
5use reqwest::{Client, Method, Response, header};
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::borrow::Cow;
9use serde::{Deserialize, Serialize};
10use time::format_description::well_known::Rfc3339;
11
12pub mod itch_api_types;
13pub mod heuristics;
14mod game_files_operations;
15mod itch_manifest;
16mod extract;
17use crate::itch_api_types::*;
18use crate::game_files_operations::*;
19
20const COVER_IMAGE_DEFAULT_FILENAME: &str = "cover";
21
22// This isn't inside itch_types because it is not something that the itch API returns
23// These platforms are *interpreted* from the data provided by the API
24/// The different platforms a upload can be made for
25#[derive(Serialize, Clone, clap::ValueEnum, Eq, PartialEq, Hash)]
26pub enum GamePlatform {
27  Linux,
28  Windows,
29  OSX,
30  Android,
31  Web,
32  Flash,
33  Java,
34  UnityWebPlayer,
35}
36
37impl std::fmt::Display for GamePlatform {
38  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39    write!(f, "{}", serde_json::to_string(&self).unwrap())
40  }
41}
42
43impl Upload {
44  pub fn to_game_platforms(&self) -> Vec<GamePlatform> {
45    let mut platforms: Vec<GamePlatform> = Vec::new();
46
47    match self.r#type {
48      UploadType::HTML => platforms.push(GamePlatform::Web),
49      UploadType::Flash => platforms.push(GamePlatform::Flash),
50      UploadType::Java => platforms.push(GamePlatform::Java),
51      UploadType::Unity => platforms.push(GamePlatform::UnityWebPlayer),
52      _ => (),
53    }
54
55    for t in self.traits.iter() {
56      match t {
57        UploadTrait::PLinux => platforms.push(GamePlatform::Linux),
58        UploadTrait::PWindows => platforms.push(GamePlatform::Windows),
59        UploadTrait::POSX => platforms.push(GamePlatform::OSX),
60        UploadTrait::PAndroid => platforms.push(GamePlatform::Android),
61        _ => ()
62      }
63    }
64
65    platforms
66  }
67}
68
69pub enum DownloadStatus {
70  Warning(String),
71  DownloadedCover(PathBuf),
72  StartingDownload(),
73  Download(u64),
74  Extract,
75}
76
77pub enum LaunchMethod<'a> {
78  AlternativeExecutable(&'a Path),
79  ManifestAction(&'a str),
80  Heuristics(&'a GamePlatform, &'a Game),
81}
82
83/// Some information about a installed upload
84#[derive(Serialize, Deserialize)]
85pub struct InstalledUpload {
86  pub upload_id: u64,
87  pub game_folder: PathBuf,
88  pub cover_image: Option<String>,
89  pub upload: Option<Upload>,
90  pub game: Option<Game>,
91}
92
93impl InstalledUpload {
94  /// Returns true if the info has been updated
95  pub async fn add_missing_info(&mut self, client: &Client, api_key: &str, force_update: bool) -> Result<bool, String> {
96    let mut updated = false;
97
98    if self.upload.is_none() || force_update {
99      self.upload = Some(get_upload_info(client, api_key, self.upload_id).await?);
100      updated = true;
101    }
102    if self.game.is_none() || force_update {
103      self.game = Some(get_game_info(client, api_key, self.upload.as_ref().expect("The upload info has just been received. Why isn't it there?").game_id).await?);
104      updated = true;
105    }
106
107    Ok(updated)
108  }
109}
110
111impl std::fmt::Display for InstalledUpload {
112  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113    let (u_name, u_created_at, u_updated_at, u_traits) = match self.upload.as_ref() {
114      None => ("", String::new(), String::new(), String::new()),
115      Some(u) => (
116        u.display_name.as_deref().unwrap_or(&u.filename),
117        u.created_at.format(&Rfc3339).unwrap_or_default(),
118        u.updated_at.format(&Rfc3339).unwrap_or_default(),
119        u.traits.iter().map(|t| t.to_string()).collect::<Vec<String>>().join(", "),
120      )
121    };
122
123    let (g_id, g_name, g_description, g_url, g_created_at, g_published_at, a_id, a_name, a_url) = match self.game.as_ref() {
124      None => (String::new(), "", "", "", String::new(), String::new(), String::new(), "", ""),
125      Some(g) => (
126        g.id.to_string(),
127        g.title.as_str(),
128        g.short_text.as_deref().unwrap_or_default(),
129        g.url.as_str(),
130        g.created_at.format(&Rfc3339).unwrap_or_default(),
131        g.published_at.as_ref().and_then(|date| date.format(&Rfc3339).ok()).unwrap_or_default(),
132        g.user.id.to_string(),
133        g.user.display_name.as_deref().unwrap_or(&g.user.username),
134        g.user.url.as_str(),
135      )
136    };
137
138    write!(f, "\
139Upload id: {}
140Game folder: \"{}\"
141Cover image: \"{}\"
142  Upload:
143    Name: {u_name}
144    Created at: {u_created_at}
145    Updated at: {u_updated_at}
146    Traits: {u_traits}
147  Game:
148    Id: {g_id}
149    Name: {g_name}
150    Description: {g_description}
151    URL: {g_url}
152    Created at: {g_created_at}
153    Published at: {g_published_at}
154  Author
155    Id: {a_id}
156    Name: {a_name}
157    URL: {a_url}",
158      self.upload_id,
159      self.game_folder.to_string_lossy(),
160      self.cover_image.as_deref().unwrap_or_default(),
161    )
162  }
163}
164
165/// Make a request to the itch.io API
166/// 
167/// # Arguments
168/// 
169/// * `client` - An asynchronous reqwest Client
170/// 
171/// * `method` - The request method (GET, POST, etc.)
172/// 
173/// * `url` - A itch.io API address to make the request against
174/// 
175/// * `api_key` - A valid Itch.io API key to make the request
176/// 
177/// * `options` - A closure that modifies the request builder just before sending it
178/// 
179/// # Returns
180/// 
181/// The reqwest response
182/// 
183/// An error if sending the request fails
184async fn itch_request(
185  client: &Client,
186  method: Method,
187  url: &ItchApiUrl,
188  api_key: &str,
189  options: impl FnOnce(reqwest::RequestBuilder) -> reqwest::RequestBuilder
190) -> Result<Response, String> {
191  let mut request: reqwest::RequestBuilder = client.request(method, url.to_string());
192
193  request = match url {
194    // https://itchapi.ryhn.link/API/V1/index.html#authentication
195    ItchApiUrl::V1(..) => request.header(header::AUTHORIZATION, format!("Bearer {api_key}")),
196    // https://itchapi.ryhn.link/API/V2/index.html#authentication
197    ItchApiUrl::V2(..) => request.header(header::AUTHORIZATION, api_key),
198  };
199  // This header is set to ensure the use of the v2 version
200  // https://itchapi.ryhn.link/API/V2/index.html
201  if let ItchApiUrl::V2(_) = url {
202    request = request.header(header::ACCEPT, "application/vnd.itch.v2");
203  }
204
205  // The callback is the final option before sending because
206  // it needs to be able to modify anything
207  request = options(request);
208
209  request.send().await
210    .map_err(|e| format!("Error while sending request: {e}"))
211}
212
213/// Make a request to the itch.io API and parse the response as json
214/// 
215/// # Arguments
216/// 
217/// * `client` - An asynchronous reqwest Client
218/// 
219/// * `method` - The request method (GET, POST, etc.)
220/// 
221/// * `url` - A itch.io API address to make the request against
222/// 
223/// * `api_key` - A valid Itch.io API key to make the request
224/// 
225/// * `options` - A closure that modifies the request builder just before sending it
226/// 
227/// # Returns
228/// 
229/// The reqwest response parsed as JSON into the provided type
230/// 
231/// An error if sending the request or parsing it fails
232async fn itch_request_json<T>(
233  client: &Client,
234  method: Method,
235  url: &ItchApiUrl,
236  api_key: &str,
237  options: impl FnOnce(reqwest::RequestBuilder) -> reqwest::RequestBuilder,
238) -> Result<T, String> where
239  T: serde::de::DeserializeOwned,
240{
241  let text = itch_request(client, method, url, api_key, options).await?
242    .text().await
243    .map_err(|e| format!("Error while reading response body: {e}"))?;
244
245  serde_json::from_str::<ApiResponse<T>>(&text)
246    .map_err(|e| format!("Error while parsing JSON body: {e}\n\n{}", text))?
247    .into_result()
248}
249
250/// Download a file given a reqwest Response
251/// 
252/// # Arguments
253/// 
254/// * `file_response` - A reqwest Response for the file
255/// 
256/// * `file_path` - The path where the file will be placed
257/// 
258/// * `md5_hash` - A md5 hash to check the file against. If none, don't verify the download
259/// 
260/// * `progress_callback` - A closure called with the number of downloaded bytes at the moment
261/// 
262/// * `callback_interval` - The minimum time span between each progress_callback call
263/// 
264/// # Returns
265/// 
266/// A hasher, empty if update_md5_hash is false
267/// 
268/// An error if the download, of any filesystem operation fails; or if the hash provided doesn't match the file
269async fn download_file(
270  file_response: Response,
271  file_path: &Path,
272  md5_hash: Option<&str>,
273  progress_callback: impl Fn(u64),
274  callback_interval: Duration,
275) -> Result<(), String> {
276  // Prepare the download, the hasher, and the callback variables
277  let mut downloaded_bytes: u64 = 0;
278  let mut file = tokio::fs::File::create(&file_path).await
279    .map_err(|e| e.to_string())?;
280  let mut stream = file_response.bytes_stream();
281  let mut hasher = Md5::new();
282  let mut last_callback = Instant::now();
283
284  // Save chunks to the file async
285  // Also, compute the md5 hash while it is being downloaded
286  while let Some(chunk) = stream.next().await {
287    // Return an error if the chunk is invalid
288    let chunk = chunk
289      .map_err(|e| format!("Error reading chunk: {e}"))?;
290
291    // Write the chunk to the file
292    file.write_all(&chunk).await
293      .map_err(|e| format!("Error writing chunk to the file: {e}"))?;
294
295    // If the file has a md5 hash, update the hasher
296    if md5_hash.is_some() {
297      hasher.update(&chunk);
298    }
299  
300    // Send a callback with the progress
301    downloaded_bytes += chunk.len() as u64;
302    if last_callback.elapsed() > callback_interval {
303      last_callback = Instant::now();
304      progress_callback(downloaded_bytes);
305    }
306  }
307
308  progress_callback(downloaded_bytes);
309
310  // If the hashes aren't equal, exit with an error
311  if let Some(hash) = md5_hash {
312    let file_hash = format!("{:x}", hasher.finalize());
313
314    if !file_hash.eq_ignore_ascii_case(&hash) {
315      return Err(format!("File verification failed! The file hash and the hash provided by the server are different.\n\
316File hash:   {file_hash}
317Server hash: {hash}"
318      ));
319    }
320  }
321
322  // Sync the file to ensure all the data has been written
323  file.sync_all().await
324    .map_err(|e| e.to_string())?;
325
326  Ok(())
327}
328
329/// Complete the login with the TOTP 2nd factor verification
330/// 
331/// # Arguments
332/// 
333/// * `client` - An asynchronous reqwest Client
334/// 
335/// * `totp_token` - The TOTP token returned by the previous login step
336/// 
337/// * `totp_code` - The 6-digit code returned by the TOTP application
338/// 
339/// # Returns
340/// 
341/// A LoginSuccess struct with the new API key
342/// 
343/// An error if something goes wrong
344async fn totp_verification(client: &Client, totp_token: &str, totp_code: u64) -> Result<LoginSuccess, String> {
345  itch_request_json::<LoginSuccess>(
346    client,
347    Method::POST,
348    &ItchApiUrl::V2(format!("totp/verify")),
349    "",
350    |b| b.form(&[
351      ("token", totp_token),
352      ("code", &totp_code.to_string())
353    ]),
354  ).await
355    .map_err(|e| format!("An error occurred while attempting log in:\n{e}"))
356}
357
358/// Login to Itch.io
359/// 
360/// Retrieve a API key from a username and password authentication
361/// 
362/// # Arguments
363/// 
364/// * `client` - An asynchronous reqwest Client
365/// 
366/// * `username` - The username OR email of the accout to log in with
367/// 
368/// * `password` - The password of the accout to log in with
369/// 
370/// * `recaptcha_response` - If required, the reCAPTCHA token from https://itch.io/captcha
371/// 
372/// * `totp_code` - If required, The 6-digit code returned by the TOTP application
373/// 
374/// # Returns
375/// 
376/// A LoginSuccess struct with the new API key
377/// 
378/// An error if something goes wrong
379pub async fn login(client: &Client, username: &str, password: &str, recaptcha_response: Option<&str>, totp_code: Option<u64>) -> Result<LoginSuccess, String> {
380  let mut params: Vec<(&'static str, &str)> = vec![
381    ("username", username),
382    ("password", password),
383    ("force_recaptcha", "false"),
384    ("source", "desktop"),
385  ];
386
387  if let Some(rr) = recaptcha_response {
388    params.push(("recaptcha_response", rr));
389  }
390
391  let response = itch_request_json::<LoginResponse>(
392    client,
393    Method::POST,
394    &ItchApiUrl::V2(format!("login")),
395    "",
396    |b| b.form(&params),
397  ).await
398    .map_err(|e| format!("An error occurred while attempting log in:\n{e}"))?;
399
400  let ls = match response {
401    LoginResponse::CaptchaError(e) => {
402      return Err(format!(
403  r#"A reCAPTCHA verification is required to continue!
404  Go to "{}" and solve the reCAPTCHA.
405  To obtain the token, paste the following command on the developer console:
406    console.log(grecaptcha.getResponse())
407  Then run the login command again with the --recaptcha-response option."#,
408        e.recaptcha_url.as_str()
409      ));
410    }
411    LoginResponse::TOTPError(e) => {
412      let Some(totp_code) = totp_code else {
413        return Err(format!(
414  r#"The accout has 2 step verification enabled via TOTP
415  Run the login command again with the --totp-code={{VERIFICATION_CODE}} option."#
416        ));
417      };
418
419      totp_verification(client, e.token.as_str(), totp_code).await?
420    }
421    LoginResponse::Success(ls) => ls
422  };
423
424  Ok(ls)
425}
426
427/// Get the API key's profile
428/// 
429/// This can be used to verify that a given Itch.io API key is valid
430/// 
431/// # Arguments
432/// 
433/// * `client` - An asynchronous reqwest Client
434/// 
435/// * `api_key` - A valid Itch.io API key to make the request
436/// 
437/// # Returns
438/// 
439/// A User struct with the info provided by the API
440/// 
441/// An error if something goes wrong
442pub async fn get_profile(client: &Client, api_key: &str) -> Result<User, String> {
443  itch_request_json::<ProfileResponse>(
444    client,
445    Method::GET,
446    &ItchApiUrl::V2(format!("profile")),
447    api_key,
448    |b| b,
449  ).await
450    .map(|res| res.user)
451    .map_err(|e| format!("An error occurred while attempting to get the profile info:\n{e}"))
452}
453
454/// Get the user's owned game keys
455/// 
456/// # Arguments
457/// 
458/// * `client` - An asynchronous reqwest Client
459/// 
460/// * `api_key` - A valid Itch.io API key to make the request
461/// 
462/// # Returns
463/// 
464/// A vector of OwnedKey structs with the info provided by the API
465/// 
466/// An error if something goes wrong
467pub async fn get_owned_keys(client: &Client, api_key: &str) -> Result<Vec<OwnedKey>, String> {
468  let mut keys: Vec<OwnedKey> = Vec::new();
469  let mut page: u64 = 1;
470  loop {
471    let mut response = itch_request_json::<OwnedKeysResponse>(
472      client,
473      Method::GET,
474      &ItchApiUrl::V2(format!("profile/owned-keys")),
475      api_key,
476      |b| b.query(&[("page", page)]),
477    ).await
478      .map_err(|e| format!("An error occurred while attempting to obtain the list of the user's game keys: {e}"))?;
479
480    let num_keys: u64 = response.owned_keys.len() as u64;
481    keys.append(&mut response.owned_keys);
482    // Warning!!!
483    // response.collection_games was merged into games, but it WAS NOT dropped!
484    // Its length is still accessible, but this doesn't make sense!
485    
486    if num_keys < response.per_page || num_keys == 0 {
487      break;
488    }
489    page += 1;
490  }
491
492  Ok(keys)
493}
494
495/// Get the information about a game in Itch.io
496/// 
497/// # Arguments
498/// 
499/// * `client` - An asynchronous reqwest Client
500/// 
501/// * `api_key` - A valid Itch.io API key to make the request
502/// 
503/// * `game_id` - The ID of the game from which information will be obtained
504/// 
505/// # Returns
506/// 
507/// A Game struct with the info provided by the API
508/// 
509/// An error if something goes wrong
510pub async fn get_game_info(client: &Client, api_key: &str, game_id: u64) -> Result<Game, String> {
511  itch_request_json::<GameInfoResponse>(
512    client,
513    Method::GET,
514    &ItchApiUrl::V2(format!("games/{game_id}")),
515    api_key,
516    |b| b,
517  ).await
518    .map(|res| res.game)
519    .map_err(|e| format!("An error occurred while attempting to obtain the game info:\n{e}"))
520}
521
522/// Get the game's uploads (downloadable files)
523/// 
524/// # Arguments
525/// 
526/// * `client` - An asynchronous reqwest Client
527/// 
528/// * `api_key` - A valid Itch.io API key to make the request
529/// 
530/// * `game_id` - The ID of the game from which information will be obtained
531/// 
532/// # Returns
533/// 
534/// A vector of Upload structs with the info provided by the API
535/// 
536/// An error if something goes wrong
537pub async fn get_game_uploads(client: &Client, api_key: &str, game_id: u64) -> Result<Vec<Upload>, String> {
538  itch_request_json::<GameUploadsResponse>(
539    client,
540    Method::GET,
541    &ItchApiUrl::V2(format!("games/{game_id}/uploads")),
542    api_key,
543    |b| b,
544  ).await
545    .map(|res| res.uploads)
546    .map_err(|e| format!("An error occurred while attempting to obtain the game uploads:\n{e}"))
547}
548
549
550pub fn get_game_platforms(uploads: &[Upload], game_name: &str) -> Result<HashMap<GamePlatform, u64>, String> {
551  heuristics::get_game_platforms(uploads, game_name)
552}
553
554/// Get an upload's info
555/// 
556/// # Arguments
557/// 
558/// * `client` - An asynchronous reqwest Client
559/// 
560/// * `api_key` - A valid Itch.io API key to make the request
561/// 
562/// * `upload_id` - The ID of the upload from which information will be obtained
563/// 
564/// # Returns
565/// 
566/// A Upload struct with the info provided by the API
567/// 
568/// An error if something goes wrong
569pub async fn get_upload_info(client: &Client, api_key: &str, upload_id: u64) -> Result<Upload, String> {
570  itch_request_json::<UploadResponse>(
571    client,
572    Method::GET,
573    &ItchApiUrl::V2(format!("uploads/{upload_id}")),
574    api_key,
575    |b| b,
576  ).await
577    .map(|res| res.upload)
578    .map_err(|e| format!("An error occurred while attempting to obtain the upload information:\n{e}"))
579}
580
581/// List the user's game collections
582/// 
583/// # Arguments
584/// 
585/// * `client` - An asynchronous reqwest Client
586/// 
587/// * `api_key` - A valid Itch.io API key to make the request
588/// 
589/// # Returns
590/// 
591/// A vector of Collection structs with the info provided by the API
592/// 
593/// An error if something goes wrong
594pub async fn get_collections(client: &Client, api_key: &str) -> Result<Vec<Collection>, String> {
595  itch_request_json::<CollectionsResponse>(
596    client,
597    Method::GET,
598    &ItchApiUrl::V2(format!("profile/collections")),
599    api_key,
600    |b| b,
601  ).await
602    .map(|res| res.collections)
603    .map_err(|e| format!("An error occurred while attempting to obtain the list of the profile's collections:\n{e}"))
604}
605
606/// List a collection's games
607/// 
608/// # Arguments
609/// 
610/// * `client` - An asynchronous reqwest Client
611/// 
612/// * `api_key` - A valid Itch.io API key to make the request
613/// 
614/// * `collection_id` - The ID of the collection from which information will be obtained
615/// 
616/// # Returns
617/// 
618/// A vector of CollectionGameItem structs with the info provided by the API
619/// 
620/// An error if something goes wrong
621pub async fn get_collection_games(client: &Client, api_key: &str, collection_id: u64) -> Result<Vec<CollectionGameItem>, String> {   
622  let mut games: Vec<CollectionGameItem> = Vec::new();
623  let mut page: u64 = 1;
624  loop {
625    let mut response = itch_request_json::<CollectionGamesResponse>(
626      client,
627      Method::GET,
628      &ItchApiUrl::V2(format!("collections/{collection_id}/collection-games")),
629      api_key,
630      |b| b.query(&[("page", page)]),
631    ).await
632      .map_err(|e| format!("An error occurred while attempting to obtain the list of the collection's games: {e}"))?;
633
634    let num_games: u64 = response.collection_games.len() as u64;
635    games.append(&mut response.collection_games);
636    // Warning!!!
637    // response.collection_games was merged into games, but it WAS NOT dropped!
638    // Its length is still accessible, but this doesn't make sense!
639    
640    if num_games < response.per_page || num_games == 0 {
641      break;
642    }
643    page += 1;
644  }
645
646  Ok(games)
647}
648
649/// Download a game cover image from the provided url
650/// 
651/// # Arguments
652/// 
653/// * `client` - An asynchronous reqwest Client
654/// 
655/// * `cover_url` - The url to the cover image file
656/// 
657/// * `folder` - The game folder where the cover will be placed
658/// 
659/// # Returns
660/// 
661/// The path of the downloaded image
662/// 
663/// An error if something goes wrong
664async fn download_game_cover(client: &Client, cover_url: &str, cover_filename: &str, folder: &Path) -> Result<PathBuf, String> {
665  let cover_extension = cover_url.rsplit(".").next().unwrap_or_default();
666  let cover_path = folder.join(format!("{cover_filename}.{cover_extension}"));
667        
668  if cover_path.try_exists().map_err(|e| e.to_string())? {
669    return Ok(cover_path);
670  }
671
672  let cover_response = client.request(Method::GET, cover_url)
673    .send().await
674    .map_err(|e| format!("Error while sending request: {e}"))?;
675
676  download_file(
677    cover_response,
678    &cover_path,
679    None,
680    |_| (),
681    Duration::MAX,
682  ).await?;
683 
684  Ok(cover_path)
685}
686
687/// Download a game cover image from its game ID
688/// 
689/// # Arguments
690/// 
691/// * `client` - An asynchronous reqwest Client
692/// 
693/// * `api_key` - A valid Itch.io API key to make the request
694/// 
695/// * `game_id` - The ID of the game from which the cover will be downloaded
696/// 
697/// * `cover_filename` - The new filename of the cover (without the extension)
698/// 
699/// * `folder` - The game folder where the cover will be placed
700/// 
701/// # Returns
702/// 
703/// The path of the downloaded image, or None if the game doesn't have one
704/// 
705/// An error if something goes wrong
706pub async fn download_game_cover_from_id(client: &Client, api_key: &str, game_id: u64, cover_filename: Option<&str>, folder: Option<&Path>) -> Result<Option<PathBuf>, String> {
707  // Get the game info from the server
708  let game_info = get_game_info(client, api_key, game_id).await?;
709  // If the game doesn't have a cover, return
710  let Some(cover_url) = game_info.cover_url else {
711    return Ok(None);
712  };
713
714  // If the folder isn't set, set it to the default game folder
715  let folder = match folder {
716    Some(f) => f,
717    None => &get_game_folder(&game_info.title)?,
718  };
719
720  // Create the folder where the file is going to be placed if it doesn't already exist
721  tokio::fs::create_dir_all(folder).await
722    .map_err(|e| format!("Couldn't create the folder \"{}\": {e}", folder.to_string_lossy()))?;
723
724  // If the cover filename isn't set, set it to "cover"
725  let cover_filename = match cover_filename {
726    Some(f) => f,
727    None => COVER_IMAGE_DEFAULT_FILENAME,
728  };
729
730  download_game_cover(client, cover_url.as_str(), cover_filename, folder).await.map(|p| Some(p))
731}
732
733/// Download a game upload
734/// 
735/// # Arguments
736/// 
737/// * `client` - An asynchronous reqwest Client
738/// 
739/// * `api_key` - A valid Itch.io API key to make the request
740/// 
741/// * `upload_id` - The ID of the upload which will be downloaded
742/// 
743/// * `game_folder` - The folder where the downloadeded game files will be placed
744/// 
745/// * `upload_info` - A closure which reports the upload and the game info before the download starts
746/// 
747/// * `progress_callback` - A closure which reports the download progress
748/// 
749/// * `callback_interval` - The minimum time span between each progress_callback call
750/// 
751/// # Returns
752/// 
753/// The installation info about the upload
754/// 
755/// An error if something goes wrong
756pub async fn download_upload(
757  client: &Client,
758  api_key: &str,
759  upload_id: u64,
760  game_folder: Option<&Path>,
761  upload_info: impl FnOnce(&Upload, &Game),
762  progress_callback: impl Fn(DownloadStatus),
763  callback_interval: Duration,
764) -> Result<InstalledUpload, String> {
765
766  // --- DOWNLOAD PREPARATION --- 
767
768  // Obtain information about the game and the upload that will be downloaeded
769  let upload: Upload = get_upload_info(client, api_key, upload_id).await?;
770  let game: Game = get_game_info(client, api_key, upload.game_id).await?;
771  
772  // Send to the caller the game and the upload info
773  upload_info(&upload, &game);
774
775  // Set the game_folder and the file variables  
776  // If the game_folder is unset, set it to ~/Games/{game_name}/
777  let game_folder = match game_folder {
778    Some(f) => f,
779    None => &get_game_folder(&game.title)?,
780  };
781
782  // The new upload_folder is game_folder + the upload id
783  let upload_folder: PathBuf = get_upload_folder(game_folder, upload_id);
784
785  // Check if the folder where the upload files will be placed is empty
786  if !is_folder_empty(&upload_folder)? {
787    return Err(format!("The upload folder isn't empty!: \"{}\"", upload_folder.to_string_lossy()));
788  }
789  
790  // Create the folder if it doesn't already exist
791  tokio::fs::create_dir_all(&upload_folder).await
792    .map_err(|e| format!("Couldn't create the folder \"{}\": {e}", upload_folder.to_string_lossy()))?;
793  
794  // upload_archive is the location where the upload will be downloaded
795  let upload_archive: PathBuf = upload_folder.join(&upload.filename);
796
797
798  // --- DOWNLOAD --- 
799
800  // Download the cover image
801  let cover_image: Option<PathBuf> = match game.cover_url {
802    None => None,
803    Some(ref cover_url) => Some(
804      download_game_cover(client, cover_url, COVER_IMAGE_DEFAULT_FILENAME, &game_folder).await
805        .inspect(|cp| progress_callback(DownloadStatus::DownloadedCover(cp.to_path_buf())))?
806    )
807  };
808
809  progress_callback(DownloadStatus::StartingDownload());
810
811  // Start the download, but don't save it to a file yet
812  let file_response = itch_request(
813    client,
814    Method::GET,
815    &ItchApiUrl::V2(format!("uploads/{upload_id}/download")),
816    api_key,
817    |b| b
818  ).await?;
819
820  // Download the file
821  download_file(
822    file_response,
823    &upload_archive,
824    upload.md5_hash.as_deref(),
825    |bytes| progress_callback(DownloadStatus::Download(bytes)),
826    callback_interval,
827  ).await?;
828  
829  // Print a warning if the upload doesn't have a hash in the server
830  if upload.md5_hash.is_none() {
831    progress_callback(DownloadStatus::Warning("Missing md5 hash. Couldn't verify the file integrity!".to_string()));
832  }
833
834
835  // --- FILE EXTRACTION ---
836
837  progress_callback(DownloadStatus::Extract);
838
839  // Extracts the downloaded archive (if it's an archive)
840  // game_files can be the path of an executable or the path to the extracted folder
841  extract::extract(&upload_archive).await
842    .map_err(|e| e.to_string())?;
843
844  Ok(InstalledUpload {
845    upload_id,
846    // Get the absolute (canonical) form of the path
847    game_folder: game_folder.canonicalize()
848      .map_err(|e| format!("Error getting the canonical form of the game folder! Maybe it doesn't exist: {}\n{e}", game_folder.to_string_lossy()))?,
849    cover_image: cover_image.map(|p| p.file_name().expect("Cover image doesn't have a filename?").to_string_lossy().to_string()),
850    upload: Some(upload),
851    game: Some(game),
852  })
853}
854
855/// Import an already installed upload
856/// 
857/// # Arguments
858/// 
859/// * `client` - An asynchronous reqwest Client
860/// 
861/// * `api_key` - A valid Itch.io API key to make the request
862/// 
863/// * `upload_id` - The ID of upload which will be imported
864/// 
865/// * `game_folder` - The folder where the game files are currectly placed
866/// 
867/// # Returns
868/// 
869/// The installation info about the upload
870/// 
871/// An error if something goes wrong
872pub async fn import(client: &Client, api_key: &str, upload_id: u64, game_folder: &Path) -> Result<InstalledUpload, String> {
873  // Obtain information about the game and the upload that will be downloaeded
874  let upload: Upload = get_upload_info(client, api_key, upload_id).await?;
875  let game: Game = get_game_info(client, api_key, upload.game_id).await?;
876  
877  let cover_image: Option<String> = find_cover_filename(game_folder)?;
878
879  Ok(InstalledUpload {
880    upload_id,
881    // Get the absolute (canonical) form of the path
882    game_folder: game_folder.canonicalize()
883      .map_err(|e| format!("Error getting the canonical form of the game folder! Maybe it doesn't exist: {}\n{e}", game_folder.to_string_lossy()))?,
884    cover_image,
885    upload: Some(upload),
886    game: Some(game),
887  })
888}
889
890/// Remove an installed upload
891/// 
892/// # Arguments
893/// 
894/// * `upload_id` - The ID of upload which will be removed
895/// 
896/// * `game_folder` - The folder with the game files where the upload will be removed from
897/// 
898/// # Returns
899/// 
900/// An error if something goes wrong
901pub async fn remove(upload_id: u64, game_folder: &Path) -> Result<(), String> {
902
903  let upload_folder = get_upload_folder(game_folder, upload_id);
904
905  // If there isn't a upload_folder, or it is empty, that means the game
906  // has already been removed, so return Ok(())
907  if is_folder_empty(&upload_folder)? {
908    return Ok(())
909  }
910
911  remove_folder_safely(upload_folder).await?;
912  // The upload folder has been removed
913
914  // If there isn't another upload folder, remove the whole game folder
915  remove_folder_without_child_folders(&game_folder).await?;
916
917  Ok(())
918}
919
920/// Move an installed upload to a new game folder
921/// 
922/// # Arguments
923/// 
924/// * `upload_id` - The ID of upload which will be moved
925/// 
926/// * `src_game_folder` - The folder where the game files are currently placed
927/// 
928/// * `dst_game_folder` - The folder where the game files will be moved to
929/// 
930/// # Returns
931/// 
932/// The new game folder in its absolute (canonical) form
933/// 
934/// An error if something goes wrong
935pub async fn r#move(upload_id: u64, src_game_folder: &Path, dst_game_folder: &Path) -> Result<PathBuf, String> {
936  let src_upload_folder = get_upload_folder(src_game_folder, upload_id);
937
938  // If there isn't a src_upload_folder, exit with error
939  if !src_upload_folder.try_exists().map_err(|e| format!("Couldn't check if the upload folder exists: {e}"))? {
940    return Err(format!("The source game folder doesn't exsit!"));
941  }
942  
943  let dst_upload_folder = get_upload_folder(dst_game_folder, upload_id);
944  // If there is a dst_upload_folder with contents, exit with error
945  if !is_folder_empty(&dst_upload_folder)? {
946    return Err(format!("The upload folder destination isn't empty!: \"{}\"", dst_upload_folder.to_string_lossy()));
947  }
948  
949  // Move the upload folder
950  move_folder(src_upload_folder.as_path(), dst_upload_folder.as_path()).await?;
951
952  // Copy the cover image (if it exists)
953  let cover_image = find_cover_filename(src_game_folder)?;
954  if let Some(cover) = cover_image {
955    tokio::fs::copy(src_game_folder.join(cover.as_str()), dst_game_folder.join(cover.as_str())).await
956      .map_err(|e| format!("Couldn't copy game cover image: {e}"))?;
957  }
958
959  // If src_game_folder doesn't contain any other upload, remove it
960  remove_folder_without_child_folders(src_game_folder).await?;
961
962  dst_game_folder.canonicalize()
963    .map_err(|e| format!("Error getting the canonical form of the destination game folder! Maybe it doesn't exist: {}\n{e}", dst_game_folder.to_string_lossy()))
964}
965
966/// Retrieve the itch manifest from an installed upload
967/// 
968/// # Arguments
969/// 
970/// * `upload_id` - The ID of upload from which the info will be retrieved
971/// 
972/// * `game_folder` - The folder with the game files where the upload folder is placed
973/// 
974/// # Returns
975/// 
976/// A Manifest struct with the manifest actions info, or None if the manifest isn't present
977/// 
978/// An error if something goes wrong
979pub async fn get_upload_manifest(upload_id: u64, game_folder: &Path) -> Result<Option<itch_manifest::Manifest>, String> {
980  let upload_folder = get_upload_folder(game_folder, upload_id);
981
982  itch_manifest::read_manifest(&upload_folder)
983}
984
985/// Launchs an installed upload
986/// 
987/// # Arguments
988/// 
989/// * `upload_id` - The ID of upload which will be launched
990/// 
991/// * `game_folder` - The folder where the game uploads are placed
992/// 
993/// * `launch_action` - The name of the launch action in the upload folder's itch manifest
994/// 
995/// * `heuristics_info` - Some info required to guess which file is the upload executable
996/// 
997/// * `upload_executable` - Instead of heuristics_info, provide the path to the upload executable file
998/// 
999/// * `wrapper` - A list of a wrapper and its options to run the game with
1000/// 
1001/// * `game_arguments` - A list of arguments to launch the upload executable with
1002/// 
1003/// * `launch_start_callback` - A callback triggered just before the upload executable runs, providing information about what is about to be executed.
1004/// 
1005/// # Returns
1006/// 
1007/// An error if something goes wrong
1008pub async fn launch(
1009  upload_id: u64,
1010  game_folder: &Path,
1011  launch_method: LaunchMethod<'_>,
1012  wrapper: &[String],
1013  game_arguments: &[String],
1014  launch_start_callback: impl FnOnce(&Path, &str)
1015) -> Result<(), String> {
1016  let upload_folder: PathBuf = get_upload_folder(game_folder, upload_id);
1017  
1018  // Determine the upload executable and its launch arguments from the function arguments, manifest, or heuristics.
1019  let (upload_executable, game_arguments): (&Path, Cow<[String]>) = match launch_method {
1020    // 1. If the launch method is an alternative executable, then that executable with the arguments provided to the function
1021    LaunchMethod::AlternativeExecutable(p) => (p, Cow::Borrowed(game_arguments)),
1022    // 2. If the launch method is a manifest action, use its executable
1023    LaunchMethod::ManifestAction(a) => {
1024      let ma = itch_manifest::launch_action(&upload_folder, Some(a))?
1025        .ok_or(format!("The provided launch action doesn't exist in the manifest: {a}"))?;
1026      (
1027        &PathBuf::from(ma.path),
1028        match game_arguments.is_empty(){
1029          // a) If the function's game arguments aren't empty, use those.
1030          false => Cow::Borrowed(game_arguments),
1031          // b) Otherwise, use the arguments from the manifest.
1032          true => Cow::Owned(ma.args.unwrap_or_default()),
1033        },
1034      )
1035    }
1036    // 3. Otherwise, if the launch method are the heuristics, use them to locate the executable
1037    LaunchMethod::Heuristics(gp, g) => {
1038      // But first, check if the game has a manifest with a "play" action, and use it if possible
1039      let mao = itch_manifest::launch_action(&upload_folder, None)?;
1040
1041      match mao {
1042        // If the manifest has a "play" action, launch from it
1043        Some(ma) => (
1044          &PathBuf::from(ma.path),
1045          match game_arguments.is_empty(){
1046            // a) If the function's game arguments aren't empty, use those.
1047            false => Cow::Borrowed(game_arguments),
1048            // b) Otherwise, use the arguments from the manifest.
1049            true => Cow::Owned(ma.args.unwrap_or_default()),
1050          },
1051        ),
1052        // Else, now use the heuristics to determine the executable, with the function's game arguments
1053        None => (
1054          &heuristics::get_game_executable(upload_folder.as_path(), gp, g).await?,
1055          Cow::Borrowed(game_arguments),
1056        )
1057      }
1058    }
1059  };
1060  
1061  let upload_executable = upload_executable.canonicalize()
1062    .map_err(|e| format!("Error getting the canonical form of the upload executable path! Maybe it doesn't exist: {}\n{e}", upload_executable.to_string_lossy()))?;
1063
1064  // Make the file executable
1065  make_executable(&upload_executable)?;
1066
1067  // Create the tokio process
1068  let mut game_process = {
1069    let mut wrapper_iter = wrapper.iter();
1070    match wrapper_iter.next() {
1071      // If it doesn't have a wrapper, just run the executable
1072      None => tokio::process::Command::new(&upload_executable),
1073      Some(w) => {
1074        // If the game has a wrapper, then run the wrapper with its
1075        // arguments and add the game executable as the last argument
1076        let mut gp = tokio::process::Command::new(w);
1077        gp.args(wrapper_iter.as_slice())
1078          .arg(&upload_executable);
1079        gp
1080      }
1081    }
1082  };
1083
1084  // Add the working directory and the game arguments
1085  game_process.current_dir(&upload_folder)
1086    .args(game_arguments.as_ref());
1087
1088  launch_start_callback(upload_executable.as_path(), format!("{:?}", game_process).as_str());
1089
1090  let mut child = game_process.spawn()
1091    .map_err(|e| {
1092      let code = e.raw_os_error();
1093      if code.is_some_and(|n| n == 8) {
1094        format!("Couldn't spawn the child process because it is not an executable format for this OS\n\
1095          Maybe a wrapper is missing or the selected game executable isn't the correct one!")
1096      } else {
1097        format!("Couldn't spawn the child process: {e}")
1098      }
1099    })?;
1100
1101  child.wait().await
1102    .map_err(|e| format!("Error while awaiting for child exit!: {e}"))?;
1103
1104  Ok(())
1105}
1106
1107/// Get the web url of web a game (if it exists)
1108/// 
1109/// # Arguments
1110/// 
1111/// * `uploads` - The list of a game's uploads
1112///
1113/// # Returns
1114/// 
1115/// The web game URL if any
1116pub fn get_uploads_web_game_url(uploads: &[Upload]) -> Option<String> {
1117  uploads.iter()
1118    .find(|u| matches!(u.r#type, UploadType::HTML))
1119    .map(|u| get_web_game_url(u.id))
1120}
1121
1122/// Get the url to a itch.io web game
1123/// 
1124/// # Arguments
1125/// 
1126/// * `upload_id` - The ID of the html upload
1127/// 
1128/// # Returns
1129/// 
1130/// The web game URL
1131pub fn get_web_game_url(upload_id: u64) -> String {
1132  format!("https://html-classic.itch.zone/html/{upload_id}/index.html")
1133}