use std::collections::HashMap; use std::path::PathBuf; use std::time::Duration; use crate::core::config::{AppConfig, RegionConfig, RegionProviderConfig}; use crate::core::errors::AssetExecutionError; use crate::core::export_pipeline::extract_unity_asset_bundle; use crate::core::regions::{compile_patterns, matches_any}; use crate::core::retry::retry_async; use aes::cipher::block_padding::Pkcs7; use aes::cipher::{BlockDecryptMut, KeyIvInit}; use chrono::FixedOffset; use common::updater::{AssetCategory, DownloadTask, SyncContext}; use reqwest::header::{ ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CONNECTION, COOKIE, HeaderMap, HeaderValue, SET_COOKIE, USER_AGENT, }; use serde::{Deserialize, Serialize}; type Aes128CbcDec = cbc::Decryptor; type Aes192CbcDec = cbc::Decryptor; type Aes256CbcDec = cbc::Decryptor; /// Deserializes a msgpack/JSON null or missing value as an empty String. /// Go silently coerces nil → zero value for non-pointer types; this helper /// mirrors that behavior for String fields. fn de_null_as_empty_string<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, { Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AssetBundleDetail { #[serde(rename = "bundleName", deserialize_with = "de_null_as_empty_string")] pub bundle_name: String, #[serde(rename = "cacheFileName", deserialize_with = "de_null_as_empty_string")] pub cache_file_name: String, #[serde( rename = "cacheDirectoryName", deserialize_with = "de_null_as_empty_string" )] pub cache_directory_name: String, // nuverse regions use `crc` instead of `hash`; the server may send nil here. #[serde(rename = "hash", deserialize_with = "de_null_as_empty_string")] pub hash: String, #[serde(rename = "category")] pub category: AssetCategory, #[serde(rename = "crc")] pub crc: i64, #[serde(rename = "fileSize")] pub file_size: i64, #[serde(rename = "dependencies")] pub dependencies: Vec, #[serde(rename = "paths", default)] pub paths: Vec, #[serde(rename = "isBuiltin")] pub is_builtin: bool, #[serde(rename = "isRelocate")] pub is_relocate: Option, #[serde(rename = "md5Hash")] pub md5_hash: Option, #[serde(rename = "downloadPath")] pub download_path: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AssetBundleInfo { #[serde(rename = "version")] pub version: Option, #[serde(rename = "os")] pub os: Option, #[serde(rename = "bundles")] pub bundles: HashMap, } #[derive(Debug, Clone)] pub struct AssetExecutionContext { client: reqwest::Client, region_name: String, region: RegionConfig, retry: crate::core::config::RetryConfig, runtime_cookie: Option, resolved_asset_version: Option, pub sync_context: SyncContext, } impl AssetExecutionContext { pub fn new( app_config: &AppConfig, sync_context: &SyncContext, region: &RegionConfig, ) -> Result { let mut headers = HeaderMap::new(); headers.insert(ACCEPT, HeaderValue::from_static("*/*")); headers.insert( USER_AGENT, HeaderValue::from_static("ProductName/134 CFNetwork/1408.0.4 Darwin/22.5.0"), ); headers.insert(CONNECTION, HeaderValue::from_static("keep-alive")); headers.insert( ACCEPT_ENCODING, HeaderValue::from_static("gzip, deflate, br"), ); headers.insert( ACCEPT_LANGUAGE, HeaderValue::from_static("zh-CN,zh-Hans;q=0.9"), ); headers.insert( "X-Unity-Version", HeaderValue::from_str(®ion.runtime.unity_version) .map_err(|err| AssetExecutionError::HttpClient(err.to_string()))?, ); let mut builder = reqwest::Client::builder() .default_headers(headers) .connect_timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(180)) .pool_max_idle_per_host(100) .tcp_keepalive(Duration::from_secs(30)); if let Some(proxy) = &app_config.execution.proxy && !proxy.is_empty() { builder = builder.proxy( reqwest::Proxy::all(proxy) .map_err(|err| AssetExecutionError::HttpClient(err.to_string()))?, ); } Ok(Self { client: builder .build() .map_err(|err| AssetExecutionError::HttpClient(err.to_string()))?, sync_context: sync_context.clone(), region_name: sync_context.region.clone(), region: region.clone(), retry: app_config.execution.retry.clone(), runtime_cookie: None, resolved_asset_version: sync_context.asset_version.clone(), }) } pub async fn fetch_tasks(&mut self) -> Result, AssetExecutionError> { if self.requires_cookies() { self.fetch_runtime_cookies().await?; } let info = self.fetch_asset_bundle_info().await?; let tasks = self.build_download_tasks(&info); Ok(tasks) } pub async fn download( &self, task: &DownloadTask, app_config: &AppConfig, ) -> Result<(PathBuf, /*single file*/ bool), AssetExecutionError> { let ctx = self.clone(); ctx.download_and_export_bundle(app_config, task).await } fn requires_cookies(&self) -> bool { match &self.region.provider { RegionProviderConfig::ColorfulPalette { required_cookies, .. } => *required_cookies, RegionProviderConfig::Nuverse { required_cookies, .. } => *required_cookies, } } async fn fetch_runtime_cookies(&mut self) -> Result<(), AssetExecutionError> { let url = match &self.region.provider { RegionProviderConfig::ColorfulPalette { cookie_bootstrap_url, .. } | RegionProviderConfig::Nuverse { cookie_bootstrap_url, .. } => cookie_bootstrap_url.clone().unwrap_or_else(|| { "https://issue.sekai.colorfulpalette.org/api/signature".to_string() }), }; self.runtime_cookie = retry_async( &self.retry, "cookie bootstrap", |_| async { let response = self.client.post(&url).send().await?; if response.status().is_success() { Ok(response .headers() .get(SET_COOKIE) .and_then(|value| value.to_str().ok()) .map(str::to_string)) } else { Err(AssetExecutionError::HttpStatus { url: url.clone(), status: response.status().as_u16(), }) } }, is_retryable_http_error, ) .await?; Ok(()) } async fn fetch_asset_bundle_info(&mut self) -> Result { let url = self.render_asset_info_url().await?; let body = self.get_with_retry(&url).await?; decrypt_asset_bundle_info( self.region.crypto.aes_key_hex.as_deref().ok_or_else(|| { AssetExecutionError::MissingCryptoConfig { region: self.region_name.clone(), } })?, self.region.crypto.aes_iv_hex.as_deref().ok_or_else(|| { AssetExecutionError::MissingCryptoConfig { region: self.region_name.clone(), } })?, &body, ) } async fn render_asset_info_url(&mut self) -> Result { match &self.region.provider { RegionProviderConfig::ColorfulPalette { asset_info_url_template, profile, profile_hashes, .. } => { let asset_version = self.sync_context.asset_version.as_deref().ok_or_else(|| { AssetExecutionError::MissingAssetVersionOrHash { region: self.region_name.clone(), } })?; let asset_hash = self.sync_context.asset_hash.as_deref().ok_or_else(|| { AssetExecutionError::MissingAssetVersionOrHash { region: self.region_name.clone(), } })?; let profile_hash = profile_hashes.get(profile).ok_or_else(|| { AssetExecutionError::MissingProfileHash { region: self.region_name.clone(), profile: profile.clone(), } })?; Ok(asset_info_url_template .replace("{env}", profile) .replace("{hash}", profile_hash) .replace("{asset_version}", asset_version) .replace("{asset_hash}", asset_hash) + &time_arg_jst()) } RegionProviderConfig::Nuverse { asset_version_url, asset_info_url_template, .. } => { // For nuverse, always fetch the version from asset_version_url. // The incoming request.asset_version is intentionally ignored here // to match Go reference behavior. let app_version = self.sync_context.app_version.as_ref().ok_or_else(|| { AssetExecutionError::MissingAppVersion { region: self.region_name.clone(), } })?; let version_url = asset_version_url.replace("{app_version}", app_version); let resolved_version = String::from_utf8_lossy(&self.get_with_retry(&version_url).await?) .trim() .to_string(); self.resolved_asset_version = Some(resolved_version.clone()); Ok(asset_info_url_template .replace("{app_version}", app_version) .replace("{asset_version}", &resolved_version) + &time_arg_jst()) } } } fn render_bundle_url(&self, task: &DownloadTask) -> Result { match &self.region.provider { RegionProviderConfig::ColorfulPalette { asset_bundle_url_template, profile, profile_hashes, .. } => { let asset_version = self.sync_context.asset_version.as_deref().ok_or_else(|| { AssetExecutionError::MissingAssetVersionOrHash { region: self.region_name.clone(), } })?; let asset_hash = self.sync_context.asset_hash.as_deref().ok_or_else(|| { AssetExecutionError::MissingAssetVersionOrHash { region: self.region_name.clone(), } })?; let profile_hash = profile_hashes.get(profile).ok_or_else(|| { AssetExecutionError::MissingProfileHash { region: self.region_name.clone(), profile: profile.clone(), } })?; Ok(asset_bundle_url_template .replace("{bundle_path}", &task.download_path) .replace("{asset_version}", asset_version) .replace("{asset_hash}", asset_hash) .replace("{env}", profile) .replace("{hash}", profile_hash) + &time_arg_jst()) } RegionProviderConfig::Nuverse { asset_bundle_url_template, .. } => { let app_version = self.sync_context.app_version.as_ref().ok_or_else(|| { AssetExecutionError::MissingAppVersion { region: self.region_name.clone(), } })?; let asset_version = self .resolved_asset_version .as_deref() .unwrap_or(""); Ok(asset_bundle_url_template .replace("{bundle_path}", &task.download_path) .replace("{app_version}", app_version) .replace("{asset_version}", asset_version) + &time_arg_jst()) } } } async fn get_with_retry(&self, url: &str) -> Result, AssetExecutionError> { retry_async( &self.retry, "http get", |_| async { let mut request = self.client.get(url); if let Some(cookie) = &self.runtime_cookie { request = request.header(COOKIE, cookie); } match request.send().await { Ok(response) if response.status().is_success() => { Ok(response.bytes().await?.to_vec()) } Ok(response) => Err(AssetExecutionError::HttpStatus { url: url.to_string(), status: response.status().as_u16(), }), Err(err) => Err(AssetExecutionError::Http(err)), } }, is_retryable_http_error, ) .await } fn build_download_tasks(&self, info: &AssetBundleInfo) -> Vec { let skip_patterns = compile_patterns(&self.sync_context.filters.skip); let start_app_patterns = compile_patterns(&self.sync_context.filters.start_app); let on_demand_patterns = compile_patterns(&self.sync_context.filters.on_demand); let mut tasks = Vec::new(); for (bundle_name, detail) in &info.bundles { if matches_any(&skip_patterns, bundle_name) { continue; } let category_patterns = match &detail.category { AssetCategory::StartApp => &start_app_patterns, AssetCategory::OnDemand => &on_demand_patterns, AssetCategory::Other(_) => continue, }; if category_patterns.is_empty() || !matches_any(category_patterns, bundle_name) { continue; } let bundle_hash = match self.region.provider { RegionProviderConfig::Nuverse { .. } => detail.crc.to_string(), RegionProviderConfig::ColorfulPalette { .. } => detail.hash.clone(), }; tasks.push(DownloadTask { download_path: download_path_for_region(&self.region.provider, bundle_name, detail), bundle_path: bundle_name.clone(), bundle_hash, category: detail.category.clone(), }); } tasks.sort_by(|a, b| a.bundle_path.cmp(&b.bundle_path)); tasks } async fn download_and_export_bundle( &self, app_config: &AppConfig, task: &DownloadTask, ) -> Result<(PathBuf, bool), AssetExecutionError> { let bundle_url = self.render_bundle_url(task)?; let body = self.get_with_retry(&bundle_url).await?; let deobfuscated = deobfuscate(&body); let temp_file = std::env::temp_dir() .join("sekai-updater") .join("obf") .join(&self.region_name) .join(&task.bundle_path); if let Some(parent) = temp_file.parent() { std::fs::create_dir_all(parent).map_err(|source| { AssetExecutionError::CreateTempDir { path: parent.to_path_buf(), source, } })?; } std::fs::write(&temp_file, deobfuscated).map_err(|source| { AssetExecutionError::WriteTempFile { path: temp_file.clone(), source, } })?; let category = match task.category { AssetCategory::StartApp => "StartApp", AssetCategory::OnDemand => "OnDemand", AssetCategory::Other(_) => "OnDemand", }; let export_result = extract_unity_asset_bundle( app_config, &self.sync_context, &self.region, &temp_file, &task.bundle_path, &task.download_path, category, ) .await; let _ = std::fs::remove_file(&temp_file); export_result.map_err(Into::into) } } pub async fn fetch_live_asset_bundle_info( app_config: &AppConfig, sync_context: &SyncContext, region: &RegionConfig, ) -> Result { let mut context = AssetExecutionContext::new(app_config, sync_context, region)?; if context.requires_cookies() { context.fetch_runtime_cookies().await?; } context.fetch_asset_bundle_info().await } fn is_retryable_http_error(err: &AssetExecutionError) -> bool { match err { AssetExecutionError::Http(_) => true, AssetExecutionError::HttpStatus { status, .. } => *status >= 500, _ => false, } } pub fn decrypt_asset_bundle_info( aes_key_hex: &str, aes_iv_hex: &str, content: &[u8], ) -> Result { if content.is_empty() { return Err(AssetExecutionError::EmptyEncryptedContent); } if !content.len().is_multiple_of(16) { return Err(AssetExecutionError::InvalidEncryptedBlockSize); } let key = hex::decode(aes_key_hex) .map_err(|err| AssetExecutionError::InvalidAesKeyHex(err.to_string()))?; let iv = hex::decode(aes_iv_hex) .map_err(|err| AssetExecutionError::InvalidAesIvHex(err.to_string()))?; if iv.len() != 16 { return Err(AssetExecutionError::InvalidAesIvLength { got: iv.len() }); } let mut buf = content.to_vec(); let decrypted = match key.len() { 16 => Aes128CbcDec::new_from_slices(&key, &iv) .map_err(|err| AssetExecutionError::AssetInfoDecode(err.to_string()))? .decrypt_padded_mut::(&mut buf) .map_err(|err| AssetExecutionError::AssetInfoDecode(err.to_string()))?, 24 => Aes192CbcDec::new_from_slices(&key, &iv) .map_err(|err| AssetExecutionError::AssetInfoDecode(err.to_string()))? .decrypt_padded_mut::(&mut buf) .map_err(|err| AssetExecutionError::AssetInfoDecode(err.to_string()))?, 32 => Aes256CbcDec::new_from_slices(&key, &iv) .map_err(|err| AssetExecutionError::AssetInfoDecode(err.to_string()))? .decrypt_padded_mut::(&mut buf) .map_err(|err| AssetExecutionError::AssetInfoDecode(err.to_string()))?, _ => { return Err(AssetExecutionError::AssetInfoDecode(format!( "unsupported AES key length {}", key.len() ))); } }; rmp_serde::from_slice::(decrypted) .map_err(|err| AssetExecutionError::AssetInfoDecode(err.to_string())) } pub fn deobfuscate(data: &[u8]) -> Vec { const SIMPLE: [u8; 4] = [0x20, 0x00, 0x00, 0x00]; const XOR_HEADER: [u8; 4] = [0x10, 0x00, 0x00, 0x00]; if data.starts_with(&SIMPLE) { return data[4..].to_vec(); } if data.starts_with(&XOR_HEADER) { let body = &data[4..]; if body.len() < 128 { return body.to_vec(); } let mut header = vec![0u8; 128]; let pattern = [0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00]; for idx in 0..128 { header[idx] = body[idx] ^ pattern[idx % pattern.len()]; } let mut output = header; output.extend_from_slice(&body[128..]); return output; } data.to_vec() } pub fn should_download_bundle( sync_context: &SyncContext, bundle_name: &str, category: &AssetCategory, ) -> bool { let compiled = match category { AssetCategory::StartApp => compile_patterns(&sync_context.filters.start_app), AssetCategory::OnDemand => compile_patterns(&sync_context.filters.on_demand), AssetCategory::Other(_) => return false, }; if compiled.is_empty() { return false; } matches_any(&compiled, bundle_name) } fn download_path_for_region( provider: &RegionProviderConfig, bundle_name: &str, detail: &AssetBundleDetail, ) -> String { match provider { RegionProviderConfig::ColorfulPalette { .. } => bundle_name.to_string(), RegionProviderConfig::Nuverse { .. } => detail .download_path .as_ref() .map(|prefix| format!("{prefix}/{bundle_name}")) .unwrap_or_else(|| bundle_name.to_string()), } } fn time_arg_jst() -> String { let tz = FixedOffset::east_opt(9 * 3600).unwrap(); format!( "?t={}", chrono::Utc::now().with_timezone(&tz).format("%Y%m%d%H%M%S") ) }