Downloading and extracting mod archives from profile uuid

master
hheik 2024-08-24 17:51:54 +03:00
commit 897857fc98
5 changed files with 1646 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/target
/notes

1352
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

11
Cargo.toml Normal file
View File

@ -0,0 +1,11 @@
[package]
name = "munsikka"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
base64 = "0.22.1"
reqwest = { version = "0.12.7", features = [ "blocking", "json" ] }
yaml-rust2 = "0.8.1"

182
src/lib.rs Normal file
View File

@ -0,0 +1,182 @@
use std::{
env::var,
path::{Path, PathBuf},
process::Command,
};
use reqwest::blocking::Client;
use yaml_rust2::Yaml;
pub const BASE_URL: &str = "http://thunderstore.io";
#[derive(Debug)]
pub struct ModInfo {
pub name: String,
pub author: String,
pub version: (i64, i64, i64),
pub website_url: String,
pub enabled: bool,
}
impl ModInfo {
pub fn from_yaml(yaml: &Yaml) -> Option<Self> {
Some(Self {
name: yaml["displayName"].as_str()?.to_owned(),
author: yaml["authorName"].as_str()?.to_owned(),
version: (
yaml["versionNumber"]["major"].as_i64()?,
yaml["versionNumber"]["minor"].as_i64()?,
yaml["versionNumber"]["patch"].as_i64()?,
),
website_url: yaml["websiteUrl"].as_str()?.to_owned(),
enabled: yaml["enabled"].as_bool()?,
})
}
pub fn download_url(&self) -> String {
let url = self
.website_url
.replace("c/lethal-company/p", "package/download");
format!("{url}{}", self.version_string(),)
}
pub fn unique_name(&self) -> String {
format!(
"{name}_{author}_{version}",
name = self.name,
author = self.author,
version = self.version_string(),
)
}
pub fn version_string(&self) -> String {
format!("{}.{}.{}", self.version.0, self.version.1, self.version.2)
}
}
pub struct FetchExtractor {
pub client: Client,
pub base_dir: PathBuf,
pub preprocessor: Option<Box<dyn Fn(Vec<u8>) -> Vec<u8>>>,
}
impl FetchExtractor {
pub fn archive_path(&self, unique_name: &str) -> PathBuf {
self.base_dir
.join(PathBuf::from(format!("{}.zip", unique_name)))
}
pub fn extracted_path(&self, unique_name: &str) -> PathBuf {
self.base_dir
.join(PathBuf::from(format!("{}", unique_name)))
}
pub fn is_fetched(&self, unique_name: &str) -> Option<PathBuf> {
match self.archive_path(unique_name).exists() {
true => Some(self.archive_path(unique_name)),
false => None,
}
}
pub fn is_extracted(&self, unique_name: &str) -> Option<PathBuf> {
match self.extracted_path(unique_name).exists() {
true => Some(self.extracted_path(unique_name)),
false => None,
}
}
/// Removes the archive and extracted files
pub fn clean(&self, unique_name: &str) -> std::io::Result<()> {
if unique_name.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
"unique_name is empty, this may lead to unexpected behaviour",
));
}
if let Some(parent_dir) = self.archive_path(unique_name).parent() {
std::fs::create_dir_all(parent_dir)?;
}
if let Some(parent_dir) = self.extracted_path(unique_name).parent() {
std::fs::create_dir_all(parent_dir)?;
}
if let Some(archive_path) = self.is_fetched(unique_name) {
if archive_path.is_file() {
std::fs::remove_file(self.archive_path(unique_name))?;
} else {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
format!("{:?} is not a file", archive_path),
));
}
}
if let Some(extract_path) = self.is_extracted(unique_name) {
if extract_path.is_dir() {
std::fs::remove_dir_all(self.extracted_path(unique_name))?;
} else {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
format!("{:?} is not a directory", extract_path),
));
}
}
Ok(())
}
/// Fetches the archive and writes it to `self.archive_path()`
pub fn fetch(&self, url: &str, unique_name: &str) -> reqwest::Result<()> {
// Don't re-download if it already exists
if self.is_fetched(unique_name).is_some() {
return Ok(());
}
let mut zip_data = self.client.get(url).send()?.bytes()?.to_vec();
if let Some(preprocessor) = &self.preprocessor {
zip_data = preprocessor(zip_data);
}
let path = self.archive_path(unique_name);
if let Some(parent_dir) = path.parent() {
std::fs::create_dir_all(parent_dir).unwrap();
}
std::fs::write(&path, zip_data).unwrap();
Ok(())
}
/// Extracts the archive to `self.extracted_path()`
pub fn extract(&self, unique_name: &str) -> std::io::Result<()> {
// Don't re-extract if it already exists
if self.is_extracted(unique_name).is_some() {
return Ok(());
}
if let Some(parent_dir) = self.extracted_path(unique_name).parent() {
std::fs::create_dir_all(parent_dir)?;
}
unzip_to(
&self.archive_path(unique_name),
&self.extracted_path(unique_name),
)
.unwrap();
Ok(())
}
}
fn unzip_to(from: &Path, to: &Path) -> Result<(), ()> {
// TODO: use the zip library
Command::new("unzip")
.arg(from)
.arg("-d")
.arg(to)
.output()
.unwrap();
Ok(())
}
pub fn data_dir() -> PathBuf {
PathBuf::from(var("HOME").unwrap()).join(".local/share/munsikka")
}

99
src/main.rs Normal file
View File

@ -0,0 +1,99 @@
use std::{error::Error, path::PathBuf};
use munsikka::*;
use base64::prelude::*;
use yaml_rust2::{Yaml, YamlLoader};
fn main() {
// First and only argument is the (legacy) profile UUID from a mod manager user
let profile_uuid = std::env::args()
.skip(1)
.next()
.expect("The programs expects profile uuid as an argument to fetch the mod list.");
let http_client = reqwest::blocking::Client::builder()
.user_agent("munsikka-mod-manager")
.build()
.unwrap();
let profile_fetcher = FetchExtractor {
base_dir: data_dir().join(PathBuf::from("profile")),
client: http_client.clone(),
preprocessor: Some(Box::new(profile_preprocessor)),
};
let mod_fetcher = FetchExtractor {
base_dir: data_dir().join(PathBuf::from("mod")),
client: http_client.clone(),
preprocessor: None,
};
let mod_list = fetch_mod_list(&profile_fetcher, &profile_uuid).unwrap();
fetch_mods(&mod_fetcher, &mod_list).unwrap();
}
/// Map the legacy profile response into a zip binary
fn profile_preprocessor(profile_response: Vec<u8>) -> Vec<u8> {
// The response is a base64-encoded zip file with some junk data before it.
// So we strip it down.
let base64_zip = String::from_utf8(profile_response).unwrap();
let base64_zip = base64_zip
.lines()
.filter(|line| !line.is_empty() && !line.starts_with("#"))
.next()
.unwrap();
BASE64_STANDARD.decode(base64_zip).unwrap()
}
fn fetch_mod_list(
fetcher: &FetchExtractor,
profile_uuid: &str,
) -> Result<Vec<ModInfo>, Box<dyn Error>> {
let profile_url = format!("{BASE_URL}/api/experimental/legacyprofile/get/{profile_uuid}");
fetcher.clean(&profile_uuid)?;
fetcher.fetch(&profile_url, &profile_uuid)?;
fetcher.extract(profile_uuid)?;
let mods_yaml_path = fetcher
.extracted_path(profile_uuid)
.join(PathBuf::from("mods.yml"));
let mods_yaml = std::fs::read_to_string(&mods_yaml_path)?;
// Handle yaml
let mods = &YamlLoader::load_from_str(&mods_yaml)?[0];
Ok(mods
.as_vec()
.ok_or(std::io::Error::new(
std::io::ErrorKind::Other,
format!("YAML file is not a list: {:?}", mods_yaml_path),
))?
.iter()
.filter(|yaml| yaml["enabled"] == Yaml::Boolean(true))
.map(|yaml| ModInfo::from_yaml(yaml).unwrap())
.collect())
}
fn fetch_mods(fetcher: &FetchExtractor, mod_list: &Vec<ModInfo>) -> Result<(), Box<dyn Error>> {
println!("Fetching {} mods...", mod_list.len());
for mod_info in mod_list.iter() {
print!(
" {version: >10} | {name: <30} | {author: <30} ",
name = mod_info.name,
author = mod_info.author,
version = mod_info.version_string(),
);
// Skip download & extract if the mod is already extracted
if fetcher.is_extracted(&mod_info.unique_name()).is_none() {
fetcher.fetch(&mod_info.download_url(), &mod_info.unique_name())?;
fetcher.extract(&mod_info.unique_name())?;
println!("");
} else {
println!("📦");
}
}
Ok(())
}