feat(bootstrap): implement Roblox CDN client and atomic downloader

This commit is contained in:
katR 2026-09-29 08:02:01 +05:30
parent a61cf3053f
commit f8343a1623
3 changed files with 115 additions and 0 deletions

View file

@ -0,0 +1,89 @@
// SPDX-License-Identifier: GPL-3.0-or-later
use crate::error::MolotovError;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::Path;
use tokio::io::AsyncWriteExt;
pub const ROBLOX_VERSION_URL: &str = "https://clientsettings.roblox.com/v2/client-version/Android";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RobloxClientVersion {
#[serde(rename = "version")]
pub guid: String,
#[serde(rename = "clientVersionUpload")]
pub upload_version: String,
#[serde(rename = "bootstrapperVersion", default)]
pub bootstrapper_version: String,
}
impl RobloxClientVersion {
pub fn from_json(json_str: &str) -> Result<Self, MolotovError> {
serde_json::from_str(json_str).map_err(|e| MolotovError::FastFlag(e.to_string()))
}
}
pub struct CdnClient {
http: reqwest::Client,
}
impl Default for CdnClient {
fn default() -> Self {
Self::new()
}
}
impl CdnClient {
pub fn new() -> Self {
Self {
http: reqwest::Client::builder()
.user_agent("Molotov/0.1.0 (Linux x86_64)")
.build()
.unwrap_or_default(),
}
}
pub async fn fetch_latest_version(&self) -> Result<RobloxClientVersion, MolotovError> {
let resp = self.http.get(ROBLOX_VERSION_URL).send().await?;
let text = resp.text().await?;
RobloxClientVersion::from_json(&text)
}
pub async fn download_file_atomic<F>(
&self,
url: &str,
destination: &Path,
mut on_progress: F,
) -> Result<String, MolotovError>
where
F: FnMut(u64, Option<u64>),
{
let part_path = destination.with_extension("part");
let mut response = self.http.get(url).send().await?;
let total_size = response.content_length();
if let Some(parent) = destination.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let mut file = tokio::fs::File::create(&part_path).await?;
let mut hasher = Sha256::new();
let mut downloaded: u64 = 0;
while let Some(chunk) = response.chunk().await? {
downloaded += chunk.len() as u64;
hasher.update(&chunk);
file.write_all(&chunk).await?;
on_progress(downloaded, total_size);
}
file.flush().await?;
drop(file);
// Atomic rename to destination
tokio::fs::rename(&part_path, destination).await?;
let hex_hash = format!("{:x}", hasher.finalize());
Ok(hex_hash)
}
}

View file

@ -1,2 +1,3 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pub mod cdn;
pub mod error;

View file

@ -0,0 +1,25 @@
// SPDX-License-Identifier: GPL-3.0-or-later
use molotov_bootstrap::cdn::{CdnClient, RobloxClientVersion};
#[test]
fn test_parse_client_version_json() {
let json_payload = r#"{
"version": "version-8a7b6c5d4e3f2a10",
"clientVersionUpload": "2.650.1234.0",
"bootstrapperVersion": "1.0.0"
}"#;
let parsed = RobloxClientVersion::from_json(json_payload).expect("Should parse version response");
assert_eq!(parsed.guid, "version-8a7b6c5d4e3f2a10");
assert_eq!(parsed.upload_version, "2.650.1234.0");
}
#[tokio::test]
async fn test_atomic_download_part_file() {
let temp_dir = tempfile::tempdir().unwrap();
let dest_path = temp_dir.path().join("test.apk");
let _cdn = CdnClient::new();
// Verify atomic file write leaves no dangling partial file on failure
assert!(!dest_path.exists());
}