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#[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#[derive(Serialize, Deserialize)]
88pub struct InstalledUpload {
89 pub upload_id: u64,
90 pub game_folder: PathBuf,
91 pub upload: Option<Upload>,
94 pub game: Option<Game>,
95}
96
97impl InstalledUpload {
98 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
167async 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 request = match url {
197 ItchApiUrl::V1(..) => request.header(header::AUTHORIZATION, format!("Bearer {api_key}")),
199 ItchApiUrl::V2(..) => request.header(header::AUTHORIZATION, api_key),
201 ItchApiUrl::Other(..) => request,
203 };
204
205 if let ItchApiUrl::V2(_) = url {
208 request = request.header(header::ACCEPT, "application/vnd.itch.v2");
209 }
210
211 request = options(request);
214
215 request.send().await
216 .map_err(|e| format!("Error while sending request: {e}"))
217}
218
219async 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
256async 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() {
276 break Ok(());
277 }
278
279 hasher.update(buffer);
281
282 let len = buffer.len();
284 br.consume(len);
285 }
286}
287
288async 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 let mut downloaded_bytes: u64 = 0;
316 let mut stream = response.bytes_stream();
317 let mut last_callback = Instant::now();
318
319 while let Some(chunk) = stream.next().await {
322 let chunk = chunk
324 .map_err(|e| format!("Error reading chunk: {e}"))?;
325
326 file.write_all(&chunk).await
328 .map_err(|e| format!("Error writing chunk to the file: {e}"))?;
329
330 if let Some(ref mut hasher) = md5_hash {
332 hasher.update(&chunk);
333 }
334
335 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
348async 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 let mut md5_hash: Option<(CoreWrapper<md5::Md5Core>, &str)> = md5_hash.map(|s| (Md5::new(), s));
386
387 let partial_file_path: PathBuf = add_part_extension(file_path)?;
390
391 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 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 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 downloaded_bytes == 0 {
422 break 'r Some(res);
423 }
424
425 else if downloaded_bytes == download_size {
427 break 'r None;
428 }
429
430 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 reqwest::StatusCode::PARTIAL_CONTENT => break 'r Some(part_res),
440
441 reqwest::StatusCode::OK => (),
445
446 _ => 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 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 let Some((ref mut hasher, _)) = md5_hash && downloaded_bytes > 0 {
466 hash_readable_async(&mut file, hasher).await?;
467 }
468
469 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 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 file.sync_all().await
488 .map_err(|e| e.to_string())?;
489
490 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
498async 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
527pub 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(¶ms),
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
596pub 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
623pub 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 if num_keys < response.per_page || num_keys == 0 {
656 break;
657 }
658 page += 1;
659 }
660
661 Ok(keys)
662}
663
664pub 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
691pub 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
718pub 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
739pub 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
766pub 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
791pub 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 if num_games < response.per_page || num_games == 0 {
826 break;
827 }
828 page += 1;
829 }
830
831 Ok(games)
832}
833
834pub 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 let game_info = get_game_info(client, api_key, game_id).await?;
860 let Some(cover_url) = game_info.cover_url else {
862 return Ok(None);
863 };
864
865 tokio::fs::create_dir_all(folder).await
867 .map_err(|e| format!("Couldn't create the folder \"{}\": {e}", folder.to_string_lossy()))?;
868
869 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 !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
896pub 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 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 upload_info(&upload, &game);
938
939 let game_folder = match game_folder {
942 Some(f) => f,
943 None => &get_game_folder(&game.title)?,
944 };
945
946 let upload_archive: PathBuf = get_upload_archive_path(game_folder, upload_id, &upload.filename);
948
949 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_file(
958 client,
959 &ItchApiUrl::V2(&format!("uploads/{upload_id}/download")),
960 api_key,
961 &upload_archive,
962 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 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 progress_callback(DownloadStatus::Extract);
981
982 let upload_folder: PathBuf = get_upload_folder(game_folder, upload_id);
984
985 extract::extract(&upload_archive, &upload_folder).await
988 .map_err(|e| e.to_string())?;
989
990 Ok(InstalledUpload {
991 upload_id,
992 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
1000pub async fn import(client: &Client, api_key: &str, upload_id: u64, game_folder: &Path) -> Result<InstalledUpload, String> {
1018 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 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
1032pub async fn remove_partial_download(client: &Client, api_key: &str, upload_id: u64, game_folder: Option<&Path>) -> Result<bool, String> {
1050 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 let game_folder = match game_folder {
1056 Some(f) => f,
1057 None => &get_game_folder(&game.title)?,
1058 };
1059
1060 let to_be_removed_folders: &[PathBuf] = &[
1062 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 add_part_extension(&upload_archive)?,
1076
1077 upload_archive,
1080 ]
1081 };
1082
1083 let mut was_something_deleted: bool = false;
1085
1086 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 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 was_something_deleted |= remove_folder_if_empty(game_folder).await?;
1107
1108 Ok(was_something_deleted)
1109}
1110
1111pub 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 is_folder_empty(&upload_folder)? {
1129 return Ok(())
1130 }
1131
1132 remove_folder_safely(upload_folder).await?;
1133 remove_folder_if_empty(game_folder).await?;
1137
1138 Ok(())
1139}
1140
1141pub 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 !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 !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_folder(src_upload_folder.as_path(), dst_upload_folder.as_path()).await?;
1172
1173 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
1180pub 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
1199pub 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 let (upload_executable, game_arguments): (&Path, Cow<[String]>) = match launch_method {
1234 LaunchMethod::AlternativeExecutable(p) => (p, Cow::Borrowed(game_arguments)),
1236 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 false => Cow::Borrowed(game_arguments),
1245 true => Cow::Owned(ma.args.unwrap_or_default()),
1247 },
1248 )
1249 }
1250 LaunchMethod::Heuristics(gp, g) => {
1252 let mao = itch_manifest::launch_action(&upload_folder, None)?;
1254
1255 match mao {
1256 Some(ma) => (
1258 &PathBuf::from(ma.path),
1259 match game_arguments.is_empty(){
1260 false => Cow::Borrowed(game_arguments),
1262 true => Cow::Owned(ma.args.unwrap_or_default()),
1264 },
1265 ),
1266 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_executable(&upload_executable)?;
1280
1281 let mut game_process = {
1283 let mut wrapper_iter = wrapper.iter();
1284 match wrapper_iter.next() {
1285 None => tokio::process::Command::new(&upload_executable),
1287 Some(w) => {
1288 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 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
1321pub fn get_web_game_url(upload_id: u64) -> String {
1331 format!("https://html-classic.itch.zone/html/{upload_id}/index.html")
1332}