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#[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#[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 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
165async 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 ItchApiUrl::V1(..) => request.header(header::AUTHORIZATION, format!("Bearer {api_key}")),
196 ItchApiUrl::V2(..) => request.header(header::AUTHORIZATION, api_key),
198 };
199 if let ItchApiUrl::V2(_) = url {
202 request = request.header(header::ACCEPT, "application/vnd.itch.v2");
203 }
204
205 request = options(request);
208
209 request.send().await
210 .map_err(|e| format!("Error while sending request: {e}"))
211}
212
213async 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
250async 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 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 while let Some(chunk) = stream.next().await {
287 let chunk = chunk
289 .map_err(|e| format!("Error reading chunk: {e}"))?;
290
291 file.write_all(&chunk).await
293 .map_err(|e| format!("Error writing chunk to the file: {e}"))?;
294
295 if md5_hash.is_some() {
297 hasher.update(&chunk);
298 }
299
300 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 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 file.sync_all().await
324 .map_err(|e| e.to_string())?;
325
326 Ok(())
327}
328
329async 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
358pub 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(¶ms),
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
427pub 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
454pub 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 if num_keys < response.per_page || num_keys == 0 {
487 break;
488 }
489 page += 1;
490 }
491
492 Ok(keys)
493}
494
495pub 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
522pub 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
554pub 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
581pub 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
606pub 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 if num_games < response.per_page || num_games == 0 {
641 break;
642 }
643 page += 1;
644 }
645
646 Ok(games)
647}
648
649async 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
687pub 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 let game_info = get_game_info(client, api_key, game_id).await?;
709 let Some(cover_url) = game_info.cover_url else {
711 return Ok(None);
712 };
713
714 let folder = match folder {
716 Some(f) => f,
717 None => &get_game_folder(&game_info.title)?,
718 };
719
720 tokio::fs::create_dir_all(folder).await
722 .map_err(|e| format!("Couldn't create the folder \"{}\": {e}", folder.to_string_lossy()))?;
723
724 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
733pub 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 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 upload_info(&upload, &game);
774
775 let game_folder = match game_folder {
778 Some(f) => f,
779 None => &get_game_folder(&game.title)?,
780 };
781
782 let upload_folder: PathBuf = get_upload_folder(game_folder, upload_id);
784
785 if !is_folder_empty(&upload_folder)? {
787 return Err(format!("The upload folder isn't empty!: \"{}\"", upload_folder.to_string_lossy()));
788 }
789
790 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 let upload_archive: PathBuf = upload_folder.join(&upload.filename);
796
797
798 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 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_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 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 progress_callback(DownloadStatus::Extract);
838
839 extract::extract(&upload_archive).await
842 .map_err(|e| e.to_string())?;
843
844 Ok(InstalledUpload {
845 upload_id,
846 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
855pub async fn import(client: &Client, api_key: &str, upload_id: u64, game_folder: &Path) -> Result<InstalledUpload, String> {
873 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 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
890pub 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 is_folder_empty(&upload_folder)? {
908 return Ok(())
909 }
910
911 remove_folder_safely(upload_folder).await?;
912 remove_folder_without_child_folders(&game_folder).await?;
916
917 Ok(())
918}
919
920pub 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 !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 !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_folder(src_upload_folder.as_path(), dst_upload_folder.as_path()).await?;
951
952 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 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
966pub 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
985pub 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 let (upload_executable, game_arguments): (&Path, Cow<[String]>) = match launch_method {
1020 LaunchMethod::AlternativeExecutable(p) => (p, Cow::Borrowed(game_arguments)),
1022 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 false => Cow::Borrowed(game_arguments),
1031 true => Cow::Owned(ma.args.unwrap_or_default()),
1033 },
1034 )
1035 }
1036 LaunchMethod::Heuristics(gp, g) => {
1038 let mao = itch_manifest::launch_action(&upload_folder, None)?;
1040
1041 match mao {
1042 Some(ma) => (
1044 &PathBuf::from(ma.path),
1045 match game_arguments.is_empty(){
1046 false => Cow::Borrowed(game_arguments),
1048 true => Cow::Owned(ma.args.unwrap_or_default()),
1050 },
1051 ),
1052 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_executable(&upload_executable)?;
1066
1067 let mut game_process = {
1069 let mut wrapper_iter = wrapper.iter();
1070 match wrapper_iter.next() {
1071 None => tokio::process::Command::new(&upload_executable),
1073 Some(w) => {
1074 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 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
1107pub 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
1122pub fn get_web_game_url(upload_id: u64) -> String {
1132 format!("https://html-classic.itch.zone/html/{upload_id}/index.html")
1133}