diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bcf9ab..591e276 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] - Add the ability to download dex metadata for an app from Google Play +- Added RuStore support ## [0.18.0] - 2025-10-30 - Adding the ability to specify json as an output format when listing versions of an app available diff --git a/README.md b/README.md index 95a5a0c..7f05da6 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,12 @@ Or, to download from the Huawei AppGallery: apkeep -a com.elysiumlabs.newsbytes -d huawei-app-gallery . ``` +Or, to download from RuStore: + +```shell +apkeep -a com.vk.calls -d ru-store . +``` + To download a specific version of an APK (possible for APKPure or F-Droid), use the `@version` convention: @@ -117,6 +123,7 @@ You can use this tool to download from a few distinct sources. verifies that these APKs are signed by the F-Droid maintainers, and alerts the user if an APK was downloaded but could not be verified * The Huawei AppGallery (`-d huawei-app-gallery`), an app store popular in China +* RuStore (`-d ru-store`), a Russian app store platform ## Usage Note diff --git a/USAGE b/USAGE index f83534d..ce30a86 100644 --- a/USAGE +++ b/USAGE @@ -17,7 +17,7 @@ Options: -l, --list-versions List the versions available -d, --download-source - Where to download the APKs from [default: apk-pure] [possible values: apk-pure, google-play, f-droid, huawei-app-gallery] + Where to download the APKs from [default: apk-pure] [possible values: apk-pure, google-play, f-droid, huawei-app-gallery, ru-store] -o, --options A comma-separated list of additional options to pass to the download source -i, --ini diff --git a/src/cli.rs b/src/cli.rs index 36dddbe..2b2e175 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -6,6 +6,7 @@ pub enum DownloadSource { GooglePlay, FDroid, HuaweiAppGallery, + RuStore, } impl std::fmt::Display for DownloadSource { diff --git a/src/download_sources/fdroid.rs b/src/download_sources/fdroid.rs index cdeed5d..438a7a4 100644 --- a/src/download_sources/fdroid.rs +++ b/src/download_sources/fdroid.rs @@ -539,11 +539,11 @@ fn verify_and_return_json(dir: &TempDir, files: &[String], fingerprint: &[u8], v let actual_manifest_shasum = if use_entry { let mut hasher = Sha256::new(); hasher.update(manifest_file_data.clone()); - Vec::from(hasher.finalize().as_slice()) + hasher.finalize().to_vec() } else { let mut hasher = Sha1::new(); hasher.update(manifest_file_data.clone()); - Vec::from(hasher.finalize().as_slice()) + hasher.finalize().to_vec() }; if signed_file_manifest_shasum != actual_manifest_shasum[..] { return Err(Box::new(SimpleError::new(format!("The manifest {} from the signed file does not match the actual manifest {}.", sha_algorithm_name, sha_algorithm_name)))); @@ -572,11 +572,11 @@ fn verify_and_return_json(dir: &TempDir, files: &[String], fingerprint: &[u8], v let actual_shasum = if use_entry { let mut hasher = Sha256::new(); hasher.update(json_file_data.clone()); - Vec::from(hasher.finalize().as_slice()) + hasher.finalize().to_vec() } else { let mut hasher = Sha1::new(); hasher.update(json_file_data.clone()); - Vec::from(hasher.finalize().as_slice()) + hasher.finalize().to_vec() }; if manifest_file_shasum != actual_shasum[..] { return Err(Box::new(SimpleError::new(format!("The {} from the manifest file does not match the actual {}.", file_algo, file_algo)))); @@ -621,7 +621,7 @@ async fn verify_and_return_index_from_entry(dir: &TempDir, repo: &str, json: &st let actual_index_shasum = { let mut hasher = Sha256::new(); hasher.update(index_file_data.clone()); - Vec::from(hasher.finalize().as_slice()) + hasher.finalize().to_vec() }; let index_sha256 = match hex::decode(index_sha256) { Ok(index_sha256) => index_sha256, diff --git a/src/download_sources/huawei_app_gallery.rs b/src/download_sources/huawei_app_gallery.rs index 0439f3b..c47a84d 100644 --- a/src/download_sources/huawei_app_gallery.rs +++ b/src/download_sources/huawei_app_gallery.rs @@ -73,7 +73,7 @@ async fn download_from_response(response: Response, app_string: String, outpath: let list_value = response_obj.get("list").unwrap(); if list_value.is_array() { let list = list_value.as_array().unwrap(); - if !list.is_empty() && list[0].is_object(){ + if !list.is_empty() && list[0].is_object() { let first_list_entry = list[0].as_object().unwrap(); if first_list_entry.contains_key("downurl") { let downurl = first_list_entry.get("downurl").unwrap(); @@ -118,6 +118,8 @@ async fn download_from_response(response: Response, app_string: String, outpath: } } } + } else { + mp_log.println(format!("App not found on Huawei AppGallery: {}. Skipping...", app_string)).unwrap(); } } } diff --git a/src/download_sources/mod.rs b/src/download_sources/mod.rs index a517b8e..04c9361 100644 --- a/src/download_sources/mod.rs +++ b/src/download_sources/mod.rs @@ -2,3 +2,4 @@ pub mod google_play; pub mod fdroid; pub mod apkpure; pub mod huawei_app_gallery; +pub mod rustore; diff --git a/src/download_sources/rustore.rs b/src/download_sources/rustore.rs new file mode 100644 index 0000000..bd5aa5b --- /dev/null +++ b/src/download_sources/rustore.rs @@ -0,0 +1,200 @@ +use std::path::Path; +use std::rc::Rc; + +use futures_util::StreamExt; +use indicatif::MultiProgress; +use reqwest::header::{HeaderMap, HeaderValue}; +use serde_json::{Value, json}; +use tokio_dl_stream_to_disk::{AsyncDownload, error::ErrorKind as TDSTDErrorKind}; +use tokio::time::{sleep, Duration as TokioDuration}; + +use crate::util::progress_bar::progress_wrapper; + +fn http_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert("User-Agent", HeaderValue::from_static("RuStore/1.78.0.1 (Android 11; SDK 30; arm64-v8a; samsung SM-N935F; en)")); + headers.insert("Content-Type", HeaderValue::from_static("application/json")); + headers +} + +fn download_request_body(app_id: u64) -> String { + serde_json::to_string(&json!({ + "appId": app_id, + "firstInstall": true, + "mobileServices": ["GMS", "HMS"], + "supportedAbis": ["arm64-v8a"], + "screenDensity": 420, + "supportedLocales": ["en_US"], + "sdkVersion": 30, + "withoutSplits": true, + "signatureFingerprint": null + })).unwrap() +} + +async fn get_app_id(http_client: &reqwest::Client, headers: &HeaderMap, package_name: &str) -> Option { + let lookup_url = format!("https://backapi.rustore.ru/applicationData/store-app?packageNames={}", package_name); + + match http_client.get(&lookup_url).headers(headers.clone()).send().await { + Ok(response) if response.status().is_success() => { + match response.text().await { + Ok(body) => { + match serde_json::from_str::(&body) { + Ok(json_response) => { + if let Some(array) = json_response.as_array() { + if let Some(first_app) = array.first() { + if let Some(id) = first_app.get("id") { + return id.as_u64(); + } + } + } + None + } + Err(_) => None + } + } + Err(_) => None + } + } + _ => None + } +} + +async fn get_download_url(http_client: &reqwest::Client, headers: &HeaderMap, app_id: u64) -> Option { + let download_url = "https://backapi.rustore.ru/applicationData/v2/download-link"; + + match http_client + .post(download_url) + .headers(headers.clone()) + .body(download_request_body(app_id)) + .send().await + { + Ok(response) if response.status().is_success() => { + match response.text().await { + Ok(body) => { + match serde_json::from_str::(&body) { + Ok(json_response) => { + if let Some(code) = json_response.get("code") { + if code.as_str() == Some("OK") { + if let Some(body) = json_response.get("body") { + if let Some(download_urls) = body.get("downloadUrls") { + if let Some(array) = download_urls.as_array() { + if let Some(first_url) = array.first() { + if let Some(url) = first_url.get("url") { + return url.as_str().map(|s| s.to_string()); + } + } + } + } + } + } + } + None + } + Err(_) => None + } + } + Err(_) => None + } + } + _ => None + } +} + +pub async fn download_apps( + apps: Vec<(String, Option)>, + parallel: usize, + sleep_duration: u64, + outpath: &Path, +) { + let http_client = Rc::new(reqwest::Client::new()); + let headers = http_headers(); + + let mp = Rc::new(MultiProgress::new()); + futures_util::stream::iter( + apps.into_iter().map(|app| { + let (app_id, app_version) = app; + let http_client = Rc::clone(&http_client); + let headers = headers.clone(); + let mp = Rc::clone(&mp); + let mp_log = Rc::clone(&mp); + async move { + if app_version.is_none() { + mp_log.suspend(|| println!("Downloading {}...", app_id)); + if sleep_duration > 0 { + sleep(TokioDuration::from_millis(sleep_duration)).await; + } + + match get_app_id(&http_client, &headers, &app_id).await { + Some(rustore_app_id) => { + match get_download_url(&http_client, &headers, rustore_app_id).await { + Some(download_url) => { + download_apk(download_url, app_id.to_string(), outpath, mp).await; + } + None => { + mp_log.println(format!("Could not get download URL for {}. Skipping...", app_id)).unwrap(); + } + } + } + None => { + mp_log.println(format!("App not found on RuStore: {}. Skipping...", app_id)).unwrap(); + } + } + } else { + mp_log.println(format!("Specific versions can not be downloaded from RuStore ({}@{}). Skipping...", app_id, app_version.unwrap())).unwrap(); + } + } + }) + ).buffer_unordered(parallel).collect::>().await; +} + +async fn download_apk(download_url: String, app_string: String, outpath: &Path, mp: Rc) { + let mp_log = Rc::clone(&mp); + let mp = Rc::clone(&mp); + let fname = format!("{}.apk", app_string); + + match AsyncDownload::new(&download_url, Path::new(outpath), &fname).get().await { + Ok(mut dl) => { + let length = dl.length(); + let cb = match length { + Some(length) => Some(progress_wrapper(mp)(fname.clone(), length)), + None => None, + }; + + match dl.download(&cb).await { + Ok(_) => mp_log.suspend(|| println!("{} downloaded successfully!", app_string)), + Err(err) if matches!(err.kind(), TDSTDErrorKind::FileExists) => { + mp_log.println(format!("File already exists for {}. Skipping...", app_string)).unwrap(); + }, + Err(err) if matches!(err.kind(), TDSTDErrorKind::PermissionDenied) => { + mp_log.println(format!("Permission denied when attempting to write file for {}. Skipping...", app_string)).unwrap(); + }, + Err(_) => { + mp_log.println(format!("An error has occurred attempting to download {}. Retry #1...", app_string)).unwrap(); + match AsyncDownload::new(&download_url, Path::new(outpath), &fname).download(&cb).await { + Ok(_) => mp_log.suspend(|| println!("{} downloaded successfully!", app_string)), + Err(_) => { + mp_log.println(format!("An error has occurred attempting to download {}. Retry #2...", app_string)).unwrap(); + match AsyncDownload::new(&download_url, Path::new(outpath), &fname).download(&cb).await { + Ok(_) => mp_log.suspend(|| println!("{} downloaded successfully!", app_string)), + Err(_) => { + mp_log.println(format!("An error has occurred attempting to download {}. Skipping...", app_string)).unwrap(); + } + } + } + } + } + } + }, + Err(_) => { + mp_log.println(format!("Invalid response for {}. Skipping...", app_string)).unwrap(); + } + } +} + +pub async fn list_versions(apps: Vec<(String, Option)>) { + for app in apps { + let (app_id, _) = app; + println!("Versions available for {} on RuStore:", app_id); + println!("| RuStore does not make old versions of apps available."); + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 5b4997d..81d3acd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -144,6 +144,7 @@ use download_sources::google_play; use download_sources::fdroid; use download_sources::apkpure; use download_sources::huawei_app_gallery; +use download_sources::rustore; type CSVList = Vec<(String, Option)>; fn fetch_csv_list(csv: &str, field: usize, version_field: Option) -> Result> { @@ -279,6 +280,9 @@ async fn main() { DownloadSource::HuaweiAppGallery => { huawei_app_gallery::list_versions(list).await; } + DownloadSource::RuStore => { + rustore::list_versions(list).await; + } } } else { let parallel = matches.get_one::("parallel").map(|v| *v).unwrap(); @@ -390,6 +394,9 @@ async fn main() { DownloadSource::HuaweiAppGallery => { huawei_app_gallery::download_apps(list, parallel, sleep_duration, &outpath.unwrap()).await; } + DownloadSource::RuStore => { + rustore::download_apps(list, parallel, sleep_duration, &outpath.unwrap()).await; + } } } }