1pub mod errors;
2mod extract;
3mod filesystem;
4mod game_files;
5mod heuristics;
6pub mod itch_api;
7pub mod itch_manifest;
8pub mod wharf;
9
10pub use crate::itch_api::ItchClient;
11use crate::itch_api::{types::*, *};
12
13use md5::{Digest, Md5};
14use reqwest::{Method, Response, header};
15use serde::{Deserialize, Serialize};
16use std::borrow::Cow;
17use std::path::{Path, PathBuf};
18use tokio::io::AsyncBufReadExt;
19use tokio::time::{Duration, Instant};
20
21#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize, clap::ValueEnum)]
25pub enum GamePlatform {
26 Linux,
27 Windows,
28 OSX,
29 Android,
30 Web,
31 Flash,
32 Java,
33 UnityWebPlayer,
34}
35
36impl Upload {
37 #[must_use]
38 pub fn to_game_platforms(&self) -> Vec<GamePlatform> {
39 let mut platforms: Vec<GamePlatform> = Vec::new();
40
41 match self.r#type {
42 UploadType::Html => platforms.push(GamePlatform::Web),
43 UploadType::Flash => platforms.push(GamePlatform::Flash),
44 UploadType::Java => platforms.push(GamePlatform::Java),
45 UploadType::Unity => platforms.push(GamePlatform::UnityWebPlayer),
46 _ => (),
47 }
48
49 for t in &self.traits {
50 match t {
51 UploadTrait::PLinux => platforms.push(GamePlatform::Linux),
52 UploadTrait::PWindows => platforms.push(GamePlatform::Windows),
53 UploadTrait::POsx => platforms.push(GamePlatform::OSX),
54 UploadTrait::PAndroid => platforms.push(GamePlatform::Android),
55 UploadTrait::Demo => (),
56 }
57 }
58
59 platforms
60 }
61}
62
63pub enum DownloadStatus {
64 Warning(String),
65 StartingDownload { bytes_to_download: u64 },
66 DownloadProgress { downloaded_bytes: u64 },
67 Extract,
68}
69
70pub enum LaunchMethod {
71 AlternativeExecutable {
72 executable_path: PathBuf,
73 },
74 ManifestAction {
75 manifest_action_name: String,
76 },
77 Heuristics {
78 game_platform: GamePlatform,
79 game_title: String,
80 },
81}
82
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85pub struct InstalledUpload {
86 pub upload_id: UploadID,
87 pub game_folder: PathBuf,
88 pub game_id: GameID,
89 pub game_title: String,
90}
91
92async fn hash_readable_async(
104 readable: impl tokio::io::AsyncRead + Unpin,
105 hasher: &mut Md5,
106) -> Result<(), String> {
107 let mut br = tokio::io::BufReader::new(readable);
108
109 loop {
110 let buffer = filesystem::fill_buffer(&mut br).await?;
111
112 if buffer.is_empty() {
114 break Ok(());
115 }
116
117 hasher.update(buffer);
119
120 let len = buffer.len();
122 br.consume(len);
123 }
124}
125
126async fn stream_response_into_file(
146 response: Response,
147 file: &mut tokio::fs::File,
148 mut md5_hash: Option<&mut Md5>,
149 progress_callback: impl Fn(u64),
150 callback_interval: Duration,
151) -> Result<u64, String> {
152 let mut downloaded_bytes: u64 = 0;
154 let mut stream = response.bytes_stream();
155 let mut last_callback = Instant::now();
156
157 use futures_util::StreamExt;
158
159 while let Some(chunk) = match stream.next().await {
162 None => Ok(None),
163 Some(result) => result
164 .map(Some)
165 .map_err(|e| format!("Couldn't read chunk from network!\n{e}")),
166 }? {
167 filesystem::write_all(file, &chunk).await?;
169
170 if let Some(hasher) = &mut md5_hash {
172 hasher.update(&chunk);
173 }
174
175 downloaded_bytes += chunk.len() as u64;
177 if last_callback.elapsed() > callback_interval {
178 last_callback = Instant::now();
179 progress_callback(downloaded_bytes);
180 }
181 }
182
183 progress_callback(downloaded_bytes);
184
185 Ok(downloaded_bytes)
186}
187
188async fn download_file(
210 client: &ItchClient,
211 url: &ItchApiUrl,
212 file_path: &Path,
213 md5_hash: Option<&str>,
214 file_size_callback: impl Fn(u64),
215 progress_callback: impl Fn(u64),
216 callback_interval: Duration,
217) -> Result<(), String> {
218 let mut md5_hash: Option<(Md5, &str)> = md5_hash.map(|s| (Md5::new(), s));
220
221 let partial_file_path: PathBuf = game_files::add_part_extension(file_path)?;
224
225 if filesystem::exists(file_path).await? {
228 filesystem::rename(file_path, &partial_file_path).await?;
229 }
230
231 let mut file = filesystem::open_file(
234 &partial_file_path,
235 tokio::fs::OpenOptions::new()
236 .create(true)
237 .append(true)
238 .read(true),
239 )
240 .await?;
241
242 let mut downloaded_bytes: u64 = filesystem::read_file_metadata(&file).await?.len();
243
244 let file_response: Option<Response> = 'r: {
245 let res = client
247 .itch_request(url, Method::GET, |b| b)
248 .await
249 .map_err(|e| e.to_string())?;
250
251 let download_size = res.content_length().ok_or_else(|| {
252 format!(
253 "Couldn't get content length!
254 URL: {url}"
255 )
256 })?;
257
258 file_size_callback(download_size);
259
260 if downloaded_bytes == 0 {
262 break 'r Some(res);
263 }
264 else if downloaded_bytes == download_size {
266 break 'r None;
267 }
268 else if downloaded_bytes < download_size {
270 let part_res = client
271 .itch_request(url, Method::GET, |b| {
272 b.header(header::RANGE, format!("bytes={downloaded_bytes}-"))
273 })
274 .await
275 .map_err(|e| e.to_string())?;
276
277 match part_res.status() {
278 reqwest::StatusCode::PARTIAL_CONTENT => break 'r Some(part_res),
281
282 reqwest::StatusCode::OK => (),
286
287 _ => {
289 return Err(format!(
290 "The HTTP server to download the file from didn't return HTTP code 200 nor 206, so exiting!
291 It returned code: {}
292 URL: {url}", part_res.status().as_str()));
293 }
294 }
295 }
296
297 downloaded_bytes = 0;
304 filesystem::set_file_len(&file, 0).await?;
305
306 Some(res)
307 };
308
309 if let Some((ref mut hasher, _)) = md5_hash
311 && downloaded_bytes > 0
312 {
313 hash_readable_async(&mut file, hasher).await?;
314 }
315
316 if let Some(res) = file_response {
318 stream_response_into_file(
319 res,
320 &mut file,
321 md5_hash.as_mut().map(|(h, _)| h),
322 |b| progress_callback(downloaded_bytes + b),
323 callback_interval,
324 )
325 .await?;
326 }
327
328 if let Some((hasher, hash)) = md5_hash {
330 let file_hash = format!("{:x}", hasher.finalize());
331
332 if !file_hash.eq_ignore_ascii_case(hash) {
333 return Err(format!("File verification failed! The file hash and the hash provided by the server are different.\n
334 File hash: {file_hash}
335 Server hash: {hash}"
336 ));
337 }
338 }
339
340 filesystem::file_sync_all(&file).await?;
342
343 filesystem::rename(&partial_file_path, file_path).await?;
346
347 Ok(())
348}
349
350#[must_use]
360pub fn get_game_platforms(uploads: &[Upload]) -> Vec<(UploadID, GamePlatform)> {
361 let mut platforms: Vec<(UploadID, GamePlatform)> = Vec::new();
362
363 for u in uploads {
364 for p in u.to_game_platforms() {
365 platforms.push((u.id, p));
366 }
367 }
368
369 platforms
370}
371
372pub async fn download_game_cover(
396 client: &ItchClient,
397 game_id: GameID,
398 folder: &Path,
399 cover_filename: Option<&str>,
400 force_download: bool,
401) -> Result<Option<PathBuf>, String> {
402 let game = get_game_info(client, game_id)
404 .await
405 .map_err(|e| e.to_string())?;
406 let Some(cover_url) = game.game_info.cover_url else {
408 return Ok(None);
409 };
410
411 filesystem::create_dir(folder).await?;
413
414 let cover_filename = match cover_filename {
416 Some(f) => f,
417 None => game_files::COVER_IMAGE_DEFAULT_FILENAME,
418 };
419
420 let cover_path = folder.join(cover_filename);
421
422 if !force_download && filesystem::exists(&cover_path).await? {
424 return Ok(Some(cover_path));
425 }
426
427 download_file(
428 client,
429 &ItchApiUrl::from_api_endpoint(ItchApiVersion::Other, cover_url),
430 &cover_path,
431 None,
432 |_| (),
433 |_| (),
434 Duration::MAX,
435 )
436 .await?;
437
438 Ok(Some(cover_path))
439}
440
441pub async fn download_upload(
467 client: &ItchClient,
468 upload_id: UploadID,
469 game_folder: Option<&Path>,
470 skip_hash_verification: bool,
471 upload_info: impl FnOnce(&Upload, &Game),
472 progress_callback: impl Fn(DownloadStatus),
473 callback_interval: Duration,
474) -> Result<InstalledUpload, String> {
475 let upload: Upload = get_upload_info(client, upload_id)
479 .await
480 .map_err(|e| e.to_string())?;
481 let game: Game = get_game_info(client, upload.game_id)
482 .await
483 .map_err(|e| e.to_string())?;
484
485 upload_info(&upload, &game);
487
488 let game_folder = match game_folder {
491 Some(f) => f,
492 None => &game_files::get_game_folder(&game.game_info.title)?,
493 };
494
495 let upload_archive: PathBuf =
497 game_files::get_upload_archive_path(game_folder, upload_id, &upload.filename);
498
499 filesystem::create_dir(game_folder).await?;
501
502 let hash: Option<&str> = upload.get_hash();
504
505 download_file(
509 client,
510 &ItchApiUrl::from_api_endpoint(ItchApiVersion::V2, format!("uploads/{upload_id}/download")),
511 &upload_archive,
512 hash.filter(|_| !skip_hash_verification),
514 |bytes| {
515 progress_callback(DownloadStatus::StartingDownload {
516 bytes_to_download: bytes,
517 });
518 },
519 |bytes| {
520 progress_callback(DownloadStatus::DownloadProgress {
521 downloaded_bytes: bytes,
522 });
523 },
524 callback_interval,
525 )
526 .await?;
527
528 if skip_hash_verification {
531 progress_callback(DownloadStatus::Warning(
532 "Skipping hash verification! The file integrity won't be checked!".to_string(),
533 ));
534 } else if hash.is_none() {
535 progress_callback(DownloadStatus::Warning(
536 "Missing MD5 hash. Couldn't verify the file integrity!".to_string(),
537 ));
538 }
539
540 progress_callback(DownloadStatus::Extract);
543
544 let upload_folder: PathBuf = game_files::get_upload_folder(game_folder, upload_id);
546
547 extract::extract(&upload_archive, &upload_folder)
550 .await
551 .map_err(|e| e.to_string())?;
552
553 Ok(InstalledUpload {
554 upload_id,
555 game_folder: filesystem::get_canonical_path(game_folder).await?,
557 game_id: game.game_info.id,
558 game_title: game.game_info.title,
559 })
560}
561
562pub async fn import(
580 client: &ItchClient,
581 upload_id: UploadID,
582 game_folder: &Path,
583) -> Result<InstalledUpload, String> {
584 let upload: Upload = get_upload_info(client, upload_id)
586 .await
587 .map_err(|e| e.to_string())?;
588 let game: Game = get_game_info(client, upload.game_id)
589 .await
590 .map_err(|e| e.to_string())?;
591
592 Ok(InstalledUpload {
593 upload_id,
594 game_folder: filesystem::get_canonical_path(game_folder).await?,
596 game_id: game.game_info.id,
597 game_title: game.game_info.title,
598 })
599}
600
601pub async fn remove_partial_download(
619 client: &ItchClient,
620 upload_id: UploadID,
621 game_folder: Option<&Path>,
622) -> Result<bool, String> {
623 let upload: Upload = get_upload_info(client, upload_id)
625 .await
626 .map_err(|e| e.to_string())?;
627 let game: Game = get_game_info(client, upload.game_id)
628 .await
629 .map_err(|e| e.to_string())?;
630
631 let game_folder = match game_folder {
633 Some(f) => f,
634 None => &game_files::get_game_folder(&game.game_info.title)?,
635 };
636
637 let to_be_removed_folders: &[PathBuf] = &[
639 game_files::add_part_extension(&game_files::get_upload_folder(game_folder, upload_id))?,
644 ];
645
646 let to_be_removed_files: &[PathBuf] = {
647 let upload_archive =
648 game_files::get_upload_archive_path(game_folder, upload_id, &upload.filename);
649
650 &[
651 game_files::add_part_extension(&upload_archive)?,
654 upload_archive,
657 ]
658 };
659
660 let mut was_something_deleted: bool = false;
662
663 for f in to_be_removed_files {
665 if filesystem::exists(f).await? {
666 filesystem::remove_file(f).await?;
667 was_something_deleted = true;
668 }
669 }
670
671 for f in to_be_removed_folders {
673 if filesystem::exists(f).await? {
674 game_files::remove_folder_safely(f).await?;
675 was_something_deleted = true;
676 }
677 }
678
679 was_something_deleted |= game_files::remove_folder_if_empty(game_folder).await?;
681
682 Ok(was_something_deleted)
683}
684
685pub async fn remove(upload_id: UploadID, game_folder: &Path) -> Result<(), String> {
697 let upload_folder = game_files::get_upload_folder(game_folder, upload_id);
698
699 if filesystem::is_folder_empty(&upload_folder).await? {
702 return Ok(());
703 }
704
705 game_files::remove_folder_safely(&upload_folder).await?;
706 game_files::remove_folder_if_empty(game_folder).await?;
710
711 Ok(())
712}
713
714pub async fn r#move(
732 upload_id: UploadID,
733 src_game_folder: &Path,
734 dst_game_folder: &Path,
735) -> Result<PathBuf, String> {
736 let src_upload_folder = game_files::get_upload_folder(src_game_folder, upload_id);
737
738 filesystem::ensure_is_dir(&src_upload_folder).await?;
740
741 let dst_upload_folder = game_files::get_upload_folder(dst_game_folder, upload_id);
742
743 filesystem::ensure_is_empty(&dst_upload_folder).await?;
745
746 game_files::move_folder(&src_upload_folder, &dst_upload_folder).await?;
748
749 game_files::remove_folder_if_empty(src_game_folder).await?;
751
752 filesystem::get_canonical_path(dst_game_folder)
753 .await
754 .map_err(std::convert::Into::into)
755}
756
757pub async fn get_upload_manifest(
773 upload_id: UploadID,
774 game_folder: &Path,
775) -> Result<Option<Manifest>, String> {
776 let upload_folder = game_files::get_upload_folder(game_folder, upload_id);
777
778 itch_manifest::read_manifest(&upload_folder).await
779}
780
781pub async fn launch(
803 upload_id: UploadID,
804 game_folder: &Path,
805 launch_method: LaunchMethod,
806 wrapper: &[String],
807 game_arguments: &[String],
808 environment_variables: &[(String, String)],
809 launch_start_callback: impl FnOnce(&Path, &tokio::process::Command),
810) -> Result<(), String> {
811 let upload_folder: PathBuf = game_files::get_upload_folder(game_folder, upload_id);
812
813 let (upload_executable, game_arguments): (PathBuf, Cow<[String]>) = match launch_method {
815 LaunchMethod::AlternativeExecutable { executable_path } => {
817 (executable_path, Cow::Borrowed(game_arguments))
818 }
819 LaunchMethod::ManifestAction {
821 manifest_action_name,
822 } => {
823 let ma = itch_manifest::launch_action(&upload_folder, Some(&manifest_action_name))
824 .await?
825 .ok_or_else(|| {
826 format!(
827 "The provided launch action doesn't exist in the manifest: {manifest_action_name}"
828 )
829 })?;
830 (
831 ma.get_canonical_path(&upload_folder).await?,
832 if game_arguments.is_empty() {
834 Cow::Owned(ma.args.unwrap_or_default())
835 }
836 else {
838 Cow::Borrowed(game_arguments)
839 },
840 )
841 }
842 LaunchMethod::Heuristics {
844 game_platform,
845 game_title,
846 } => {
847 let mao = itch_manifest::launch_action(&upload_folder, None).await?;
849
850 match mao {
851 Some(ma) => (
853 ma.get_canonical_path(&upload_folder).await?,
854 if game_arguments.is_empty() {
856 Cow::Owned(ma.args.unwrap_or_default())
857 }
858 else {
860 Cow::Borrowed(game_arguments)
861 },
862 ),
863 None => (
865 heuristics::get_game_executable(&upload_folder, game_platform, game_title).await?,
866 Cow::Borrowed(game_arguments),
867 ),
868 }
869 }
870 };
871
872 let upload_executable = filesystem::get_canonical_path(&upload_executable).await?;
873
874 filesystem::make_executable(&upload_executable).await?;
876
877 let mut game_process = {
879 let mut wrapper_iter = wrapper.iter();
880 match wrapper_iter.next() {
881 None => tokio::process::Command::new(&upload_executable),
883 Some(w) => {
884 let mut gp = tokio::process::Command::new(w);
887 gp.args(wrapper_iter).arg(&upload_executable);
888 gp
889 }
890 }
891 };
892
893 game_process
895 .current_dir(&upload_folder)
896 .args(&*game_arguments)
897 .envs(environment_variables.iter().map(|(k, v)| (k, v)));
898
899 launch_start_callback(&upload_executable, &game_process);
900
901 let mut child = filesystem::spawn_command(&mut game_process)?;
902 filesystem::wait_child(&mut child).await?;
903
904 Ok(())
905}
906
907#[must_use]
917pub fn get_web_game_url(upload_id: UploadID) -> String {
918 format!("https://html-classic.itch.zone/html/{upload_id}/index.html")
919}