From aa685aaefd7b1fb1887bdbe4d11e98f5fecd9796 Mon Sep 17 00:00:00 2001 From: lgou2w Date: Sun, 11 Aug 2024 11:13:16 +0800 Subject: [PATCH] chore: infrastructure work --- src-tauri/src/bootstrap/tauri.rs | 3 +- src-tauri/src/business/gacha_url.rs | 581 ++++++++++++++------------- src-tauri/src/business/mod.rs | 11 + src-tauri/src/database/mod.rs | 22 +- src-tauri/src/models/account.rs | 38 +- src-tauri/src/models/gacha_record.rs | 28 -- src-tauri/src/models/kv.rs | 20 - src/api/commands/business.ts | 13 +- src/api/commands/database.ts | 6 +- src/interfaces/Account.ts | 18 +- 10 files changed, 367 insertions(+), 373 deletions(-) diff --git a/src-tauri/src/bootstrap/tauri.rs b/src-tauri/src/bootstrap/tauri.rs index a3548a6..8cac0f4 100644 --- a/src-tauri/src/bootstrap/tauri.rs +++ b/src-tauri/src/bootstrap/tauri.rs @@ -102,7 +102,7 @@ pub async fn start(singleton: Singleton, tracing: Tracing, database: Database) { database::account_questioner::database_find_accounts_by_business, database::account_questioner::database_find_account_by_business_and_uid, database::account_questioner::database_create_account, - database::account_questioner::database_update_account_data_dir_by_business_and_uid, + database::account_questioner::database_update_account_data_folder_by_business_and_uid, database::account_questioner::database_update_account_gacha_url_by_business_and_uid, database::account_questioner::database_update_account_properties_by_business_and_uid, database::account_questioner::database_delete_account_by_business_and_uid, @@ -112,6 +112,7 @@ pub async fn start(singleton: Singleton, tracing: Tracing, database: Database) { database::gacha_record_questioner_additions::database_delete_gacha_records_by_business_and_uid, business::business_locate_data_folder, business::business_obtain_gacha_url, + business::business_from_dirty_gacha_url, business::business_create_gacha_records_fetcher_channel, ]) .build(generate_context!()) diff --git a/src-tauri/src/business/gacha_url.rs b/src-tauri/src/business/gacha_url.rs index b99b67b..3bd7d87 100644 --- a/src-tauri/src/business/gacha_url.rs +++ b/src-tauri/src/business/gacha_url.rs @@ -22,10 +22,10 @@ use crate::models::{BizInternals, Business, BusinessRegion, GachaRecord}; declare_error_kinds! { GachaUrlError, kinds { - #[error("Web caches path does not exist: {path}")] + #[error("Webcaches path does not exist: {path}")] WebCachesNotFound { path: PathBuf }, - #[error("Error opening web caches: {cause}")] + #[error("Error opening webcaches: {cause}")] OpenWebCaches { cause: std::io::Error => serde_json::json!({ "kind": format_args!("{}", cause.kind()), @@ -47,6 +47,9 @@ declare_error_kinds! { #[error("Illegal gacha url")] Illegal { url: String }, + #[error("Illegal gacha url game biz (expected: {expected}, actual: {actual}")] + IllegalBiz { url: String, expected: String, actual: String }, + #[error("Invalid gacha url query params: {params:?}")] InvalidParams { params: Vec }, @@ -74,26 +77,6 @@ declare_error_kinds! { } } -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GachaUrl { - pub business: Business, - pub region: BusinessRegion, - pub owner_uid: u32, - #[serde(with = "rfc3339")] - pub creation_time: OffsetDateTime, - // Some important parameters of the Raw gacha url, - // which are normally essential. - pub param_game_biz: String, - pub param_region: String, - pub param_lang: String, - pub param_authkey: String, - pub value: Url, -} - -// Dirty url without parsing and validation -type DirtyGachaUrl = (String, OffsetDateTime); - static REGEX_GACHA_URL: Lazy = Lazy::new(|| { Regex::new(r"(?i)^https:\/\/.*(mihoyo.com|hoyoverse.com).*(\/getGachaLog\?).*(authkey\=).*$") .unwrap() @@ -103,7 +86,7 @@ static REGEX_WEB_CACHES_VERSION: Lazy = Lazy::new(|| { Regex::new(r"^(?P\d+)\.(?P\d+)\.(?P\d+)(\.(?P\d+))?$").unwrap() }); -/// `Web Caches` version number. For example: `x.y.z` or `x.y.z.a` +/// `WebCaches` version number. For example: `x.y.z` or `x.y.z.a` #[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] struct WebCachesVersion(u8, u8, u8, Option); @@ -134,163 +117,214 @@ impl WebCachesVersion { } } -impl GachaUrl { +#[derive(Debug)] +pub struct DirtyGachaUrl { + pub creation_time: Option, + pub value: String, +} + +impl DirtyGachaUrl { #[tracing::instrument] - pub async fn obtain( - business: &Business, - region: &BusinessRegion, + pub async fn from_web_caches( data_folder: impl AsRef + Debug, - expected_uid: u32, - ) -> Result { - let biz = BizInternals::mapped(business, region); - let dirty_urls = Self::from_web_caches(data_folder).await?; - Self::consistency_check(biz, dirty_urls, expected_uid).await + ) -> Result, GachaUrlError> { + info!("Reading gacha urls from webcaches..."); + + let cache_data_folder = Self::combie_cache_data_folder(data_folder).await?; + let gacha_urls = Self::read_cache_data_gacha_urls(cache_data_folder) + .map_err(|cause| GachaUrlErrorKind::ReadDiskCache { cause })?; + + Ok(gacha_urls) } - #[tracing::instrument(fields(?data_folder, web_caches_version))] - pub async fn from_web_caches( + #[tracing::instrument] + async fn combie_cache_data_folder( data_folder: impl AsRef + Debug, - ) -> Result, GachaUrlError> { - info!("Reading valid gacha urls from web caches..."); + ) -> Result { + info!("Finding the webcaches data folder from the data folder"); let span = Span::current(); - let cache_data_folder = { - let web_caches_folder = data_folder.as_ref().join("webCaches"); - if !web_caches_folder.is_dir() { - warn!("Web caches folder does not exist: {web_caches_folder:?}"); - return Err(GachaUrlErrorKind::WebCachesNotFound { - path: web_caches_folder, - })?; - } - - let mut walk_dir = tokio::fs::read_dir(&web_caches_folder) - .await - .map_err(|cause| GachaUrlErrorKind::OpenWebCaches { cause })?; + let web_caches_folder = data_folder.as_ref().join("webCaches"); + if !web_caches_folder.is_dir() { + warn!("Webcaches folder does not exist: {web_caches_folder:?}"); + return Err(GachaUrlErrorKind::WebCachesNotFound { + path: web_caches_folder, + })?; + } - let mut versions = Vec::new(); - while let Ok(Some(entry)) = walk_dir.next_entry().await { - if !entry.path().is_dir() { - continue; - } + let mut walk_dir = tokio::fs::read_dir(&web_caches_folder) + .await + .map_err(|cause| GachaUrlErrorKind::OpenWebCaches { cause })?; - let entry_name = entry.file_name(); - if let Some(version) = WebCachesVersion::parse(entry_name.to_string_lossy()) { - versions.push(version); - } + let mut versions = Vec::new(); + while let Ok(Some(entry)) = walk_dir.next_entry().await { + if !entry.path().is_dir() { + continue; } - if versions.is_empty() { - warn!("List of versions of web caches not found"); - return Err(GachaUrlErrorKind::WebCachesNotFound { - path: web_caches_folder, - })?; + let entry_name = entry.file_name(); + if let Some(version) = WebCachesVersion::parse(entry_name.to_string_lossy()) { + versions.push(version); } + } + + if versions.is_empty() { + warn!("List of versions of webcaches not found"); + return Err(GachaUrlErrorKind::WebCachesNotFound { + path: web_caches_folder, + })?; + } - // Sort by version asc - versions.sort(); + // Sort by version asc + versions.sort(); - // Get the latest version - let latest_version = versions.last().unwrap().to_string(); // SAFETY - info!("Retrieve the latest version of web caches: {latest_version}"); - span.record("web_caches_version", &latest_version); + // Get the latest version + let latest_version = versions.last().unwrap().to_string(); // SAFETY + info!("Retrieve the latest version of webcaches: {latest_version}"); + span.record("web_caches_version", &latest_version); + Ok( web_caches_folder .join(latest_version) .join("Cache") - .join("Cache_Data") - }; + .join("Cache_Data"), + ) + } - // TODO: Async fs? - #[inline] - fn read_disk_cache_gacha_urls( - cache_data_folder: PathBuf, - ) -> std::io::Result> { - info!("Starting to read disk cache gacha urls..."); + #[tracing::instrument] + fn read_cache_data_gacha_urls( + cache_data_folder: impl AsRef + Debug, + ) -> std::io::Result> { + info!("Starting to read disk cache gacha urls..."); + let cache_data_folder = cache_data_folder.as_ref(); - info!("Reading index file..."); - let index_file = IndexFile::from_file(cache_data_folder.join("index"))?; + info!("Reading index file..."); + let index_file = IndexFile::from_file(cache_data_folder.join("index"))?; - info!("Reading block data_1 file..."); - let block_file1 = BlockFile::from_file(cache_data_folder.join("data_1"))?; + info!("Reading block data_1 file..."); + let block_file1 = BlockFile::from_file(cache_data_folder.join("data_1"))?; - info!("Reading block data_2 file..."); - let block_file2 = BlockFile::from_file(cache_data_folder.join("data_2"))?; + info!("Reading block data_2 file..."); + let block_file2 = BlockFile::from_file(cache_data_folder.join("data_2"))?; - let mut urls = Vec::new(); - let now_local = OffsetDateTime::now_utc().to_offset(*consts::LOCAL_OFFSET); + let mut urls = Vec::new(); + let now_local = OffsetDateTime::now_utc().to_offset(*consts::LOCAL_OFFSET); - info!("Foreach the cache address table of the index file..."); - for addr in index_file.table { - // The previous places should not print logs. - // Because the table of cache address is too large. - //debug!("Read the entry store at cache address: {addr:?}"); + info!("Foreach the cache address table of the index file..."); + for addr in index_file.table { + // The previous places should not print logs. + // Because the table of cache address is too large. + //debug!("Read the entry store at cache address: {addr:?}"); - // Read the entry store from the data_1 block file by cache address - let entry_store = block_file1.read_entry_store(&addr)?; + // Read the entry store from the data_1 block file by cache address + let entry_store = block_file1.read_entry_store(&addr)?; - // Gacha url must be a long key and stored in the data_2 block file, - // So the long key of entry store must not be zero. - if !entry_store.has_long_key() { - continue; - } - - // Maybe the long key points to data_3 or something else - // See: https://github.com/lgou2w/HoYo.Gacha/issues/15 - if entry_store.long_key.file_number() != block_file2.header.this_file as u32 { - continue; - } + // Gacha url must be a long key and stored in the data_2 block file, + // So the long key of entry store must not be zero. + if !entry_store.has_long_key() { + continue; + } - // Convert creation time - let creation_time = { - let timestamp = (entry_store.creation_time / 1_000_000) as i64 - 11_644_473_600; - OffsetDateTime::from_unix_timestamp(timestamp) - .unwrap() // FIXME: SAFETY? - .to_offset(*consts::LOCAL_OFFSET) - }; - - // By default, this gacha url is valid for 1 day. - if creation_time + time::Duration::DAY < now_local { - continue; // It's expired - } + // Maybe the long key points to data_3 or something else + // See: https://github.com/lgou2w/HoYo.Gacha/issues/15 + if entry_store.long_key.file_number() != block_file2.header.this_file as u32 { + continue; + } - // Read the long key of entry store from the data_2 block file - let url = entry_store.read_long_key(&block_file2)?; + // Convert creation time + let creation_time = { + let timestamp = (entry_store.creation_time / 1_000_000) as i64 - 11_644_473_600; + OffsetDateTime::from_unix_timestamp(timestamp) + .unwrap() // FIXME: SAFETY? + .to_offset(*consts::LOCAL_OFFSET) + }; - // These url start with '1/0/', only get the later part - let url = if let Some(stripped) = url.strip_prefix("1/0/") { - stripped - } else { - &url - }; + // By default, this gacha url is valid for 1 day. + if creation_time + time::Duration::DAY < now_local { + continue; // It's expired + } - // Verify that the url is the correct gacha url - if !REGEX_GACHA_URL.is_match(url) { - continue; - } + // Read the long key of entry store from the data_2 block file + let url = entry_store.read_long_key(&block_file2)?; - info!( - message = "Valid gacha url exist in the cache address", - ?addr, - ?entry_store.long_key, - ?creation_time, - url - ); + // These url start with '1/0/', only get the later part + let url = if let Some(stripped) = url.strip_prefix("1/0/") { + stripped + } else { + &url + }; - urls.push((url.to_string(), creation_time)); + // Verify that the url is the correct gacha url + if !REGEX_GACHA_URL.is_match(url) { + continue; } - // Sort by creation time desc - urls.sort_by(|a, b| b.1.cmp(&a.1)); - - info!("Total number of gacha urls found: {}", urls.len()); + info!( + message = "Valid gacha url exist in the cache address", + ?addr, + ?entry_store.long_key, + ?creation_time, + url + ); - Ok(urls) + urls.push(Self { + creation_time: Some(creation_time), + value: url.to_owned(), + }); } - Ok( - read_disk_cache_gacha_urls(cache_data_folder) - .map_err(|cause| GachaUrlErrorKind::ReadDiskCache { cause })?, - ) + // Sort by creation time desc + urls.sort_by(|a, b| b.creation_time.cmp(&a.creation_time)); + + info!("Total number of gacha urls found: {}", urls.len()); + Ok(urls) + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GachaUrl { + pub business: Business, + pub region: BusinessRegion, + pub owner_uid: u32, + #[serde(with = "rfc3339::option")] + pub creation_time: Option, + #[serde(flatten)] + pub url: ParsedGachaUrl, +} + +impl GachaUrl { + // Read all valid gacha urls from the webcaches data folder + // and check for timeliness and consistency to get the latest gacha url. + #[tracing::instrument] + pub async fn obtain( + business: &Business, + region: &BusinessRegion, + data_folder: impl AsRef + Debug, + expected_uid: u32, + ) -> Result { + let biz = BizInternals::mapped(business, region); + let dirty_urls = DirtyGachaUrl::from_web_caches(data_folder).await?; + Self::consistency_check(biz, dirty_urls, expected_uid).await + } + + // Verifying timeliness and consistency from a dirty gacha url + #[tracing::instrument] + pub async fn from_dirty( + business: &Business, + region: &BusinessRegion, + dirty_url: String, + expected_uid: u32, + ) -> Result { + let biz = BizInternals::mapped(business, region); + let dirty_urls = vec![DirtyGachaUrl { + // Because the creation time is not known from the dirty gacha url. + // The server will not return the creation time. + creation_time: None, + value: dirty_url, + }]; + + Self::consistency_check(biz, dirty_urls, expected_uid).await } #[tracing::instrument(skip(biz, dirty_urls), fields(urls = dirty_urls.len(), ?expected_uid))] @@ -302,14 +336,8 @@ impl GachaUrl { info!("Find owner consistency gacha url..."); let mut actual = Vec::with_capacity(dirty_urls.len()); - for (dirty, creation_time) in dirty_urls { - let ParsedGachaUrl { - param_game_biz, - param_region, - param_lang, - param_authkey, - value: gacha_url, - } = match parse_gacha_url(biz, &dirty, None, None) { + for dirty in dirty_urls { + let parsed = match ParsedGachaUrl::parse(biz, &dirty.value, None, None) { Ok(parsed) => parsed, Err(error) => { warn!("Error parsing gacha url: {error:?}"); @@ -317,7 +345,7 @@ impl GachaUrl { } }; - match request_gacha_url_with_retry(gacha_url.clone(), None).await { + match request_gacha_url_with_retry(parsed.value.clone(), None).await { Err(error) => { warn!("Error requesting gacha url: {error:?}"); continue; @@ -335,20 +363,16 @@ impl GachaUrl { info!( message = "Capture the gacha url with the expected uid", expected_uid, - ?creation_time, - url = ?dirty, + creation_time = ?dirty.creation_time, + url = ?dirty.value, ); return Ok(GachaUrl { business: *biz.business, region: *biz.region, + creation_time: dirty.creation_time, + url: parsed, owner_uid: expected_uid, - creation_time, - param_game_biz, - param_region, - param_lang, - param_authkey, - value: gacha_url, }); } else { // The gacha url does not match the expected uid @@ -376,6 +400,122 @@ impl GachaUrl { } } +// A gacha url that has been parsed and validated, +// but may be expired as it needs to be requested to be known. +#[derive(Debug, Serialize)] +pub struct ParsedGachaUrl { + // Some important parameters of the Raw gacha url, + // which are normally essential. + pub param_game_biz: String, + pub param_region: String, + pub param_lang: String, + pub param_authkey: String, + // Valid gacha url + pub value: Url, +} + +impl ParsedGachaUrl { + #[tracing::instrument(skip(biz))] + pub fn parse( + biz: &BizInternals, + gacha_url: &str, + gacha_type: Option<&str>, + end_id: Option<&str>, + ) -> Result { + if !REGEX_GACHA_URL.is_match(gacha_url) { + Err(GachaUrlErrorKind::Illegal { + url: gacha_url.into(), + })?; + } + + // SAFETY + let query_start = gacha_url.find('?').unwrap(); + let base_url = &gacha_url[..query_start]; + let query_str = &gacha_url[query_start + 1..]; + + let mut queries = url::form_urlencoded::parse(query_str.as_bytes()) + .into_owned() + .collect::>(); + + macro_rules! required_param { + ($name:literal) => { + queries + .get($name) + .filter(|s| !s.is_empty()) + .ok_or(GachaUrlErrorKind::InvalidParams { + params: vec![$name.into()], + })? + }; + } + + let param_game_biz = required_param!("game_biz").to_owned(); + let param_region = required_param!("region").to_owned(); + let param_lang = required_param!("lang").to_owned(); + let param_authkey = required_param!("authkey").to_owned(); + + // Verify that the game biz matches + if param_game_biz != biz.codename { + Err(GachaUrlErrorKind::IllegalBiz { + url: gacha_url.into(), + expected: biz.codename.into(), + actual: param_game_biz.clone(), + })?; + } + + let (gacha_type_field, init_type_field) = match biz.business { + Business::GenshinImpact => ("gacha_type", "init_type"), + Business::HonkaiStarRail => ("gacha_type", "default_gacha_type"), + Business::ZenlessZoneZero => ("real_gacha_type", "init_log_gacha_base_type"), + }; + + let origin_gacha_type = queries + .get(gacha_type_field) + .cloned() + .or(queries.get(init_type_field).cloned()) + .ok_or_else(|| { + warn!( + "Gacha url missing important '{gacha_type_field}' or '{init_type_field}' parameters: {queries:?}" + ); + + GachaUrlErrorKind::InvalidParams { + params: vec![gacha_type_field.into(), init_type_field.into()], + } + })?; + + let origin_end_id = queries.get("end_id").cloned(); + let gacha_type = gacha_type.unwrap_or(&origin_gacha_type); + + // Deletion and modification of some query parameters + queries.remove(gacha_type_field); + queries.remove("page"); + queries.remove("size"); + queries.remove("begin_id"); + queries.remove("end_id"); + queries.insert("page".into(), "1".into()); + queries.insert("size".into(), "20".into()); + queries.insert(gacha_type_field.into(), gacha_type.into()); + + if let Some(end_id) = end_id.or(origin_end_id.as_deref()) { + queries.insert("end_id".into(), end_id.into()); + } + + let url = Url::parse_with_params(base_url, queries).map_err(|cause| { + // Normally, this is never reachable here. + // Unless it's a `url` crate issue. + warn!("Error parsing gacha url with params: {cause}"); + GachaUrlErrorKind::Parse { cause } + })?; + + Ok(Self { + param_game_biz, + param_region, + param_lang, + param_authkey, + value: url, + }) + } +} + #[derive(Deserialize)] struct GachaRecordsResponse { retcode: i32, @@ -457,111 +597,8 @@ struct GachaRecordsPagination { region_time_zone: Option, } -#[derive(Debug)] -struct ParsedGachaUrl { - // Some important parameters of the Raw gacha url, - // which are normally essential. - pub param_game_biz: String, - pub param_region: String, - pub param_lang: String, - pub param_authkey: String, - // Gacha url - pub value: Url, -} - -#[tracing::instrument(skip(biz))] -pub fn parse_gacha_url( - biz: &BizInternals, - gacha_url: &str, - gacha_type: Option<&str>, - end_id: Option<&str>, -) -> Result { - if !REGEX_GACHA_URL.is_match(gacha_url) { - Err(GachaUrlErrorKind::Illegal { - url: gacha_url.into(), - })?; - } - - // SAFETY - let query_start = gacha_url.find('?').unwrap(); - let base_url = &gacha_url[..query_start]; - let query_str = &gacha_url[query_start + 1..]; - - let mut queries = url::form_urlencoded::parse(query_str.as_bytes()) - .into_owned() - .collect::>(); - - macro_rules! required_param { - ($name:literal) => { - queries - .get($name) - .filter(|s| !s.is_empty()) - .ok_or(GachaUrlErrorKind::InvalidParams { - params: vec![$name.into()], - })? - }; - } - - let param_game_biz = required_param!("game_biz").to_owned(); - let param_region = required_param!("region").to_owned(); - let param_lang = required_param!("lang").to_owned(); - let param_authkey = required_param!("authkey").to_owned(); - - let (gacha_type_field, init_type_field) = match biz.business { - Business::GenshinImpact => ("gacha_type", "init_type"), - Business::HonkaiStarRail => ("gacha_type", "default_gacha_type"), - Business::ZenlessZoneZero => ("real_gacha_type", "init_log_gacha_base_type"), - }; - - let origin_gacha_type = queries - .get(gacha_type_field) - .cloned() - .or(queries.get(init_type_field).cloned()) - .ok_or_else(|| { - warn!( - "Gacha url missing important '{gacha_type_field}' or '{init_type_field}' parameters: {queries:?}" - ); - - GachaUrlErrorKind::InvalidParams { - params: vec![gacha_type_field.into(), init_type_field.into()], - } - })?; - - let origin_end_id = queries.get("end_id").cloned(); - let gacha_type = gacha_type.unwrap_or(&origin_gacha_type); - - // Deletion and modification of some query parameters - queries.remove(gacha_type_field); - queries.remove("page"); - queries.remove("size"); - queries.remove("begin_id"); - queries.remove("end_id"); - queries.insert("page".into(), "1".into()); - queries.insert("size".into(), "20".into()); - queries.insert(gacha_type_field.into(), gacha_type.into()); - - if let Some(end_id) = end_id.or(origin_end_id.as_deref()) { - queries.insert("end_id".into(), end_id.into()); - } - - let url = Url::parse_with_params(base_url, queries).map_err(|cause| { - // Normally, this is never reachable here. - // Unless it's a `url` crate issue. - warn!("Error parsing gacha url with params: {cause}"); - GachaUrlErrorKind::Parse { cause } - })?; - - Ok(ParsedGachaUrl { - param_game_biz, - param_region, - param_lang, - param_authkey, - value: url, - }) -} - #[tracing::instrument(skip(url))] -pub async fn request_gacha_url( +async fn request_gacha_url( url: Url, timeout: Option, ) -> Result { @@ -595,7 +632,7 @@ pub async fn request_gacha_url( } #[tracing::instrument(skip(url))] -pub fn request_gacha_url_with_retry( +fn request_gacha_url_with_retry( url: Url, retries: Option, ) -> BoxFuture<'static, Result> { @@ -647,7 +684,7 @@ pub async fn fetch_gacha_records( info!("Fetching the gacha records..."); let biz = BizInternals::mapped(business, region); - let parsed = parse_gacha_url(biz, gacha_url, gacha_type, end_id)?; + let parsed = ParsedGachaUrl::parse(biz, gacha_url, gacha_type, end_id)?; let pagination = match request_gacha_url_with_retry(parsed.value, None).await { Err(error) => { warn!("Responded with an error while fetching the gacha records: {error:?}"); @@ -699,22 +736,22 @@ mod tests { let biz = BizInternals::GENSHIN_IMPACT_OFFICIAL; assert!(matches!( - parse_gacha_url(biz, "", None, None).map_err(Error::into_inner), + ParsedGachaUrl::parse(biz, "", None, None).map_err(Error::into_inner), Err(GachaUrlErrorKind::Illegal { url }) if url.is_empty() )); assert!(matches!( - parse_gacha_url(biz, "?", None, None).map_err(Error::into_inner), + ParsedGachaUrl::parse(biz, "?", None, None).map_err(Error::into_inner), Err(GachaUrlErrorKind::Illegal { url }) if url == "?" )); assert!(matches!( - parse_gacha_url(biz, "https://.mihoyo.com/getGachaLog?", None, None).map_err(Error::into_inner), + ParsedGachaUrl::parse(biz, "https://.mihoyo.com/getGachaLog?", None, None).map_err(Error::into_inner), Err(GachaUrlErrorKind::Illegal { url }) if url == "https://.mihoyo.com/getGachaLog?" )); assert!(matches!( - parse_gacha_url( + ParsedGachaUrl::parse( biz, "https://fake-test.mihoyo.com/getGachaLog?authkey=", None, @@ -731,9 +768,9 @@ mod tests { param_lang, param_authkey, value: _value, - } = parse_gacha_url(biz, "https://fake-test.mihoyo.com/getGachaLog?game_biz=biz®ion=region&lang=lang&authkey=authkey&gacha_type=gacha_type", None, None).unwrap(); + } = ParsedGachaUrl::parse(biz, &format!("https://fake-test.mihoyo.com/getGachaLog?game_biz={game_biz}®ion=region&lang=lang&authkey=authkey&gacha_type=gacha_type", game_biz = biz.codename), None, None).unwrap(); - assert_eq!(param_game_biz, "biz"); + assert_eq!(param_game_biz, biz.codename); assert_eq!(param_region, "region"); assert_eq!(param_lang, "lang"); assert_eq!(param_authkey, "authkey"); diff --git a/src-tauri/src/business/mod.rs b/src-tauri/src/business/mod.rs index 3d62f69..c86b9f4 100644 --- a/src-tauri/src/business/mod.rs +++ b/src-tauri/src/business/mod.rs @@ -40,6 +40,17 @@ pub async fn business_obtain_gacha_url( GachaUrl::obtain(&business, ®ion, &data_folder, expected_uid).await } +#[tauri::command] +#[tracing::instrument(skip_all)] +pub async fn business_from_dirty_gacha_url( + business: Business, + region: BusinessRegion, + dirty_url: String, + expected_uid: u32, +) -> Result { + GachaUrl::from_dirty(&business, ®ion, dirty_url, expected_uid).await +} + #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateGachaRecordsFetcherChannelOptions { diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index a469116..fc19dea 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -131,11 +131,11 @@ CREATE TABLE IF NOT EXISTS `hg.kvs` ( ); CREATE TABLE IF NOT EXISTS `hg.accounts` ( - `business` INTEGER NOT NULL, - `uid` INTEGER NOT NULL, - `data_dir` TEXT NOT NULL, - `gacha_url` TEXT, - `properties` TEXT, + `business` INTEGER NOT NULL, + `uid` INTEGER NOT NULL, + `data_folder` TEXT NOT NULL, + `gacha_url` TEXT, + `properties` TEXT, PRIMARY KEY (`business`, `uid`) ); CREATE INDEX IF NOT EXISTS `hg.accounts.business_idx` ON `hg.accounts` (`business`); @@ -324,17 +324,17 @@ declare_questioner_with_handlers! { uid: u32, }: fetch_optional -> Option, - "INSERT INTO `hg.accounts` (`business`, `uid`, `data_dir`, `properties`) VALUES (?, ?, ?, ?) RETURNING *;" + "INSERT INTO `hg.accounts` (`business`, `uid`, `data_folder`, `properties`) VALUES (?, ?, ?, ?) RETURNING *;" = create_account { business: Business, uid: u32, - data_dir: String, + data_folder: String, properties: Option, }: fetch_one -> Account, - "UPDATE `hg.accounts` SET `data_dir` = ? WHERE `business` = ? AND `uid` = ? RETURNING *;" - = update_account_data_dir_by_business_and_uid { - data_dir: String, + "UPDATE `hg.accounts` SET `data_folder` = ? WHERE `business` = ? AND `uid` = ? RETURNING *;" + = update_account_data_folder_by_business_and_uid { + data_folder: String, business: Business, uid: u32, }: fetch_optional -> Option, @@ -364,7 +364,7 @@ impl<'r> FromRow<'r, SqliteRow> for Account { Ok(Self { business: row.try_get("business")?, uid: row.try_get("uid")?, - data_dir: row.try_get("data_dir")?, + data_folder: row.try_get("data_folder")?, gacha_url: row.try_get("gacha_url")?, properties: row.try_get("properties")?, }) diff --git a/src-tauri/src/models/account.rs b/src-tauri/src/models/account.rs index 6e2ca8e..200b513 100644 --- a/src-tauri/src/models/account.rs +++ b/src-tauri/src/models/account.rs @@ -1,4 +1,3 @@ -use std::hash::{Hash, Hasher}; use std::ops::{Deref, DerefMut}; use serde::{Deserialize, Serialize}; @@ -32,35 +31,11 @@ impl DerefMut for AccountProperties { pub struct Account { pub business: Business, pub uid: u32, - pub data_dir: String, + pub data_folder: String, pub gacha_url: Option, pub properties: Option, } -impl PartialEq for Account { - fn eq(&self, other: &Self) -> bool { - self.business == other.business && self.uid == other.uid - } -} - -impl PartialOrd for Account { - fn partial_cmp(&self, other: &Self) -> Option { - Some( - self - .business - .cmp(&other.business) - .then(self.uid.cmp(&other.uid)), - ) - } -} - -impl Hash for Account { - fn hash(&self, state: &mut H) { - self.business.hash(state); - self.uid.hash(state); - } -} - // Tests #[cfg(test)] @@ -72,14 +47,16 @@ mod tests { let mut account = Account { business: Business::GenshinImpact, uid: 100_000_001, - data_dir: "empty".into(), + data_folder: "empty".into(), gacha_url: None, properties: None, }; assert!(matches!( serde_json::to_string(&account).as_deref(), - Ok(r#"{"business":0,"uid":100000001,"dataDir":"empty","gachaUrl":null,"properties":null}"#) + Ok( + r#"{"business":0,"uid":100000001,"dataFolder":"empty","gachaUrl":null,"properties":null}"# + ) )); account.gacha_url.replace("some gacha url".into()); @@ -93,7 +70,7 @@ mod tests { assert!(matches!( serde_json::to_string(&account).as_deref(), Ok( - r#"{"business":0,"uid":100000001,"dataDir":"empty","gachaUrl":"some gacha url","properties":{"foo":"bar"}}"# + r#"{"business":0,"uid":100000001,"dataFolder":"empty","gachaUrl":"some gacha url","properties":{"foo":"bar"}}"# ) )); } @@ -104,7 +81,7 @@ mod tests { { "business": 0, "uid": 100000001, - "dataDir": "some game data dir", + "dataFolder": "some data folder", "gachaUrl": "some gacha url", "properties": { "foo": "bar", @@ -116,6 +93,7 @@ mod tests { let account = serde_json::from_str::(json).unwrap(); assert_eq!(account.business, Business::GenshinImpact); assert_eq!(account.uid, 100_000_001); + assert_eq!(account.data_folder, "some data folder"); assert_eq!(account.gacha_url.as_deref(), Some("some gacha url")); assert_eq!( diff --git a/src-tauri/src/models/gacha_record.rs b/src-tauri/src/models/gacha_record.rs index 880ef6d..815fa25 100644 --- a/src-tauri/src/models/gacha_record.rs +++ b/src-tauri/src/models/gacha_record.rs @@ -1,5 +1,3 @@ -use std::hash::{Hash, Hasher}; - use serde::{Deserialize, Serialize}; use super::Business; @@ -47,29 +45,3 @@ pub struct GachaRecord { pub item_type: String, pub item_id: String, } - -impl PartialEq for GachaRecord { - fn eq(&self, other: &Self) -> bool { - self.business == other.business && self.uid == other.uid && self.id == other.id - } -} - -impl PartialOrd for GachaRecord { - fn partial_cmp(&self, other: &Self) -> Option { - Some( - self - .business - .cmp(&other.business) - .then(self.uid.cmp(&other.uid)) - .then(self.id.cmp(&other.id)), - ) - } -} - -impl Hash for GachaRecord { - fn hash(&self, state: &mut H) { - self.business.hash(state); - self.uid.hash(state); - self.id.hash(state); - } -} diff --git a/src-tauri/src/models/kv.rs b/src-tauri/src/models/kv.rs index 48f96f1..07208c4 100644 --- a/src-tauri/src/models/kv.rs +++ b/src-tauri/src/models/kv.rs @@ -1,5 +1,3 @@ -use std::hash::{Hash, Hasher}; - use serde::{Deserialize, Serialize}; use time::serde::rfc3339; use time::OffsetDateTime; @@ -12,21 +10,3 @@ pub struct Kv { #[serde(with = "rfc3339")] pub updated_at: OffsetDateTime, } - -impl PartialEq for Kv { - fn eq(&self, other: &Self) -> bool { - self.key == other.key - } -} - -impl PartialOrd for Kv { - fn partial_cmp(&self, other: &Self) -> Option { - self.key.partial_cmp(&other.key) - } -} - -impl Hash for Kv { - fn hash(&self, state: &mut H) { - self.key.hash(state); - } -} diff --git a/src/api/commands/business.ts b/src/api/commands/business.ts index 8a1e4c8..7afa71b 100644 --- a/src/api/commands/business.ts +++ b/src/api/commands/business.ts @@ -50,6 +50,7 @@ export type GachaUrlError = DetailedError { business: T region: BusinessRegion ownerUid: Account['uid'] - creationTime: string + creationTime: string | null paramGameBiz: string paramRegion: string paramLang: string @@ -86,6 +87,16 @@ export type ObtainGachaUrlArgs = NonNullable<{ export type ObtainGachaUrl = (args: ObtainGachaUrlArgs, options?: InvokeOptions) => Promise> export const obtainGachaUrl: ObtainGachaUrl = declareCommand('business_obtain_gacha_url') +export type FromDirtyGachaUrlArgs = NonNullable<{ + business: T + region: BusinessRegion + dirtyUrl: string + expectedUid: Account['uid'] +}> + +export type FromDirtyGachaUrl = (args: FromDirtyGachaUrlArgs, options?: InvokeOptions) => Promise> +export const fromDirtyGachaUrl: FromDirtyGachaUrl = declareCommand('business_from_dirty_gacha_url') + // Business Advanced export type CreateGachaRecordsFetcherChannelArgs = NonNullable<{ diff --git a/src/api/commands/database.ts b/src/api/commands/database.ts index fb47aef..ff8f640 100644 --- a/src/api/commands/database.ts +++ b/src/api/commands/database.ts @@ -59,11 +59,11 @@ export const findAccountsByBusiness = declareCommand export const findAccountByBusinessAndUid = declareCommand('database_find_account_by_business_and_uid') -export type CreateAccountArgs = Pick & Partial> +export type CreateAccountArgs = Pick & Partial> export const createAccount = declareCommand('database_create_account') -export type UpdateAccountDataDirByBusinessAndUidArgs = Pick -export const updateAccountDataDirByBusinessAndUid = declareCommand('database_update_account_data_dir_by_business_and_uid') +export type UpdateAccountDataFolderByBusinessAndUidArgs = Pick +export const updateAccountDataFolderByBusinessAndUid = declareCommand('database_update_account_data_folder_by_business_and_uid') export type UpdateAccountGachaUrlByBusinessAndUidArgs = Pick export const updateAccountGachaUrlByBusinessAndUid = declareCommand('database_update_account_gacha_url_by_business_and_uid') diff --git a/src/interfaces/Account.ts b/src/interfaces/Account.ts index 385fcb6..855ecf8 100644 --- a/src/interfaces/Account.ts +++ b/src/interfaces/Account.ts @@ -1,4 +1,4 @@ -import { Business, Businesses } from './Business' +import { Business, Businesses, GenshinImpact, HonkaiStarRail, ZenlessZoneZero } from './Business' // Account // See: src-tauri/src/models/account.rs @@ -7,25 +7,29 @@ export interface KnownAccountProperties { displayName: string | null } -export interface Account { - business: Business +export interface Account { + business: T uid: number - dataDir: string + dataFolder: string gachaUrl: string | null properties: KnownAccountProperties & Record | null } +export type GenshinImpactAccount = Account +export type HonkaiStarRailAccount = Account +export type ZenlessZoneZeroAccount = Account + // Utilities -export function isGenshinImpactAccount (account: Account): boolean { +export function isGenshinImpactAccount (account: Account): account is GenshinImpactAccount { return account.business === Businesses.GenshinImpact } -export function isHonkaiStarRailAccount (account: Account): boolean { +export function isHonkaiStarRailAccount (account: Account): account is HonkaiStarRailAccount { return account.business === Businesses.HonkaiStarRail } -export function isZenlessZoneZeroAccount (account: Account): boolean { +export function isZenlessZoneZeroAccount (account: Account): account is ZenlessZoneZeroAccount { return account.business === Businesses.ZenlessZoneZero }