From f8343a1623327c39ebebd58b3a331b5194b19e7d Mon Sep 17 00:00:00 2001 From: oiupoyt Date: Tue, 29 Sep 2026 08:02:01 +0530 Subject: [PATCH] feat(bootstrap): implement Roblox CDN client and atomic downloader --- crates/molotov-bootstrap/src/cdn.rs | 89 ++++++++++++++++++++++ crates/molotov-bootstrap/src/lib.rs | 1 + crates/molotov-bootstrap/tests/cdn_test.rs | 25 ++++++ 3 files changed, 115 insertions(+) create mode 100644 crates/molotov-bootstrap/src/cdn.rs create mode 100644 crates/molotov-bootstrap/tests/cdn_test.rs diff --git a/crates/molotov-bootstrap/src/cdn.rs b/crates/molotov-bootstrap/src/cdn.rs new file mode 100644 index 0000000..48b1f16 --- /dev/null +++ b/crates/molotov-bootstrap/src/cdn.rs @@ -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 { + 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 { + 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( + &self, + url: &str, + destination: &Path, + mut on_progress: F, + ) -> Result + where + F: FnMut(u64, Option), + { + 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) + } +} diff --git a/crates/molotov-bootstrap/src/lib.rs b/crates/molotov-bootstrap/src/lib.rs index 31ab16a..31752fe 100644 --- a/crates/molotov-bootstrap/src/lib.rs +++ b/crates/molotov-bootstrap/src/lib.rs @@ -1,2 +1,3 @@ // SPDX-License-Identifier: GPL-3.0-or-later +pub mod cdn; pub mod error; diff --git a/crates/molotov-bootstrap/tests/cdn_test.rs b/crates/molotov-bootstrap/tests/cdn_test.rs new file mode 100644 index 0000000..2965c36 --- /dev/null +++ b/crates/molotov-bootstrap/tests/cdn_test.rs @@ -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()); +}