#![warn(clippy::all, clippy::pedantic, clippy::perf, clippy::nursery)]
#![allow(clippy::struct_field_names)]
use crate::{
api::{
endpoint::Requester,
login::{AuthToken, checkin, fetch_auth, toc, upload_device},
},
error::{
CategoryError, DetailsError, DownloadsError, IntoGoogleError, LoginError, PropsError,
ReviewsError, SearchError, SearchSuggestionError,
},
proto::{Item, Review, SearchSuggestEntry},
};
pub use api::{
oauth::OAuthRequest,
properties::{DeviceProperties, PropsMap},
};
use async_stream::try_stream;
use futures_core::stream::Stream;
mod api;
mod consts;
pub mod error;
pub mod proto;
macro_rules! async_doc {
($code:literal) => {
concat!(
r#"```
# tokio_test::block_on(async {
# const PROPS: &str = include_str!("../example.properties");
# let mut props_map = gpipipi::PropsMap::parse_aurora_config(PROPS)?;
# let props = props_map.remove("arm").ok_or("No 'arm' device props!")?;
# let aas_token = std::env::var("TOKEN").map_err(|_| "Set 'TOKEN' to an oauth token!")?;
# let email = std::env::var("EMAIL").map_err(|_| "Set 'EMAIL' to the email used for the token!")?;
"#,
$code,
"
# Ok::<(), Box<dyn std::error::Error>>(())
# })?;
# Ok::<(), Box<dyn std::error::Error>>(())
```"
)
}
}
pub struct Client {
aas_token: String,
email: String,
props: DeviceProperties,
auth_token: AuthToken,
checkin_token: String,
config_token: String,
dfe_cookie: String,
gsf: i64,
}
impl Client {
#[doc = async_doc!(r#"let mut props_map = gpipipi::PropsMap::parse_aurora_config(PROPS)?;
let props = props_map.remove("arm").ok_or("No 'arm' device props!")?;
let client = gpipipi::Client::login(email, aas_token, props).await?;"#)]
pub async fn login<A: Into<String>, B: Into<String>>(
email: A,
aas_token: B,
props: DeviceProperties,
) -> Result<Self, LoginError> {
let email = email.into();
let aas_token = aas_token.into();
let checkin_response = checkin(&props).await?;
let gsf = checkin_response
.android_id
.ok_or(LoginError::NoAndroidId())?
.cast_signed();
let checkin_token = checkin_response
.device_checkin_consistency_token
.ok_or(LoginError::NoDeviceToken())?;
let upload_response = upload_device(&props, gsf, &checkin_token).await?;
let config_token = upload_response
.upload_device_config_token
.ok_or(LoginError::NoConfigToken())?;
let auth_token = fetch_auth(&props, &aas_token, &email, gsf).await?;
let dfe_cookie = toc(&auth_token, gsf, &checkin_token, &props).await?;
Ok(Self {
aas_token,
email,
props,
auth_token,
checkin_token,
config_token,
dfe_cookie,
gsf,
})
}
#[doc = async_doc!(r#"let client = gpipipi::Client::login(email, aas_token, props).await?;
let details = client.details("com.roblox.client", None).await?;"#)]
pub async fn details<A: AsRef<str>>(
&self,
package_name: A,
maybe_version: Option<i64>,
) -> Result<proto::AppDetails, DetailsError> {
let maybe_version = maybe_version.map(|a| a.to_string());
let params = maybe_version.as_ref().map_or_else(
|| [("doc", package_name.as_ref()), ("", "")],
|version| [("doc", package_name.as_ref()), ("vc", version)],
);
let request = self.request().await?.params(¶ms);
let mut response = request.fdfe_request("details", None).await?;
let response = response
.payload
.take()
.and_then(|a| a.details_response)
.and_then(|a| a.item)
.and_then(|a| a.details)
.and_then(|a| a.app_details)
.into_google_error(&response)?;
Ok(response)
}
#[doc = async_doc!(r#"let client = gpipipi::Client::login(email, aas_token, props).await?;
let download_urls = client.downloads("com.roblox.client", Some(2036)).await?;"#)]
pub async fn downloads<A: AsRef<str>>(
&self,
package_name: A,
version: Option<i64>,
) -> Result<DownloadResponse, DownloadsError> {
let version = if let Some(version) = version {
version
} else {
self.lastest_version(package_name.as_ref()).await?
}
.to_string();
let headers = [("content-length", "0")];
let params = [
("ot", "1"),
("doc", package_name.as_ref()),
("vc", &version),
];
let mut request = self.request().await?.headers(&headers).params(¶ms);
let mut response = request.fdfe_request("purchase", Some(Vec::new())).await?;
let delivery_token = response
.payload
.take()
.and_then(|a| a.buy_response)
.and_then(|a| a.encoded_delivery_token)
.into_google_error(&response)?;
let delivery_params = [
("doc", package_name.as_ref()),
("dtok", &delivery_token),
("vc", &version),
("ot", "1"),
];
request = request.headers(&[]).params(&delivery_params);
let mut delivery_response = request.fdfe_request("delivery", None).await?;
let app_delivery_data = delivery_response
.payload
.take()
.and_then(|a| a.delivery_response)
.and_then(|a| a.app_delivery_data)
.into_google_error(&response)?;
let base = app_delivery_data
.download_url
.zip(app_delivery_data.sha1)
.map(|(url, sha1)| CdnUrl {
filename: "base".to_string(),
url,
sha1,
})
.into_google_error(&response)?;
let splits: Vec<_> = app_delivery_data
.split_delivery_data
.into_iter()
.filter_map(|a| {
a.name
.zip(a.download_url)
.zip(a.sha1)
.map(|((filename, url), sha1)| CdnUrl {
filename,
sha1,
url,
})
})
.collect();
let obbs: Vec<_> = app_delivery_data
.additional_file
.into_iter()
.filter_map(|a| {
a.file_type
.zip(a.sha1)
.zip(a.version_code)
.zip(a.download_url)
.map(|(((ftype, sha1), version), url)| {
let prefix = if ftype == 0 { "main" } else { "patch" };
CdnUrl {
filename: format!("{prefix}.{version}.{}.obb", package_name.as_ref()),
url,
sha1,
}
})
})
.collect();
Ok(DownloadResponse { splits, obbs, base })
}
#[doc = async_doc!("let client = gpipipi::Client::login(email, aas_token, props).await?;
let categories = client.categories(gpipipi::CategoryType::Game).await?;")]
pub async fn categories(&self, cat_type: CategoryType) -> Result<Vec<Category>, CategoryError> {
let type_name = format!("{cat_type:?}").to_uppercase();
let headers = [("User-Agent", consts::LEGACY_USERAGENT)];
let params = [("c", "3"), ("cat", &type_name)];
let request = self.request().await?.headers(&headers).params(¶ms);
let mut response = request.fdfe_request("categoriesList", None).await?;
let items = response
.payload
.take()
.and_then(|a| a.list_response)
.and_then(|a| a.item)
.and_then(|mut a| a.sub_item.pop())
.into_google_error(&response)?;
Ok(items
.sub_item
.into_iter()
.filter_map(|a| {
a.title
.zip(a.annotations.and_then(|a| {
a.annotation_link
.and_then(|a| a.resolved_link)
.and_then(|a| a.browse_url)
}))
.map(|(title, browse_url)| Category {
images: a.image.into_iter().filter_map(|a| a.image_url).collect(),
cat_type,
browse_url,
title,
})
})
.collect())
}
#[doc = async_doc!(r#"let client = gpipipi::Client::login(email, aas_token, props).await?;
let suggestions = client.search_suggest("robl").await?;"#)]
pub async fn search_suggest<A: AsRef<str>>(
&self,
query: A,
) -> Result<Vec<SearchSuggestEntry>, SearchSuggestionError> {
let headers = [("User-Agent", consts::LEGACY_USERAGENT)];
let params = [
("q", query.as_ref()),
("ssis", "120"),
("sb", "5"),
("sst", "2"),
("c", "3"),
];
let request = self.request().await?.headers(&headers).params(¶ms);
let mut response = request.fdfe_request("searchSuggest", None).await?;
let response = response
.payload
.take()
.and_then(|a| a.search_suggest_response)
.map(|a| a.entry)
.into_google_error(&response)?;
Ok(response)
}
#[doc = async_doc!(r#"let client = gpipipi::Client::login(email, aas_token, props).await?;
let single_search = client.search("robl", None).await?;
let single_search2 = client.search("robl", single_search.next_page).await?;"#)]
pub async fn search<A: AsRef<str>>(
&self,
query: A,
next_page_url: Option<NextSearchPage>,
) -> Result<SearchResults, SearchError> {
let (payload, next_page, response) = if let Some(next_page_url) = next_page_url {
let request = self.request().await?;
let mut response = request.fdfe_request(&next_page_url.0, None).await?;
(response.payload.take(), None, response)
} else {
let params = [("q", query.as_ref()), ("ksm", "1"), ("c", "3")];
let request = self.request().await?.params(¶ms);
let mut response = request.fdfe_request("search", None).await?;
(
response
.pre_fetch
.take()
.and_then(|a| a.response)
.and_then(|a| a.payload),
Some(
response
.payload
.take()
.and_then(|a| a.search_response)
.and_then(|a| a.next_page_url)
.map(NextSearchPage),
),
response,
)
};
let next_page = next_page.unwrap_or_else(|| {
payload
.as_ref()
.and_then(|a| a.list_response.as_ref())
.and_then(|a| a.item.as_ref())
.and_then(|a| a.container_metadata.as_ref())
.and_then(|a| a.next_page_url.clone())
.map(NextSearchPage)
});
let items = payload
.and_then(|a| a.list_response)
.and_then(|a| a.item)
.map(|a| {
a.sub_item
.into_iter()
.filter_map(|mut a| a.sub_item.pop())
.collect::<Vec<_>>()
})
.into_google_error(&response)?;
Ok(SearchResults { next_page, items })
}
#[doc = async_doc!(r#"use futures_util::TryStreamExt;
let client = gpipipi::Client::login(email, aas_token, props).await?;
let mut search_stream = client.search_stream("robl");
# let mut count = 0;
while let Some(page) = search_stream.try_next().await? {
# count += 1;
# if count >= 3 {
# break
# }
for item in page {
println!("Found app: {:?}", item.title);
}
}"#)]
pub fn search_stream<'a, A: AsRef<str> + 'a>(
&'a self,
query: A,
) -> impl Stream<Item = Result<Vec<Item>, SearchError>> + 'a {
Box::pin(try_stream! {
let mut next_page: Option<NextSearchPage> = None;
loop {
let result = self.search(query.as_ref(), next_page).await?;
yield result.items;
next_page = result.next_page;
if next_page.is_none() {
break;
}
}
})
}
#[doc = async_doc!(r#"let client = gpipipi::Client::login(email, aas_token, props).await?;
let reviews = client
.reviews("com.roblox.client", Some(&gpipipi::ReviewsFilter::Newest), None)
.await?;
let reviews_page2 = client
.reviews("com.roblox.client", Some(&gpipipi::ReviewsFilter::Newest), reviews.next_page)
.await?;"#)]
pub async fn reviews<A: AsRef<str>>(
&self,
package_name: A,
filter: Option<&ReviewsFilter>,
next_page_url: Option<NextReviewsPage>,
) -> Result<ReviewsResults, ReviewsError> {
let (reviews_response, next_page, response) = if let Some(next_page_url) = next_page_url {
let request = self.request().await?;
let mut response = request.fdfe_request(&next_page_url.0, None).await?;
let review_response = response.payload.take().and_then(|a| a.review_response);
let next_page = review_response
.as_ref()
.and_then(|a| a.next_page_url.clone())
.map(NextReviewsPage);
(review_response, next_page, response)
} else {
let params = [
("doc", package_name.as_ref()),
filter.map_or(("", ""), |filter| match filter {
ReviewsFilter::All => ("sfilter", "ALL"),
ReviewsFilter::Newest => ("sort", "0"),
ReviewsFilter::Positive => ("sent", "1"),
ReviewsFilter::Critical => ("sent", "2"),
}),
];
let request = self.request().await?.params(¶ms);
let mut response = request.fdfe_request("rev", None).await?;
let review_response = response.payload.take().and_then(|a| a.review_response);
let next_page = review_response
.as_ref()
.and_then(|a| a.next_page_url.clone())
.map(NextReviewsPage);
(review_response, next_page, response)
};
let reviews = reviews_response
.and_then(|a| a.user_reviews_response)
.map(|a| a.review)
.into_google_error(&response)?;
Ok(ReviewsResults { next_page, reviews })
}
#[doc = async_doc!(r#"use futures_util::TryStreamExt;
let client = gpipipi::Client::login(email, aas_token, props).await?;
let mut reviews_stream = client
.reviews_stream("com.roblox.client", Some(gpipipi::ReviewsFilter::Newest));
# let mut count = 0;
while let Some(page) = reviews_stream.try_next().await? {
# count += 1;
# if count >= 3 {
# break
# }
for review in page {
println!("{:?} stars: {:?}", review.star_rating, review.comment);
}
}"#)]
pub fn reviews_stream<'a, A: AsRef<str> + 'a>(
&'a self,
package_name: A,
filter: Option<ReviewsFilter>,
) -> impl Stream<Item = Result<Vec<Review>, ReviewsError>> + 'a {
Box::pin(try_stream! {
let mut next_page: Option<NextReviewsPage> = None;
loop {
let result = self.reviews(package_name.as_ref(), filter.as_ref(), next_page).await?;
yield result.reviews;
next_page = result.next_page;
if next_page.is_none() {
break;
}
}
})
}
async fn lastest_version(&self, package_name: &str) -> Result<i64, DetailsError> {
let response = self
.details(package_name, None)
.await?
.version_code
.into_google_error_map(|| "no version was returned by google".to_string())?;
Ok(response)
}
async fn request(&'_ self) -> Result<Requester<'_>, PropsError> {
Requester::new()
.checkin_token(&self.checkin_token)
.config_token(&self.config_token)
.auth_token(&self.auth_token)
.dfe_cookie(&self.dfe_cookie)
.aas_token(&self.aas_token)
.email(&self.email)
.props(&self.props)
.gsf(self.gsf)
.default_headers()
.await
}
}
#[derive(Debug)]
pub struct CdnUrl {
pub filename: String,
pub sha1: String,
pub url: String,
}
#[derive(Debug)]
pub struct DownloadResponse {
pub splits: Vec<CdnUrl>,
pub obbs: Vec<CdnUrl>,
pub base: CdnUrl,
}
#[derive(Debug, Clone, Copy)]
pub enum CategoryType {
Application,
Game,
Family,
}
#[derive(Debug)]
pub struct Category {
pub cat_type: CategoryType,
pub images: Vec<String>,
pub browse_url: String,
pub title: String,
}
#[derive(Debug)]
pub struct SearchResults {
pub next_page: Option<NextSearchPage>,
pub items: Vec<Item>,
}
#[derive(Debug)]
pub struct NextSearchPage(String);
#[derive(Debug)]
pub enum ReviewsFilter {
All,
Newest,
Positive,
Critical,
}
#[derive(Debug)]
pub struct ReviewsResults {
pub next_page: Option<NextReviewsPage>,
pub reviews: Vec<Review>,
}
#[derive(Debug)]
pub struct NextReviewsPage(String);