scratch_io/
lib.rs

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