pub mod errors;
mod extract;
mod filesystem;
mod game_files;
mod heuristics;
pub mod itch_api;
pub mod itch_manifest;
pub mod wharf;
pub use crate::itch_api::ItchClient;
use crate::itch_api::{types::*, *};
use md5::{Digest, Md5};
use reqwest::{Method, Response, header};
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::path::{Path, PathBuf};
use tokio::io::AsyncBufReadExt;
use tokio::time::{Duration, Instant};
#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize, clap::ValueEnum)]
pub enum GamePlatform {
Linux,
Windows,
OSX,
Android,
Web,
Flash,
Java,
UnityWebPlayer,
}
impl Upload {
#[must_use]
pub fn to_game_platforms(&self) -> Vec<GamePlatform> {
let mut platforms: Vec<GamePlatform> = Vec::new();
match self.r#type {
UploadType::Html => platforms.push(GamePlatform::Web),
UploadType::Flash => platforms.push(GamePlatform::Flash),
UploadType::Java => platforms.push(GamePlatform::Java),
UploadType::Unity => platforms.push(GamePlatform::UnityWebPlayer),
_ => (),
}
for t in &self.traits {
match t {
UploadTrait::PLinux => platforms.push(GamePlatform::Linux),
UploadTrait::PWindows => platforms.push(GamePlatform::Windows),
UploadTrait::POsx => platforms.push(GamePlatform::OSX),
UploadTrait::PAndroid => platforms.push(GamePlatform::Android),
UploadTrait::Demo => (),
}
}
platforms
}
}
pub enum DownloadStatus {
Warning(String),
StartingDownload { bytes_to_download: u64 },
DownloadProgress { downloaded_bytes: u64 },
Extract,
}
pub enum LaunchMethod {
AlternativeExecutable {
executable_path: PathBuf,
},
ManifestAction {
manifest_action_name: String,
},
Heuristics {
game_platform: GamePlatform,
game_title: String,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InstalledUpload {
pub upload_id: UploadID,
pub game_folder: PathBuf,
pub game_id: GameID,
pub game_title: String,
}
async fn hash_readable_async(
readable: impl tokio::io::AsyncRead + Unpin,
hasher: &mut Md5,
) -> Result<(), String> {
let mut br = tokio::io::BufReader::new(readable);
loop {
let buffer = filesystem::fill_buffer(&mut br).await?;
if buffer.is_empty() {
break Ok(());
}
hasher.update(buffer);
let len = buffer.len();
br.consume(len);
}
}
async fn stream_response_into_file(
response: Response,
file: &mut tokio::fs::File,
mut md5_hash: Option<&mut Md5>,
progress_callback: impl Fn(u64),
callback_interval: Duration,
) -> Result<u64, String> {
let mut downloaded_bytes: u64 = 0;
let mut stream = response.bytes_stream();
let mut last_callback = Instant::now();
use futures_util::StreamExt;
while let Some(chunk) = match stream.next().await {
None => Ok(None),
Some(result) => result
.map(Some)
.map_err(|e| format!("Couldn't read chunk from network!\n{e}")),
}? {
filesystem::write_all(file, &chunk).await?;
if let Some(hasher) = &mut md5_hash {
hasher.update(&chunk);
}
downloaded_bytes += chunk.len() as u64;
if last_callback.elapsed() > callback_interval {
last_callback = Instant::now();
progress_callback(downloaded_bytes);
}
}
progress_callback(downloaded_bytes);
Ok(downloaded_bytes)
}
async fn download_file(
client: &ItchClient,
url: &ItchApiUrl,
file_path: &Path,
md5_hash: Option<&str>,
file_size_callback: impl Fn(u64),
progress_callback: impl Fn(u64),
callback_interval: Duration,
) -> Result<(), String> {
let mut md5_hash: Option<(Md5, &str)> = md5_hash.map(|s| (Md5::new(), s));
let partial_file_path: PathBuf = game_files::add_part_extension(file_path)?;
if filesystem::exists(file_path).await? {
filesystem::rename(file_path, &partial_file_path).await?;
}
let mut file = filesystem::open_file(
&partial_file_path,
tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.read(true),
)
.await?;
let mut downloaded_bytes: u64 = filesystem::read_file_metadata(&file).await?.len();
let file_response: Option<Response> = 'r: {
let res = client
.itch_request(url, Method::GET, |b| b)
.await
.map_err(|e| e.to_string())?;
let download_size = res.content_length().ok_or_else(|| {
format!(
"Couldn't get content length!
URL: {url}"
)
})?;
file_size_callback(download_size);
if downloaded_bytes == 0 {
break 'r Some(res);
}
else if downloaded_bytes == download_size {
break 'r None;
}
else if downloaded_bytes < download_size {
let part_res = client
.itch_request(url, Method::GET, |b| {
b.header(header::RANGE, format!("bytes={downloaded_bytes}-"))
})
.await
.map_err(|e| e.to_string())?;
match part_res.status() {
reqwest::StatusCode::PARTIAL_CONTENT => break 'r Some(part_res),
reqwest::StatusCode::OK => (),
_ => {
return Err(format!(
"The HTTP server to download the file from didn't return HTTP code 200 nor 206, so exiting!
It returned code: {}
URL: {url}", part_res.status().as_str()));
}
}
}
downloaded_bytes = 0;
filesystem::set_file_len(&file, 0).await?;
Some(res)
};
if let Some((ref mut hasher, _)) = md5_hash
&& downloaded_bytes > 0
{
hash_readable_async(&mut file, hasher).await?;
}
if let Some(res) = file_response {
stream_response_into_file(
res,
&mut file,
md5_hash.as_mut().map(|(h, _)| h),
|b| progress_callback(downloaded_bytes + b),
callback_interval,
)
.await?;
}
if let Some((hasher, hash)) = md5_hash {
let file_hash = format!("{:x}", hasher.finalize());
if !file_hash.eq_ignore_ascii_case(hash) {
return Err(format!("File verification failed! The file hash and the hash provided by the server are different.\n
File hash: {file_hash}
Server hash: {hash}"
));
}
}
filesystem::file_sync_all(&file).await?;
filesystem::rename(&partial_file_path, file_path).await?;
Ok(())
}
#[must_use]
pub fn get_game_platforms(uploads: &[Upload]) -> Vec<(UploadID, GamePlatform)> {
let mut platforms: Vec<(UploadID, GamePlatform)> = Vec::new();
for u in uploads {
for p in u.to_game_platforms() {
platforms.push((u.id, p));
}
}
platforms
}
pub async fn download_game_cover(
client: &ItchClient,
game_id: GameID,
folder: &Path,
cover_filename: Option<&str>,
force_download: bool,
) -> Result<Option<PathBuf>, String> {
let game = get_game_info(client, game_id)
.await
.map_err(|e| e.to_string())?;
let Some(cover_url) = game.game_info.cover_url else {
return Ok(None);
};
filesystem::create_dir(folder).await?;
let cover_filename = match cover_filename {
Some(f) => f,
None => game_files::COVER_IMAGE_DEFAULT_FILENAME,
};
let cover_path = folder.join(cover_filename);
if !force_download && filesystem::exists(&cover_path).await? {
return Ok(Some(cover_path));
}
download_file(
client,
&ItchApiUrl::from_api_endpoint(ItchApiVersion::Other, cover_url),
&cover_path,
None,
|_| (),
|_| (),
Duration::MAX,
)
.await?;
Ok(Some(cover_path))
}
pub async fn download_upload(
client: &ItchClient,
upload_id: UploadID,
game_folder: Option<&Path>,
skip_hash_verification: bool,
upload_info: impl FnOnce(&Upload, &Game),
progress_callback: impl Fn(DownloadStatus),
callback_interval: Duration,
) -> Result<InstalledUpload, String> {
let upload: Upload = get_upload_info(client, upload_id)
.await
.map_err(|e| e.to_string())?;
let game: Game = get_game_info(client, upload.game_id)
.await
.map_err(|e| e.to_string())?;
upload_info(&upload, &game);
let game_folder = match game_folder {
Some(f) => f,
None => &game_files::get_game_folder(&game.game_info.title)?,
};
let upload_archive: PathBuf =
game_files::get_upload_archive_path(game_folder, upload_id, &upload.filename);
filesystem::create_dir(game_folder).await?;
let hash: Option<&str> = upload.get_hash();
download_file(
client,
&ItchApiUrl::from_api_endpoint(ItchApiVersion::V2, format!("uploads/{upload_id}/download")),
&upload_archive,
hash.filter(|_| !skip_hash_verification),
|bytes| {
progress_callback(DownloadStatus::StartingDownload {
bytes_to_download: bytes,
});
},
|bytes| {
progress_callback(DownloadStatus::DownloadProgress {
downloaded_bytes: bytes,
});
},
callback_interval,
)
.await?;
if skip_hash_verification {
progress_callback(DownloadStatus::Warning(
"Skipping hash verification! The file integrity won't be checked!".to_string(),
));
} else if hash.is_none() {
progress_callback(DownloadStatus::Warning(
"Missing MD5 hash. Couldn't verify the file integrity!".to_string(),
));
}
progress_callback(DownloadStatus::Extract);
let upload_folder: PathBuf = game_files::get_upload_folder(game_folder, upload_id);
extract::extract(&upload_archive, &upload_folder)
.await
.map_err(|e| e.to_string())?;
Ok(InstalledUpload {
upload_id,
game_folder: filesystem::get_canonical_path(game_folder).await?,
game_id: game.game_info.id,
game_title: game.game_info.title,
})
}
pub async fn import(
client: &ItchClient,
upload_id: UploadID,
game_folder: &Path,
) -> Result<InstalledUpload, String> {
let upload: Upload = get_upload_info(client, upload_id)
.await
.map_err(|e| e.to_string())?;
let game: Game = get_game_info(client, upload.game_id)
.await
.map_err(|e| e.to_string())?;
Ok(InstalledUpload {
upload_id,
game_folder: filesystem::get_canonical_path(game_folder).await?,
game_id: game.game_info.id,
game_title: game.game_info.title,
})
}
pub async fn remove_partial_download(
client: &ItchClient,
upload_id: UploadID,
game_folder: Option<&Path>,
) -> Result<bool, String> {
let upload: Upload = get_upload_info(client, upload_id)
.await
.map_err(|e| e.to_string())?;
let game: Game = get_game_info(client, upload.game_id)
.await
.map_err(|e| e.to_string())?;
let game_folder = match game_folder {
Some(f) => f,
None => &game_files::get_game_folder(&game.game_info.title)?,
};
let to_be_removed_folders: &[PathBuf] = &[
game_files::add_part_extension(&game_files::get_upload_folder(game_folder, upload_id))?,
];
let to_be_removed_files: &[PathBuf] = {
let upload_archive =
game_files::get_upload_archive_path(game_folder, upload_id, &upload.filename);
&[
game_files::add_part_extension(&upload_archive)?,
upload_archive,
]
};
let mut was_something_deleted: bool = false;
for f in to_be_removed_files {
if filesystem::exists(f).await? {
filesystem::remove_file(f).await?;
was_something_deleted = true;
}
}
for f in to_be_removed_folders {
if filesystem::exists(f).await? {
game_files::remove_folder_safely(f).await?;
was_something_deleted = true;
}
}
was_something_deleted |= game_files::remove_folder_if_empty(game_folder).await?;
Ok(was_something_deleted)
}
pub async fn remove(upload_id: UploadID, game_folder: &Path) -> Result<(), String> {
let upload_folder = game_files::get_upload_folder(game_folder, upload_id);
if filesystem::is_folder_empty(&upload_folder).await? {
return Ok(());
}
game_files::remove_folder_safely(&upload_folder).await?;
game_files::remove_folder_if_empty(game_folder).await?;
Ok(())
}
pub async fn r#move(
upload_id: UploadID,
src_game_folder: &Path,
dst_game_folder: &Path,
) -> Result<PathBuf, String> {
let src_upload_folder = game_files::get_upload_folder(src_game_folder, upload_id);
filesystem::ensure_is_dir(&src_upload_folder).await?;
let dst_upload_folder = game_files::get_upload_folder(dst_game_folder, upload_id);
filesystem::ensure_is_empty(&dst_upload_folder).await?;
game_files::move_folder(&src_upload_folder, &dst_upload_folder).await?;
game_files::remove_folder_if_empty(src_game_folder).await?;
filesystem::get_canonical_path(dst_game_folder)
.await
.map_err(std::convert::Into::into)
}
pub async fn get_upload_manifest(
upload_id: UploadID,
game_folder: &Path,
) -> Result<Option<Manifest>, String> {
let upload_folder = game_files::get_upload_folder(game_folder, upload_id);
itch_manifest::read_manifest(&upload_folder).await
}
pub async fn launch(
upload_id: UploadID,
game_folder: &Path,
launch_method: LaunchMethod,
wrapper: &[String],
game_arguments: &[String],
environment_variables: &[(String, String)],
launch_start_callback: impl FnOnce(&Path, &tokio::process::Command),
) -> Result<(), String> {
let upload_folder: PathBuf = game_files::get_upload_folder(game_folder, upload_id);
let (upload_executable, game_arguments): (PathBuf, Cow<[String]>) = match launch_method {
LaunchMethod::AlternativeExecutable { executable_path } => {
(executable_path, Cow::Borrowed(game_arguments))
}
LaunchMethod::ManifestAction {
manifest_action_name,
} => {
let ma = itch_manifest::launch_action(&upload_folder, Some(&manifest_action_name))
.await?
.ok_or_else(|| {
format!(
"The provided launch action doesn't exist in the manifest: {manifest_action_name}"
)
})?;
(
ma.get_canonical_path(&upload_folder).await?,
if game_arguments.is_empty() {
Cow::Owned(ma.args.unwrap_or_default())
}
else {
Cow::Borrowed(game_arguments)
},
)
}
LaunchMethod::Heuristics {
game_platform,
game_title,
} => {
let mao = itch_manifest::launch_action(&upload_folder, None).await?;
match mao {
Some(ma) => (
ma.get_canonical_path(&upload_folder).await?,
if game_arguments.is_empty() {
Cow::Owned(ma.args.unwrap_or_default())
}
else {
Cow::Borrowed(game_arguments)
},
),
None => (
heuristics::get_game_executable(&upload_folder, game_platform, game_title).await?,
Cow::Borrowed(game_arguments),
),
}
}
};
let upload_executable = filesystem::get_canonical_path(&upload_executable).await?;
filesystem::make_executable(&upload_executable).await?;
let mut game_process = {
let mut wrapper_iter = wrapper.iter();
match wrapper_iter.next() {
None => tokio::process::Command::new(&upload_executable),
Some(w) => {
let mut gp = tokio::process::Command::new(w);
gp.args(wrapper_iter).arg(&upload_executable);
gp
}
}
};
game_process
.current_dir(&upload_folder)
.args(&*game_arguments)
.envs(environment_variables.iter().map(|(k, v)| (k, v)));
launch_start_callback(&upload_executable, &game_process);
let mut child = filesystem::spawn_command(&mut game_process)?;
filesystem::wait_child(&mut child).await?;
Ok(())
}
#[must_use]
pub fn get_web_game_url(upload_id: UploadID) -> String {
format!("https://html-classic.itch.zone/html/{upload_id}/index.html")
}