├── .envrc ├── .gitignore ├── external └── hpatchz │ ├── hpatchz │ └── LICENSE ├── src ├── games │ ├── genshin │ │ ├── voice_data │ │ │ ├── mod.rs │ │ │ └── locale.rs │ │ ├── mod.rs │ │ ├── api │ │ │ ├── mod.rs │ │ │ └── schema.rs │ │ ├── telemetry.rs │ │ ├── consts.rs │ │ ├── repairer.rs │ │ └── game.rs │ ├── star_rail │ │ ├── voice_data │ │ │ ├── mod.rs │ │ │ └── locale.rs │ │ ├── mod.rs │ │ ├── api │ │ │ ├── mod.rs │ │ │ └── schema.rs │ │ ├── telemetry.rs │ │ ├── consts.rs │ │ ├── repairer.rs │ │ └── game.rs │ ├── mod.rs │ ├── zzz │ │ ├── mod.rs │ │ ├── api │ │ │ ├── mod.rs │ │ │ └── schema.rs │ │ ├── telemetry.rs │ │ ├── consts.rs │ │ ├── repairer.rs │ │ └── game.rs │ └── honkai │ │ ├── mod.rs │ │ ├── api │ │ ├── mod.rs │ │ └── schema.rs │ │ ├── telemetry.rs │ │ ├── repairer.rs │ │ ├── consts.rs │ │ ├── game.rs │ │ └── version_diff.rs ├── traits │ ├── mod.rs │ ├── game.rs │ ├── version_diff.rs │ └── git_sync.rs ├── patches │ ├── mod.rs │ └── jadeite │ │ ├── mod.rs │ │ └── metadata.rs ├── external │ ├── mod.rs │ └── hpatchz.rs ├── installer │ ├── mod.rs │ ├── free_space.rs │ ├── downloader.rs │ ├── archives.rs │ └── installer.rs ├── check_domain.rs ├── prettify_bytes.rs ├── sophon │ ├── api_schemas │ │ ├── mod.rs │ │ ├── sophon_diff.rs │ │ ├── sophon_manifests.rs │ │ └── game_branches.rs │ ├── protos │ │ ├── mod.rs │ │ ├── SophonManifest.proto │ │ └── SophonPatch.proto │ └── mod.rs ├── lib.rs ├── repairer.rs └── version.rs ├── rustfmt.toml ├── README.md └── Cargo.toml /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /.direnv 3 | 4 | Cargo.lock 5 | -------------------------------------------------------------------------------- /external/hpatchz/hpatchz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/an-anime-team/anime-game-core/HEAD/external/hpatchz/hpatchz -------------------------------------------------------------------------------- /src/games/genshin/voice_data/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod locale; 2 | pub mod package; 3 | 4 | pub mod prelude { 5 | pub use super::locale::VoiceLocale; 6 | pub use super::package::VoicePackage; 7 | } 8 | -------------------------------------------------------------------------------- /src/games/star_rail/voice_data/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod locale; 2 | pub mod package; 3 | 4 | pub mod prelude { 5 | pub use super::locale::VoiceLocale; 6 | pub use super::package::VoicePackage; 7 | } 8 | -------------------------------------------------------------------------------- /src/traits/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod game; 2 | pub mod version_diff; 3 | pub mod git_sync; 4 | 5 | pub mod prelude { 6 | pub use super::game::*; 7 | pub use super::version_diff::*; 8 | pub use super::git_sync::*; 9 | } 10 | -------------------------------------------------------------------------------- /src/games/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "genshin")] 2 | pub mod genshin; 3 | 4 | #[cfg(feature = "star-rail")] 5 | pub mod star_rail; 6 | 7 | #[cfg(feature = "zzz")] 8 | pub mod zzz; 9 | 10 | #[cfg(feature = "honkai")] 11 | pub mod honkai; 12 | -------------------------------------------------------------------------------- /src/patches/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "patch-jadeite")] 2 | pub mod jadeite; 3 | 4 | pub mod prelude { 5 | #[cfg(feature = "patch-jadeite")] 6 | pub use super::jadeite::{ 7 | self, 8 | JadeiteLatest, 9 | metadata::* 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /src/external/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod hpatchz; 2 | 3 | use std::collections::HashMap; 4 | 5 | use kinda_virtual_fs::*; 6 | 7 | lazy_static::lazy_static! { 8 | static ref STORAGE: Storage = Storage::new(HashMap::from([ 9 | ("hpatchz".to_string(), Entry::new(include_bytes!("../../external/hpatchz/hpatchz").to_vec())) 10 | ])); 11 | } 12 | -------------------------------------------------------------------------------- /src/games/zzz/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod consts; 2 | pub mod api; 3 | pub mod version_diff; 4 | pub mod game; 5 | pub mod telemetry; 6 | 7 | #[cfg(feature = "install")] 8 | pub mod repairer; 9 | 10 | pub mod prelude { 11 | pub use super::consts::*; 12 | pub use super::version_diff::*; 13 | pub use super::game::Game; 14 | pub use super::telemetry; 15 | 16 | #[cfg(feature = "install")] 17 | pub use super::repairer; 18 | } 19 | -------------------------------------------------------------------------------- /src/games/honkai/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod consts; 2 | pub mod api; 3 | pub mod version_diff; 4 | pub mod game; 5 | pub mod telemetry; 6 | 7 | #[cfg(feature = "install")] 8 | pub mod repairer; 9 | 10 | pub mod prelude { 11 | pub use super::consts::*; 12 | pub use super::version_diff::*; 13 | pub use super::game::Game; 14 | pub use super::telemetry; 15 | 16 | #[cfg(feature = "install")] 17 | pub use super::repairer; 18 | } 19 | -------------------------------------------------------------------------------- /src/installer/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod downloader; 2 | pub mod archives; 3 | pub mod installer; 4 | pub mod free_space; 5 | 6 | pub mod prelude { 7 | pub use super::archives::Archive; 8 | pub use super::free_space; 9 | 10 | pub use super::downloader::{ 11 | Downloader, 12 | DownloadingError 13 | }; 14 | 15 | pub use super::installer::{ 16 | Installer, 17 | Update as InstallerUpdate 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /src/check_domain.rs: -------------------------------------------------------------------------------- 1 | /// Check whether given domain name is resolvable 2 | /// 3 | /// Timeout is optional amount of seconds 4 | #[tracing::instrument(level = "trace")] 5 | pub fn available + std::fmt::Debug>(domain: T) -> anyhow::Result { 6 | for ip in dns_lookup::lookup_host(domain.as_ref())? { 7 | if !ip.is_loopback() && !ip.is_unspecified() { 8 | return Ok(true); 9 | } 10 | } 11 | 12 | Ok(false) 13 | } 14 | -------------------------------------------------------------------------------- /src/prettify_bytes.rs: -------------------------------------------------------------------------------- 1 | #[inline] 2 | pub fn prettify_bytes(bytes: u64) -> String { 3 | if bytes > 1024 * 1024 * 1024 { 4 | format!("{:.2} GB", bytes as f64 / 1024.0 / 1024.0 / 1024.0) 5 | } 6 | 7 | else if bytes > 1024 * 1024 { 8 | format!("{:.2} MB", bytes as f64 / 1024.0 / 1024.0) 9 | } 10 | 11 | else if bytes > 1024 { 12 | format!("{:.2} KB", bytes as f64 / 1024.0) 13 | } 14 | 15 | else { 16 | format!("{:.2} B", bytes) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/games/star_rail/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod consts; 2 | pub mod api; 3 | pub mod version_diff; 4 | pub mod game; 5 | pub mod voice_data; 6 | pub mod telemetry; 7 | 8 | #[cfg(feature = "install")] 9 | pub mod repairer; 10 | 11 | pub mod prelude { 12 | pub use super::consts::*; 13 | pub use super::version_diff::*; 14 | pub use super::game::Game; 15 | pub use super::voice_data::prelude::*; 16 | pub use super::telemetry; 17 | #[cfg(feature = "install")] 18 | pub use super::repairer; 19 | } 20 | -------------------------------------------------------------------------------- /src/games/genshin/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod consts; 2 | pub mod api; 3 | pub mod version_diff; 4 | pub mod game; 5 | pub mod voice_data; 6 | pub mod telemetry; 7 | 8 | #[cfg(feature = "install")] 9 | pub mod repairer; 10 | 11 | pub mod prelude { 12 | pub use super::consts::*; 13 | pub use super::version_diff::*; 14 | pub use super::game::Game; 15 | pub use super::voice_data::prelude::*; 16 | pub use super::telemetry; 17 | 18 | #[cfg(feature = "install")] 19 | pub use super::repairer; 20 | } 21 | -------------------------------------------------------------------------------- /src/traits/game.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use crate::version::Version; 4 | 5 | pub trait GameExt { 6 | /// Game edition 7 | type Edition; 8 | 9 | fn new(path: impl Into, edition: Self::Edition) -> Self; 10 | 11 | fn path(&self) -> &Path; 12 | fn edition(&self) -> Self::Edition; 13 | 14 | /// Checks if the game is installed 15 | fn is_installed(&self) -> bool { 16 | self.path().exists() 17 | } 18 | 19 | fn get_latest_version(edition: Self::Edition) -> anyhow::Result; 20 | fn get_version(&self) -> anyhow::Result; 21 | } 22 | -------------------------------------------------------------------------------- /src/sophon/api_schemas/mod.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use sophon_diff::SophonDiff; 3 | use sophon_manifests::SophonDownloadInfo; 4 | 5 | pub mod game_branches; 6 | pub mod sophon_diff; 7 | pub mod sophon_manifests; 8 | 9 | #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] 10 | pub struct ApiResponse { 11 | pub retcode: i16, 12 | pub message: String, 13 | pub data: T 14 | } 15 | 16 | #[allow(clippy::large_enum_variant)] 17 | #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] 18 | pub enum DownloadOrDiff { 19 | Download(SophonDownloadInfo), 20 | Patch(SophonDiff) 21 | } 22 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2024" 2 | style_edition = "2024" 3 | unstable_features = true 4 | comment_width = 80 5 | wrap_comments = true 6 | normalize_comments = true 7 | enum_discrim_align_threshold = 20 8 | # float_literal_trailing_zero = "IfNoPostfix" 9 | imports_granularity = "Module" 10 | group_imports = "StdExternalCrate" 11 | imports_layout = "Mixed" 12 | overflow_delimited_expr = true 13 | reorder_impl_items = true 14 | reorder_imports = false 15 | reorder_modules = false 16 | single_line_let_else_max_width = 0 17 | struct_lit_single_line = false 18 | trailing_comma = "Never" 19 | use_field_init_shorthand = true 20 | control_brace_style = "ClosingNextLine" 21 | -------------------------------------------------------------------------------- /src/games/zzz/api/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod schema; 2 | 3 | use crate::zzz::consts::GameEdition; 4 | 5 | #[cached::proc_macro::cached( 6 | key = "GameEdition", 7 | convert = r#"{ game_edition }"#, 8 | result 9 | )] 10 | #[tracing::instrument(level = "trace")] 11 | pub fn request(game_edition: GameEdition) -> anyhow::Result { 12 | tracing::trace!("Fetching API for {:?}", game_edition); 13 | 14 | let schema: schema::Response = minreq::get(game_edition.api_uri()) 15 | .with_timeout(*crate::REQUESTS_TIMEOUT) 16 | .send()?.json()?; 17 | 18 | schema.data.game_packages.into_iter() 19 | .find(|game| game.game.biz.starts_with("nap_")) 20 | .ok_or_else(|| anyhow::anyhow!("Failed to find the game in the API")) 21 | } 22 | -------------------------------------------------------------------------------- /src/games/genshin/api/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod schema; 2 | 3 | use crate::genshin::consts::GameEdition; 4 | 5 | #[cached::proc_macro::cached( 6 | key = "GameEdition", 7 | convert = r#"{ game_edition }"#, 8 | result 9 | )] 10 | #[tracing::instrument(level = "trace")] 11 | pub fn request(game_edition: GameEdition) -> anyhow::Result { 12 | tracing::trace!("Fetching API for {:?}", game_edition); 13 | 14 | let schema: schema::Response = minreq::get(game_edition.api_uri()) 15 | .with_timeout(*crate::REQUESTS_TIMEOUT) 16 | .send()?.json()?; 17 | 18 | schema.data.game_packages.into_iter() 19 | .find(|game| game.game.biz.starts_with("hk4e_")) 20 | .ok_or_else(|| anyhow::anyhow!("Failed to find the game in the API")) 21 | } 22 | -------------------------------------------------------------------------------- /src/games/star_rail/api/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod schema; 2 | 3 | use crate::star_rail::consts::GameEdition; 4 | 5 | #[cached::proc_macro::cached( 6 | key = "GameEdition", 7 | convert = r#"{ game_edition }"#, 8 | result 9 | )] 10 | #[tracing::instrument(level = "trace")] 11 | pub fn request(game_edition: GameEdition) -> anyhow::Result { 12 | tracing::trace!("Fetching API for {:?}", game_edition); 13 | 14 | let schema: schema::Response = minreq::get(game_edition.api_uri()) 15 | .with_timeout(*crate::REQUESTS_TIMEOUT) 16 | .send()?.json()?; 17 | 18 | schema.data.game_packages.into_iter() 19 | .find(|game| game.game.biz.starts_with("hkrpg_")) 20 | .ok_or_else(|| anyhow::anyhow!("Failed to find the game in the API")) 21 | } 22 | -------------------------------------------------------------------------------- /src/games/honkai/api/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod schema; 2 | 3 | use crate::honkai::consts::GameEdition; 4 | 5 | #[cached::proc_macro::cached( 6 | key = "GameEdition", 7 | convert = r#"{ game_edition }"#, 8 | result 9 | )] 10 | #[tracing::instrument(level = "trace")] 11 | pub fn request(game_edition: GameEdition) -> anyhow::Result { 12 | tracing::trace!("Fetching API for {:?}", game_edition); 13 | 14 | let schema: schema::Response = minreq::get(game_edition.api_uri()) 15 | .with_timeout(*crate::REQUESTS_TIMEOUT) 16 | .send()?.json()?; 17 | 18 | schema.data.game_packages.into_iter() 19 | .find(|game| game.game.id == game_edition.api_game_id()) 20 | .ok_or_else(|| anyhow::anyhow!("Failed to find the game in the API")) 21 | } 22 | -------------------------------------------------------------------------------- /src/games/honkai/telemetry.rs: -------------------------------------------------------------------------------- 1 | use super::consts::GameEdition; 2 | 3 | /// Check whether telemetry servers disabled 4 | /// 5 | /// If some of them is not disabled, then this function will return its address 6 | /// 7 | /// ``` 8 | /// use anime_game_core::honkai::telemetry; 9 | /// use anime_game_core::honkai::consts::GameEdition; 10 | /// 11 | /// if let Ok(None) = telemetry::is_disabled(GameEdition::Global) { 12 | /// println!("Telemetry is disabled"); 13 | /// } 14 | /// ``` 15 | #[tracing::instrument(level = "debug")] 16 | pub fn is_disabled(game_edition: GameEdition) -> anyhow::Result> { 17 | tracing::debug!("Checking telemetry servers status"); 18 | 19 | for server in game_edition.telemetry_servers() { 20 | if crate::check_domain::available(server)? { 21 | tracing::warn!("Server is not disabled: {server}"); 22 | 23 | return Ok(Some(server.to_string())); 24 | } 25 | } 26 | 27 | Ok(None) 28 | } 29 | -------------------------------------------------------------------------------- /src/games/zzz/telemetry.rs: -------------------------------------------------------------------------------- 1 | use super::consts::GameEdition; 2 | 3 | /// Check whether telemetry servers disabled 4 | /// 5 | /// If some of them is not disabled, then this function will return its address 6 | /// 7 | /// ``` 8 | /// use anime_game_core::genshin::telemetry; 9 | /// use anime_game_core::genshin::consts::GameEdition; 10 | /// 11 | /// if let Ok(None) = telemetry::is_disabled(GameEdition::Global) { 12 | /// println!("Telemetry is disabled"); 13 | /// } 14 | /// ``` 15 | #[tracing::instrument(level = "debug")] 16 | pub fn is_disabled(game_edition: GameEdition) -> anyhow::Result> { 17 | tracing::debug!("Checking telemetry servers status"); 18 | 19 | for server in game_edition.telemetry_servers() { 20 | if crate::check_domain::available(server)? { 21 | tracing::warn!("Server is not disabled: {server}"); 22 | 23 | return Ok(Some(server.to_string())); 24 | } 25 | } 26 | 27 | Ok(None) 28 | } 29 | -------------------------------------------------------------------------------- /src/sophon/protos/mod.rs: -------------------------------------------------------------------------------- 1 | use SophonManifest::SophonManifestProto; 2 | 3 | include!(concat!(env!("OUT_DIR"), "/protos/mod.rs")); 4 | 5 | impl SophonManifestProto { 6 | pub fn total_bytes_compressed(&self) -> u64 { 7 | self.Assets 8 | .iter() 9 | .flat_map(|asset| &asset.AssetChunks) 10 | .map(|asset_chunk| asset_chunk.ChunkSize) 11 | .sum() 12 | } 13 | 14 | pub fn total_bytes_decompressed(&self) -> u64 { 15 | self.Assets 16 | .iter() 17 | .flat_map(|asset| &asset.AssetChunks) 18 | .map(|asset_chunk| asset_chunk.ChunkSizeDecompressed) 19 | .sum() 20 | } 21 | 22 | pub fn total_chunks(&self) -> u64 { 23 | self.Assets 24 | .iter() 25 | .flat_map(|asset| &asset.AssetChunks) 26 | .count() as u64 27 | } 28 | 29 | pub fn total_files(&self) -> u64 { 30 | self.Assets.len() as u64 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/games/genshin/telemetry.rs: -------------------------------------------------------------------------------- 1 | use super::consts::GameEdition; 2 | 3 | /// Check whether telemetry servers disabled 4 | /// 5 | /// If some of them is not disabled, then this function will return its address 6 | /// 7 | /// ``` 8 | /// use anime_game_core::genshin::telemetry; 9 | /// use anime_game_core::genshin::consts::GameEdition; 10 | /// 11 | /// if let Ok(None) = telemetry::is_disabled(GameEdition::Global) { 12 | /// println!("Telemetry is disabled"); 13 | /// } 14 | /// ``` 15 | #[tracing::instrument(level = "debug")] 16 | pub fn is_disabled(game_edition: GameEdition) -> anyhow::Result> { 17 | tracing::debug!("Checking telemetry servers status"); 18 | 19 | for server in game_edition.telemetry_servers() { 20 | if crate::check_domain::available(server)? { 21 | tracing::warn!("Server is not disabled: {server}"); 22 | 23 | return Ok(Some(server.to_string())); 24 | } 25 | } 26 | 27 | Ok(None) 28 | } 29 | -------------------------------------------------------------------------------- /src/games/star_rail/telemetry.rs: -------------------------------------------------------------------------------- 1 | use super::consts::GameEdition; 2 | 3 | /// Check whether telemetry servers disabled 4 | /// 5 | /// If some of them is not disabled, then this function will return its address 6 | /// 7 | /// ``` 8 | /// use anime_game_core::star_rail::telemetry; 9 | /// use anime_game_core::star_rail::consts::GameEdition; 10 | /// 11 | /// if let Ok(None) = telemetry::is_disabled(GameEdition::Global) { 12 | /// println!("Telemetry is disabled"); 13 | /// } 14 | /// ``` 15 | #[tracing::instrument(level = "debug")] 16 | pub fn is_disabled(game_edition: GameEdition) -> anyhow::Result> { 17 | tracing::debug!("Checking telemetry servers status"); 18 | 19 | for server in game_edition.telemetry_servers() { 20 | if crate::check_domain::available(server)? { 21 | tracing::warn!("Server is not disabled: {server}"); 22 | 23 | return Ok(Some(server.to_string())); 24 | } 25 | } 26 | 27 | Ok(None) 28 | } 29 | -------------------------------------------------------------------------------- /src/sophon/api_schemas/sophon_diff.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeMap; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use super::sophon_manifests::{DownloadInfo, Manifest, ManifestStats}; 6 | 7 | #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] 8 | pub struct SophonDiffs { 9 | pub build_id: String, 10 | pub patch_id: String, 11 | pub tag: String, 12 | pub manifests: Vec 13 | } 14 | 15 | impl SophonDiffs { 16 | /// `matching_field` is usually either `game` or one of the voiceover 17 | /// language options 18 | pub fn get_manifests_for(&self, matching_field: &str) -> Option<&SophonDiff> { 19 | self.manifests 20 | .iter() 21 | .find(|manifest| manifest.matching_field == matching_field) 22 | } 23 | } 24 | 25 | #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] 26 | pub struct SophonDiff { 27 | pub category_id: String, 28 | pub category_name: String, 29 | pub matching_field: String, 30 | pub manifest: Manifest, 31 | pub diff_download: DownloadInfo, 32 | pub manifest_download: DownloadInfo, 33 | pub stats: BTreeMap 34 | } 35 | -------------------------------------------------------------------------------- /src/external/hpatchz.rs: -------------------------------------------------------------------------------- 1 | use std::process::Command; 2 | use std::io::{Error, ErrorKind}; 3 | use std::os::unix::prelude::PermissionsExt; 4 | use std::path::PathBuf; 5 | 6 | /// Try to apply hdiff patch 7 | #[tracing::instrument(level = "debug")] 8 | pub fn patch + std::fmt::Debug>(file: T, patch: T, output: T) -> std::io::Result<()> { 9 | tracing::debug!("Applying hdiff patch"); 10 | 11 | let hpatchz = super::STORAGE.map("hpatchz")?; 12 | 13 | // Allow to execute this binary 14 | std::fs::set_permissions(&hpatchz, std::fs::Permissions::from_mode(0o777))?; 15 | 16 | let output = Command::new(hpatchz) 17 | .arg("-f") 18 | .arg(file.into().as_os_str()) 19 | .arg(patch.into().as_os_str()) 20 | .arg(output.into().as_os_str()) 21 | .output()?; 22 | 23 | if String::from_utf8_lossy(output.stdout.as_slice()).contains("patch ok!") { 24 | Ok(()) 25 | } 26 | 27 | else { 28 | let err = String::from_utf8_lossy(&output.stderr); 29 | 30 | tracing::error!("Failed to apply hdiff patch: {err}"); 31 | 32 | Err(Error::new(ErrorKind::Other, format!("Failed to apply hdiff patch: {err}"))) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/sophon/protos/SophonManifest.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | message SophonManifestProto 4 | { 5 | repeated SophonManifestAssetProperty Assets = 1; 6 | } 7 | 8 | message SophonManifestAssetProperty 9 | { 10 | string AssetName = 1; // Path to the file relative to the game directory 11 | repeated SophonManifestAssetChunk AssetChunks = 2; 12 | uint32 AssetType = 3; // 0 for files, 64 for directories. No other values were ever observed 13 | uint64 AssetSize = 4; // Size of the entire file 14 | string AssetHashMd5 = 5; // MD5 checksum of the entire file 15 | } 16 | 17 | message SophonManifestAssetChunk 18 | { 19 | string ChunkName = 1; 20 | string ChunkDecompressedHashMd5 = 2; // MD5 checksum of the uncompressed chunk data 21 | uint64 ChunkOnFileOffset = 3; // Offset at which this chunk should be put into the resulting file 22 | uint64 ChunkSize = 4; // Size of the compressed chunk 23 | uint64 ChunkSizeDecompressed = 5; // Size of the uncompressed chunk 24 | uint64 ChunkCompressedHashXxh = 6; // not an xxh64 checlsum of the compressed data 25 | string ChunkCompressedHashMd5 = 7; // MD5 checksum of the compressed chunk data 26 | } 27 | -------------------------------------------------------------------------------- /src/sophon/protos/SophonPatch.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | message SophonPatchProto 4 | { 5 | repeated SophonPatchAssetProperty PatchAssets = 1; 6 | map UnusedAssets = 2; 7 | } 8 | 9 | message SophonPatchAssetProperty 10 | { 11 | string AssetName = 1; // Path to the file relative to the game directory 12 | uint64 AssetSize = 2; // Size of the entire file 13 | string AssetHashMd5 = 3; // md5 file hash AFTER patching 14 | map AssetPatchChunks = 4; 15 | } 16 | 17 | message SophonPatchAssetChunk 18 | { 19 | string PatchName = 1; 20 | string VersionTag = 2; // Game version, e.g. "5.5.0" or "5.4.0" (when updating to "5.6.0") 21 | string BuildId = 3; 22 | uint64 PatchSize = 4; 23 | string PatchMd5 = 5; 24 | uint64 PatchOffset = 6; 25 | uint64 PatchLength = 7; 26 | string OriginalFileName = 8; 27 | uint64 OriginalFileLength = 9; 28 | string OriginalFileMd5 = 10; // md5 file hash BEFORE patching 29 | } 30 | 31 | message SophonUnusedAssetInfo 32 | { 33 | repeated SophonUnusedAssetFile Assets = 1; 34 | } 35 | 36 | message SophonUnusedAssetFile 37 | { 38 | string FileName = 1; 39 | uint64 FileSize = 2; 40 | string FileMd5 = 3; 41 | } 42 | -------------------------------------------------------------------------------- /src/installer/free_space.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use sysinfo::Disks; 4 | 5 | // TODO: support for relative paths 6 | 7 | /// Get available free disk space by specified path 8 | /// 9 | /// Can return `None` if path is not prefixed by any available disk 10 | pub fn available(path: impl AsRef) -> Option { 11 | let mut disks = Disks::new_with_refreshed_list(); 12 | 13 | disks.sort_by(|a, b| { 14 | let a = a.mount_point().as_os_str().len(); 15 | let b = b.mount_point().as_os_str().len(); 16 | 17 | a.cmp(&b).reverse() 18 | }); 19 | 20 | let path = path.as_ref() 21 | .read_link() 22 | .unwrap_or_else(|_| path.as_ref().to_path_buf()); 23 | 24 | for disk in disks.iter() { 25 | if path.starts_with(disk.mount_point()) { 26 | return Some(disk.available_space()); 27 | } 28 | } 29 | 30 | None 31 | } 32 | 33 | /// Check if two paths exist on the same disk 34 | pub fn is_same_disk(path1: impl AsRef, path2: impl AsRef) -> bool { 35 | let mut disks = Disks::new_with_refreshed_list(); 36 | 37 | disks.sort_by(|a, b| { 38 | let a = a.mount_point().as_os_str().len(); 39 | let b = b.mount_point().as_os_str().len(); 40 | 41 | a.cmp(&b).reverse() 42 | }); 43 | 44 | let path1 = path1.as_ref() 45 | .read_link() 46 | .unwrap_or_else(|_| path1.as_ref().to_path_buf()); 47 | 48 | let path2 = path2.as_ref() 49 | .read_link() 50 | .unwrap_or_else(|_| path2.as_ref().to_path_buf()); 51 | 52 | for disk in disks.iter() { 53 | let disk_path = disk.mount_point(); 54 | 55 | if path1.starts_with(disk_path) && path2.starts_with(disk_path) { 56 | return true; 57 | } 58 | } 59 | 60 | false 61 | } 62 | -------------------------------------------------------------------------------- /src/sophon/api_schemas/sophon_manifests.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] 4 | pub struct SophonDownloads { 5 | pub build_id: String, 6 | pub tag: String, 7 | pub manifests: Vec 8 | } 9 | 10 | impl SophonDownloads { 11 | /// `matching_field` is usually either `game` or one of the voiceover 12 | /// language options 13 | pub fn get_manifests_for(&self, matching_field: &str) -> Option<&SophonDownloadInfo> { 14 | self.manifests 15 | .iter() 16 | .find(|manifest| manifest.matching_field == matching_field) 17 | } 18 | } 19 | 20 | #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] 21 | pub struct SophonDownloadInfo { 22 | pub category_id: String, 23 | pub category_name: String, 24 | pub matching_field: String, 25 | pub manifest: Manifest, 26 | pub chunk_download: DownloadInfo, 27 | pub manifest_download: DownloadInfo, 28 | pub stats: ManifestStats, 29 | pub deduplicated_stats: ManifestStats 30 | } 31 | 32 | #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] 33 | pub struct Manifest { 34 | pub id: String, 35 | pub checksum: String, 36 | pub compressed_size: String, 37 | pub uncompressed_size: String 38 | } 39 | 40 | #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] 41 | pub struct DownloadInfo { 42 | pub encryption: u8, 43 | pub password: String, 44 | pub compression: u8, 45 | pub url_prefix: String, 46 | pub url_suffix: String 47 | } 48 | 49 | impl DownloadInfo { 50 | pub fn download_url(&self, id: &str) -> String { 51 | format!("{}{}/{id}", self.url_prefix, self.url_suffix) 52 | } 53 | } 54 | 55 | #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] 56 | pub struct ManifestStats { 57 | pub compressed_size: String, 58 | pub uncompressed_size: String, 59 | pub file_count: String, 60 | pub chunk_count: String 61 | } 62 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | /// Core library version 2 | pub const VERSION: &str = env!("CARGO_PKG_VERSION"); 3 | 4 | lazy_static::lazy_static! { 5 | /// Default requests timeout in seconds 6 | pub static ref REQUESTS_TIMEOUT: u64 = match std::env::var("LAUNCHER_REQUESTS_TIMEOUT") { 7 | Ok(timeout) => timeout.parse().unwrap_or(8), 8 | Err(_) => 8 9 | }; 10 | } 11 | 12 | pub mod version; 13 | pub mod traits; 14 | pub mod prettify_bytes; 15 | pub mod check_domain; 16 | 17 | #[cfg(feature = "patches")] 18 | pub mod patches; 19 | 20 | // Games-specific functionality 21 | 22 | mod games; 23 | 24 | pub use minreq; 25 | 26 | #[cfg(feature = "sophon")] 27 | pub use reqwest; 28 | 29 | #[cfg(feature = "genshin")] 30 | pub use games::genshin; 31 | 32 | #[cfg(feature = "star-rail")] 33 | pub use games::star_rail; 34 | 35 | #[cfg(feature = "zzz")] 36 | pub use games::zzz; 37 | 38 | #[cfg(feature = "honkai")] 39 | pub use games::honkai; 40 | 41 | // Core functionality 42 | 43 | #[cfg(feature = "external")] 44 | pub mod external; 45 | 46 | #[cfg(feature = "install")] 47 | pub mod installer; 48 | 49 | #[cfg(feature = "install")] 50 | pub mod repairer; 51 | 52 | #[cfg(feature = "sophon")] 53 | pub mod sophon; 54 | 55 | pub mod prelude { 56 | pub use super::version::*; 57 | pub use super::prettify_bytes::prettify_bytes; 58 | 59 | pub use super::traits::prelude::*; 60 | 61 | #[cfg(feature = "patches")] 62 | #[allow(unused_imports)] 63 | pub use super::patches::prelude::*; 64 | 65 | #[cfg(feature = "genshin")] 66 | pub use super::genshin::prelude as genshin; 67 | 68 | #[cfg(feature = "star-rail")] 69 | pub use super::star_rail::prelude as star_rail; 70 | 71 | #[cfg(feature = "zzz")] 72 | pub use super::zzz::prelude as zzz; 73 | 74 | #[cfg(feature = "honkai")] 75 | pub use super::honkai::prelude as honkai; 76 | 77 | #[cfg(feature = "install")] 78 | pub use super::installer::prelude::*; 79 | 80 | #[cfg(feature = "install")] 81 | pub use super::repairer::*; 82 | } 83 | -------------------------------------------------------------------------------- /src/games/zzz/api/schema.rs: -------------------------------------------------------------------------------- 1 | use serde::{Serialize, Deserialize}; 2 | 3 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 4 | pub struct Response { 5 | pub retcode: u16, 6 | pub message: String, 7 | pub data: Data 8 | } 9 | 10 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 11 | pub struct Data { 12 | pub game_packages: Vec 13 | } 14 | 15 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 16 | pub struct GamePackage { 17 | pub game: GameId, 18 | pub main: GameInfo, 19 | pub pre_download: Option 20 | } 21 | 22 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 23 | pub struct GameId { 24 | pub id: String, 25 | pub biz: String 26 | } 27 | 28 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 29 | pub struct GameInfo { 30 | pub major: GameLatestInfo, 31 | pub patches: Vec 32 | } 33 | 34 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 35 | pub struct GameLatestInfo { 36 | pub version: String, 37 | pub game_pkgs: Vec, 38 | pub audio_pkgs: Vec, 39 | pub res_list_url: String 40 | } 41 | 42 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 43 | pub struct Segment { 44 | pub url: String, 45 | pub md5: String, 46 | pub size: String, 47 | pub decompressed_size: String 48 | } 49 | 50 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 51 | pub struct AudioPackage { 52 | pub language: String, 53 | pub url: String, 54 | pub md5: String, 55 | pub size: String, 56 | pub decompressed_size: String 57 | } 58 | 59 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 60 | pub struct GamePatch { 61 | pub version: String, 62 | pub game_pkgs: Vec, 63 | pub audio_pkgs: Vec 64 | } 65 | 66 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 67 | pub struct GamePredownloadInfo { 68 | pub major: Option, 69 | pub patches: Vec 70 | } 71 | -------------------------------------------------------------------------------- /src/games/genshin/api/schema.rs: -------------------------------------------------------------------------------- 1 | use serde::{Serialize, Deserialize}; 2 | 3 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 4 | pub struct Response { 5 | pub retcode: u16, 6 | pub message: String, 7 | pub data: Data 8 | } 9 | 10 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 11 | pub struct Data { 12 | pub game_packages: Vec 13 | } 14 | 15 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 16 | pub struct GamePackage { 17 | pub game: GameId, 18 | pub main: GameInfo, 19 | pub pre_download: Option 20 | } 21 | 22 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 23 | pub struct GameId { 24 | pub id: String, 25 | pub biz: String 26 | } 27 | 28 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 29 | pub struct GameInfo { 30 | pub major: GameLatestInfo, 31 | pub patches: Vec 32 | } 33 | 34 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 35 | pub struct GameLatestInfo { 36 | pub version: String, 37 | pub game_pkgs: Vec, 38 | pub audio_pkgs: Vec, 39 | pub res_list_url: String 40 | } 41 | 42 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 43 | pub struct Segment { 44 | pub url: String, 45 | pub md5: String, 46 | pub size: String, 47 | pub decompressed_size: String 48 | } 49 | 50 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 51 | pub struct AudioPackage { 52 | pub language: String, 53 | pub url: String, 54 | pub md5: String, 55 | pub size: String, 56 | pub decompressed_size: String 57 | } 58 | 59 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 60 | pub struct GamePatch { 61 | pub version: String, 62 | pub game_pkgs: Vec, 63 | pub audio_pkgs: Vec 64 | } 65 | 66 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 67 | pub struct GamePredownloadInfo { 68 | pub major: Option, 69 | pub patches: Vec 70 | } 71 | -------------------------------------------------------------------------------- /src/games/honkai/api/schema.rs: -------------------------------------------------------------------------------- 1 | use serde::{Serialize, Deserialize}; 2 | 3 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 4 | pub struct Response { 5 | pub retcode: u16, 6 | pub message: String, 7 | pub data: Data 8 | } 9 | 10 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 11 | pub struct Data { 12 | pub game_packages: Vec 13 | } 14 | 15 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 16 | pub struct GamePackage { 17 | pub game: GameId, 18 | pub main: GameInfo, 19 | pub pre_download: Option 20 | } 21 | 22 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 23 | pub struct GameId { 24 | pub id: String, 25 | pub biz: String 26 | } 27 | 28 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 29 | pub struct GameInfo { 30 | pub major: GameLatestInfo, 31 | pub patches: Vec 32 | } 33 | 34 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 35 | pub struct GameLatestInfo { 36 | pub version: String, 37 | pub game_pkgs: Vec, 38 | pub audio_pkgs: Vec, 39 | pub res_list_url: String 40 | } 41 | 42 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 43 | pub struct Segment { 44 | pub url: String, 45 | pub md5: String, 46 | pub size: String, 47 | pub decompressed_size: String 48 | } 49 | 50 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 51 | pub struct AudioPackage { 52 | pub language: String, 53 | pub url: String, 54 | pub md5: String, 55 | pub size: String, 56 | pub decompressed_size: String 57 | } 58 | 59 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 60 | pub struct GamePatch { 61 | pub version: String, 62 | pub game_pkgs: Vec, 63 | pub audio_pkgs: Vec 64 | } 65 | 66 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 67 | pub struct GamePredownloadInfo { 68 | pub major: Option, 69 | pub patches: Vec 70 | } 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🦀 Anime Game Core 2 | 3 | Unified library to control different games installations. Provides basic instruments for adding support for mechanics like game updating 4 | 5 | > ⚠️ Current implementation is considered legacy. No deep changes will be made in 1.* branch. 6 | > The universal launcher project now uses 2.* branch, but all the functions there could be implemented within the app itself instead of having a separate library. 7 | 8 | ## Features 9 | 10 | | Description | Feature | 11 | |----------------------------------------------------------------|---------------| 12 | | Manage games installations (parse versions, check for updates) | default | 13 | | Install games and download updates | `install` | 14 | | Manage voice packages, download and update them | `install` | 15 | | Repair game installations | `install` | 16 | 17 | ## Supported games 18 | 19 | | Name | Feature | 20 | |--------------------------------------------------------------------------------------|---------------------------| 21 | | [An Anime Game](https://github.com/an-anime-team/an-anime-game-launcher) | `gen-shin` (without dash) | 22 | | [The Honkers Railway](https://github.com/an-anime-team/the-honkers-railway-launcher) | `star-rail` | 23 | | [Sleepy](https://github.com/an-anime-team/sleepy-launcher) | `zzz` | 24 | | [Honkers](https://github.com/an-anime-team/honkers-launcher) | `hon-kai` (without dash) | 25 | | [An Anime Borb](https://github.com/an-anime-team/an-anime-borb-launcher) | `pgr` | 26 | | [Waves](https://github.com/an-anime-team/wavey-launcher) | `wuwa` | 27 | 28 | ⚠️ This library does not bind 7z archives format support, and would require `7z` binary available in user's system. This format may be used in games like honkers 29 | -------------------------------------------------------------------------------- /src/games/star_rail/api/schema.rs: -------------------------------------------------------------------------------- 1 | use serde::{Serialize, Deserialize}; 2 | 3 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 4 | pub struct Response { 5 | pub retcode: u16, 6 | pub message: String, 7 | pub data: Data 8 | } 9 | 10 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 11 | pub struct Data { 12 | pub game_packages: Vec 13 | } 14 | 15 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 16 | pub struct GamePackage { 17 | pub game: GameId, 18 | pub main: GameInfo, 19 | pub pre_download: Option 20 | } 21 | 22 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 23 | pub struct GameId { 24 | pub id: String, 25 | pub biz: String 26 | } 27 | 28 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 29 | pub struct GameInfo { 30 | pub major: GameLatestInfo, 31 | pub patches: Vec 32 | } 33 | 34 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 35 | pub struct GameLatestInfo { 36 | pub version: String, 37 | pub game_pkgs: Vec, 38 | pub audio_pkgs: Vec, 39 | pub res_list_url: String 40 | } 41 | 42 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 43 | pub struct Segment { 44 | pub url: String, 45 | pub md5: String, 46 | pub size: String, 47 | pub decompressed_size: String 48 | } 49 | 50 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 51 | pub struct AudioPackage { 52 | pub language: String, 53 | pub url: String, 54 | pub md5: String, 55 | pub size: String, 56 | pub decompressed_size: String 57 | } 58 | 59 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 60 | pub struct GamePatch { 61 | pub version: String, 62 | pub game_pkgs: Vec, 63 | pub audio_pkgs: Vec 64 | } 65 | 66 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 67 | pub struct GamePredownloadInfo { 68 | pub major: Option, 69 | pub patches: Vec 70 | } 71 | -------------------------------------------------------------------------------- /src/games/zzz/consts.rs: -------------------------------------------------------------------------------- 1 | use serde::{Serialize, Deserialize}; 2 | 3 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 4 | pub enum GameEdition { 5 | Global, 6 | China 7 | } 8 | 9 | impl Default for GameEdition { 10 | #[inline] 11 | fn default() -> Self { 12 | Self::Global 13 | } 14 | } 15 | 16 | impl GameEdition { 17 | #[inline] 18 | pub fn list() -> &'static [GameEdition] { 19 | &[Self::Global, Self::China] 20 | } 21 | 22 | #[inline] 23 | pub fn api_uri(&self) -> &str { 24 | match self { 25 | GameEdition::Global => concat!("https://sg-hyp-api.", "ho", "yo", "verse", ".com/hyp/hyp-connect/api/getGamePackages?launcher_id=VYTpXlbWo8"), 26 | GameEdition::China => concat!("https://hyp-api.", "mih", "oyo", ".com/hyp/hyp-connect/api/getGamePackages?launcher_id=jGHBHlcOq1") 27 | } 28 | } 29 | 30 | #[inline] 31 | pub fn data_folder(&self) -> &str { 32 | concat!("Zen", "lessZ", "oneZero_Data") 33 | } 34 | 35 | #[inline] 36 | pub fn telemetry_servers(&self) -> &[&str] { 37 | match self { 38 | GameEdition::Global => &[ 39 | concat!("log-upload-os.", "ho", "yo", "verse", ".com"), 40 | concat!("overseauspider.", "yu", "ans", "hen", ".com"), 41 | concat!("apm-log-upload-os.", "ho", "yo", "verse", ".com"), 42 | concat!("zzz-log-upload-os.", "ho", "yo", "verse", ".com") 43 | ], 44 | GameEdition::China => &[ 45 | concat!("log-upload.", "mih", "oyo", ".com"), 46 | concat!("uspider.", "yu", "ans", "hen", ".com"), 47 | concat!("apm-log-upload-os.", "ho", "yo", "verse", ".com"), 48 | concat!("zzz-log-upload-os.", "ho", "yo", "verse", ".com") 49 | ] 50 | } 51 | } 52 | 53 | pub fn from_system_lang() -> Self { 54 | let locale = std::env::var("LC_ALL") 55 | .unwrap_or_else(|_| std::env::var("LC_MESSAGES") 56 | .unwrap_or_else(|_| std::env::var("LANG") 57 | .unwrap_or(String::from("en_us")))) 58 | .to_ascii_lowercase(); 59 | 60 | if locale.starts_with("zh_cn") { 61 | Self::China 62 | } else { 63 | Self::Global 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /external/hpatchz/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | HDiffPatch 4 | Copyright (c) 2012-2021 housisong 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | 24 | ---------------------------------------------------------------------------------- 25 | 26 | libdivsufsort 27 | Copyright (c) 2003-2008 Yuta Mori All Rights Reserved. 28 | 29 | Permission is hereby granted, free of charge, to any person 30 | obtaining a copy of this software and associated documentation 31 | files (the "Software"), to deal in the Software without 32 | restriction, including without limitation the rights to use, 33 | copy, modify, merge, publish, distribute, sublicense, and/or sell 34 | copies of the Software, and to permit persons to whom the 35 | Software is furnished to do so, subject to the following 36 | conditions: 37 | 38 | The above copyright notice and this permission notice shall be 39 | included in all copies or substantial portions of the Software. 40 | 41 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 42 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 43 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 44 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 45 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 46 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 47 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 48 | OTHER DEALINGS IN THE SOFTWARE. 49 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "anime-game-core" 3 | version = "1.37.1" 4 | authors = ["Nikita Podvirnyi "] 5 | license = "GPL-3.0" 6 | readme = "README.md" 7 | repository = "https://github.com/an-anime-team/anime-game-core" 8 | edition = "2021" 9 | publish = false 10 | 11 | [dependencies] 12 | minreq = { version = "2.13", features = ["json-using-serde", "https-rustls-probe", "proxy"] } 13 | dns-lookup = "2.0" 14 | 15 | serde = { version = "1.0", features = ["derive"] } 16 | serde_json = "1.0" 17 | 18 | fs_extra = "1.3.0" 19 | cached = { version = "0.55", features = ["proc_macro"] } 20 | 21 | anyhow = { version = "1.0", features = ["backtrace"] } 22 | thiserror = "1.0" 23 | tracing = "0.1" 24 | lazy_static = "1.5.0" 25 | 26 | # Install feature 27 | sysinfo = { version = "0.35", optional = true, features = ["linux-netdevs"] } 28 | zip = { version = "3.0", optional = true } 29 | tar = { version = "0.4", optional = true } 30 | # sevenz-rust = { version = "0.2", optional = true } 31 | 32 | # Compression types for tar 33 | xz = { version = "0.1", optional = true } 34 | bzip2 = { version = "0.4", optional = true } 35 | flate2 = { version = "1.0", optional = true } # TODO: check https://crates.io/crates/zune-inflate 36 | 37 | # Linux patch feature 38 | md-5 = { version = "0.10", features = ["asm"], optional = true } 39 | 40 | # External feature 41 | kinda-virtual-fs = { version = "0.1.1", optional = true } 42 | 43 | # Needed for Sophon 44 | zstd = { version = "0.13", optional = true } 45 | reqwest = { version = "0.12", features = ["blocking", "h2", "http2", "json", "rustls-tls", "rustls-tls-webpki-roots", "socks"], default-features = false, optional = true } 46 | protobuf = { version = "3.7", optional = true } 47 | crossbeam-deque = { version = "0.8.6", optional = true } 48 | 49 | [features] 50 | genshin = ["sophon"] 51 | star-rail = ["sophon"] 52 | zzz = [] 53 | honkai = [] 54 | sophon = ["dep:md-5", "dep:zstd", "dep:reqwest", "dep:protobuf", "dep:protobuf-codegen", "dep:crossbeam-deque", "external"] 55 | 56 | install = [ 57 | # Only genshin need it so perhaps I should 58 | # somehow disable this feature for other games? 59 | "external", 60 | 61 | "dep:sysinfo", 62 | 63 | "dep:zip", 64 | "dep:tar", 65 | 66 | "dep:xz", 67 | "dep:bzip2", 68 | "dep:flate2", 69 | 70 | "dep:md-5" 71 | ] 72 | 73 | external = ["dep:kinda-virtual-fs"] 74 | 75 | patches = [] 76 | patch-jadeite = [] 77 | 78 | all = [ 79 | "install", 80 | "external", 81 | "patches" 82 | ] 83 | 84 | [build-dependencies] 85 | protobuf-codegen = { version = "3.7.2", optional = true } 86 | -------------------------------------------------------------------------------- /src/games/star_rail/voice_data/locale.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 4 | pub enum VoiceLocale { 5 | English, 6 | Japanese, 7 | Korean, 8 | Chinese 9 | } 10 | 11 | impl VoiceLocale { 12 | #[inline] 13 | pub fn list() -> &'static [VoiceLocale] { 14 | &[Self::English, Self::Japanese, Self::Korean, Self::Chinese] 15 | } 16 | 17 | /// Convert enum value to its name 18 | /// 19 | /// `VoiceLocale::English` -> `English` 20 | #[inline] 21 | pub fn to_name(&self) -> &str { 22 | match self { 23 | Self::English => "English", 24 | Self::Japanese => "Japanese", 25 | Self::Korean => "Korean", 26 | Self::Chinese => "Chinese" 27 | } 28 | } 29 | 30 | /// Convert enum value to its code 31 | /// 32 | /// `VoiceLocale::English` -> `en-us` 33 | #[inline] 34 | pub fn to_code(&self) -> &str { 35 | match self { 36 | Self::English => "en-us", 37 | Self::Japanese => "ja-jp", 38 | Self::Korean => "ko-kr", 39 | Self::Chinese => "zh-cn" 40 | } 41 | } 42 | 43 | /// Convert enum value to its folder name 44 | /// 45 | /// `VoiceLocale::English` -> `English(US)` 46 | #[inline] 47 | pub fn to_folder(&self) -> &str { 48 | match self { 49 | Self::English => "English", 50 | Self::Japanese => "Japanese", 51 | Self::Korean => "Korean", 52 | Self::Chinese => "Chinese(PRC)" 53 | } 54 | } 55 | 56 | /// Try to convert string to enum 57 | /// 58 | /// - `English` -> `VoiceLocale::English` 59 | /// - `English(US)` -> `VoiceLocale::English` 60 | /// - `en-us` -> `VoiceLocale::English` 61 | #[inline] 62 | pub fn from_str>(str: T) -> Option { 63 | match str.as_ref() { 64 | // Locales names 65 | "English" => Some(Self::English), 66 | "Japanese" => Some(Self::Japanese), 67 | "Korean" => Some(Self::Korean), 68 | "Chinese" => Some(Self::Chinese), 69 | 70 | // Lowercased variants 71 | "english" => Some(Self::English), 72 | "japanese" => Some(Self::Japanese), 73 | "korean" => Some(Self::Korean), 74 | "chinese" => Some(Self::Chinese), 75 | 76 | // Folders 77 | "Chinese(PRC)" => Some(Self::Chinese), 78 | 79 | // Codes 80 | "en-us" => Some(Self::English), 81 | "ja-jp" => Some(Self::Japanese), 82 | "ko-kr" => Some(Self::Korean), 83 | "zh-cn" => Some(Self::Chinese), 84 | 85 | _ => None 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/games/genshin/voice_data/locale.rs: -------------------------------------------------------------------------------- 1 | use serde::{Serialize, Deserialize}; 2 | 3 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 4 | pub enum VoiceLocale { 5 | English, 6 | Japanese, 7 | Korean, 8 | Chinese 9 | } 10 | 11 | impl VoiceLocale { 12 | #[inline] 13 | pub fn list() -> &'static [VoiceLocale] { 14 | &[Self::English, Self::Japanese, Self::Korean, Self::Chinese] 15 | } 16 | 17 | /// Convert enum value to its name 18 | /// 19 | /// `VoiceLocale::English` -> `English` 20 | #[inline] 21 | pub fn to_name(&self) -> &str { 22 | match self { 23 | Self::English => "English", 24 | Self::Japanese => "Japanese", 25 | Self::Korean => "Korean", 26 | Self::Chinese => "Chinese" 27 | } 28 | } 29 | 30 | /// Convert enum value to its code 31 | /// 32 | /// `VoiceLocale::English` -> `en-us` 33 | #[inline] 34 | pub fn to_code(&self) -> &str { 35 | match self { 36 | Self::English => "en-us", 37 | Self::Japanese => "ja-jp", 38 | Self::Korean => "ko-kr", 39 | Self::Chinese => "zh-cn" 40 | } 41 | } 42 | 43 | #[inline] 44 | /// Convert enum value to its folder name 45 | /// 46 | /// `VoiceLocale::English` -> `English(US)` 47 | pub fn to_folder(&self) -> &str { 48 | match self { 49 | Self::English => "English(US)", 50 | Self::Japanese => "Japanese", 51 | Self::Korean => "Korean", 52 | Self::Chinese => "Chinese" 53 | } 54 | } 55 | 56 | #[inline] 57 | /// Try to convert string to enum 58 | /// 59 | /// - `English` -> `VoiceLocale::English` 60 | /// - `English(US)` -> `VoiceLocale::English` 61 | /// - `en-us` -> `VoiceLocale::English` 62 | pub fn from_str>(str: T) -> Option { 63 | match str.as_ref() { 64 | // Locales names 65 | "English" => Some(Self::English), 66 | "Japanese" => Some(Self::Japanese), 67 | "Korean" => Some(Self::Korean), 68 | "Chinese" => Some(Self::Chinese), 69 | 70 | // Lowercased variants 71 | "english" => Some(Self::English), 72 | "japanese" => Some(Self::Japanese), 73 | "korean" => Some(Self::Korean), 74 | "chinese" => Some(Self::Chinese), 75 | 76 | // Folders 77 | "English(US)" => Some(Self::English), 78 | 79 | // Codes 80 | "en-us" => Some(Self::English), 81 | "ja-jp" => Some(Self::Japanese), 82 | "ko-kr" => Some(Self::Korean), 83 | "zh-cn" => Some(Self::Chinese), 84 | 85 | _ => None 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/games/genshin/consts.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use serde::{Serialize, Deserialize}; 4 | 5 | use super::voice_data::locale::VoiceLocale; 6 | 7 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 8 | pub enum GameEdition { 9 | Global, 10 | China 11 | } 12 | 13 | impl Default for GameEdition { 14 | #[inline] 15 | fn default() -> Self { 16 | Self::Global 17 | } 18 | } 19 | 20 | impl GameEdition { 21 | #[inline] 22 | pub fn list() -> &'static [GameEdition] { 23 | &[Self::Global, Self::China] 24 | } 25 | 26 | #[inline] 27 | pub fn api_uri(&self) -> &str { 28 | match self { 29 | GameEdition::Global => concat!("https://sg-hyp-api.", "ho", "yo", "verse", ".com/hyp/hyp-connect/api/getGamePackages?launcher_id=VYTpXlbWo8"), 30 | GameEdition::China => concat!("https://hyp-api.", "mih", "oyo", ".com/hyp/hyp-connect/api/getGamePackages?launcher_id=jGHBHlcOq1") 31 | } 32 | } 33 | 34 | #[inline] 35 | pub fn data_folder(&self) -> &str { 36 | match self { 37 | GameEdition::Global => concat!("Ge", "nsh", "inIm", "pact_Data"), 38 | GameEdition::China => concat!("Yu", "anS", "hen", "_Data") 39 | } 40 | } 41 | 42 | #[inline] 43 | pub fn telemetry_servers(&self) -> &[&str] { 44 | match self { 45 | GameEdition::Global => &[ 46 | concat!("log-upload-os.", "ho", "yo", "verse", ".com"), 47 | concat!("overseauspider.", "yu", "ans", "hen", ".com") 48 | ], 49 | GameEdition::China => &[ 50 | concat!("log-upload.", "mih", "oyo", ".com"), 51 | concat!("uspider.", "yu", "ans", "hen", ".com") 52 | ] 53 | } 54 | } 55 | 56 | pub fn from_system_lang() -> Self { 57 | let locale = std::env::var("LC_ALL") 58 | .unwrap_or_else(|_| std::env::var("LC_MESSAGES") 59 | .unwrap_or_else(|_| std::env::var("LANG") 60 | .unwrap_or(String::from("en_us")))) 61 | .to_ascii_lowercase(); 62 | 63 | if locale.starts_with("zh_cn") { 64 | Self::China 65 | } else { 66 | Self::Global 67 | } 68 | } 69 | 70 | #[inline] 71 | pub fn game_id(&self) -> &str { 72 | match self { 73 | Self::Global => "gopR6Cufr3", 74 | Self::China => "1Z8W5NHUQb" 75 | } 76 | } 77 | } 78 | 79 | #[inline] 80 | pub fn get_voice_packages_path>(game_path: T, game_edition: GameEdition) -> PathBuf { 81 | game_path.as_ref() 82 | .join(game_edition.data_folder()) 83 | .join("StreamingAssets/AudioAssets") 84 | } 85 | 86 | #[inline] 87 | pub fn get_voice_package_path>(game_path: T, game_edition: GameEdition, locale: VoiceLocale) -> PathBuf { 88 | get_voice_packages_path(game_path, game_edition).join(locale.to_folder()) 89 | } 90 | -------------------------------------------------------------------------------- /src/games/honkai/repairer.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use cached::proc_macro::cached; 4 | 5 | use super::api; 6 | use super::consts::GameEdition; 7 | 8 | use crate::repairer::IntegrityFile; 9 | 10 | fn try_get_some_integrity_files>(game_edition: GameEdition, file_name: T, timeout: Option) -> anyhow::Result> { 11 | let decompressed_path = api::request(game_edition)?.main.major.res_list_url; 12 | 13 | let pkg_version = minreq::get(format!("{decompressed_path}/{}", file_name.as_ref())) 14 | .with_timeout(timeout.unwrap_or(*crate::REQUESTS_TIMEOUT)) 15 | .send()?; 16 | 17 | let mut files = Vec::new(); 18 | 19 | for line in String::from_utf8_lossy(pkg_version.as_bytes()).lines() { 20 | if let Ok(value) = serde_json::from_str::(line) { 21 | files.push(IntegrityFile { 22 | path: PathBuf::from(value["remoteName"].as_str().unwrap()), 23 | md5: value["md5"].as_str().unwrap().to_string(), 24 | size: value["fileSize"].as_u64().unwrap(), 25 | base_url: decompressed_path.clone() 26 | }); 27 | } 28 | } 29 | 30 | Ok(files) 31 | } 32 | 33 | /// Try to list latest game files 34 | #[cached(result)] 35 | pub fn try_get_integrity_files(game_edition: GameEdition, timeout: Option) -> anyhow::Result> { 36 | try_get_some_integrity_files(game_edition, "pkg_version", timeout) 37 | } 38 | 39 | /// Try to get specific integrity file 40 | /// 41 | /// `relative_path` must be relative to the game's root folder, so 42 | /// if your file is e.g. `/path/to/[AnimeGame]/[AnimeGame_Data]/level0`, then root folder is `/path/to/[AnimeGame]`, 43 | /// and `relative_path` must be `[AnimeGame_Data]/level0` 44 | pub fn try_get_integrity_file>(game_edition: GameEdition, relative_path: T, timeout: Option) -> anyhow::Result> { 45 | let relative_path = relative_path.into(); 46 | 47 | if let Ok(files) = try_get_integrity_files(game_edition, timeout) { 48 | for file in files { 49 | if file.path == relative_path { 50 | return Ok(Some(file)); 51 | } 52 | } 53 | } 54 | 55 | Ok(None) 56 | } 57 | 58 | /// Try to get list of files that are not more used by the game and can be deleted 59 | /// 60 | /// ⚠️ Be aware that the game can create its own files after downloading, so "unused files" may not be really unused. 61 | /// It's strongly recommended to use this function only with manual control from user's side, in example to show him 62 | /// paths to these files and let him choose what to do with them 63 | pub fn try_get_unused_files>(game_edition: GameEdition, game_dir: T, timeout: Option) -> anyhow::Result> { 64 | let used_files = try_get_integrity_files(game_edition, timeout)? 65 | .into_iter() 66 | .map(|file| file.path) 67 | .collect::>(); 68 | 69 | let skip_names = [ 70 | String::from("webCaches"), 71 | String::from("SDKCaches"), 72 | String::from("ScreenShot"), 73 | ]; 74 | 75 | crate::repairer::try_get_unused_files(game_dir, used_files, skip_names) 76 | } 77 | -------------------------------------------------------------------------------- /src/games/zzz/repairer.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use cached::proc_macro::cached; 4 | 5 | use super::api; 6 | use super::consts::GameEdition; 7 | 8 | use crate::repairer::IntegrityFile; 9 | 10 | fn try_get_some_integrity_files>(game_edition: GameEdition, file_name: T, timeout: Option) -> anyhow::Result> { 11 | let decompressed_path = api::request(game_edition)?.main.major.res_list_url; 12 | 13 | let pkg_version = minreq::get(format!("{decompressed_path}/{}", file_name.as_ref())) 14 | .with_timeout(timeout.unwrap_or(*crate::REQUESTS_TIMEOUT)) 15 | .send()?; 16 | 17 | let mut files = Vec::new(); 18 | 19 | for line in String::from_utf8_lossy(pkg_version.as_bytes()).lines() { 20 | if let Ok(value) = serde_json::from_str::(line) { 21 | files.push(IntegrityFile { 22 | path: PathBuf::from(value["remoteName"].as_str().unwrap()), 23 | md5: value["md5"].as_str().unwrap().to_string(), 24 | size: value["fileSize"].as_u64().unwrap(), 25 | base_url: decompressed_path.clone() 26 | }); 27 | } 28 | } 29 | 30 | Ok(files) 31 | } 32 | 33 | /// Try to list latest game files 34 | #[cached(result)] 35 | pub fn try_get_integrity_files(game_edition: GameEdition, timeout: Option) -> anyhow::Result> { 36 | try_get_some_integrity_files(game_edition, "pkg_version", timeout) 37 | } 38 | 39 | /// Try to get specific integrity file 40 | /// 41 | /// `relative_path` must be relative to the game's root folder, so 42 | /// if your file is e.g. `/path/to/[AnimeGame]/[AnimeGame_Data]/level0`, then root folder is `/path/to/[AnimeGame]`, 43 | /// and `relative_path` must be `[AnimeGame_Data]/level0` 44 | pub fn try_get_integrity_file(game_edition: GameEdition, relative_path: impl AsRef, timeout: Option) -> anyhow::Result> { 45 | let relative_path = relative_path.as_ref(); 46 | 47 | if let Ok(files) = try_get_integrity_files(game_edition, timeout) { 48 | for file in files { 49 | if file.path == relative_path { 50 | return Ok(Some(file)); 51 | } 52 | } 53 | } 54 | 55 | Ok(None) 56 | } 57 | 58 | /// Try to get list of files that are not more used by the game and can be deleted 59 | /// 60 | /// ⚠️ Be aware that the game can create its own files after downloading, so "unused files" may not be really unused. 61 | /// It's strongly recommended to use this function only with manual control from user's side, in example to show him 62 | /// paths to these files and let him choose what to do with them 63 | pub fn try_get_unused_files(game_edition: GameEdition, game_dir: impl Into, timeout: Option) -> anyhow::Result> { 64 | let used_files = try_get_integrity_files(game_edition, timeout)? 65 | .into_iter() 66 | .map(|file| file.path) 67 | .collect::>(); 68 | 69 | let skip_names = [ 70 | String::from("webCaches"), 71 | String::from("SDKCaches"), 72 | String::from("GeneratedSoundBanks"), 73 | String::from("ScreenShot"), 74 | ]; 75 | 76 | crate::repairer::try_get_unused_files(game_dir, used_files, skip_names) 77 | } 78 | -------------------------------------------------------------------------------- /src/games/honkai/consts.rs: -------------------------------------------------------------------------------- 1 | use serde::{Serialize, Deserialize}; 2 | 3 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 4 | pub enum GameEdition { 5 | Global, 6 | Sea, 7 | China, 8 | Taiwan, 9 | Korea, 10 | Japan 11 | } 12 | 13 | impl Default for GameEdition { 14 | #[inline] 15 | fn default() -> Self { 16 | Self::Global 17 | } 18 | } 19 | 20 | impl GameEdition { 21 | #[inline] 22 | pub fn list() -> &'static [GameEdition] { 23 | &[ 24 | Self::Global, 25 | Self::Sea, 26 | Self::China, 27 | Self::Taiwan, 28 | Self::Korea, 29 | Self::Japan 30 | ] 31 | } 32 | 33 | #[inline] 34 | pub fn api_uri(&self) -> &str { 35 | match self { 36 | GameEdition::China => concat!("https://hyp-api.", "mih", "oyo", ".com/hyp/hyp-connect/api/getGamePackages?launcher_id=jGHBHlcOq1"), 37 | _ => concat!("https://sg-hyp-api.", "ho", "yo", "verse", ".com/hyp/hyp-connect/api/getGamePackages?launcher_id=VYTpXlbWo8") 38 | } 39 | } 40 | 41 | pub fn api_game_id(&self) -> &str { 42 | // 5TIVvvcwtM glb_official 43 | // g0mMIvshDb jp_official 44 | // uxB4MC7nzC kr_official 45 | // bxPTXSET5t overseas_official 46 | // wkE5P5WsIf asia_official 47 | match self { 48 | Self::Global => "5TIVvvcwtM", 49 | Self::Sea => "bxPTXSET5t", // Nut sure 50 | Self::China => "osvnlOc0S8", 51 | Self::Taiwan => "wkE5P5WsIf", // Nut sure 52 | Self::Korea => "uxB4MC7nzC", 53 | Self::Japan => "g0mMIvshDb" 54 | } 55 | } 56 | 57 | #[inline] 58 | pub fn data_folder(&self) -> &str { 59 | "BH3_Data" 60 | } 61 | 62 | #[inline] 63 | pub fn telemetry_servers(&self) -> &[&str] { 64 | match self { 65 | Self::China => &[ 66 | concat!("log-upload.m", "iho", "yo.com"), 67 | concat!("public-data-api.m", "iho", "yo.com"), 68 | concat!("dump.gam", "esafe.q", "q.com") 69 | ], 70 | 71 | _ => &[ 72 | concat!("log-upload-os.ho", "yov", "erse.com"), 73 | concat!("sg-public-data-api.ho", "yov", "erse.com"), 74 | concat!("dump.gam", "esafe.q", "q.com") 75 | ] 76 | } 77 | } 78 | 79 | pub fn from_system_lang() -> Self { 80 | let locale = std::env::var("LC_ALL") 81 | .unwrap_or_else(|_| std::env::var("LC_MESSAGES") 82 | .unwrap_or_else(|_| std::env::var("LANG") 83 | .unwrap_or(String::from("en_us")))) 84 | .to_ascii_lowercase(); 85 | 86 | if locale.starts_with("zh_cn") { 87 | Self::China 88 | } 89 | 90 | else if locale.starts_with("zh_tw") { 91 | Self::Taiwan 92 | } 93 | 94 | else if locale.starts_with("ja") { 95 | Self::Japan 96 | } 97 | 98 | else if locale.starts_with("ko") { 99 | Self::Korea 100 | } 101 | 102 | else { 103 | Self::Global 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/games/star_rail/consts.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use super::voice_data::locale::VoiceLocale; 6 | 7 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 8 | pub enum GameEdition { 9 | Global, 10 | China 11 | } 12 | 13 | impl Default for GameEdition { 14 | #[inline] 15 | fn default() -> Self { 16 | Self::Global 17 | } 18 | } 19 | 20 | impl GameEdition { 21 | #[inline] 22 | pub fn list() -> &'static [GameEdition] { 23 | &[Self::Global, Self::China] 24 | } 25 | 26 | #[inline] 27 | pub fn api_uri(&self) -> &str { 28 | match self { 29 | GameEdition::Global => concat!( 30 | "https://sg-hyp-api.", 31 | "ho", 32 | "yo", 33 | "verse", 34 | ".com/hyp/hyp-connect/api/getGamePackages?launcher_id=VYTpXlbWo8" 35 | ), 36 | GameEdition::China => concat!( 37 | "https://hyp-api.", 38 | "mih", 39 | "oyo", 40 | ".com/hyp/hyp-connect/api/getGamePackages?launcher_id=jGHBHlcOq1" 41 | ) 42 | } 43 | } 44 | 45 | #[inline] 46 | pub fn data_folder(&self) -> &str { 47 | // Same data folder name for every region 48 | concat!("Sta", "rRai", "l_Data") 49 | } 50 | 51 | /// API IDs used by Sophon 52 | #[inline] 53 | pub fn api_game_id(&self) -> &str { 54 | match self { 55 | Self::Global => "4ziysqXOQ8", 56 | Self::China => "64kMb5iAWu" 57 | } 58 | } 59 | 60 | #[inline] 61 | pub fn telemetry_servers(&self) -> &[&str] { 62 | match self { 63 | GameEdition::Global => &[ 64 | concat!("log-upload-os.ho", "yo", "ver", "se.com"), 65 | concat!("sg-public-data-api.ho", "yo", "ver", "se.com"), 66 | concat!("hkrpg-log-upload-os.ho", "yo", "ver", "se.com") 67 | ], 68 | GameEdition::China => &[ 69 | concat!("log-upload.m", "iho", "yo.com"), 70 | concat!("public-data-api.m", "iho", "yo.com") 71 | ] 72 | } 73 | } 74 | 75 | pub fn from_system_lang() -> Self { 76 | let locale = std::env::var("LC_ALL") 77 | .unwrap_or_else(|_| { 78 | std::env::var("LC_MESSAGES") 79 | .unwrap_or_else(|_| std::env::var("LANG").unwrap_or(String::from("en_us"))) 80 | }) 81 | .to_ascii_lowercase(); 82 | 83 | if locale.starts_with("zh_cn") { 84 | Self::China 85 | } 86 | else { 87 | Self::Global 88 | } 89 | } 90 | } 91 | 92 | #[inline] 93 | pub fn get_voice_packages_path>(game_path: T, game_edition: GameEdition) -> PathBuf { 94 | game_path 95 | .as_ref() 96 | .join(game_edition.data_folder()) 97 | .join("Persistent/Audio/AudioPackage/Windows") 98 | } 99 | 100 | #[inline] 101 | pub fn get_voice_package_path>( 102 | game_path: T, 103 | game_edition: GameEdition, 104 | locale: VoiceLocale 105 | ) -> PathBuf { 106 | get_voice_packages_path(game_path, game_edition).join(locale.to_folder()) 107 | } 108 | -------------------------------------------------------------------------------- /src/patches/jadeite/mod.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use crate::version::Version; 4 | 5 | #[cfg(feature = "install")] 6 | use crate::installer::installer::*; 7 | 8 | pub mod metadata; 9 | 10 | pub const REPO_URI: &str = "https://codeberg.org/mkrsym1/jadeite"; 11 | pub const REPO_API_URI: &str = "https://codeberg.org/api/v1/repos/mkrsym1/jadeite/releases/latest"; 12 | 13 | pub const METADATA_URIS: &[&str] = &[ 14 | // Primary 15 | "https://codeberg.org/mkrsym1/jadeite/raw/branch/master/metadata.json", 16 | 17 | // Mirrors 18 | "https://notabug.org/mkrsym1/jadeite-mirror/raw/master/metadata.json" 19 | ]; 20 | 21 | #[inline] 22 | pub fn is_installed(folder: impl AsRef) -> bool { 23 | folder.as_ref().join(".version").exists() 24 | } 25 | 26 | pub fn get_version(folder: impl AsRef) -> anyhow::Result { 27 | let bytes = std::fs::read(folder.as_ref().join(".version"))?; 28 | 29 | Ok(Version::new(bytes[0], bytes[1], bytes[2])) 30 | } 31 | 32 | #[cfg(feature = "install")] 33 | #[cached::proc_macro::cached(result)] 34 | pub fn get_latest() -> anyhow::Result { 35 | let response = minreq::get(REPO_API_URI).send()?.json::()?; 36 | 37 | let version = response.get("tag_name") 38 | .and_then(|tag| tag.as_str()) 39 | .map(|tag| tag.strip_prefix('v').unwrap_or(tag)) 40 | .and_then(Version::from_str); 41 | 42 | let Some(version) = version else { 43 | anyhow::bail!("Failed to request latest patch version"); 44 | }; 45 | 46 | let download_uri = response.get("assets") 47 | .and_then(|assets| assets.as_array()) 48 | .and_then(|assets| assets.first()) 49 | .and_then(|asset| asset.get("browser_download_url")) 50 | .and_then(|url| url.as_str()); 51 | 52 | let Some(download_uri) = download_uri else { 53 | anyhow::bail!("Failed to request patch downloading URI"); 54 | }; 55 | 56 | Ok(JadeiteLatest { 57 | version, 58 | download_uri: download_uri.to_string() 59 | }) 60 | } 61 | 62 | #[cfg(feature = "install")] 63 | #[cached::proc_macro::cached(result)] 64 | pub fn get_metadata() -> anyhow::Result { 65 | for uri in METADATA_URIS { 66 | let Ok(resp) = minreq::get(*uri).with_timeout(20).send() else { 67 | tracing::warn!("Could not reach '{uri}'. Attempting to use next fallback"); 68 | continue; 69 | }; 70 | 71 | let Ok(json) = resp.json::() else { 72 | tracing::warn!("Got invalid response from '{uri}'. Attempting to use next fallback"); 73 | continue; 74 | }; 75 | 76 | return Ok(metadata::JadeiteMetadata::from(&json)); 77 | } 78 | 79 | anyhow::bail!("Could not get metadata from any of the mirrors"); 80 | } 81 | 82 | #[derive(Debug, Clone, PartialEq, Eq)] 83 | pub struct JadeiteLatest { 84 | pub version: Version, 85 | pub download_uri: String 86 | } 87 | 88 | impl JadeiteLatest { 89 | #[cfg(feature = "install")] 90 | pub fn install(&self, folder: impl AsRef, updater: impl Fn(Update) + Clone + Send + 'static) -> anyhow::Result<()> { 91 | Installer::new(&self.download_uri)? 92 | .with_filename("jadeite.zip") 93 | .with_free_space_check(false) 94 | .install(folder.as_ref(), updater); 95 | 96 | std::fs::write(folder.as_ref().join(".version"), self.version.version)?; 97 | 98 | Ok(()) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/sophon/api_schemas/game_branches.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::version::Version; 4 | 5 | #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] 6 | pub struct GameBranches { 7 | pub game_branches: Vec 8 | } 9 | 10 | impl GameBranches { 11 | /// Get the latest version for the requested id. 12 | pub fn latest_version_by_id(&self, id: impl AsRef) -> Option { 13 | let id = id.as_ref(); 14 | 15 | self.game_branches 16 | .iter() 17 | .filter(|branch_info| branch_info.game.id == id && branch_info.main.is_some()) 18 | .max_by_key(|branch_info| { 19 | &branch_info 20 | .main 21 | .as_ref() 22 | .expect("`None` cases were filtered out") 23 | .tag 24 | }) 25 | .and_then(|branch_info| { 26 | Version::from_str( 27 | &branch_info 28 | .main 29 | .as_ref() 30 | .expect("`None` cases were filtered out") 31 | .tag 32 | ) 33 | }) 34 | } 35 | 36 | /// Get `GameBranchInfo` of a specified id and game version. 37 | pub fn get_game_by_id(&self, id: impl AsRef, version: Version) -> Option<&GameBranchInfo> { 38 | let id = id.as_ref(); 39 | let version = version.to_string(); 40 | 41 | self.game_branches.iter().find(|branch_info| { 42 | branch_info.game.id == id 43 | && branch_info 44 | .main 45 | .as_ref() 46 | .map(|main_info| main_info.tag == version) 47 | .unwrap_or(false) 48 | }) 49 | } 50 | 51 | /// Get latest version of specified game by id. 52 | pub fn get_game_latest_by_id(&self, id: impl AsRef) -> Option<&GameBranchInfo> { 53 | let id = id.as_ref(); 54 | 55 | self.game_branches 56 | .iter() 57 | .filter(|branch_info| branch_info.game.id == id && branch_info.main.is_some()) 58 | .max_by_key(|branch_info| { 59 | &branch_info 60 | .main 61 | .as_ref() 62 | .expect("`None` cases were filtered out") 63 | .tag 64 | }) 65 | } 66 | } 67 | 68 | #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] 69 | pub struct GameBranchInfo { 70 | pub game: Game, 71 | pub main: Option, 72 | pub pre_download: Option 73 | } 74 | 75 | impl GameBranchInfo { 76 | pub fn version(&self) -> Option { 77 | Version::from_str(&self.main.as_ref()?.tag) 78 | } 79 | } 80 | 81 | #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] 82 | pub struct Game { 83 | pub id: String, 84 | pub biz: String 85 | } 86 | 87 | #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] 88 | pub struct PackageInfo { 89 | pub package_id: String, 90 | pub branch: String, 91 | pub password: String, 92 | pub tag: String, 93 | pub diff_tags: Vec, 94 | pub categories: Vec 95 | } 96 | 97 | impl PackageInfo { 98 | pub fn version(&self) -> Option { 99 | Version::from_str(&self.tag) 100 | } 101 | } 102 | 103 | #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] 104 | pub struct PackageCategory { 105 | pub category_id: String, 106 | pub matching_field: String 107 | } 108 | -------------------------------------------------------------------------------- /src/traits/version_diff.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use crate::version::Version; 4 | 5 | pub trait VersionDiffExt { 6 | /// Type that will be used as downloading / unpacking / installation error 7 | type Error; 8 | 9 | /// Type that will be used in the `install`-like methods 10 | /// as the current installation progress update 11 | type Update; 12 | 13 | /// Type that will represent the game edition this `VersionDiff` belongs to 14 | type Edition; 15 | 16 | /// Get selected game edition 17 | fn edition(&self) -> Self::Edition; 18 | 19 | /// Return currently installed version 20 | /// 21 | /// Return `None` if it's not installed 22 | fn current(&self) -> Option; 23 | 24 | /// Return latest available version 25 | fn latest(&self) -> Version; 26 | 27 | /// Return size of data in bytes needed to be downloaded 28 | /// 29 | /// Return `None` if this information is not available for current diff type 30 | fn downloaded_size(&self) -> Option; 31 | 32 | /// Return size of unpacked data in bytes 33 | /// 34 | /// Return `None` if this information is not available for current diff type 35 | fn unpacked_size(&self) -> Option; 36 | 37 | /// Return the path this difference should be installed to 38 | /// 39 | /// Return `None` if the path is not available for current diff type 40 | fn installation_path(&self) -> Option<&Path>; 41 | 42 | /// Get the downloading URI if it's available 43 | /// 44 | /// Return `None` if the URI is not provided 45 | fn downloading_uri(&self) -> Option; 46 | 47 | /// Get the name of the file from downloading URI 48 | /// 49 | /// - `https://example.com/example.zip` -> `example.zip` 50 | /// - `https://example.com/` -> `index.html` 51 | /// - `https://example.com` -> `index.html` 52 | /// 53 | /// Return `None` if the URI is not provided 54 | fn file_name(&self) -> Option { 55 | self.downloading_uri().map(|uri| { 56 | let Some(index) = uri.replace('\\', "/").rfind('/') else { 57 | return String::from("index.html"); 58 | }; 59 | 60 | let file = &uri[index + 1..]; 61 | 62 | file.is_empty() 63 | .then(|| String::from("index.html")) 64 | .unwrap_or_else(|| String::from(file)) 65 | }) 66 | } 67 | 68 | // TODO: think about async 69 | 70 | #[cfg(feature = "install")] 71 | /// Try to download the diff into the specified folder, 72 | /// using `Self::file_name` result as a name of the file to be saved as 73 | fn download_to(&mut self, folder: impl AsRef, progress: impl Fn(u64, u64) + Send + 'static) -> Result<(), Self::Error> { 74 | let filename = self.file_name() 75 | .expect("Failed to resolve downloading file name"); 76 | 77 | self.download_as(folder.as_ref().join(filename), progress) 78 | } 79 | 80 | #[cfg(feature = "install")] 81 | /// Try to download the diff into the specified path, assuming that it contains the file name 82 | /// this difference should be saved as 83 | fn download_as(&mut self, path: impl AsRef, progress: impl Fn(u64, u64) + Send + 'static) -> Result<(), Self::Error>; 84 | 85 | #[cfg(feature = "install")] 86 | /// Try to install the difference into the path returned by `Self::installation_path` method 87 | /// 88 | /// This method can fail if installation path is not provided 89 | fn install(&self, thread_count: usize, updater: impl Fn(Self::Update) + Clone + Send + 'static) -> Result<(), Self::Error> { 90 | let path = self.installation_path() 91 | .expect("Difference installation path is not provided"); 92 | 93 | self.install_to(path, thread_count, updater) 94 | } 95 | 96 | #[cfg(feature = "install")] 97 | /// Try to install the difference by given location 98 | fn install_to(&self, path: impl AsRef, thread_count: usize, updater: impl Fn(Self::Update) + Clone + Send + 'static) -> Result<(), Self::Error>; 99 | } 100 | -------------------------------------------------------------------------------- /src/repairer.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use std::collections::HashSet; 3 | 4 | use md5::{Md5, Digest}; 5 | 6 | #[cfg(feature = "sophon")] 7 | use crate::sophon::protos::SophonManifest::SophonManifestAssetProperty; 8 | 9 | use super::installer::downloader::{Downloader, DownloadingError}; 10 | 11 | // {"remoteName": "UnityPlayer.dll", "md5": "8c8c3d845b957e4cb84c662bed44d072", "fileSize": 33466104} 12 | #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 13 | pub struct IntegrityFile { 14 | pub path: PathBuf, 15 | pub md5: String, 16 | pub size: u64, 17 | pub base_url: String 18 | } 19 | 20 | #[cfg(feature = "sophon")] 21 | impl From<&SophonManifestAssetProperty> for IntegrityFile { 22 | fn from(value: &SophonManifestAssetProperty) -> Self { 23 | Self { 24 | path: PathBuf::from(&value.AssetName), 25 | md5: value.AssetHashMd5.clone(), 26 | size: value.AssetSize, 27 | base_url: "".to_owned() // empty because there's no single url for downloading 28 | } 29 | } 30 | } 31 | 32 | impl IntegrityFile { 33 | /// Compare files' sizes and (if needed) hashes 34 | #[tracing::instrument(level = "trace", ret)] 35 | pub fn verify + std::fmt::Debug>(&self, game_path: T) -> bool { 36 | tracing::trace!("Verifying file"); 37 | 38 | let file_path: PathBuf = game_path.into().join(&self.path); 39 | 40 | // Get file metadata, or return false if it's unavailable 41 | let Ok(metadata) = file_path.metadata() else { 42 | return false; 43 | }; 44 | 45 | // Immediately return false if sizes aren't equal 46 | if metadata.len() != self.size { 47 | false 48 | } 49 | 50 | // Otherwise compare file hashes 51 | else { 52 | tracing::trace!("Comparing hashes"); 53 | 54 | match std::fs::read(&file_path) { 55 | Ok(hash) => format!("{:x}", Md5::digest(hash)).eq_ignore_ascii_case(&self.md5), 56 | Err(_) => false 57 | } 58 | } 59 | } 60 | 61 | /// Compare files' sizes and do not compare files' hashes. Works lots faster than `verify` 62 | #[tracing::instrument(level = "trace", ret)] 63 | pub fn fast_verify + std::fmt::Debug>(&self, game_path: T) -> bool { 64 | tracing::trace!("Verifying file"); 65 | 66 | std::fs::metadata(game_path.into().join(&self.path)) 67 | .map(|metadata| metadata.len() == self.size) 68 | .unwrap_or(false) 69 | } 70 | 71 | /// Replace remote file with the latest one 72 | /// 73 | /// This method doesn't compare them, so you should do it manually 74 | #[tracing::instrument(level = "debug", ret)] 75 | pub fn repair + std::fmt::Debug>(&self, game_path: T) -> Result<(), DownloadingError> { 76 | tracing::debug!("Repairing file"); 77 | 78 | let mut downloader = Downloader::new(format!("{}/{}", self.base_url, self.path.to_string_lossy()))?; 79 | 80 | // Obviously re-download file entirely 81 | downloader.continue_downloading = false; 82 | 83 | downloader.download(game_path.into().join(&self.path), |_, _| {}) 84 | } 85 | } 86 | 87 | /// Calculate difference between actual files stored in `game_dir`, and files listed in `used_files` 88 | /// 89 | /// Returned difference will contain files that are not used by the game and should (or just can) be deleted 90 | /// 91 | /// `used_files` can be both absolute and relative to `game_dir` 92 | pub fn try_get_unused_files(game_dir: T, used_files: F, skip_names: U) -> anyhow::Result> 93 | where 94 | T: Into, 95 | F: IntoIterator, 96 | U: IntoIterator 97 | { 98 | fn list_files(path: PathBuf, skip_names: &[String]) -> std::io::Result> { 99 | let mut files = Vec::new(); 100 | 101 | for entry in std::fs::read_dir(&path)? { 102 | let entry = entry?; 103 | let entry_path = path.join(entry.file_name()); 104 | 105 | let mut should_skip = false; 106 | 107 | for skip in skip_names { 108 | if entry.file_name().to_string_lossy().contains(skip) { 109 | should_skip = true; 110 | 111 | break; 112 | } 113 | } 114 | 115 | if !should_skip { 116 | if entry.file_type()?.is_dir() { 117 | files.append(&mut list_files(entry_path, skip_names)?); 118 | } 119 | 120 | else { 121 | files.push(entry_path); 122 | } 123 | } 124 | } 125 | 126 | Ok(files) 127 | } 128 | 129 | let used_files = used_files.into_iter() 130 | .collect::>(); 131 | 132 | let skip_names = skip_names.into_iter() 133 | .collect::>(); 134 | 135 | let game_dir = game_dir.into(); 136 | 137 | Ok(list_files(game_dir.clone(), skip_names.as_slice())? 138 | .into_iter() 139 | .filter(move |path| { 140 | // File persist in used_files => unused 141 | if used_files.contains(path) { 142 | return false; 143 | } 144 | 145 | // File persist in used_files => unused 146 | if let Ok(path) = path.strip_prefix(&game_dir) { 147 | if used_files.contains(path) { 148 | return false; 149 | } 150 | } 151 | 152 | // File not persist in used_files => not unused 153 | true 154 | }) 155 | .collect()) 156 | } 157 | -------------------------------------------------------------------------------- /src/version.rs: -------------------------------------------------------------------------------- 1 | use serde::{Serialize, Deserialize}; 2 | 3 | use std::fmt::{Debug, Display, Formatter}; 4 | use std::cmp::Ordering; 5 | 6 | #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] 7 | pub struct Version { 8 | pub version: [u8; 3] 9 | } 10 | 11 | impl Version { 12 | #[inline] 13 | pub fn new(a: u8, b: u8, c: u8) -> Self { 14 | Self { 15 | version: [a, b, c] 16 | } 17 | } 18 | 19 | #[allow(clippy::should_implement_trait)] 20 | /// Get `Version` from the string 21 | /// 22 | /// ``` 23 | /// use anime_game_core::prelude::Version; 24 | /// 25 | /// let version = Version::from_str("1.10.2").expect("Failed to parse version string"); 26 | /// ``` 27 | pub fn from_str>(str: T) -> Option { 28 | let parts = str.as_ref().split('.').collect::>(); 29 | 30 | if parts.len() != 3 { 31 | return None; 32 | } 33 | 34 | if let (Ok(a), Ok(b), Ok(c)) = (parts[0].parse(), parts[1].parse(), parts[2].parse()) { 35 | return Some(Version::new(a, b, c)); 36 | } 37 | 38 | None 39 | } 40 | 41 | /// Converts `Version` struct to plain format (e.g. "123") 42 | /// 43 | /// ``` 44 | /// use anime_game_core::prelude::Version; 45 | /// 46 | /// assert_eq!(Version::new(1, 2, 3).to_plain_string(), "123"); 47 | /// ``` 48 | pub fn to_plain_string(&self) -> String { 49 | format!("{}{}{}", self.version[0], self.version[1], self.version[2]) 50 | } 51 | } 52 | 53 | impl Debug for Version { 54 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 55 | write!(f, "{}.{}.{}", self.version[0], self.version[1], self.version[2]) 56 | } 57 | } 58 | 59 | impl Display for Version { 60 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 61 | write!(f, "{}.{}.{}", self.version[0], self.version[1], self.version[2]) 62 | } 63 | } 64 | 65 | // Equality with strings 66 | 67 | impl PartialEq for Version { 68 | #[inline] 69 | fn eq(&self, other: &String) -> bool { 70 | &self.to_string() == other 71 | } 72 | } 73 | 74 | impl PartialEq for String { 75 | #[inline] 76 | fn eq(&self, other: &Version) -> bool { 77 | self == &other.to_string() 78 | } 79 | } 80 | 81 | impl PartialEq<&str> for Version { 82 | #[inline] 83 | fn eq(&self, other: &&str) -> bool { 84 | &self.to_string() == other 85 | } 86 | } 87 | 88 | impl PartialEq for &str { 89 | #[inline] 90 | fn eq(&self, other: &Version) -> bool { 91 | self == &other.to_string() 92 | } 93 | } 94 | 95 | // Comparison with strings 96 | 97 | impl PartialOrd for Version { 98 | fn partial_cmp(&self, other: &String) -> Option { 99 | self.to_string().partial_cmp(other) 100 | } 101 | } 102 | 103 | impl PartialOrd for String { 104 | fn partial_cmp(&self, other: &Version) -> Option { 105 | self.partial_cmp(&other.to_string()) 106 | } 107 | } 108 | 109 | impl PartialOrd<&str> for Version { 110 | fn partial_cmp(&self, other: &&str) -> Option { 111 | self.to_string() 112 | .as_str() 113 | .partial_cmp(*other) 114 | } 115 | } 116 | 117 | impl PartialOrd for &str { 118 | fn partial_cmp(&self, other: &Version) -> Option { 119 | self.partial_cmp(&other.to_string().as_str()) 120 | } 121 | } 122 | 123 | #[cfg(test)] 124 | mod tests { 125 | use super::*; 126 | 127 | #[test] 128 | fn test_version_new() { 129 | let version = Version::new(0, 0, 0); 130 | 131 | assert_eq!(version, "0.0.0"); 132 | assert_eq!(version, "0.0.0".to_string()); 133 | assert_eq!(Some(version), Version::from_str("0.0.0")); 134 | assert_eq!(version.to_plain_string(), "000".to_string()); 135 | } 136 | 137 | #[test] 138 | fn test_version_from_str() { 139 | let version = Version::from_str("0.0.0"); 140 | 141 | assert!(version.is_some()); 142 | 143 | let version = version.unwrap(); 144 | 145 | assert_eq!(version, "0.0.0"); 146 | assert_eq!(version, "0.0.0".to_string()); 147 | assert_eq!(version, Version::new(0, 0, 0)); 148 | assert_eq!(version.to_plain_string(), "000".to_string()); 149 | } 150 | 151 | #[test] 152 | fn test_version_long() { 153 | let version = Version::from_str("100.0.255"); 154 | 155 | assert!(version.is_some()); 156 | 157 | let version = version.unwrap(); 158 | 159 | assert_eq!(version, "100.0.255"); 160 | assert_eq!(version, "100.0.255".to_string()); 161 | assert_eq!(version, Version::new(100, 0, 255)); 162 | assert_eq!(version.to_plain_string(), "1000255".to_string()); 163 | } 164 | 165 | #[test] 166 | fn test_incorrect_versions() { 167 | assert_eq!(Version::from_str(""), None); 168 | assert_eq!(Version::from_str("..0"), None); 169 | assert_eq!(Version::from_str("0.0."), None); 170 | } 171 | 172 | #[test] 173 | #[allow(clippy::cmp_owned)] 174 | fn test_version_comparison() { 175 | assert!(Version::new(1, 0, 1) > "1.0.0"); 176 | assert!(Version::new(1, 0, 0) < "1.0.1"); 177 | 178 | assert!("1.0.0" < Version::new(1, 0, 1)); 179 | assert!("1.0.1" > Version::new(1, 0, 0)); 180 | 181 | assert!(Version::new(1, 0, 1) > String::from("1.0.0")); 182 | assert!(Version::new(1, 0, 0) < String::from("1.0.1")); 183 | 184 | assert!(String::from("1.0.0") < Version::new(1, 0, 1)); 185 | assert!(String::from("1.0.1") > Version::new(1, 0, 0)); 186 | 187 | assert!(Version::new(1, 0, 0) == "1.0.0"); 188 | assert!("1.0.0" == Version::new(1, 0, 0)); 189 | 190 | assert!(Version::new(1, 0, 0) == String::from("1.0.0")); 191 | assert!(String::from("1.0.0") == Version::new(1, 0, 0)); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/games/genshin/repairer.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use cached::proc_macro::cached; 4 | 5 | use crate::repairer::IntegrityFile; 6 | use crate::sophon; 7 | use super::consts::GameEdition; 8 | use super::voice_data::locale::VoiceLocale; 9 | 10 | // TODO: utilize the `timeout` variable! 11 | 12 | fn try_get_some_integrity_files( 13 | game_edition: GameEdition, 14 | matching_field: &str, 15 | _timeout: Option 16 | ) -> anyhow::Result> { 17 | let client = reqwest::blocking::Client::new(); 18 | 19 | let game_branches = sophon::get_game_branches_info(&client, game_edition.into())?; 20 | 21 | let latest_version = game_branches 22 | .latest_version_by_id(game_edition.game_id()) 23 | .ok_or_else(|| { 24 | anyhow::anyhow!("failed to find the latest game version") 25 | .context(format!("game id: {}", game_edition.game_id())) 26 | })?; 27 | 28 | let game_branch_info = game_branches 29 | .get_game_by_id(game_edition.game_id(), latest_version) 30 | .ok_or_else(|| { 31 | anyhow::anyhow!("failed to get the game version information") 32 | .context(format!("game id: {}", game_edition.game_id())) 33 | .context(format!("game version: {latest_version}")) 34 | })?; 35 | 36 | let downloads = sophon::installer::get_game_download_sophon_info( 37 | &client, 38 | game_branch_info 39 | .main 40 | .as_ref() 41 | .expect("The `None` case would have been caught earlier"), 42 | game_edition.into() 43 | )?; 44 | 45 | let download_info = downloads 46 | .manifests 47 | .iter() 48 | .find(|download_info| download_info.matching_field == matching_field) 49 | .ok_or_else(|| { 50 | anyhow::anyhow!("failed to find game download info") 51 | .context("matching field: {matching_field}") 52 | })?; 53 | 54 | let download_manifest = sophon::installer::get_download_manifest(&client, download_info)?; 55 | 56 | let files = download_manifest 57 | .Assets 58 | .iter() 59 | .map(IntegrityFile::from) 60 | .collect::>(); 61 | 62 | Ok(files) 63 | } 64 | 65 | /// Try to list latest game files. 66 | #[cached(result)] 67 | pub fn try_get_integrity_files( 68 | game_edition: GameEdition, 69 | timeout: Option 70 | ) -> anyhow::Result> { 71 | try_get_some_integrity_files(game_edition, "game", timeout) 72 | } 73 | 74 | /// Try to list latest voice package files. 75 | #[cached(result)] 76 | pub fn try_get_voice_integrity_files( 77 | game_edition: GameEdition, 78 | locale: VoiceLocale, 79 | timeout: Option 80 | ) -> anyhow::Result> { 81 | try_get_some_integrity_files(game_edition, locale.to_code(), timeout) 82 | } 83 | 84 | /// Try to get specific integrity file. 85 | /// 86 | /// `relative_path` must be relative to the game's root folder, so if your file 87 | /// is e.g. `/path/to/[AnimeGame]/[AnimeGame_Data]/level0`, then root folder is 88 | /// `/path/to/[AnimeGame]`, and `relative_path` must be 89 | /// `[AnimeGame_Data]/level0`. 90 | pub fn try_get_integrity_file( 91 | game_edition: GameEdition, 92 | relative_path: impl AsRef, 93 | timeout: Option 94 | ) -> anyhow::Result> { 95 | let relative_path = relative_path.as_ref(); 96 | 97 | if let Ok(files) = try_get_integrity_files(game_edition, timeout) { 98 | for file in files { 99 | if file.path == relative_path { 100 | return Ok(Some(file)); 101 | } 102 | } 103 | } 104 | 105 | for lang in VoiceLocale::list() { 106 | if let Ok(files) = try_get_voice_integrity_files(game_edition, *lang, timeout) { 107 | for file in files { 108 | if file.path == relative_path { 109 | return Ok(Some(file)); 110 | } 111 | } 112 | } 113 | } 114 | 115 | Ok(None) 116 | } 117 | 118 | /// Try to get list of files that are not more used by the game and can be 119 | /// deleted. 120 | /// 121 | /// ⚠️ Be aware that the game can create its own files after downloading, so 122 | /// "unused files" may not be really unused. It's strongly recommended to use 123 | /// this function only with manual control from user's side, in example to show 124 | /// him paths to these files and let him choose what to do with them. 125 | pub fn try_get_unused_files( 126 | game_edition: GameEdition, 127 | game_dir: impl Into, 128 | timeout: Option 129 | ) -> anyhow::Result> { 130 | let used_files = try_get_integrity_files(game_edition, timeout)? 131 | .into_iter() 132 | .map(|file| file.path) 133 | .collect::>(); 134 | 135 | let skip_names = [ 136 | String::from("webCaches"), 137 | String::from("SDKCaches"), 138 | String::from("GeneratedSoundBanks"), 139 | String::from("ScreenShot") 140 | ]; 141 | 142 | crate::repairer::try_get_unused_files(game_dir, used_files, skip_names) 143 | } 144 | 145 | /// Try to get list of files that are not more used by the game and can be 146 | /// deleted. 147 | /// 148 | /// ⚠️ Be aware that the game can create its own files after downloading, so 149 | /// "unused files" may not be really unused. It's strongly recommended to use 150 | /// this function only with manual control from user's side, in example to show 151 | /// him paths to these files and let him choose what to do with them. 152 | pub fn try_get_unused_voice_files( 153 | game_edition: GameEdition, 154 | game_dir: impl Into, 155 | locale: VoiceLocale, 156 | timeout: Option 157 | ) -> anyhow::Result> { 158 | let used_files = try_get_voice_integrity_files(game_edition, locale, timeout)? 159 | .into_iter() 160 | .map(|file| file.path) 161 | .collect::>(); 162 | 163 | crate::repairer::try_get_unused_files(game_dir, used_files, []) 164 | } 165 | -------------------------------------------------------------------------------- /src/games/star_rail/repairer.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use cached::proc_macro::cached; 4 | 5 | use crate::repairer::IntegrityFile; 6 | use crate::sophon; 7 | use super::consts::GameEdition; 8 | use super::voice_data::locale::VoiceLocale; 9 | 10 | // TODO: utilize the `timeout` variable! 11 | 12 | fn try_get_some_integrity_files( 13 | game_edition: GameEdition, 14 | matching_field: &str, 15 | _timeout: Option 16 | ) -> anyhow::Result> { 17 | let client = reqwest::blocking::Client::new(); 18 | 19 | let game_branches = sophon::get_game_branches_info(&client, game_edition.into())?; 20 | 21 | let latest_version = game_branches 22 | .latest_version_by_id(game_edition.api_game_id()) 23 | .ok_or_else(|| { 24 | anyhow::anyhow!("failed to find the latest game version") 25 | .context(format!("game id: {}", game_edition.api_game_id())) 26 | })?; 27 | 28 | let game_branch_info = game_branches 29 | .get_game_by_id(game_edition.api_game_id(), latest_version) 30 | .ok_or_else(|| { 31 | anyhow::anyhow!("failed to get the game version information") 32 | .context(format!("game id: {}", game_edition.api_game_id())) 33 | .context(format!("game version: {latest_version}")) 34 | })?; 35 | 36 | let downloads = sophon::installer::get_game_download_sophon_info( 37 | &client, 38 | game_branch_info 39 | .main 40 | .as_ref() 41 | .expect("The `None` case would have been caught earlier"), 42 | game_edition.into() 43 | )?; 44 | 45 | let download_info = downloads 46 | .manifests 47 | .iter() 48 | .find(|download_info| download_info.matching_field == matching_field) 49 | .ok_or_else(|| { 50 | anyhow::anyhow!("failed to find game download info") 51 | .context("matching field: {matching_field}") 52 | })?; 53 | 54 | let download_manifest = sophon::installer::get_download_manifest(&client, download_info)?; 55 | 56 | let files = download_manifest 57 | .Assets 58 | .iter() 59 | .map(IntegrityFile::from) 60 | .collect::>(); 61 | 62 | Ok(files) 63 | } 64 | 65 | /// Try to list latest game files. 66 | #[cached(result)] 67 | pub fn try_get_integrity_files( 68 | game_edition: GameEdition, 69 | timeout: Option 70 | ) -> anyhow::Result> { 71 | try_get_some_integrity_files(game_edition, "game", timeout) 72 | } 73 | 74 | /// Try to list latest voice package files. 75 | #[cached(result)] 76 | pub fn try_get_voice_integrity_files( 77 | game_edition: GameEdition, 78 | locale: VoiceLocale, 79 | timeout: Option 80 | ) -> anyhow::Result> { 81 | try_get_some_integrity_files(game_edition, locale.to_code(), timeout) 82 | } 83 | 84 | /// Try to get specific integrity file. 85 | /// 86 | /// `relative_path` must be relative to the game's root folder, so if your file 87 | /// is e.g. `/path/to/[AnimeGame]/[AnimeGame_Data]/level0`, then root folder is 88 | /// `/path/to/[AnimeGame]`, and `relative_path` must be 89 | /// `[AnimeGame_Data]/level0`. 90 | pub fn try_get_integrity_file( 91 | game_edition: GameEdition, 92 | relative_path: impl AsRef, 93 | timeout: Option 94 | ) -> anyhow::Result> { 95 | let relative_path = relative_path.as_ref(); 96 | 97 | if let Ok(files) = try_get_integrity_files(game_edition, timeout) { 98 | for file in files { 99 | if file.path == relative_path { 100 | return Ok(Some(file)); 101 | } 102 | } 103 | } 104 | 105 | for lang in VoiceLocale::list() { 106 | if let Ok(files) = try_get_voice_integrity_files(game_edition, *lang, timeout) { 107 | for file in files { 108 | if file.path == relative_path { 109 | return Ok(Some(file)); 110 | } 111 | } 112 | } 113 | } 114 | 115 | Ok(None) 116 | } 117 | 118 | /// Try to get list of files that are not more used by the game and can be 119 | /// deleted. 120 | /// 121 | /// ⚠️ Be aware that the game can create its own files after downloading, so 122 | /// "unused files" may not be really unused. It's strongly recommended to use 123 | /// this function only with manual control from user's side, in example to show 124 | /// him paths to these files and let him choose what to do with them. 125 | pub fn try_get_unused_files( 126 | game_edition: GameEdition, 127 | game_dir: impl Into, 128 | timeout: Option 129 | ) -> anyhow::Result> { 130 | let used_files = try_get_integrity_files(game_edition, timeout)? 131 | .into_iter() 132 | .map(|file| file.path) 133 | .collect::>(); 134 | 135 | let skip_names = [ 136 | String::from("webCaches"), 137 | String::from("SDKCaches"), 138 | String::from("GeneratedSoundBanks"), 139 | String::from("ScreenShot") 140 | ]; 141 | 142 | crate::repairer::try_get_unused_files(game_dir, used_files, skip_names) 143 | } 144 | 145 | /// Try to get list of files that are not more used by the game and can be 146 | /// deleted. 147 | /// 148 | /// ⚠️ Be aware that the game can create its own files after downloading, so 149 | /// "unused files" may not be really unused. It's strongly recommended to use 150 | /// this function only with manual control from user's side, in example to show 151 | /// him paths to these files and let him choose what to do with them. 152 | pub fn try_get_unused_voice_files( 153 | game_edition: GameEdition, 154 | game_dir: impl Into, 155 | locale: VoiceLocale, 156 | timeout: Option 157 | ) -> anyhow::Result> { 158 | let used_files = try_get_voice_integrity_files(game_edition, locale, timeout)? 159 | .into_iter() 160 | .map(|file| file.path) 161 | .collect::>(); 162 | 163 | crate::repairer::try_get_unused_files(game_dir, used_files, []) 164 | } 165 | -------------------------------------------------------------------------------- /src/traits/git_sync.rs: -------------------------------------------------------------------------------- 1 | use std::process::{Command, Stdio}; 2 | use std::path::Path; 3 | 4 | // TODO: rewrite to use git2 library 5 | 6 | pub trait RemoteGitSyncExt { 7 | /// Path to folder with local git repository 8 | fn folder(&self) -> &Path; 9 | 10 | /// Verify that the folder is synced 11 | /// 12 | /// Returns given remote with which current folder is synced, 13 | /// and `Ok(None)` if it's not synced 14 | /// 15 | /// To check only specific remote use `is_sync_with` 16 | fn is_sync(&self, remotes: T) -> anyhow::Result> 17 | where 18 | T: IntoIterator, 19 | F: AsRef 20 | { 21 | tracing::trace!("Checking local repository sync state: {:?}", self.folder()); 22 | 23 | if !self.folder().exists() { 24 | tracing::warn!("Given local repository folder doesn't exist"); 25 | 26 | return Ok(None); 27 | } 28 | 29 | for remote in remotes { 30 | if self.is_sync_with(remote.as_ref())? { 31 | return Ok(Some(remote.as_ref().to_string())); 32 | } 33 | } 34 | 35 | Ok(None) 36 | } 37 | 38 | /// Verify that the folder is synced 39 | fn is_sync_with(&self, remote: impl AsRef) -> anyhow::Result { 40 | tracing::trace!("Checking local repository sync state. Folder: {:?}. Remote: {}", self.folder(), remote.as_ref()); 41 | 42 | if !self.folder().exists() { 43 | tracing::warn!("Given local repository folder doesn't exist"); 44 | 45 | return Ok(false); 46 | } 47 | 48 | // FIXME: git ref-parse doesn't check removed files 49 | 50 | let head = Command::new("git") 51 | .arg("rev-parse") 52 | .arg("HEAD") 53 | .current_dir(self.folder()) 54 | .stdout(Stdio::piped()) 55 | .stderr(Stdio::null()) 56 | .output()?; 57 | 58 | Command::new("git") 59 | .arg("remote") 60 | .arg("set-url") 61 | .arg("origin") 62 | .arg(remote.as_ref()) 63 | .current_dir(self.folder()) 64 | .stdout(Stdio::null()) 65 | .stderr(Stdio::null()) 66 | .output()?; 67 | 68 | Command::new("git") 69 | .arg("fetch") 70 | .arg("origin") 71 | .current_dir(self.folder()) 72 | .stdout(Stdio::null()) 73 | .stderr(Stdio::null()) 74 | .output()?; 75 | 76 | let remote = Command::new("git") 77 | .arg("rev-parse") 78 | .arg("origin/HEAD") 79 | .current_dir(self.folder()) 80 | .stdout(Stdio::piped()) 81 | .stderr(Stdio::null()) 82 | .output()?; 83 | 84 | Ok(head.stdout == remote.stdout) 85 | } 86 | 87 | /// Fetch patch updates from the git repository 88 | fn sync(&self, remote: impl AsRef) -> anyhow::Result> { 89 | tracing::debug!("Syncing local patch repository with remote"); 90 | 91 | if self.folder().exists() { 92 | // git rev-parse HEAD 93 | 94 | let head_commit = String::from_utf8(Command::new("git") 95 | .arg("rev-parse") 96 | .arg("HEAD") 97 | .current_dir(self.folder()) 98 | .stdout(Stdio::piped()) 99 | .stderr(Stdio::null()) 100 | .output()? 101 | .stdout)?.trim_end().to_string(); 102 | 103 | // git remote set-url origin 104 | 105 | Command::new("git") 106 | .arg("remote") 107 | .arg("set-url") 108 | .arg("origin") 109 | .arg(remote.as_ref()) 110 | .current_dir(self.folder()) 111 | .stdout(Stdio::null()) 112 | .stderr(Stdio::null()) 113 | .output()?; 114 | 115 | // git fetch origin 116 | 117 | Command::new("git") 118 | .arg("fetch") 119 | .arg("origin") 120 | .current_dir(self.folder()) 121 | .stdout(Stdio::null()) 122 | .stderr(Stdio::null()) 123 | .output()?; 124 | 125 | // git reset --hard origin/HEAD 126 | 127 | Command::new("git") 128 | .arg("reset") 129 | .arg("--hard") 130 | .arg("origin/HEAD") 131 | .current_dir(self.folder()) 132 | .stdout(Stdio::null()) 133 | .stderr(Stdio::null()) 134 | .output()?; 135 | 136 | // git --no-pager log --oneline ..HEAD 137 | 138 | let changes = String::from_utf8(Command::new("git") 139 | .arg("--no-pager") 140 | .arg("log") 141 | .arg("--oneline") 142 | .arg(format!("{head_commit}..HEAD")) 143 | .current_dir(self.folder()) 144 | .stdout(Stdio::piped()) 145 | .stderr(Stdio::null()) 146 | .output()? 147 | .stdout)?; 148 | 149 | Ok(changes.trim_end().lines().map(|line| line[8..].to_string()).collect()) 150 | } 151 | 152 | else { 153 | // git clone 154 | 155 | Command::new("git") 156 | .arg("clone") 157 | .arg(remote.as_ref()) 158 | .arg(self.folder()) 159 | .stdout(Stdio::null()) 160 | .stderr(Stdio::null()) 161 | .output()?; 162 | 163 | // TODO: maybe it's too long? 164 | // git --no-pager log --oneline 165 | 166 | let changes = String::from_utf8(Command::new("git") 167 | .arg("--no-pager") 168 | .arg("log") 169 | .arg("--oneline") 170 | .current_dir(self.folder()) 171 | .stdout(Stdio::piped()) 172 | .stderr(Stdio::null()) 173 | .output()? 174 | .stdout)?; 175 | 176 | Ok(changes.trim_end().lines().map(|line| line[8..].to_string()).collect()) 177 | } 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/games/honkai/game.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::io::Read; 3 | use std::path::{Path, PathBuf}; 4 | 5 | use crate::version::Version; 6 | use crate::traits::game::GameExt; 7 | 8 | use super::api; 9 | use super::consts::*; 10 | use super::version_diff::*; 11 | 12 | #[derive(Debug, Clone, PartialEq, Eq)] 13 | pub struct Game { 14 | path: PathBuf, 15 | edition: GameEdition 16 | } 17 | 18 | impl GameExt for Game { 19 | type Edition = GameEdition; 20 | 21 | #[inline] 22 | fn new(path: impl Into, edition: GameEdition) -> Self { 23 | Self { 24 | path: path.into(), 25 | edition 26 | } 27 | } 28 | 29 | #[inline] 30 | fn path(&self) -> &Path { 31 | self.path.as_path() 32 | } 33 | 34 | #[inline] 35 | fn edition(&self) -> Self::Edition { 36 | self.edition 37 | } 38 | 39 | #[tracing::instrument(level = "trace", ret)] 40 | /// Try to get latest game version 41 | fn get_latest_version(edition: Self::Edition) -> anyhow::Result { 42 | tracing::trace!("Trying to get latest game version"); 43 | 44 | // I assume game's API can't return incorrect version format right? Right? 45 | Ok(Version::from_str(api::request(edition)?.main.major.version).unwrap()) 46 | } 47 | 48 | #[tracing::instrument(level = "debug", ret)] 49 | fn get_version(&self) -> anyhow::Result { 50 | tracing::debug!("Trying to get installed game version"); 51 | 52 | fn bytes_to_num(bytes: &Vec) -> u8 { 53 | bytes.iter().fold(0u8, |acc, &x| acc * 10 + (x - '0' as u8)) 54 | } 55 | 56 | let stored_version = std::fs::read(self.path.join(".version")) 57 | .map(|version| Version::new(version[0], version[1], version[2])) 58 | .ok(); 59 | 60 | let file = File::open(self.path.join(self.edition.data_folder()).join("globalgamemanagers"))?; 61 | 62 | let mut version: [Vec; 3] = [vec![], vec![], vec![]]; 63 | let mut version_ptr: usize = 0; 64 | let mut correct = true; 65 | 66 | for byte in file.bytes().skip(4000).take(10000) { 67 | if let Ok(byte) = byte { 68 | match byte { 69 | 0 => { 70 | if correct && version_ptr == 2 && version[0].len() > 0 && version[1].len() > 0 && version[2].len() > 0 { 71 | let found_version = Version::new( 72 | bytes_to_num(&version[0]), 73 | bytes_to_num(&version[1]), 74 | bytes_to_num(&version[2]) 75 | ); 76 | 77 | // Prioritize version stored in the .version file 78 | // because it's parsed from the API directly 79 | if let Some(stored_version) = stored_version { 80 | if stored_version > found_version { 81 | return Ok(stored_version); 82 | } 83 | } 84 | 85 | return Ok(found_version); 86 | } 87 | 88 | version = [vec![], vec![], vec![]]; 89 | version_ptr = 0; 90 | correct = true; 91 | } 92 | 93 | 46 => { 94 | version_ptr += 1; 95 | 96 | if version_ptr > 2 { 97 | correct = false; 98 | } 99 | } 100 | 101 | _ => { 102 | if correct && b"0123456789".contains(&byte) { 103 | version[version_ptr].push(byte); 104 | } 105 | 106 | else { 107 | correct = false; 108 | } 109 | } 110 | } 111 | } 112 | } 113 | 114 | if let Some(stored_version) = stored_version { 115 | return Ok(stored_version); 116 | } 117 | 118 | tracing::error!("Version's bytes sequence wasn't found"); 119 | 120 | anyhow::bail!("Version's bytes sequence wasn't found"); 121 | } 122 | } 123 | 124 | impl Game { 125 | #[tracing::instrument(level = "debug", ret)] 126 | pub fn try_get_diff(&self) -> anyhow::Result { 127 | tracing::debug!("Trying to find version diff for the game"); 128 | 129 | let response = api::request(self.edition)?; 130 | 131 | if self.is_installed() { 132 | let current = self.get_version()?; 133 | 134 | if current >= response.main.major.version { 135 | tracing::debug!("Game version is latest"); 136 | 137 | Ok(VersionDiff::Latest(current)) 138 | } 139 | 140 | else { 141 | tracing::debug!("Game is outdated: {} -> {}", current, response.main.major.version); 142 | 143 | Ok(VersionDiff::Diff { 144 | current, 145 | latest: Version::from_str(response.main.major.version).unwrap(), 146 | 147 | // TODO: can be a hard issue in future 148 | url: response.main.major.game_pkgs[0].url.clone(), 149 | 150 | downloaded_size: response.main.major.game_pkgs.iter() 151 | .flat_map(|pkg| pkg.size.parse::()) 152 | .sum(), 153 | 154 | unpacked_size: response.main.major.game_pkgs.iter() 155 | .flat_map(|pkg| pkg.decompressed_size.parse::()) 156 | .sum(), 157 | 158 | installation_path: Some(self.path.clone()), 159 | version_file_path: None, 160 | temp_folder: None 161 | }) 162 | } 163 | } 164 | 165 | else { 166 | tracing::debug!("Game is not installed"); 167 | 168 | Ok(VersionDiff::NotInstalled { 169 | latest: Version::from_str(&response.main.major.version).unwrap(), 170 | 171 | // TODO: can be a hard issue in future 172 | url: response.main.major.game_pkgs[0].url.clone(), 173 | 174 | downloaded_size: response.main.major.game_pkgs.iter() 175 | .flat_map(|pkg| pkg.size.parse::()) 176 | .sum(), 177 | 178 | unpacked_size: response.main.major.game_pkgs.iter() 179 | .flat_map(|pkg| pkg.decompressed_size.parse::()) 180 | .sum(), 181 | 182 | installation_path: Some(self.path.clone()), 183 | version_file_path: None, 184 | temp_folder: None 185 | }) 186 | } 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/patches/jadeite/metadata.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::Ordering; 2 | 3 | use serde_json::Value as JsonValue; 4 | 5 | use crate::version::Version; 6 | 7 | #[cfg(feature = "star-rail")] 8 | use crate::games::star_rail::consts::GameEdition as StarRailGameEdition; 9 | 10 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] 11 | pub struct JadeiteMetadata { 12 | pub jadeite: JadeitePatchMetadata, 13 | pub games: JadeiteGamesMetadata 14 | } 15 | 16 | impl From<&JsonValue> for JadeiteMetadata { 17 | fn from(value: &JsonValue) -> Self { 18 | Self { 19 | jadeite: value.get("jadeite") 20 | .map(JadeitePatchMetadata::from) 21 | .unwrap_or_default(), 22 | 23 | games: value.get("games") 24 | .map(JadeiteGamesMetadata::from) 25 | .unwrap_or_default() 26 | } 27 | } 28 | } 29 | 30 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] 31 | pub struct JadeitePatchMetadata { 32 | pub version: Version 33 | } 34 | 35 | impl Default for JadeitePatchMetadata { 36 | #[inline] 37 | fn default() -> Self { 38 | Self { 39 | version: Version::new(0, 0, 0) 40 | } 41 | } 42 | } 43 | 44 | impl From<&JsonValue> for JadeitePatchMetadata { 45 | fn from(value: &JsonValue) -> Self { 46 | let default = Self::default(); 47 | 48 | Self { 49 | version: value.get("version") 50 | .and_then(|version| version.as_str()) 51 | .and_then(Version::from_str) 52 | .unwrap_or(default.version) 53 | } 54 | } 55 | } 56 | 57 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] 58 | pub struct JadeiteGamesMetadata { 59 | pub hi3rd: JadeiteHi3rdMetadata, 60 | pub hsr: JadeiteHsrMetadata, 61 | pub wuwa: JadeiteWuwaMetadata 62 | } 63 | 64 | impl From<&JsonValue> for JadeiteGamesMetadata { 65 | fn from(value: &JsonValue) -> Self { 66 | Self { 67 | hi3rd: value.get("hi3rd") 68 | .map(JadeiteHi3rdMetadata::from) 69 | .unwrap_or_default(), 70 | 71 | hsr: value.get("hsr") 72 | .map(JadeiteHsrMetadata::from) 73 | .unwrap_or_default(), 74 | 75 | wuwa: value.get("wuwa") 76 | .map(JadeiteWuwaMetadata::from) 77 | .unwrap_or_default() 78 | } 79 | } 80 | } 81 | 82 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] 83 | pub struct JadeiteHi3rdMetadata { 84 | pub global: JadeitePatchStatus, 85 | pub sea: JadeitePatchStatus, 86 | pub china: JadeitePatchStatus, 87 | pub taiwan: JadeitePatchStatus, 88 | pub korea: JadeitePatchStatus, 89 | pub japan: JadeitePatchStatus 90 | } 91 | 92 | impl From<&JsonValue> for JadeiteHi3rdMetadata { 93 | fn from(value: &JsonValue) -> Self { 94 | Self { 95 | global: value.get("global") 96 | .map(JadeitePatchStatus::from) 97 | .unwrap_or_default(), 98 | 99 | sea: value.get("sea") 100 | .map(JadeitePatchStatus::from) 101 | .unwrap_or_default(), 102 | 103 | china: value.get("china") 104 | .map(JadeitePatchStatus::from) 105 | .unwrap_or_default(), 106 | 107 | taiwan: value.get("taiwan") 108 | .map(JadeitePatchStatus::from) 109 | .unwrap_or_default(), 110 | 111 | korea: value.get("korea") 112 | .map(JadeitePatchStatus::from) 113 | .unwrap_or_default(), 114 | 115 | japan: value.get("japan") 116 | .map(JadeitePatchStatus::from) 117 | .unwrap_or_default() 118 | } 119 | } 120 | } 121 | 122 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] 123 | pub struct JadeiteHsrMetadata { 124 | pub global: JadeitePatchStatus, 125 | pub china: JadeitePatchStatus 126 | } 127 | 128 | impl From<&JsonValue> for JadeiteHsrMetadata { 129 | fn from(value: &JsonValue) -> Self { 130 | Self { 131 | global: value.get("global") 132 | .map(JadeitePatchStatus::from) 133 | .unwrap_or_default(), 134 | 135 | china: value.get("china") 136 | .map(JadeitePatchStatus::from) 137 | .unwrap_or_default() 138 | } 139 | } 140 | } 141 | 142 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] 143 | pub struct JadeiteWuwaMetadata { 144 | pub global: JadeitePatchStatus, 145 | pub china: JadeitePatchStatus 146 | } 147 | 148 | impl From<&JsonValue> for JadeiteWuwaMetadata { 149 | fn from(value: &JsonValue) -> Self { 150 | Self { 151 | global: value.get("global") 152 | .map(JadeitePatchStatus::from) 153 | .unwrap_or_default(), 154 | 155 | china: value.get("china") 156 | .map(JadeitePatchStatus::from) 157 | .unwrap_or_default() 158 | } 159 | } 160 | } 161 | 162 | #[cfg(feature = "star-rail")] 163 | impl JadeiteHsrMetadata { 164 | pub fn for_edition(&self, edition: StarRailGameEdition) -> JadeitePatchStatus { 165 | match edition { 166 | StarRailGameEdition::Global => self.global, 167 | StarRailGameEdition::China => self.china 168 | } 169 | } 170 | } 171 | 172 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] 173 | pub struct JadeitePatchStatus { 174 | pub status: JadeitePatchStatusVariant, 175 | pub version: Version 176 | } 177 | 178 | impl Default for JadeitePatchStatus { 179 | #[inline] 180 | fn default() -> Self { 181 | Self { 182 | status: JadeitePatchStatusVariant::default(), 183 | version: Version::new(0, 0, 0) 184 | } 185 | } 186 | } 187 | 188 | impl From<&JsonValue> for JadeitePatchStatus { 189 | fn from(value: &JsonValue) -> Self { 190 | let default = Self::default(); 191 | 192 | Self { 193 | status: value.get("status") 194 | .and_then(|status| status.as_str()) 195 | .map(JadeitePatchStatusVariant::from) 196 | .unwrap_or(default.status), 197 | 198 | version: value.get("version") 199 | .and_then(|version| version.as_str()) 200 | .and_then(Version::from_str) 201 | .unwrap_or(default.version) 202 | } 203 | } 204 | } 205 | 206 | impl JadeitePatchStatus { 207 | /// Get the patch status for the provided game version 208 | pub fn get_status(&self, game_version: Version) -> JadeitePatchStatusVariant { 209 | match self.version.cmp(&game_version) { 210 | // Metadata game version is lower than the one we gave here, 211 | // so some predictions are needed 212 | Ordering::Less => match self.status { 213 | // Even if the patch was verified - return that it's not verified, at least because the game was updated 214 | JadeitePatchStatusVariant::Verified => JadeitePatchStatusVariant::Unverified, 215 | 216 | // If the patch wasn't verified - keep it unverified 217 | JadeitePatchStatusVariant::Unverified => JadeitePatchStatusVariant::Unverified, 218 | 219 | // If the patch was marked as broken - keep it broken 220 | JadeitePatchStatusVariant::Broken => JadeitePatchStatusVariant::Broken, 221 | 222 | // If the patch was marked as unsafe - keep it unsafe 223 | JadeitePatchStatusVariant::Unsafe => JadeitePatchStatusVariant::Unsafe, 224 | 225 | // If the patch was concerning - then it's still concerning 226 | JadeitePatchStatusVariant::Concerning => JadeitePatchStatusVariant::Concerning 227 | }, 228 | 229 | // Both metadata and given game versions are equal 230 | // so we just return current patch status 231 | Ordering::Equal => self.status, 232 | 233 | // Given game version is outdated, so we're not sure about its status 234 | // Here I suppose that it's just unverified and let user to decide what to do 235 | Ordering::Greater => JadeitePatchStatusVariant::Unverified 236 | } 237 | } 238 | } 239 | 240 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] 241 | pub enum JadeitePatchStatusVariant { 242 | /// Patch is verified and works fine 243 | /// 244 | /// Value: `verified` 245 | Verified, 246 | 247 | /// Patch is not verified to be working 248 | /// 249 | /// Value: `unverified` 250 | Unverified, 251 | 252 | /// Patch doesn't work 253 | /// 254 | /// Value: `broken` 255 | Broken, 256 | 257 | /// Patch is working but unsafe for use 258 | /// 259 | /// You can't run the game with this status 260 | /// 261 | /// Value: `unsafe` 262 | Unsafe, 263 | 264 | /// Patch is working but we have some concerns about it 265 | /// 266 | /// You still can run the game with this status 267 | /// 268 | /// Value: `concerning` 269 | Concerning 270 | } 271 | 272 | impl Default for JadeitePatchStatusVariant { 273 | #[inline] 274 | fn default() -> Self { 275 | Self::Unverified 276 | } 277 | } 278 | 279 | impl From<&str> for JadeitePatchStatusVariant { 280 | fn from(value: &str) -> Self { 281 | match value { 282 | "verified" => Self::Verified, 283 | "unverified" => Self::Unverified, 284 | "broken" => Self::Broken, 285 | "unsafe" => Self::Unsafe, 286 | "concerning" => Self::Concerning, 287 | 288 | // Not really a good practice but it's unlikely to happen anyway 289 | _ => Self::default() 290 | } 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /src/installer/downloader.rs: -------------------------------------------------------------------------------- 1 | use std::io::{Write, Seek}; 2 | use std::path::PathBuf; 3 | use std::fs::File; 4 | 5 | use serde::{Serialize, Deserialize}; 6 | use thiserror::Error; 7 | 8 | use super::free_space; 9 | use crate::prettify_bytes::prettify_bytes; 10 | 11 | /// Default amount of bytes `Downloader::download` method will send to `downloader` function 12 | pub const DEFAULT_CHUNK_SIZE: usize = 128 * 1024; // 128 KB 13 | 14 | #[derive(Error, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 15 | pub enum DownloadingError { 16 | /// Specified downloading path is not available in system 17 | /// 18 | /// `(path)` 19 | #[error("Path is not mounted: {0:?}")] 20 | PathNotMounted(PathBuf), 21 | 22 | /// No free space available under specified path 23 | /// 24 | /// `(path, required, available)` 25 | #[error("No free space available for specified path: {0:?} (requires {}, available {})", prettify_bytes(*.1), prettify_bytes(*.2))] 26 | NoSpaceAvailable(PathBuf, u64, u64), 27 | 28 | /// Failed to create or open output file 29 | /// 30 | /// `(path, error message)` 31 | #[error("Failed to create output file {0:?}: {1}")] 32 | OutputFileError(PathBuf, String), 33 | 34 | /// Couldn't get metadata of existing output file 35 | /// 36 | /// This metadata supposed to be used to continue downloading of the file 37 | /// 38 | /// `(path, error message)` 39 | #[error("Failed to read metadata of the output file {0:?}: {1}")] 40 | OutputFileMetadataError(PathBuf, String), 41 | 42 | /// minreq error 43 | #[error("minreq error: {0}")] 44 | Minreq(String) 45 | } 46 | 47 | impl From for DownloadingError { 48 | fn from(error: minreq::Error) -> Self { 49 | DownloadingError::Minreq(error.to_string()) 50 | } 51 | } 52 | 53 | #[derive(Debug)] 54 | pub struct Downloader { 55 | uri: String, 56 | length: Option, 57 | 58 | /// Amount of bytes `Downloader::download` method will send to `downloader` function 59 | pub chunk_size: usize, 60 | 61 | /// If true, then `Downloader` will try to continue downloading of the file. 62 | /// Otherwise it will re-download the file entirely 63 | pub continue_downloading: bool, 64 | 65 | /// Perform free space verifications before downloading file 66 | pub check_free_space: bool 67 | } 68 | 69 | impl Downloader { 70 | pub fn new>(uri: T) -> Result { 71 | let uri = uri.as_ref(); 72 | 73 | let header = minreq::head(uri) 74 | .with_timeout(*crate::REQUESTS_TIMEOUT) 75 | .send()?; 76 | 77 | let length = header.headers.get("content-length") 78 | .map(|len| len.parse().expect("Requested site's content-length is not a number")); 79 | 80 | Ok(Self { 81 | uri: uri.to_owned(), 82 | length, 83 | 84 | chunk_size: DEFAULT_CHUNK_SIZE, 85 | continue_downloading: true, 86 | check_free_space: true 87 | }) 88 | } 89 | 90 | #[inline] 91 | /// Specify downloading chunk size 92 | pub fn with_chunk_size(mut self, chunk_size: usize) -> Self { 93 | self.chunk_size = chunk_size; 94 | 95 | self 96 | } 97 | 98 | #[inline] 99 | /// Specify whether installer should continue downloading of the file 100 | pub fn with_continue_downloading(mut self, continue_downloading: bool) -> Self { 101 | self.continue_downloading = continue_downloading; 102 | 103 | self 104 | } 105 | 106 | #[inline] 107 | /// Specify whether installer should check free space availability 108 | pub fn with_free_space_check(mut self, check_free_space: bool) -> Self { 109 | self.check_free_space = check_free_space; 110 | 111 | self 112 | } 113 | 114 | #[inline] 115 | /// Get content length 116 | pub fn length(&self) -> Option { 117 | self.length 118 | } 119 | 120 | /// Get name of downloading file from uri 121 | /// 122 | /// - `https://example.com/example.zip` -> `example.zip` 123 | /// - `https://example.com` -> `index.html` 124 | pub fn get_filename(&self) -> &str { 125 | if let Some(pos) = self.uri.replace('\\', "/").rfind(|c| c == '/') { 126 | if !self.uri[pos + 1..].is_empty() { 127 | return &self.uri[pos + 1..]; 128 | } 129 | } 130 | 131 | "index.html" 132 | } 133 | 134 | pub fn download(&mut self, path: impl Into, progress: impl Fn(u64, u64) + Send + 'static) -> Result<(), DownloadingError> { 135 | let path = path.into(); 136 | 137 | let mut downloaded = 0; 138 | 139 | // Open or create output file 140 | let file = if path.exists() && self.continue_downloading { 141 | tracing::debug!("Opening output file"); 142 | 143 | let mut file = std::fs::OpenOptions::new().read(true).write(true).open(&path); 144 | 145 | // Continue downloading if the file exists and can be opened 146 | if let Ok(file) = &mut file { 147 | match file.metadata() { 148 | Ok(metadata) => { 149 | // Stop the process if the file is already downloaded 150 | if let Some(length) = self.length() { 151 | match metadata.len().cmp(&length) { 152 | std::cmp::Ordering::Less => (), 153 | 154 | std::cmp::Ordering::Equal => return Ok(()), 155 | 156 | // Trim downloaded file to prevent future issues (e.g. with extracting the archive) 157 | std::cmp::Ordering::Greater => { 158 | if let Err(err) = file.set_len(length) { 159 | return Err(DownloadingError::OutputFileError(path, err.to_string())); 160 | } 161 | 162 | return Ok(()); 163 | } 164 | } 165 | } 166 | 167 | if let Err(err) = file.seek(std::io::SeekFrom::Start(metadata.len())) { 168 | return Err(DownloadingError::OutputFileError(path, err.to_string())); 169 | } 170 | 171 | downloaded = metadata.len() as usize; 172 | } 173 | 174 | Err(err) => return Err(DownloadingError::OutputFileMetadataError(path, err.to_string())) 175 | } 176 | } 177 | 178 | file 179 | } else { 180 | tracing::debug!("Creating output file"); 181 | 182 | let base_folder = path.parent().unwrap(); 183 | 184 | if !base_folder.exists() { 185 | if let Err(err) = std::fs::create_dir_all(base_folder) { 186 | return Err(DownloadingError::OutputFileError(path, err.to_string())); 187 | } 188 | } 189 | 190 | File::create(&path) 191 | }; 192 | 193 | // Check available free space 194 | if self.check_free_space { 195 | tracing::debug!("Checking free space availability"); 196 | 197 | match free_space::available(&path) { 198 | Some(space) => { 199 | if let Some(required) = self.length() { 200 | let required = required.checked_sub(downloaded as u64) 201 | .unwrap_or_default(); 202 | 203 | if space < required { 204 | return Err(DownloadingError::NoSpaceAvailable(path, required, space)); 205 | } 206 | } 207 | } 208 | 209 | None => return Err(DownloadingError::PathNotMounted(path)) 210 | } 211 | } 212 | 213 | // Download data 214 | match file { 215 | Ok(mut file) => { 216 | let mut chunk = Vec::with_capacity(self.chunk_size); 217 | 218 | let request = minreq::head(&self.uri) 219 | .with_header("range", format!("bytes={downloaded}-")) 220 | .send()?; 221 | 222 | // Request content range (downloaded + remained content size) 223 | // 224 | // If finished or overcame: bytes */10611646760 225 | // If not finished: bytes 10611646759-10611646759/10611646760 226 | // 227 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range 228 | if let Some(range) = request.headers.get("content-range") { 229 | // Finish downloading if header says that we've already downloaded all the data 230 | if range.contains("*/") { 231 | (progress)(self.length.unwrap_or(downloaded as u64), self.length.unwrap_or(downloaded as u64)); 232 | 233 | return Ok(()); 234 | } 235 | } 236 | 237 | let request = minreq::get(&self.uri) 238 | .with_header("range", format!("bytes={downloaded}-")) 239 | .send_lazy()?; 240 | 241 | // HTTP 416 = provided range is overcame actual content length (means file is downloaded) 242 | // I check this here because HEAD request can return 200 OK while GET - 416 243 | // 244 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/416 245 | if request.status_code == 416 { 246 | (progress)(self.length.unwrap_or(downloaded as u64), self.length.unwrap_or(downloaded as u64)); 247 | 248 | return Ok(()); 249 | } 250 | 251 | for byte in request { 252 | let (byte, expected_len) = byte?; 253 | 254 | chunk.push(byte); 255 | 256 | if chunk.len() == self.chunk_size { 257 | if let Err(err) = file.write_all(&chunk) { 258 | return Err(DownloadingError::OutputFileError(path, err.to_string())); 259 | } 260 | 261 | chunk.clear(); 262 | 263 | downloaded += self.chunk_size; 264 | 265 | (progress)(downloaded as u64, self.length.unwrap_or(expected_len as u64)); 266 | } 267 | } 268 | 269 | if !chunk.is_empty() { 270 | if let Err(err) = file.write_all(&chunk) { 271 | return Err(DownloadingError::OutputFileError(path, err.to_string())); 272 | } 273 | 274 | downloaded += chunk.len(); 275 | 276 | (progress)(downloaded as u64, downloaded as u64); // may not be true..? 277 | } 278 | 279 | Ok(()) 280 | } 281 | 282 | Err(err) => Err(DownloadingError::OutputFileError(path, err.to_string())) 283 | } 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /src/games/zzz/game.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::io::Read; 3 | use std::path::{Path, PathBuf}; 4 | 5 | use crate::version::Version; 6 | use crate::traits::prelude::*; 7 | 8 | use super::api; 9 | use super::consts::*; 10 | use super::version_diff::*; 11 | 12 | #[derive(Debug, Clone, PartialEq, Eq)] 13 | pub struct Game { 14 | path: PathBuf, 15 | edition: GameEdition 16 | } 17 | 18 | impl GameExt for Game { 19 | type Edition = GameEdition; 20 | 21 | #[inline] 22 | fn new(path: impl Into, edition: GameEdition) -> Self { 23 | Self { 24 | path: path.into(), 25 | edition 26 | } 27 | } 28 | 29 | #[inline] 30 | fn path(&self) -> &Path { 31 | self.path.as_path() 32 | } 33 | 34 | #[inline] 35 | fn edition(&self) -> GameEdition { 36 | self.edition 37 | } 38 | 39 | #[tracing::instrument(level = "trace", ret)] 40 | /// Try to get latest game version 41 | fn get_latest_version(edition: GameEdition) -> anyhow::Result { 42 | tracing::trace!("Trying to get latest game version"); 43 | 44 | // I assume game's API can't return incorrect version format right? Right? 45 | Ok(Version::from_str(api::request(edition)?.main.major.version).unwrap()) 46 | } 47 | 48 | #[tracing::instrument(level = "debug", ret)] 49 | fn get_version(&self) -> anyhow::Result { 50 | tracing::debug!("Trying to get installed game version"); 51 | 52 | fn bytes_to_num(bytes: &Vec) -> u8 { 53 | bytes.iter().fold(0u8, |acc, &x| acc * 10 + (x - b'0')) 54 | } 55 | 56 | let stored_version = std::fs::read(self.path.join(".version")) 57 | .map(|version| Version::new(version[0], version[1], version[2])) 58 | .ok(); 59 | 60 | let file = File::open(self.path.join(self.edition.data_folder()).join("globalgamemanagers"))?; 61 | 62 | let mut version: [Vec; 3] = [vec![], vec![], vec![]]; 63 | let mut version_ptr: usize = 0; 64 | let mut correct = true; 65 | 66 | for byte in file.bytes().skip(4000).take(10000).flatten() { 67 | match byte { 68 | 0 => { 69 | if correct && !version[0].is_empty() && !version[1].is_empty() && !version[2].is_empty() { 70 | let found_version = Version::new( 71 | bytes_to_num(&version[0]), 72 | bytes_to_num(&version[1]), 73 | bytes_to_num(&version[2]) 74 | ); 75 | 76 | // Little workaround for the minor game patch versions (notably 1.0.1) 77 | // Prioritize version stored in the .version file 78 | // because it's parsed from the API directly 79 | if let Some(stored_version) = stored_version { 80 | if stored_version > found_version { 81 | return Ok(stored_version); 82 | } 83 | } 84 | 85 | return Ok(found_version); 86 | } 87 | 88 | version = [vec![], vec![], vec![]]; 89 | version_ptr = 0; 90 | correct = true; 91 | } 92 | 93 | b'.' => { 94 | version_ptr += 1; 95 | 96 | if version_ptr > 2 { 97 | correct = false; 98 | } 99 | } 100 | 101 | _ => { 102 | if correct && b"0123456789".contains(&byte) { 103 | version[version_ptr].push(byte); 104 | } 105 | 106 | else { 107 | correct = false; 108 | } 109 | } 110 | } 111 | } 112 | 113 | if let Some(stored_version) = stored_version { 114 | return Ok(stored_version); 115 | } 116 | 117 | tracing::error!("Version's bytes sequence wasn't found"); 118 | 119 | anyhow::bail!("Version's bytes sequence wasn't found"); 120 | } 121 | } 122 | 123 | impl Game { 124 | #[tracing::instrument(level = "debug", ret)] 125 | pub fn try_get_diff(&self) -> anyhow::Result { 126 | tracing::debug!("Trying to find version diff for the game"); 127 | 128 | let response = api::request(self.edition)?; 129 | 130 | if self.is_installed() { 131 | let current = match self.get_version() { 132 | Ok(version) => version, 133 | Err(err) => { 134 | if self.path.exists() && self.path.metadata()?.len() == 0 { 135 | let downloaded_size = response.main.major.game_pkgs.iter() 136 | .flat_map(|pkg| pkg.size.parse::()) 137 | .sum(); 138 | 139 | let unpacked_size = response.main.major.game_pkgs.iter() 140 | .flat_map(|pkg| pkg.decompressed_size.parse::()) 141 | .sum::() - downloaded_size; 142 | 143 | return Ok(VersionDiff::NotInstalled { 144 | latest: Version::from_str(&response.main.major.version).unwrap(), 145 | 146 | edition: self.edition, 147 | 148 | downloaded_size, 149 | unpacked_size, 150 | 151 | segments_uris: response.main.major.game_pkgs.into_iter() 152 | .map(|segment| segment.url) 153 | .collect(), 154 | 155 | installation_path: Some(self.path.clone()), 156 | version_file_path: None, 157 | temp_folder: None 158 | }); 159 | } 160 | 161 | return Err(err); 162 | } 163 | }; 164 | 165 | if current >= response.main.major.version { 166 | tracing::debug!("Game version is latest"); 167 | 168 | // If we're running latest game version the diff we need to download 169 | // must always be `predownload.diffs[0]`, but just to be safe I made 170 | // a loop through possible variants, and if none of them was correct 171 | // (which is not possible in reality) we should just say thath the game 172 | // is latest 173 | if let Some(predownload_info) = response.pre_download { 174 | if let Some(predownload_major) = predownload_info.major { 175 | for diff in predownload_info.patches { 176 | if diff.version == current { 177 | let downloaded_size = diff.game_pkgs.iter() 178 | .flat_map(|pkg| pkg.size.parse::()) 179 | .sum(); 180 | 181 | let unpacked_size = diff.game_pkgs.iter() 182 | .flat_map(|pkg| pkg.decompressed_size.parse::()) 183 | .sum::() - downloaded_size; 184 | 185 | return Ok(VersionDiff::Predownload { 186 | current, 187 | latest: Version::from_str(predownload_major.version).unwrap(), 188 | 189 | uri: diff.game_pkgs[0].url.clone(), // TODO: can be a hard issue in future 190 | edition: self.edition, 191 | 192 | downloaded_size, 193 | unpacked_size, 194 | 195 | installation_path: Some(self.path.clone()), 196 | version_file_path: None, 197 | temp_folder: None 198 | }); 199 | } 200 | } 201 | } 202 | } 203 | 204 | Ok(VersionDiff::Latest { 205 | version: current, 206 | edition: self.edition 207 | }) 208 | } 209 | 210 | else { 211 | tracing::debug!("Game is outdated: {} -> {}", current, response.main.major.version); 212 | 213 | for diff in response.main.patches { 214 | if diff.version == current { 215 | let downloaded_size = diff.game_pkgs.iter() 216 | .flat_map(|pkg| pkg.size.parse::()) 217 | .sum(); 218 | 219 | let unpacked_size = diff.game_pkgs.iter() 220 | .flat_map(|pkg| pkg.decompressed_size.parse::()) 221 | .sum::() - downloaded_size; 222 | 223 | return Ok(VersionDiff::Diff { 224 | current, 225 | latest: Version::from_str(response.main.major.version).unwrap(), 226 | 227 | uri: diff.game_pkgs[0].url.clone(), // TODO: can be a hard issue in future 228 | edition: self.edition, 229 | 230 | downloaded_size, 231 | unpacked_size, 232 | 233 | installation_path: Some(self.path.clone()), 234 | version_file_path: None, 235 | temp_folder: None 236 | }); 237 | } 238 | } 239 | 240 | Ok(VersionDiff::Outdated { 241 | current, 242 | latest: Version::from_str(response.main.major.version).unwrap(), 243 | edition: self.edition 244 | }) 245 | } 246 | } 247 | 248 | else { 249 | tracing::debug!("Game is not installed"); 250 | 251 | let downloaded_size = response.main.major.game_pkgs.iter() 252 | .flat_map(|pkg| pkg.size.parse::()) 253 | .sum(); 254 | 255 | let unpacked_size = response.main.major.game_pkgs.iter() 256 | .flat_map(|pkg| pkg.decompressed_size.parse::()) 257 | .sum::() - downloaded_size; 258 | 259 | Ok(VersionDiff::NotInstalled { 260 | latest: Version::from_str(&response.main.major.version).unwrap(), 261 | 262 | edition: self.edition, 263 | 264 | downloaded_size, 265 | unpacked_size, 266 | 267 | segments_uris: response.main.major.game_pkgs.into_iter() 268 | .map(|segment| segment.url) 269 | .collect(), 270 | 271 | installation_path: Some(self.path.clone()), 272 | version_file_path: None, 273 | temp_folder: None 274 | }) 275 | } 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /src/installer/archives.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use std::fs::File; 3 | use std::process::{Command, Stdio}; 4 | 5 | use serde::{Serialize, Deserialize}; 6 | 7 | use zip::ZipArchive; 8 | use tar::Archive as TarArchive; 9 | // use sevenz_rust::SevenZReader as SevenzArchive; 10 | 11 | use xz::read::XzDecoder as XzReader; 12 | use bzip2::read::BzDecoder as Bz2Reader; 13 | use flate2::read::GzDecoder as GzReader; 14 | 15 | /// Get 7z binary if some is available 16 | fn get7z() -> anyhow::Result { 17 | let result = Command::new("7z") 18 | .stdin(Stdio::null()) 19 | .stdout(Stdio::null()) 20 | .stderr(Stdio::null()) 21 | .output(); 22 | 23 | if result.is_ok() { 24 | return Ok(String::from("7z")); 25 | } 26 | 27 | Command::new("7za") 28 | .stdin(Stdio::null()) 29 | .stdout(Stdio::null()) 30 | .stderr(Stdio::null()) 31 | .output()?; 32 | 33 | Ok(String::from("7za")) 34 | } 35 | 36 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 37 | pub enum Size { 38 | Compressed(u64), 39 | Uncompressed(u64), 40 | Both { 41 | compressed: u64, 42 | uncompressed: u64 43 | } 44 | } 45 | 46 | impl Size { 47 | pub fn get_size(&self) -> u64 { 48 | match self { 49 | Size::Compressed(size) => *size, 50 | Size::Uncompressed(size) => *size, 51 | Size::Both { compressed, uncompressed: _ } => *compressed 52 | } 53 | } 54 | } 55 | 56 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 57 | pub struct Entry { 58 | pub name: String, 59 | pub size: Size 60 | } 61 | 62 | pub enum Archive { 63 | Zip(PathBuf, ZipArchive), 64 | Tar(PathBuf, TarArchive), 65 | TarXz(PathBuf, TarArchive>), 66 | TarGz(PathBuf, TarArchive>), 67 | TarBz2(PathBuf, TarArchive>), 68 | SevenZ(PathBuf/*, SevenzArchive*/), 69 | ZipMultipart(PathBuf) 70 | } 71 | 72 | impl Archive { 73 | pub fn open>(path: T) -> anyhow::Result { 74 | let path: PathBuf = path.into(); 75 | let file = File::open(&path)?; 76 | 77 | let path_str = path.to_string_lossy(); 78 | 79 | if &path_str[path_str.len() - 4..] == ".zip" { 80 | Ok(Archive::Zip(path, ZipArchive::new(file)?)) 81 | } 82 | 83 | else if &path_str[path_str.len() - 7..] == ".tar.xz" { 84 | Ok(Archive::TarXz(path, TarArchive::new(XzReader::new(file)))) 85 | } 86 | 87 | else if &path_str[path_str.len() - 7..] == ".tar.gz" { 88 | Ok(Archive::TarGz(path, TarArchive::new(GzReader::new(file)))) 89 | } 90 | 91 | else if &path_str[path_str.len() - 8..] == ".tar.bz2" { 92 | Ok(Archive::TarBz2(path, TarArchive::new(Bz2Reader::new(file)))) 93 | } 94 | 95 | else if &path_str[path_str.len() - 3..] == ".7z" { 96 | Ok(Archive::SevenZ(path/*, SevenzArchive::open(path, &[])?*/)) 97 | } 98 | 99 | else if &path_str[path_str.len() - 4..] == ".tar" { 100 | Ok(Archive::Tar(path, TarArchive::new(file))) 101 | } 102 | 103 | else if &path_str[path_str.len() - 8..] == ".zip.001" || &path_str[path_str.len() - 7..] == ".7z.001" || &path_str[path_str.len() - 4..] == ".z01" { 104 | Ok(Archive::ZipMultipart(path)) 105 | } 106 | 107 | else { 108 | Err(anyhow::anyhow!("Archive format is not supported: {}", path.to_string_lossy())) 109 | } 110 | } 111 | 112 | /// Tar archives may forbid you to extract them if you call this method 113 | pub fn get_entries(&mut self) -> anyhow::Result> { 114 | let mut entries = Vec::new(); 115 | 116 | match self { 117 | Archive::Zip(_, zip) => { 118 | for i in 0..zip.len() { 119 | let entry = zip.by_index(i)?; 120 | 121 | entries.push(Entry { 122 | name: entry.name().to_string(), 123 | size: Size::Both { 124 | compressed: entry.compressed_size(), 125 | uncompressed: entry.size() 126 | } 127 | }); 128 | } 129 | } 130 | 131 | Archive::Tar(_, tar) => { 132 | for entry in tar.entries()?.flatten() { 133 | entries.push(Entry { 134 | name: entry.path()?.to_str().unwrap().to_string(), 135 | size: Size::Compressed(entry.size()) 136 | }); 137 | } 138 | } 139 | 140 | Archive::TarXz(_, tar) => { 141 | for entry in tar.entries()?.flatten() { 142 | entries.push(Entry { 143 | name: entry.path()?.to_str().unwrap().to_string(), 144 | size: Size::Compressed(entry.size()) 145 | }); 146 | } 147 | } 148 | 149 | Archive::TarGz(_, tar) => { 150 | for entry in tar.entries()?.flatten() { 151 | entries.push(Entry { 152 | name: entry.path()?.to_str().unwrap().to_string(), 153 | size: Size::Compressed(entry.size()) 154 | }); 155 | } 156 | } 157 | 158 | Archive::TarBz2(_, tar) => { 159 | for entry in tar.entries()?.flatten() { 160 | entries.push(Entry { 161 | name: entry.path()?.to_str().unwrap().to_string(), 162 | size: Size::Compressed(entry.size()) 163 | }); 164 | } 165 | } 166 | 167 | #[allow(unused_must_use)] 168 | Archive::SevenZ(path) | 169 | Archive::ZipMultipart(path) => { 170 | /*let (send, recv) = std::sync::mpsc::channel(); 171 | 172 | sz.for_each_entries(move |entry, _| { 173 | send.send(Entry { 174 | name: entry.name.clone(), 175 | size: Size::Both { 176 | compressed: entry.compressed_size, 177 | uncompressed: entry.size 178 | } 179 | }); 180 | 181 | Ok(true) 182 | }); 183 | 184 | while let Ok(entry) = recv.recv() { 185 | entries.push(entry); 186 | }*/ 187 | 188 | let output = Command::new(get7z()?) 189 | .arg("l") 190 | .arg(&path) 191 | .stdout(Stdio::piped()) 192 | .stderr(Stdio::null()) 193 | .output()?; 194 | 195 | let output = String::from_utf8(output.stdout)?; 196 | 197 | let output = output.split("-------------------").collect::>(); 198 | let mut output = output[1..output.len() - 1].join("-------------------"); 199 | 200 | // In some cases 7z can report two ending sequences instead of one: 201 | // 202 | // ``` 203 | // ------------------- ----- ------------ ------------ ------------------------ 204 | // 2023-09-15 10:20:44 66677218871 65387995385 13810 files, 81 folders 205 | // 206 | // ------------------- ----- ------------ ------------ ------------------------ 207 | // 2023-09-15 10:20:44 66677218871 65387995385 13810 files, 81 folders 208 | // ``` 209 | // 210 | // This should filter this case 211 | if let Some((files_list, _)) = output.split_once("\n-------------------") { 212 | output = files_list.to_string(); 213 | } 214 | 215 | for line in output.split('\n').collect::>() { 216 | if !line.starts_with('-') && !line.starts_with(" -") { 217 | let words = line.split(" ").filter_map(|word| { 218 | let word = word.trim(); 219 | 220 | if word.is_empty() { 221 | None 222 | } else { 223 | Some(word) 224 | } 225 | }).collect::>(); 226 | 227 | entries.push(Entry { 228 | name: words[words.len() - 1].to_string(), 229 | size: Size::Uncompressed(words[1].parse::()?) 230 | }); 231 | } 232 | } 233 | } 234 | } 235 | 236 | Ok(entries) 237 | } 238 | 239 | #[tracing::instrument(level = "debug", skip(self))] 240 | pub fn extract + std::fmt::Debug>(&mut self, folder: T) -> anyhow::Result<()> { 241 | tracing::trace!("Extracting archive"); 242 | 243 | let folder = folder.into(); 244 | 245 | match self { 246 | Archive::Zip(archive, zip) => { 247 | if zip.extract(&folder).is_err() { 248 | Command::new("unzip") 249 | .arg("-q") 250 | .arg("-o") 251 | .arg(archive) 252 | .arg("-d") 253 | .arg(folder) 254 | .output()?; 255 | } 256 | } 257 | 258 | Archive::Tar(_, tar) => { 259 | tar.unpack(folder)?; 260 | } 261 | 262 | Archive::TarXz(_, tar) => { 263 | tar.unpack(folder)?; 264 | } 265 | 266 | Archive::TarGz(_, tar) => { 267 | tar.unpack(folder)?; 268 | } 269 | 270 | Archive::TarBz2(_, tar) => { 271 | tar.unpack(folder)?; 272 | } 273 | 274 | Archive::SevenZ(archive) | 275 | Archive::ZipMultipart(archive) => { 276 | // sevenz_rust::decompress_file(archive, folder.into())?; 277 | 278 | // Workaround to allow 7z to overwrite files 279 | // Somehow it manages to forbid itself to do this 280 | Command::new("chmod") 281 | .arg("-R") 282 | .arg("755") 283 | .arg(&folder) 284 | .output()?; 285 | 286 | // Extract the archive 287 | Command::new(get7z()?) 288 | .arg("x") 289 | .arg(archive) 290 | .arg(format!("-o{}", folder.to_string_lossy())) 291 | .arg("-aoa") 292 | .output()?; 293 | 294 | // Change permissions again 295 | Command::new("chmod") 296 | .arg("-R") 297 | .arg("755") 298 | .arg(&folder) 299 | .output()?; 300 | } 301 | } 302 | 303 | Ok(()) 304 | } 305 | } 306 | -------------------------------------------------------------------------------- /src/games/honkai/version_diff.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use serde::{Serialize, Deserialize}; 4 | use thiserror::Error; 5 | 6 | use crate::version::Version; 7 | use crate::traits::version_diff::VersionDiffExt; 8 | 9 | #[cfg(feature = "install")] 10 | use crate::installer::{ 11 | downloader::{Downloader, DownloadingError}, 12 | installer::{ 13 | Installer, 14 | Update as InstallerUpdate 15 | }, 16 | free_space 17 | }; 18 | 19 | #[derive(Error, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 20 | pub enum DiffDownloadingError { 21 | /// Your installation is already up to date and not needed to be updated 22 | #[error("Component version is already latest")] 23 | AlreadyLatest, 24 | 25 | /// Failed to fetch remove data. Redirected from `Downloader` 26 | #[error("{0}")] 27 | DownloadingError(#[from] DownloadingError), 28 | 29 | /// Installation path wasn't specified. This could happen when you 30 | /// try to call `install` method on `VersionDiff` that was generated 31 | /// in `VoicePackage::list_latest`. This method couldn't know 32 | /// your game installation path and thus indicates that it doesn't know 33 | /// where this package needs to be installed 34 | #[error("Path to the component's downloading folder is not specified")] 35 | PathNotSpecified 36 | } 37 | 38 | impl From for DiffDownloadingError { 39 | fn from(error: minreq::Error) -> Self { 40 | DownloadingError::Minreq(error.to_string()).into() 41 | } 42 | } 43 | 44 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 45 | pub enum VersionDiff { 46 | /// Latest version 47 | Latest(Version), 48 | 49 | /// Component should be updated before using it 50 | Diff { 51 | current: Version, 52 | latest: Version, 53 | url: String, 54 | 55 | downloaded_size: u64, 56 | unpacked_size: u64, 57 | 58 | /// Path to the folder this difference should be installed by the `install` method 59 | /// 60 | /// This value can be `None`, so `install` will return `Err(DiffDownloadError::PathNotSpecified)` 61 | installation_path: Option, 62 | 63 | /// Optional path to the `.version` file 64 | version_file_path: Option, 65 | 66 | /// Temp folder path 67 | temp_folder: Option 68 | }, 69 | 70 | /// Component is not yet installed 71 | NotInstalled { 72 | latest: Version, 73 | url: String, 74 | 75 | downloaded_size: u64, 76 | unpacked_size: u64, 77 | 78 | /// Path to the folder this difference should be installed by the `install` method 79 | /// 80 | /// This value can be `None`, so `install` will return `Err(DiffDownloadError::PathNotSpecified)` 81 | installation_path: Option, 82 | 83 | /// Optional path to the `.version` file 84 | version_file_path: Option, 85 | 86 | /// Temp folder path 87 | temp_folder: Option 88 | } 89 | } 90 | 91 | impl VersionDiff { 92 | /// Get `.version` file path 93 | pub fn version_file_path(&self) -> Option { 94 | match self { 95 | // Can't be installed 96 | Self::Latest(_) => None, 97 | 98 | // Can be installed 99 | Self::Diff { version_file_path, .. } | 100 | Self::NotInstalled { version_file_path, .. } => version_file_path.to_owned() 101 | } 102 | } 103 | 104 | /// Return currently selected temp folder path 105 | /// 106 | /// Default is `std::env::temp_dir()` value 107 | pub fn temp_folder(&self) -> PathBuf { 108 | match self { 109 | // Can't be installed 110 | Self::Latest(_) => std::env::temp_dir(), 111 | 112 | // Can be installed 113 | Self::Diff { temp_folder, .. } | 114 | Self::NotInstalled { temp_folder, .. } => match temp_folder { 115 | Some(path) => path.to_owned(), 116 | None => std::env::temp_dir() 117 | } 118 | } 119 | } 120 | 121 | pub fn with_temp_folder(mut self, temp: PathBuf) -> Self { 122 | match &mut self { 123 | // Can't be installed 124 | Self::Latest(_) => self, 125 | 126 | // Can be installed 127 | Self::Diff { temp_folder, .. } => { 128 | *temp_folder = Some(temp); 129 | 130 | self 131 | } 132 | 133 | Self::NotInstalled { temp_folder, .. } => { 134 | *temp_folder = Some(temp); 135 | 136 | self 137 | } 138 | } 139 | } 140 | } 141 | 142 | impl VersionDiffExt for VersionDiff { 143 | type Error = DiffDownloadingError; 144 | type Update = InstallerUpdate; 145 | type Edition = (); 146 | 147 | #[inline] 148 | fn edition(&self) -> Self::Edition { 149 | () 150 | } 151 | 152 | fn current(&self) -> Option { 153 | match self { 154 | Self::Latest(current) | 155 | Self::Diff { current, .. } => Some(*current), 156 | 157 | Self::NotInstalled { .. } => None 158 | } 159 | } 160 | 161 | fn latest(&self) -> Version { 162 | match self { 163 | Self::Latest(latest) | 164 | Self::Diff { latest, .. } | 165 | Self::NotInstalled { latest, .. } => *latest 166 | } 167 | } 168 | 169 | fn downloaded_size(&self) -> Option { 170 | match self { 171 | // Can't be installed 172 | Self::Latest(_) => None, 173 | 174 | // Can be installed 175 | Self::Diff { downloaded_size, .. } | 176 | Self::NotInstalled { downloaded_size, .. } => Some(*downloaded_size) 177 | } 178 | } 179 | 180 | fn unpacked_size(&self) -> Option { 181 | match self { 182 | // Can't be installed 183 | Self::Latest(_) => None, 184 | 185 | // Can be installed 186 | Self::Diff { unpacked_size, .. } | 187 | Self::NotInstalled { unpacked_size, .. } => Some(*unpacked_size) 188 | } 189 | } 190 | 191 | fn installation_path(&self) -> Option<&Path> { 192 | match self { 193 | // Can't be installed 194 | Self::Latest(_) => None, 195 | 196 | // Can be installed 197 | Self::Diff { installation_path, .. } | 198 | Self::NotInstalled { installation_path, .. } => match installation_path { 199 | Some(path) => Some(path.as_path()), 200 | None => None 201 | } 202 | } 203 | } 204 | 205 | fn downloading_uri(&self) -> Option { 206 | match self { 207 | // Can't be installed 208 | Self::Latest(_) => None, 209 | 210 | // Can be installed 211 | Self::Diff { url, .. } | 212 | Self::NotInstalled { url, .. } => Some(url.to_owned()) 213 | } 214 | } 215 | 216 | fn download_as(&mut self, path: impl AsRef, progress: impl Fn(u64, u64) + Send + 'static) -> Result<(), Self::Error> { 217 | tracing::debug!("Downloading version difference"); 218 | 219 | let mut downloader = Downloader::new(match self { 220 | // Can't be downloaded 221 | Self::Latest(_) => return Err(Self::Error::AlreadyLatest), 222 | 223 | // Can be downloaded 224 | Self::Diff { url, .. } | 225 | Self::NotInstalled { url, .. } => url 226 | })?; 227 | 228 | if let Err(err) = downloader.download(path.as_ref(), progress) { 229 | tracing::error!("Failed to download version difference: {err}"); 230 | 231 | return Err(err.into()); 232 | } 233 | 234 | Ok(()) 235 | } 236 | 237 | fn install_to(&self, path: impl AsRef, _thread_count: usize, updater: impl Fn(Self::Update) + Clone + Send + 'static) -> Result<(), Self::Error> { 238 | tracing::debug!("Installing version difference"); 239 | 240 | let path = path.as_ref(); 241 | 242 | let url = self.downloading_uri().expect("Failed to retreive downloading url"); 243 | let downloaded_size = self.downloaded_size().expect("Failed to retreive downloaded size"); 244 | let unpacked_size = self.unpacked_size().expect("Failed to retreive unpacked size"); 245 | 246 | let mut installer = Installer::new(url)? 247 | // Set custom temp folder location 248 | .with_temp_folder(self.temp_folder()) 249 | 250 | // Don't perform space checks in the Installer because we're doing it here 251 | .with_free_space_check(false); 252 | 253 | (updater)(InstallerUpdate::CheckingFreeSpace(installer.temp_folder.to_path_buf())); 254 | 255 | // Check available free space for archive itself 256 | let Some(space) = free_space::available(&installer.temp_folder) else { 257 | tracing::error!("Path is not mounted: {:?}", installer.temp_folder); 258 | 259 | return Err(DownloadingError::PathNotMounted(installer.temp_folder).into()); 260 | }; 261 | 262 | // We can possibly store downloaded archive + unpacked data on the same disk 263 | let required = if free_space::is_same_disk(&installer.temp_folder, path) { 264 | downloaded_size + unpacked_size 265 | } else { 266 | downloaded_size 267 | }; 268 | 269 | if space < required { 270 | tracing::error!("No free space available in the temp folder. Required: {required}. Available: {space}"); 271 | 272 | return Err(DownloadingError::NoSpaceAvailable(installer.temp_folder, required, space).into()); 273 | } 274 | 275 | (updater)(InstallerUpdate::CheckingFreeSpace(path.to_path_buf())); 276 | 277 | // Check available free space for unpacked archvie data 278 | let Some(space) = free_space::available(&path) else { 279 | tracing::error!("Path is not mounted: {:?}", &path); 280 | 281 | return Err(DownloadingError::PathNotMounted(path.to_path_buf()).into()); 282 | }; 283 | 284 | // We can possibly store downloaded archive + unpacked data on the same disk 285 | let required = if free_space::is_same_disk(&path, &installer.temp_folder) { 286 | unpacked_size + downloaded_size 287 | } else { 288 | unpacked_size 289 | }; 290 | 291 | if space < required { 292 | tracing::error!("No free space available in the installation folder. Required: {required}. Available: {space}"); 293 | 294 | return Err(DownloadingError::NoSpaceAvailable(path.to_path_buf(), required, space).into()); 295 | } 296 | 297 | // Install data 298 | let installer_updater = updater.clone(); 299 | 300 | installer.install(path, move |update| (installer_updater)(update)); 301 | 302 | // Create `.version` file here even if hdiff patching is failed because 303 | // it's easier to explain user why he should run files repairer than 304 | // why he should re-download entire game update because something is failed 305 | #[allow(unused_must_use)] { 306 | let version_path = self.version_file_path() 307 | .unwrap_or_else(|| path.join(".version")); 308 | 309 | std::fs::write(version_path, self.latest().version); 310 | } 311 | 312 | Ok(()) 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /src/installer/installer.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use std::os::unix::prelude::PermissionsExt; 3 | 4 | use serde::{Serialize, Deserialize}; 5 | 6 | use super::downloader::{Downloader, DownloadingError}; 7 | use super::archives::Archive; 8 | use super::free_space; 9 | 10 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 11 | pub enum Update { 12 | CheckingFreeSpace(PathBuf), 13 | 14 | /// `(temp path)` 15 | DownloadingStarted(PathBuf), 16 | 17 | /// `(current bytes, total bytes)` 18 | DownloadingProgress(u64, u64), 19 | 20 | DownloadingFinished, 21 | DownloadingError(DownloadingError), 22 | 23 | /// `(unpacking path)` 24 | UpdatingPermissionsStarted(PathBuf), 25 | 26 | /// `(done files, total files)` 27 | UpdatingPermissions(u64, u64), 28 | 29 | UpdatingPermissionsFinished, 30 | 31 | /// `(unpacking path)` 32 | UnpackingStarted(PathBuf), 33 | 34 | /// `(current bytes, total bytes)` 35 | UnpackingProgress(u64, u64), 36 | 37 | UnpackingFinished, 38 | UnpackingError(String) 39 | } 40 | 41 | impl From for Update { 42 | #[inline] 43 | fn from(err: DownloadingError) -> Self { 44 | Self::DownloadingError(err) 45 | } 46 | } 47 | 48 | #[derive(Debug)] 49 | pub struct Installer { 50 | pub downloader: Downloader, 51 | 52 | /// Path to the temp folder used to store archive before unpacking 53 | pub temp_folder: PathBuf, 54 | 55 | /// Perform free space verifications before downloading file 56 | pub check_free_space: bool, 57 | 58 | /// How `Downloader` should save the file before unpacking it 59 | pub filename: Option 60 | } 61 | 62 | impl Installer { 63 | #[inline] 64 | pub fn new>(uri: T) -> Result { 65 | Ok(Self { 66 | downloader: Downloader::new(uri.as_ref())? 67 | .with_free_space_check(false), 68 | 69 | temp_folder: std::env::temp_dir(), 70 | check_free_space: true, 71 | filename: None 72 | }) 73 | } 74 | 75 | /// Get name of downloading file from uri 76 | /// 77 | /// - `https://example.com/example.zip` -> `example.zip` 78 | /// - `https://example.com` -> `index.html` 79 | #[inline] 80 | pub fn get_filename(&self) -> &str { 81 | self.filename.as_deref().unwrap_or(self.downloader.get_filename()) 82 | } 83 | 84 | #[inline] 85 | fn get_temp_path(&self) -> PathBuf { 86 | self.temp_folder.join(self.get_filename()) 87 | } 88 | 89 | #[inline] 90 | /// Specify path to the temp folder used to store archive before unpacking 91 | pub fn with_temp_folder(mut self, path: impl Into) -> Self { 92 | self.temp_folder = path.into(); 93 | 94 | self 95 | } 96 | 97 | #[inline] 98 | /// Specify whether installer should check free space availability 99 | pub fn with_free_space_check(mut self, check_free_space: bool) -> Self { 100 | self.check_free_space = check_free_space; 101 | 102 | self 103 | } 104 | 105 | #[inline] 106 | /// Specify name of the file `Downloader` will save archive as before unpacking it 107 | pub fn with_filename(mut self, filename: impl ToString) -> Self { 108 | self.filename = Some(filename.to_string()); 109 | 110 | self 111 | } 112 | 113 | /// Download archive from specified uri and unpack it 114 | pub fn install(&mut self, unpack_to: impl Into, updater: impl Fn(Update) + Clone + Send + 'static) { 115 | tracing::trace!("Checking free space availability"); 116 | 117 | let temp_path = self.get_temp_path(); 118 | let unpack_to = unpack_to.into(); 119 | 120 | // Perform free space verifications if needed 121 | if self.check_free_space { 122 | // Check available free space for archive itself 123 | (updater)(Update::CheckingFreeSpace(temp_path.clone())); 124 | 125 | // Get downloaded file size to substract it from the free space check 126 | let downloaded = match temp_path.metadata() { 127 | Ok(metadata) => metadata.len(), 128 | Err(_) => 0 129 | }; 130 | 131 | let Some(space) = free_space::available(&temp_path) else { 132 | tracing::error!("Path is not mounted: {:?}", temp_path); 133 | 134 | (updater)(DownloadingError::PathNotMounted(temp_path).into()); 135 | 136 | return; 137 | }; 138 | 139 | if let Some(required) = self.downloader.length() { 140 | // We can possibly store downloaded archive + unpacked data on the same disk 141 | let required = if free_space::is_same_disk(&temp_path, &unpack_to) { 142 | (required as f64 * 2.5).ceil() as u64 143 | } else { 144 | required 145 | }; 146 | 147 | // Sub downloaded size from the required one 148 | let required = required.checked_sub(downloaded) 149 | .unwrap_or_default(); 150 | 151 | if space < required { 152 | tracing::error!("No free space available in the temp folder. Required: {required}. Available: {space}"); 153 | 154 | (updater)(DownloadingError::NoSpaceAvailable(temp_path, required, space).into()); 155 | 156 | return; 157 | } 158 | } 159 | 160 | // Check available free space for unpacked archvie data (archive size * 1.5) 161 | (updater)(Update::CheckingFreeSpace(unpack_to.clone())); 162 | 163 | let Some(space) = free_space::available(&unpack_to) else { 164 | tracing::error!("Path is not mounted: {:?}", temp_path); 165 | 166 | (updater)(DownloadingError::PathNotMounted(unpack_to).into()); 167 | 168 | return; 169 | }; 170 | 171 | if let Some(required) = self.downloader.length() { 172 | // We can possibly store downloaded archive + unpacked data on the same disk 173 | let required = if free_space::is_same_disk(&unpack_to, &temp_path) { 174 | (required as f64 * 2.5).ceil() as u64 175 | } else { 176 | (required as f64 * 1.5).ceil() as u64 177 | }; 178 | 179 | if space < required { 180 | tracing::error!("No free space available in the installation folder. Required: {required}. Available: {space}"); 181 | 182 | (updater)(DownloadingError::NoSpaceAvailable(unpack_to, required, space).into()); 183 | 184 | return; 185 | } 186 | } 187 | } 188 | 189 | tracing::trace!("Downloading archive"); 190 | 191 | // Download archive 192 | let download_progress_updater = updater.clone(); 193 | 194 | (updater)(Update::DownloadingStarted(temp_path.clone())); 195 | 196 | if let Err(err) = self.downloader.download(&temp_path, move |curr, total| (download_progress_updater)(Update::DownloadingProgress(curr, total))) { 197 | tracing::error!("Failed to download archive: {err}"); 198 | 199 | (updater)(Update::DownloadingError(err)); 200 | 201 | return; 202 | } 203 | 204 | (updater)(Update::DownloadingFinished); 205 | 206 | match Archive::open(&temp_path) { 207 | Ok(mut archive) => { 208 | // Temporary workaround as we can't get archive extraction process 209 | // directly - we'll spawn it in another thread and check this archive entries appearence in the filesystem 210 | let mut total = 0; 211 | 212 | let entries = archive 213 | .get_entries() 214 | .expect("Failed to get archive entries"); 215 | 216 | let entries_number = entries.len() as u64; 217 | 218 | (updater)(Update::UpdatingPermissionsStarted(unpack_to.clone())); 219 | 220 | for (i, entry) in entries.iter().enumerate() { 221 | total += entry.size.get_size(); 222 | 223 | let path = unpack_to.join(&entry.name); 224 | 225 | // Failed to change permissions => likely patch-related file and was made by the sudo, so root 226 | #[allow(unused_must_use)] 227 | if let Err(_) = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o666)) { 228 | // For weird reason we can delete files made by root, but can't modify their permissions 229 | // We're not checking its result because if it's error - then it's either couldn't be removed (which is not the case) 230 | // or the file doesn't exist, which we obviously can just ignore 231 | std::fs::remove_file(&path); 232 | } 233 | 234 | (updater)(Update::UpdatingPermissions(i as u64 + 1, entries_number)); 235 | } 236 | 237 | (updater)(Update::UpdatingPermissionsFinished); 238 | 239 | tracing::trace!("Extracting archive"); 240 | 241 | let unpacking_path = unpack_to.clone(); 242 | let unpacking_updater = updater.clone(); 243 | 244 | let handle_2 = std::thread::spawn(move || { 245 | let mut entries = entries.into_iter() 246 | .map(|entry| (unpacking_path.join(&entry.name), entry.size.get_size(), true)) 247 | .collect::>(); 248 | 249 | let mut unpacked = 0; 250 | 251 | loop { 252 | std::thread::sleep(std::time::Duration::from_millis(250)); 253 | 254 | let mut empty = true; 255 | 256 | for (path, size, remained) in &mut entries { 257 | if *remained { 258 | empty = false; 259 | 260 | if std::path::Path::new(path).exists() { 261 | *remained = false; 262 | 263 | unpacked += *size; 264 | } 265 | } 266 | } 267 | 268 | (unpacking_updater)(Update::UnpackingProgress(unpacked, total)); 269 | 270 | if empty { 271 | break; 272 | } 273 | } 274 | }); 275 | 276 | // Run archive extraction in another thread to not to freeze the current one 277 | let handle_1 = std::thread::spawn(move || { 278 | (updater)(Update::UnpackingStarted(unpack_to.clone())); 279 | 280 | // We have to create new instance of Archive here 281 | // because otherwise it may not work after get_entries method call 282 | match Archive::open(&temp_path) { 283 | Ok(mut archive) => match archive.extract(unpack_to) { 284 | Ok(_) => { 285 | // TODO error handling 286 | #[allow(unused_must_use)] { 287 | std::fs::remove_file(temp_path); 288 | } 289 | 290 | (updater)(Update::UnpackingFinished); 291 | } 292 | 293 | Err(err) => (updater)(Update::UnpackingError(err.to_string())) 294 | } 295 | 296 | Err(err) => (updater)(Update::UnpackingError(err.to_string())) 297 | } 298 | }); 299 | 300 | handle_1.join().unwrap(); 301 | handle_2.join().unwrap(); 302 | } 303 | 304 | Err(err) => (updater)(Update::UnpackingError(err.to_string())) 305 | } 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /src/sophon/mod.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::io::{Read, Seek, SeekFrom}; 3 | use std::iter::Peekable; 4 | use std::os::unix::fs::PermissionsExt; 5 | use std::path::{Path, PathBuf}; 6 | 7 | use api_schemas::game_branches::GameBranches; 8 | use api_schemas::ApiResponse; 9 | use crossbeam_deque::{Injector, Steal, Stealer, Worker}; 10 | use md5::{Digest, Md5}; 11 | use protobuf::Message; 12 | use reqwest::blocking::Client; 13 | use serde::de::DeserializeOwned; 14 | use serde::{Deserialize, Serialize}; 15 | use thiserror::Error; 16 | 17 | #[cfg(feature = "genshin")] 18 | use crate::genshin; 19 | #[cfg(feature = "star-rail")] 20 | use crate::star_rail; 21 | use crate::prettify_bytes::prettify_bytes; 22 | 23 | pub mod api_schemas; 24 | pub mod installer; 25 | pub mod protos; 26 | pub mod repairer; 27 | pub mod updater; 28 | 29 | const DEFAULT_CHUNK_RETRIES: u8 = 4; 30 | 31 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 32 | enum ArtifactDownloadState { 33 | // Chunk successfully downloaded 34 | Downloaded, 35 | // Download failed, run out of retries 36 | Failed, 37 | // Amount of retries left, 0 means last retry is being run 38 | Downloading(u8) 39 | } 40 | 41 | impl Default for ArtifactDownloadState { 42 | #[inline(always)] 43 | fn default() -> Self { 44 | Self::Downloading(DEFAULT_CHUNK_RETRIES) 45 | } 46 | } 47 | 48 | #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] 49 | pub enum GameEdition { 50 | Global, 51 | China 52 | } 53 | 54 | #[cfg(feature = "genshin")] 55 | impl From for GameEdition { 56 | fn from(value: genshin::consts::GameEdition) -> Self { 57 | match value { 58 | genshin::consts::GameEdition::China => Self::China, 59 | genshin::consts::GameEdition::Global => Self::Global 60 | } 61 | } 62 | } 63 | 64 | #[cfg(feature = "star-rail")] 65 | impl From for GameEdition { 66 | fn from(value: star_rail::consts::GameEdition) -> Self { 67 | match value { 68 | star_rail::consts::GameEdition::China => Self::China, 69 | star_rail::consts::GameEdition::Global => Self::Global 70 | } 71 | } 72 | } 73 | 74 | impl GameEdition { 75 | #[inline] 76 | pub fn branches_host(&self) -> &str { 77 | match self { 78 | Self::Global => { 79 | concat!( 80 | "https://", "s", "g-hy", "p-api.", "h", "oy", "over", "se", ".com" 81 | ) 82 | } 83 | Self::China => concat!("https://", "hy", "p-api.", "mi", "h", "oyo", ".com") 84 | } 85 | } 86 | 87 | #[inline] 88 | pub fn api_host(&self) -> &str { 89 | match self { 90 | Self::Global => concat!( 91 | "https://", 92 | "s", 93 | "g-pu", 94 | "blic-api.", 95 | "h", 96 | "oy", 97 | "over", 98 | "se", 99 | ".com" 100 | ), 101 | Self::China => concat!("https://", "api-t", "ak", "umi.", "mi", "h", "oyo", ".com") 102 | } 103 | } 104 | 105 | #[inline] 106 | pub fn launcher_id(&self) -> &str { 107 | match self { 108 | Self::Global => "VYTpXlbWo8", 109 | Self::China => "jGHBHlcOq1" 110 | } 111 | } 112 | } 113 | 114 | struct ThreadQueue<'a, T> { 115 | global: &'a Injector, 116 | local: Worker, 117 | stealers: &'a [Stealer] 118 | } 119 | 120 | impl<'a, T> ThreadQueue<'a, T> { 121 | /// based on the example from crossbeam deque 122 | fn next_job(&self) -> Option { 123 | self.local.pop().or_else(|| { 124 | std::iter::repeat_with(|| { 125 | self.global 126 | .steal_batch_and_pop(&self.local) 127 | .or_else(|| self.stealers.iter().map(|s| s.steal()).collect()) 128 | }) 129 | .find(|s| !s.is_retry()) 130 | .and_then(Steal::success) 131 | }) 132 | } 133 | } 134 | 135 | #[derive(Debug)] 136 | struct DownloadQueue<'b, T, I: Iterator + 'b> { 137 | tasks_iter: Peekable, 138 | retries_queue: &'b Injector 139 | } 140 | 141 | impl<'b, I, T> DownloadQueue<'b, T, I> 142 | where 143 | I: Iterator + 'b 144 | { 145 | fn is_empty(&mut self) -> bool { 146 | self.tasks_iter.peek().is_none() && self.retries_queue.is_empty() 147 | } 148 | } 149 | 150 | impl<'b, I, T> Iterator for DownloadQueue<'b, T, I> 151 | where 152 | I: Iterator + 'b 153 | { 154 | type Item = T; 155 | 156 | fn next(&mut self) -> Option { 157 | self.tasks_iter.next().or_else(|| { 158 | std::iter::repeat_with(|| self.retries_queue.steal()) 159 | .find(|s| !s.is_retry()) 160 | .and_then(Steal::success) 161 | }) 162 | } 163 | } 164 | 165 | #[inline(always)] 166 | fn get_game_branches_url(edition: GameEdition) -> String { 167 | format!( 168 | "{}/hyp/hyp-connect/api/getGameBranches?launcher_id={}", 169 | edition.branches_host(), 170 | edition.launcher_id() 171 | ) 172 | } 173 | 174 | #[inline(always)] 175 | pub fn get_game_branches_info( 176 | client: &Client, 177 | edition: GameEdition 178 | ) -> Result { 179 | api_get_request(client, get_game_branches_url(edition)) 180 | } 181 | 182 | fn api_get_request( 183 | client: &Client, 184 | url: impl AsRef 185 | ) -> Result { 186 | let response = client.get(url.as_ref()).send()?.error_for_status()?; 187 | 188 | Ok(response.json::>()?.data) 189 | } 190 | 191 | fn api_post_request( 192 | client: &Client, 193 | url: impl AsRef 194 | ) -> Result { 195 | let response = client.post(url.as_ref()).send()?.error_for_status()?; 196 | 197 | Ok(response.json::>()?.data) 198 | } 199 | 200 | fn get_protobuf_from_url( 201 | client: &Client, 202 | url: impl AsRef, 203 | compression: bool 204 | ) -> Result { 205 | let response = client.get(url.as_ref()).send()?.error_for_status()?; 206 | 207 | let compressed_manifest = response.bytes()?; 208 | 209 | let protobuf_bytes = if compression { 210 | zstd::decode_all(&*compressed_manifest).unwrap() 211 | } 212 | else { 213 | compressed_manifest.into() 214 | }; 215 | 216 | let parsed_manifest = T::parse_from_bytes(&protobuf_bytes).unwrap(); 217 | 218 | Ok(parsed_manifest) 219 | } 220 | 221 | fn finalize_file(file: &Path, target: &Path, size: u64, hash: &str) -> Result<(), SophonError> { 222 | if check_file(file, size, hash)? { 223 | tracing::debug!( 224 | result = ?file, 225 | destination = ?target, 226 | "File hash check passed, copying into final destination" 227 | ); 228 | ensure_parent(target)?; 229 | add_user_write_permission_to_file(target)?; 230 | std::fs::copy(file, target)?; 231 | Ok(()) 232 | } 233 | else { 234 | Err(SophonError::FileHashMismatch { 235 | path: file.to_owned(), 236 | expected: hash.to_owned(), 237 | got: file_md5_hash_str(file)? 238 | }) 239 | } 240 | } 241 | 242 | fn ensure_parent(path: impl AsRef) -> std::io::Result<()> { 243 | if let Some(parent) = path.as_ref().parent() { 244 | if !parent.exists() { 245 | std::fs::create_dir_all(parent)?; 246 | } 247 | } 248 | 249 | Ok(()) 250 | } 251 | 252 | fn md5_hash_str(data: &[u8]) -> String { 253 | format!("{:x}", Md5::digest(data)) 254 | } 255 | 256 | fn bytes_check_md5(data: &[u8], expected_hash: &str) -> bool { 257 | let computed_hash = md5_hash_str(data); 258 | 259 | expected_hash == computed_hash 260 | } 261 | 262 | // MD5 hash calculation without reading the whole file contents into RAM 263 | fn file_md5_hash_str(file_path: impl AsRef) -> std::io::Result { 264 | let mut file = File::open(&file_path)?; 265 | let mut md5 = Md5::new(); 266 | 267 | std::io::copy(&mut file, &mut md5)?; 268 | 269 | Ok(format!("{:x}", md5.finalize())) 270 | } 271 | 272 | fn check_file( 273 | file_path: impl AsRef, 274 | expected_size: u64, 275 | expected_md5: &str 276 | ) -> std::io::Result { 277 | let Ok(fs_metadata) = std::fs::metadata(&file_path) 278 | else { 279 | return Ok(false); 280 | }; 281 | 282 | let file_size = fs_metadata.len(); 283 | 284 | if file_size != expected_size { 285 | return Ok(false); 286 | } 287 | 288 | let file_md5 = file_md5_hash_str(&file_path)?; 289 | 290 | Ok(file_md5 == expected_md5) 291 | } 292 | 293 | fn add_user_write_permission_to_file(path: impl AsRef) -> std::io::Result<()> { 294 | if !path.as_ref().exists() { 295 | return Ok(()); 296 | } 297 | 298 | let mut permissions = std::fs::metadata(&path)?.permissions(); 299 | 300 | if permissions.readonly() { 301 | let perm_mode = permissions.mode(); 302 | let user_write_mode = perm_mode | 0o200; 303 | 304 | permissions.set_mode(user_write_mode); 305 | 306 | std::fs::set_permissions(path, permissions)?; 307 | } 308 | 309 | Ok(()) 310 | } 311 | 312 | fn file_region_hash_md5(file: &mut File, offset: u64, length: u64) -> std::io::Result { 313 | file.seek(SeekFrom::Start(offset))?; 314 | 315 | let mut region_reader = file.take(length); 316 | let mut hasher = Md5::new(); 317 | 318 | std::io::copy(&mut region_reader, &mut hasher)?; 319 | 320 | Ok(format!("{:x}", hasher.finalize())) 321 | } 322 | 323 | // TODO: 324 | // - Cull some variants of SophonError, especially those that are unused 325 | // - Make some better variants describign where the error happened, perhaps 326 | // steal anyhow's context idea but simpler, especially useful for I/O errors. 327 | // - Cull unused installer/update messages 328 | 329 | #[derive(Error, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 330 | pub enum SophonError { 331 | /// Specified downloading path is not available in system 332 | /// 333 | /// `(path)` 334 | #[error("Path is not mounted: {0:?}")] 335 | PathNotMounted(PathBuf), 336 | 337 | /// No free space available under specified path 338 | #[error("No free space available for specified path: {0:?} (requires {}, available {})", prettify_bytes(*.required), prettify_bytes(*.available))] 339 | NoSpaceAvailable { 340 | path: PathBuf, 341 | required: u64, 342 | available: u64 343 | }, 344 | 345 | /// Failed to create or open output file 346 | #[error("Failed to create output file {path:?}: {message}")] 347 | OutputFileError { path: PathBuf, message: String }, 348 | 349 | /// Failed to create or open temporary output file 350 | #[error("Failed to create temporary output file {path:?}: {message}")] 351 | TempFileError { path: PathBuf, message: String }, 352 | 353 | /// Couldn't get metadata of existing output file 354 | /// 355 | /// This metadata supposed to be used to continue downloading of the file 356 | #[error("Failed to read metadata of the output file {path:?}: {message}")] 357 | OutputFileMetadataError { path: PathBuf, message: String }, 358 | 359 | /// reqwest error 360 | #[error("reqwest error: {0}")] 361 | Reqwest(String), 362 | 363 | #[error("Chunk hash mismatch: expected `{expected}`, got `{got}`")] 364 | ChunkHashMismatch { expected: String, got: String }, 365 | 366 | #[error("File {path:?} hash mismatch: expected `{expected}`, got `{got}`")] 367 | FileHashMismatch { 368 | path: PathBuf, 369 | expected: String, 370 | got: String 371 | }, 372 | 373 | #[error("IO error: {0}")] 374 | IoError(String), 375 | 376 | #[error("Failed to download chunk {0}, out of retries")] 377 | ChunkDownloadFailed(String), 378 | 379 | #[error("Failed to apply hdiff patch: {0}")] 380 | PatchingError(String) 381 | } 382 | 383 | impl From for SophonError { 384 | #[inline(always)] 385 | fn from(error: reqwest::Error) -> Self { 386 | Self::Reqwest(error.to_string()) 387 | } 388 | } 389 | 390 | impl From for SophonError { 391 | #[inline(always)] 392 | fn from(value: std::io::Error) -> Self { 393 | Self::IoError(value.to_string()) 394 | } 395 | } 396 | -------------------------------------------------------------------------------- /src/games/star_rail/game.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::io::Read; 3 | use std::path::{Path, PathBuf}; 4 | 5 | use anyhow::Context; 6 | 7 | use crate::sophon; 8 | use crate::version::Version; 9 | use crate::traits::game::GameExt; 10 | use super::api; 11 | use super::consts::*; 12 | use super::version_diff::*; 13 | use super::voice_data::locale::VoiceLocale; 14 | use super::voice_data::package::VoicePackage; 15 | 16 | #[derive(Debug, Clone, PartialEq, Eq)] 17 | pub struct Game { 18 | path: PathBuf, 19 | edition: GameEdition 20 | } 21 | 22 | impl GameExt for Game { 23 | type Edition = GameEdition; 24 | 25 | #[inline] 26 | fn new(path: impl Into, edition: GameEdition) -> Self { 27 | Self { 28 | path: path.into(), 29 | edition 30 | } 31 | } 32 | 33 | #[inline] 34 | fn path(&self) -> &Path { 35 | self.path.as_path() 36 | } 37 | 38 | #[inline] 39 | fn edition(&self) -> GameEdition { 40 | self.edition 41 | } 42 | 43 | fn is_installed(&self) -> bool { 44 | self.path 45 | .join(self.edition.data_folder()) 46 | .join("data.unity3d") 47 | .exists() 48 | } 49 | 50 | #[tracing::instrument(level = "trace", ret)] 51 | /// Try to get latest game version 52 | fn get_latest_version(edition: GameEdition) -> anyhow::Result { 53 | tracing::trace!("Trying to get latest game version"); 54 | 55 | // I assume game's API can't return incorrect version format right? Right? 56 | Ok(Version::from_str(api::request(edition)?.main.major.version).unwrap()) 57 | } 58 | 59 | #[tracing::instrument(level = "debug", ret)] 60 | fn get_version(&self) -> anyhow::Result { 61 | tracing::debug!("Trying to get installed game version"); 62 | 63 | fn bytes_to_num(bytes: &[u8]) -> u8 { 64 | bytes.iter().fold(0u8, |acc, &x| acc * 10 + (x - b'0')) 65 | } 66 | 67 | let stored_version = std::fs::read(self.path.join(".version")) 68 | .map(|version| Version::new(version[0], version[1], version[2])) 69 | .ok(); 70 | 71 | let file = File::open( 72 | self.path 73 | .join(self.edition.data_folder()) 74 | .join("data.unity3d") 75 | )?; 76 | 77 | let mut version: [Vec; 3] = [vec![], vec![], vec![]]; 78 | let mut version_ptr: usize = 0; 79 | let mut correct = true; 80 | 81 | for byte in file.bytes().skip(2000).take(10000) { 82 | if let Ok(byte) = byte { 83 | match byte { 84 | 0 => { 85 | version = [vec![], vec![], vec![]]; 86 | version_ptr = 0; 87 | correct = true; 88 | } 89 | 90 | 46 => { 91 | version_ptr += 1; 92 | 93 | if version_ptr > 2 { 94 | correct = false; 95 | } 96 | } 97 | 98 | 38 => { 99 | if correct 100 | && version[0].len() > 0 101 | && version[1].len() > 0 102 | && version[2].len() > 0 103 | { 104 | let found_version = Version::new( 105 | bytes_to_num(&version[0]), 106 | bytes_to_num(&version[1]), 107 | bytes_to_num(&version[2]) 108 | ); 109 | 110 | // Prioritize version stored in the .version file 111 | // because it's parsed from the API directly 112 | if let Some(stored_version) = stored_version { 113 | if stored_version > found_version { 114 | return Ok(stored_version); 115 | } 116 | } 117 | 118 | return Ok(found_version); 119 | } 120 | 121 | correct = false; 122 | } 123 | 124 | _ => { 125 | if correct && b"0123456789".contains(&byte) { 126 | version[version_ptr].push(byte); 127 | } 128 | else { 129 | correct = false; 130 | } 131 | } 132 | } 133 | } 134 | } 135 | 136 | if let Some(stored_version) = stored_version { 137 | return Ok(stored_version); 138 | } 139 | 140 | tracing::error!("Version's bytes sequence wasn't found"); 141 | 142 | anyhow::bail!("Version's bytes sequence wasn't found"); 143 | } 144 | } 145 | 146 | impl Game { 147 | /// Get list of installed voice packages 148 | pub fn get_voice_packages(&self) -> anyhow::Result> { 149 | let content = std::fs::read_dir(get_voice_packages_path(&self.path, self.edition))?; 150 | 151 | let packages = content 152 | .into_iter() 153 | .flatten() 154 | .flat_map(|entry| { 155 | VoiceLocale::from_str(entry.file_name().to_string_lossy()) 156 | .map(|locale| get_voice_package_path(&self.path, self.edition, locale)) 157 | .map(|path| VoicePackage::new(path, self.edition)) 158 | }) 159 | .flatten() 160 | .collect(); 161 | 162 | Ok(packages) 163 | } 164 | 165 | #[tracing::instrument(level = "debug", ret)] 166 | pub fn try_get_diff(&self) -> anyhow::Result { 167 | tracing::debug!("Trying to find version diff for the game"); 168 | 169 | let game_edition = self.edition; 170 | 171 | let client = reqwest::blocking::Client::new(); 172 | 173 | let game_branches = sophon::get_game_branches_info(&client, game_edition.into()) 174 | .context("Getting game branches")?; 175 | let latest_branch = game_branches 176 | .get_game_latest_by_id(game_edition.api_game_id()) 177 | .ok_or_else(|| { 178 | anyhow::anyhow!("Failed to find the latest game version") 179 | .context(format!("game id: {}", game_edition.api_game_id())) 180 | })?; 181 | let latest_version = latest_branch 182 | .version() 183 | .expect("Valid version returned by the api"); 184 | 185 | let response = api::request(self.edition)?; 186 | 187 | if self.is_installed() { 188 | let current = match self.get_version() { 189 | Ok(version) => version, 190 | Err(err) => { 191 | let game_downloads = sophon::installer::get_game_download_sophon_info( 192 | &client, 193 | latest_branch 194 | .main 195 | .as_ref() 196 | .expect("The `None` case if filtered out earlier"), 197 | game_edition.into() 198 | ) 199 | .context("Getting download info")?; 200 | 201 | let download_info = game_downloads 202 | .get_manifests_for("game") 203 | .cloned() 204 | .ok_or_else(|| anyhow::anyhow!("Failed to get game manifest"))?; 205 | 206 | let downloaded_size = download_info.stats.compressed_size.parse()?; 207 | let unpacked_size = download_info.stats.uncompressed_size.parse()?; 208 | 209 | return Ok(VersionDiff::NotInstalled { 210 | latest: latest_version, 211 | download_info, 212 | edition: game_edition, 213 | downloaded_size, 214 | unpacked_size, 215 | installation_path: Some(self.path.clone()), 216 | version_file_path: None, 217 | temp_folder: None 218 | }); 219 | } 220 | }; 221 | 222 | if current >= latest_version { 223 | tracing::debug!("Game version is latest"); 224 | 225 | if let Some(predownload_info) = &latest_branch.pre_download { 226 | if predownload_info 227 | .diff_tags 228 | .iter() 229 | .any(|pre_diff| *pre_diff == current) 230 | { 231 | let diffs = sophon::updater::get_game_diffs_sophon_info( 232 | &client, 233 | predownload_info, 234 | game_edition.into() 235 | )?; 236 | 237 | let diff_info = diffs.get_manifests_for("game").unwrap().clone(); 238 | 239 | return Ok(VersionDiff::Predownload { 240 | current, 241 | latest: Version::from_str(&predownload_info.tag).unwrap(), 242 | 243 | downloaded_size: diff_info 244 | .stats 245 | .get(¤t.to_string()) 246 | .unwrap() 247 | .compressed_size 248 | .parse() 249 | .unwrap(), 250 | 251 | unpacked_size: diff_info 252 | .stats 253 | .get(¤t.to_string()) 254 | .unwrap() 255 | .uncompressed_size 256 | .parse() 257 | .unwrap(), 258 | 259 | download_info: sophon::api_schemas::DownloadOrDiff::Patch(diff_info), 260 | edition: self.edition, 261 | 262 | installation_path: Some(self.path.clone()), 263 | version_file_path: None, 264 | temp_folder: None 265 | }); 266 | } 267 | } 268 | 269 | Ok(VersionDiff::Latest { 270 | version: current, 271 | edition: self.edition 272 | }) 273 | } 274 | else { 275 | tracing::debug!( 276 | current_version = current.to_string(), 277 | latest_version = latest_version.to_string(), 278 | "Game is outdated" 279 | ); 280 | 281 | let diffs = sophon::updater::get_game_diffs_sophon_info( 282 | &client, 283 | latest_branch 284 | .main 285 | .as_ref() 286 | .expect("The `None` case is filtered out earlier"), 287 | game_edition.into() 288 | ) 289 | .context("Getting game diffs")?; 290 | 291 | if latest_branch 292 | .main 293 | .as_ref() 294 | .expect("The `None` case is filtered out earlier") 295 | .diff_tags 296 | .iter() 297 | .any(|tag| *tag == current) 298 | { 299 | for diff in &diffs.manifests { 300 | if diff.matching_field == "game" { 301 | if let Some((_, stats)) = 302 | diff.stats.iter().find(|(tag, _)| **tag == current) 303 | { 304 | let diff = diff.clone(); 305 | 306 | let downloaded_size = stats.compressed_size.parse()?; 307 | let unpacked_size = stats.uncompressed_size.parse()?; 308 | 309 | return Ok(VersionDiff::Diff { 310 | current, 311 | latest: latest_version, 312 | diff, 313 | edition: game_edition, 314 | downloaded_size, 315 | unpacked_size, 316 | installation_path: Some(self.path.clone()), 317 | version_file_path: None, 318 | temp_folder: None 319 | }); 320 | } 321 | } 322 | } 323 | } 324 | 325 | Ok(VersionDiff::Outdated { 326 | current, 327 | latest: Version::from_str(response.main.major.version).unwrap(), 328 | edition: self.edition 329 | }) 330 | } 331 | } 332 | else { 333 | tracing::debug!("Game is not installed"); 334 | let game_downloads = sophon::installer::get_game_download_sophon_info( 335 | &client, 336 | latest_branch 337 | .main 338 | .as_ref() 339 | .expect("The `None` case if filtered out earlier"), 340 | game_edition.into() 341 | ) 342 | .context("Getting download info")?; 343 | 344 | let download_info = game_downloads 345 | .get_manifests_for("game") 346 | .cloned() 347 | .ok_or_else(|| anyhow::anyhow!("Failed to get game manifest"))?; 348 | 349 | let downloaded_size = download_info.stats.compressed_size.parse()?; 350 | let unpacked_size = download_info.stats.uncompressed_size.parse()?; 351 | 352 | Ok(VersionDiff::NotInstalled { 353 | latest: latest_version, 354 | download_info, 355 | edition: game_edition, 356 | downloaded_size, 357 | unpacked_size, 358 | installation_path: Some(self.path.clone()), 359 | version_file_path: None, 360 | temp_folder: None 361 | }) 362 | } 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /src/games/genshin/game.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::io::Read; 3 | use std::path::{Path, PathBuf}; 4 | 5 | use crate::sophon; 6 | use crate::version::Version; 7 | use crate::traits::prelude::*; 8 | use super::api; 9 | use super::consts::*; 10 | use super::version_diff::*; 11 | use super::voice_data::locale::VoiceLocale; 12 | use super::voice_data::package::VoicePackage; 13 | 14 | #[derive(Debug, Clone, PartialEq, Eq)] 15 | pub struct Game { 16 | path: PathBuf, 17 | edition: GameEdition 18 | } 19 | 20 | impl GameExt for Game { 21 | type Edition = GameEdition; 22 | 23 | #[inline] 24 | fn new(path: impl Into, edition: GameEdition) -> Self { 25 | Self { 26 | path: path.into(), 27 | edition 28 | } 29 | } 30 | 31 | #[inline] 32 | fn path(&self) -> &Path { 33 | self.path.as_path() 34 | } 35 | 36 | #[inline] 37 | fn edition(&self) -> GameEdition { 38 | self.edition 39 | } 40 | 41 | #[inline] 42 | fn is_installed(&self) -> bool { 43 | self.path 44 | .join(self.edition.data_folder()) 45 | .join("globalgamemanagers") 46 | .exists() 47 | } 48 | 49 | #[tracing::instrument(level = "trace", ret)] 50 | /// Try to get latest game version 51 | fn get_latest_version(edition: GameEdition) -> anyhow::Result { 52 | tracing::trace!("Trying to get latest game version"); 53 | 54 | let version = api::request(edition)?.main.major.version; 55 | 56 | Version::from_str(&version).ok_or_else(|| { 57 | anyhow::anyhow!("api returned invalid game version format").context(version) 58 | }) 59 | } 60 | 61 | #[tracing::instrument(level = "debug", ret)] 62 | fn get_version(&self) -> anyhow::Result { 63 | tracing::debug!("Trying to get installed game version"); 64 | 65 | #[inline] 66 | fn bytes_to_num(bytes: &[u8]) -> u8 { 67 | bytes.iter().fold(0u8, |acc, &x| acc * 10 + (x - b'0')) 68 | } 69 | 70 | let stored_version = std::fs::read(self.path.join(".version")) 71 | .map(|version| Version::new(version[0], version[1], version[2])) 72 | .ok(); 73 | 74 | let path = self 75 | .path 76 | .join(self.edition.data_folder()) 77 | .join("globalgamemanagers"); 78 | 79 | let file = File::open(path)?; 80 | 81 | let mut version: [Vec; 3] = [vec![], vec![], vec![]]; 82 | let mut version_ptr: usize = 0; 83 | let mut correct = true; 84 | 85 | for byte in file.bytes().skip(4000).take(10000).flatten() { 86 | match byte { 87 | 0 => { 88 | version = [vec![], vec![], vec![]]; 89 | version_ptr = 0; 90 | correct = true; 91 | } 92 | 93 | 46 => { 94 | version_ptr += 1; 95 | 96 | if version_ptr > 2 { 97 | correct = false; 98 | } 99 | } 100 | 101 | 95 => { 102 | if correct 103 | && !version[0].is_empty() 104 | && !version[1].is_empty() 105 | && !version[2].is_empty() 106 | { 107 | let found_version = Version::new( 108 | bytes_to_num(&version[0]), 109 | bytes_to_num(&version[1]), 110 | bytes_to_num(&version[2]) 111 | ); 112 | 113 | // Little workaround for the minor game patch versions (notably 1.0.1) 114 | // Prioritize version stored in the .version file 115 | // because it's parsed from the API directly 116 | if let Some(stored_version) = stored_version { 117 | if stored_version > found_version { 118 | return Ok(stored_version); 119 | } 120 | } 121 | 122 | return Ok(found_version); 123 | } 124 | 125 | correct = false; 126 | } 127 | 128 | _ => { 129 | if correct && b"0123456789".contains(&byte) { 130 | version[version_ptr].push(byte); 131 | } 132 | else { 133 | correct = false; 134 | } 135 | } 136 | } 137 | } 138 | 139 | if let Some(stored_version) = stored_version { 140 | return Ok(stored_version); 141 | } 142 | 143 | tracing::error!("Version's bytes sequence wasn't found"); 144 | 145 | anyhow::bail!("Version's bytes sequence wasn't found"); 146 | } 147 | } 148 | 149 | impl Game { 150 | /// Get list of installed voice packages 151 | pub fn get_voice_packages(&self) -> anyhow::Result> { 152 | let content = std::fs::read_dir(get_voice_packages_path(&self.path, self.edition))?; 153 | 154 | let packages = content 155 | .into_iter() 156 | .flatten() 157 | .flat_map(|entry| { 158 | VoiceLocale::from_str(entry.file_name().to_string_lossy()) 159 | .map(|locale| get_voice_package_path(&self.path, self.edition, locale)) 160 | .map(|path| VoicePackage::new(path, self.edition)) 161 | }) 162 | .flatten() 163 | .collect(); 164 | 165 | Ok(packages) 166 | } 167 | 168 | #[tracing::instrument(level = "debug", ret)] 169 | pub fn try_get_diff(&self) -> anyhow::Result { 170 | tracing::debug!("Trying to find version diff for the game"); 171 | 172 | let game_edition = self.edition; 173 | 174 | let client = reqwest::blocking::Client::new(); 175 | 176 | let game_branches = sophon::get_game_branches_info(&client, game_edition.into()) 177 | .inspect_err(|err| tracing::error!(?err, "getting game branches error"))?; 178 | 179 | let latest_version = game_branches 180 | .latest_version_by_id(self.edition.game_id()) 181 | .ok_or_else(|| { 182 | anyhow::anyhow!("failed to find the latest game version") 183 | .context(format!("game id: {}", game_edition.game_id())) 184 | })?; 185 | 186 | let branch_info = game_branches 187 | .get_game_by_id(self.edition.game_id(), latest_version) 188 | .ok_or_else(|| { 189 | anyhow::anyhow!("failed to get the game version information") 190 | .context(format!("game id: {}", game_edition.game_id())) 191 | .context(format!("game version: {latest_version}")) 192 | })?; 193 | 194 | if self.is_installed() { 195 | let current = match self.get_version() { 196 | Ok(version) => version, 197 | 198 | Err(err) => { 199 | if self.path.exists() && self.path.metadata()?.len() == 0 { 200 | let game_downloads = sophon::installer::get_game_download_sophon_info( 201 | &client, 202 | branch_info 203 | .main 204 | .as_ref() 205 | .expect("The `None` case would have been caught earlier"), 206 | game_edition.into() 207 | ) 208 | .inspect_err(|err| tracing::error!(?err, "getting download info error"))?; 209 | 210 | let download_info = game_downloads 211 | .get_manifests_for("game") 212 | .cloned() 213 | .ok_or_else(|| anyhow::anyhow!("failed to get game manifest"))?; 214 | 215 | let downloaded_size = download_info.stats.compressed_size.parse()?; 216 | let unpacked_size = download_info.stats.uncompressed_size.parse()?; 217 | 218 | return Ok(VersionDiff::NotInstalled { 219 | latest: latest_version, 220 | 221 | edition: self.edition, 222 | 223 | downloaded_size, 224 | unpacked_size, 225 | download_info, 226 | 227 | installation_path: Some(self.path.clone()), 228 | version_file_path: None, 229 | temp_folder: None 230 | }); 231 | } 232 | 233 | return Err(err); 234 | } 235 | }; 236 | 237 | if current >= latest_version { 238 | tracing::debug!("Game version is latest"); 239 | 240 | // If we're running latest game version the diff we need to download 241 | // must always be `predownload.diffs[0]`, but just to be safe I made 242 | // a loop through possible variants, and if none of them was correct 243 | // (which is not possible in reality) we should just say thath the game 244 | // is latest 245 | if let Some(predownload_info) = &branch_info.pre_download { 246 | if predownload_info 247 | .diff_tags 248 | .iter() 249 | .any(|pre_diff| *pre_diff == current) 250 | { 251 | let diffs = sophon::updater::get_game_diffs_sophon_info( 252 | &client, 253 | predownload_info, 254 | game_edition.into() 255 | )?; 256 | 257 | let diff_info = diffs.get_manifests_for("game").unwrap().clone(); 258 | 259 | return Ok(VersionDiff::Predownload { 260 | current, 261 | latest: Version::from_str(&predownload_info.tag).unwrap(), 262 | 263 | downloaded_size: diff_info 264 | .stats 265 | .get(¤t.to_string()) 266 | .unwrap() 267 | .compressed_size 268 | .parse() 269 | .unwrap(), 270 | 271 | unpacked_size: diff_info 272 | .stats 273 | .get(¤t.to_string()) 274 | .unwrap() 275 | .uncompressed_size 276 | .parse() 277 | .unwrap(), 278 | 279 | download_info: sophon::api_schemas::DownloadOrDiff::Patch(diff_info), 280 | edition: self.edition, 281 | 282 | installation_path: Some(self.path.clone()), 283 | version_file_path: None, 284 | temp_folder: None 285 | }); 286 | } 287 | } 288 | 289 | Ok(VersionDiff::Latest { 290 | version: current, 291 | edition: self.edition 292 | }) 293 | } 294 | else { 295 | tracing::debug!( 296 | current_version = current.to_string(), 297 | latest_version = latest_version.to_string(), 298 | "Game is outdated" 299 | ); 300 | 301 | let diffs = sophon::updater::get_game_diffs_sophon_info( 302 | &client, 303 | branch_info 304 | .main 305 | .as_ref() 306 | .expect("The `None` case would have been caught earlier"), 307 | game_edition.into() 308 | )?; 309 | 310 | if branch_info 311 | .main 312 | .as_ref() 313 | .expect("The `None` case would have been caught earlier") 314 | .diff_tags 315 | .iter() 316 | .any(|tag| *tag == current) 317 | { 318 | for diff in &diffs.manifests { 319 | if diff.matching_field == "game" { 320 | if let Some((_, stats)) = 321 | diff.stats.iter().find(|(tag, _)| **tag == current) 322 | { 323 | let downloaded_size = stats.compressed_size.parse()?; 324 | let unpacked_size = stats.uncompressed_size.parse()?; 325 | 326 | return Ok(VersionDiff::Diff { 327 | current, 328 | latest: latest_version, 329 | 330 | edition: self.edition, 331 | 332 | downloaded_size, 333 | unpacked_size, 334 | 335 | diff: diff.clone(), 336 | 337 | installation_path: Some(self.path.clone()), 338 | version_file_path: None, 339 | temp_folder: None 340 | }); 341 | } 342 | } 343 | } 344 | } 345 | 346 | Ok(VersionDiff::Outdated { 347 | current, 348 | latest: latest_version, 349 | edition: self.edition 350 | }) 351 | } 352 | } 353 | else { 354 | tracing::debug!("Game is not installed"); 355 | 356 | let game_downloads = sophon::installer::get_game_download_sophon_info( 357 | &client, 358 | branch_info 359 | .main 360 | .as_ref() 361 | .expect("The `None` case would have been caught earlier"), 362 | game_edition.into() 363 | )?; 364 | 365 | let download_info = game_downloads 366 | .get_manifests_for("game") 367 | .cloned() 368 | .ok_or_else(|| anyhow::anyhow!("failed to get game manifest"))?; 369 | 370 | let downloaded_size = download_info.stats.compressed_size.parse()?; 371 | let unpacked_size = download_info.stats.uncompressed_size.parse()?; 372 | 373 | Ok(VersionDiff::NotInstalled { 374 | latest: latest_version, 375 | 376 | edition: self.edition, 377 | 378 | downloaded_size, 379 | unpacked_size, 380 | download_info, 381 | 382 | installation_path: Some(self.path.clone()), 383 | version_file_path: None, 384 | temp_folder: None 385 | }) 386 | } 387 | } 388 | } 389 | --------------------------------------------------------------------------------