feat(bootstrap): implement Roblox CDN client and atomic downloader
This commit is contained in:
parent
a61cf3053f
commit
f8343a1623
3 changed files with 115 additions and 0 deletions
89
crates/molotov-bootstrap/src/cdn.rs
Normal file
89
crates/molotov-bootstrap/src/cdn.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
pub mod cdn;
|
||||
pub mod error;
|
||||
|
|
|
|||
25
crates/molotov-bootstrap/tests/cdn_test.rs
Normal file
25
crates/molotov-bootstrap/tests/cdn_test.rs
Normal 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());
|
||||
}
|
||||
Reference in a new issue