#![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;
use std::future::Future;
mod api;
mod consts;
pub mod error;
pub mod proto;
macro_rules! async_doc {
($code:literal) => {
concat!(
r#"```
# tokio_test::block_on(async {
# #[allow(unused_imports)] use gpipipi::Client;
# 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("AAS_TOKEN").map_err(|_| "Set 'AAS_TOKEN' to an aas token, running the binary turns an oauth token into one!")?;
# 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>>(())
```"
)
}
}
macro_rules! short_doc {
($code:literal) => {
concat!(
r#"```
# tokio_test::block_on(async {
# #[allow(unused_imports)] use gpipipi::Client;
# 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 auth_token = std::env::var("AUTH_TOKEN").map_err(|_| "Set 'AUTH_TOKEN' to an auth token generated from an aas token!")?;
"#,
$code,
"
# Ok::<(), Box<dyn std::error::Error>>(())
# })?;
# Ok::<(), Box<dyn std::error::Error>>(())
```"
)
}
}
mod sealed {
use crate::{api::endpoint::Requester, error::PropsError};
use std::future::Future;
pub trait Sealed {
fn requester(&self) -> impl Future<Output = Result<Requester<'_>, PropsError>> + Send;
}
}
struct Session {
props: DeviceProperties,
auth_token: AuthToken,
checkin_token: String,
config_token: String,
dfe_cookie: String,
gsf: i64,
}
impl Session {
async fn prepare(props: &DeviceProperties) -> Result<(i64, String, String), LoginError> {
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())?;
Ok((gsf, checkin_token, config_token))
}
fn requester(&'_ self) -> Requester<'_> {
Requester::new()
.checkin_token(&self.checkin_token)
.config_token(&self.config_token)
.auth_token(&self.auth_token)
.dfe_cookie(&self.dfe_cookie)
.props(&self.props)
.gsf(self.gsf)
}
}
pub struct LongLivedClient {
aas_token: String,
email: String,
session: Session,
}
impl LongLivedClient {
#[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::LongLivedClient::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 (gsf, checkin_token, config_token) = Session::prepare(&props).await?;
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,
session: Session {
props,
auth_token,
checkin_token,
config_token,
dfe_cookie,
gsf,
},
})
}
}
impl sealed::Sealed for LongLivedClient {
async fn requester(&self) -> Result<Requester<'_>, PropsError> {
self.session
.requester()
.aas_token(&self.aas_token)
.email(&self.email)
.default_headers()
.await
}
}
impl Client for LongLivedClient {}
pub struct ShortLivedClient {
session: Session,
}
impl ShortLivedClient {
#[doc = short_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::ShortLivedClient::login(auth_token, props).await?;"#)]
pub async fn login<A: Into<String>>(
auth_token: A,
props: DeviceProperties,
) -> Result<Self, LoginError> {
let (gsf, checkin_token, config_token) = Session::prepare(&props).await?;
let auth_token = AuthToken::without_expire(auth_token.into());
let dfe_cookie = toc(&auth_token, gsf, &checkin_token, &props).await?;
Ok(Self {
session: Session {
props,
auth_token,
checkin_token,
config_token,
dfe_cookie,
gsf,
},
})
}
#[doc = short_doc!(r#"let client = gpipipi::ShortLivedClient::login(&auth_token, props).await?;
client.set_auth_token(auth_token);"#)]
pub fn set_auth_token<A: Into<String>>(&self, auth_token: A) {
self.session.auth_token.set(auth_token.into(), None);
}
}
impl sealed::Sealed for ShortLivedClient {
async fn requester(&self) -> Result<Requester<'_>, PropsError> {
self.session.requester().default_headers().await
}
}
impl Client for ShortLivedClient {}
#[doc = async_doc!(r#"async fn first_hit(client: &impl Client) -> Result<Option<String>, gpipipi::error::SearchError> {
let results = client.search("robl", None).await?;
Ok(results.items.into_iter().next().and_then(|a| a.title))
}
let client = gpipipi::LongLivedClient::login(email, aas_token, props).await?;
println!("{:?}", first_hit(&client).await?);"#)]
#[allow(private_bounds)]
pub trait Client: sealed::Sealed + Sync {
#[doc = async_doc!(r#"let client = gpipipi::LongLivedClient::login(email, aas_token, props).await?;
let details = client.details("com.roblox.client", None).await?;"#)]
fn details<A: AsRef<str> + Send>(
&self,
package_name: A,
maybe_version: Option<i64>,
) -> impl Future<Output = Result<proto::AppDetails, DetailsError>> + Send {
async move {
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.requester().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::LongLivedClient::login(email, aas_token, props).await?;
let download_urls = client.downloads("com.roblox.client", Some(2036)).await?;"#)]
fn downloads<A: AsRef<str> + Send>(
&self,
package_name: A,
version: Option<i64>,
) -> impl Future<Output = Result<DownloadResponse, DownloadsError>> + Send {
async move {
let version = if let Some(version) = version {
version
} else {
latest_version(self, 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.requester().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::LongLivedClient::login(email, aas_token, props).await?;
let categories = client.categories(gpipipi::CategoryType::Game).await?;")]
fn categories(
&self,
cat_type: CategoryType,
) -> impl Future<Output = Result<Vec<Category>, CategoryError>> + Send {
async move {
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.requester().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::LongLivedClient::login(email, aas_token, props).await?;
let suggestions = client.search_suggest("robl").await?;"#)]
fn search_suggest<A: AsRef<str> + Send>(
&self,
query: A,
) -> impl Future<Output = Result<Vec<SearchSuggestEntry>, SearchSuggestionError>> + Send {
async move {
let headers = [("User-Agent", consts::LEGACY_USERAGENT)];
let params = [
("q", query.as_ref()),
("ssis", "120"),
("sb", "5"),
("sst", "2"),
("c", "3"),
];
let request = self.requester().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::LongLivedClient::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?;"#)]
fn search<A: AsRef<str> + Send>(
&self,
query: A,
next_page_url: Option<NextSearchPage>,
) -> impl Future<Output = Result<SearchResults, SearchError>> + Send {
async move {
let (payload, next_page, response) = if let Some(next_page_url) = next_page_url {
let request = self.requester().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.requester().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::LongLivedClient::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);
}
}"#)]
fn search_stream<'a, A: AsRef<str> + Send + 'a>(
&'a self,
query: A,
) -> impl Stream<Item = Result<Vec<Item>, SearchError>> + Send + '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::LongLivedClient::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?;"#)]
fn reviews<A: AsRef<str> + Send>(
&self,
package_name: A,
filter: Option<&ReviewsFilter>,
next_page_url: Option<NextReviewsPage>,
) -> impl Future<Output = Result<ReviewsResults, ReviewsError>> + Send {
async move {
let (reviews_response, next_page, response) = if let Some(next_page_url) = next_page_url
{
let request = self.requester().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.requester().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::LongLivedClient::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);
}
}"#)]
fn reviews_stream<'a, A: AsRef<str> + Send + 'a>(
&'a self,
package_name: A,
filter: Option<ReviewsFilter>,
) -> impl Stream<Item = Result<Vec<Review>, ReviewsError>> + Send + '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 latest_version<A: Client + ?Sized>(
client: &A,
package_name: &str,
) -> Result<i64, DetailsError> {
let response = client
.details(package_name, None)
.await?
.version_code
.into_google_error_map(|| "no version was returned by google".to_string())?;
Ok(response)
}
#[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);