use std::{
fs::{self, File},
io::{self, Write},
path::{Path, PathBuf},
process::Command,
};
use reqwest::Client;
use serde::Deserialize;
use crate::{
config::RepoConfig,
console::Console,
error::{DoomError, Result},
installer::download_injector_zip_to_cache,
paths::{home_dir, RepoContext},
};
const SAMUEL_RELEASE_API: &str = "https://api.github.com/repos/brongo/SAMUEL/releases/latest";
const DEFAULT_SAMUEL_EXE_NAME: &str = "SAMUEL-v2.1.2.exe";
const DEFAULT_INJECTOR_ZIP_NAME: &str = "EternalModInjector.zip";
const DEFAULT_EXPORTS_DIR_NAME: &str = "exports";
const DEFAULT_MODEL_EXPORTS_DIR_NAME: &str = "modelExports";
#[derive(Debug, Deserialize)]
struct GithubRelease {
tag_name: String,
assets: Vec<GithubAsset>,
}
#[derive(Debug, Deserialize)]
struct GithubAsset {
name: String,
browser_download_url: String,
}
pub fn discover_idstudio_root(repo: &RepoContext) -> Option<PathBuf> {
discover_root_from_env(["IDSTUDIO_MODS_ROOT", "IDSTUDIO_ROOT"])
.or_else(|| RepoConfig::load_or_default(repo).discover_idstudio_root(repo))
}
pub fn discover_activemods_root(repo: &RepoContext) -> Option<PathBuf> {
discover_root_from_env(["DOOM_ACTIVEMODS_ROOT", "ACTIVEMODS_ROOT"])
.or_else(|| RepoConfig::load_or_default(repo).discover_activemods_root(repo))
}
pub fn discover_samuel_exe(tool_cache_root: &Path) -> Option<PathBuf> {
cached_samuel_exe(tool_cache_root).or_else(|| {
default_downloads_dir()
.map(|downloads| downloads.join(DEFAULT_SAMUEL_EXE_NAME))
.filter(|path| path.is_file())
})
}
pub fn discover_injector_zip(tool_cache_root: &Path) -> Option<PathBuf> {
cached_injector_zip(tool_cache_root).or_else(|| {
default_downloads_dir()
.map(|downloads| downloads.join(DEFAULT_INJECTOR_ZIP_NAME))
.filter(|path| path.is_file())
})
}
pub fn discover_samuel_export_root() -> Option<PathBuf> {
default_downloads_dir()
.map(|downloads| downloads.join(DEFAULT_EXPORTS_DIR_NAME))
.filter(|path| path.is_dir())
}
pub fn discover_model_export_root() -> Option<PathBuf> {
default_downloads_dir()
.map(|downloads| downloads.join(DEFAULT_MODEL_EXPORTS_DIR_NAME))
.filter(|path| path.is_dir())
}
pub async fn download_samuel(
console: &Console,
tool_cache_root: &Path,
dry_run: bool,
) -> Result<PathBuf> {
if let Some(existing) = cached_samuel_exe(tool_cache_root) {
console.log_info(format!(
"Using cached {} executable: {}",
console.format_tool_name("SAMUEL"),
console.format_path(&existing)
));
return Ok(existing);
}
console.log_info(format!(
"Fetching latest {} release metadata from GitHub...",
console.format_tool_name("SAMUEL")
));
let client = github_client()?;
let release = client
.get(SAMUEL_RELEASE_API)
.send()
.await?
.error_for_status()?
.json::<GithubRelease>()
.await?;
let asset = release
.assets
.iter()
.find(|asset| asset.name.ends_with("Windows.zip"))
.ok_or_else(|| {
DoomError::message("Latest SAMUEL release does not expose a Windows zip asset.")
})?;
let version_root = tool_cache_root.join("samuel").join(&release.tag_name);
let zip_path = version_root.join(&asset.name);
let extract_root = version_root.join("extracted");
if dry_run {
console.log_dry_run(format!(
"download {} -> {}",
asset.browser_download_url,
console.format_path(&zip_path)
));
return Ok(extract_root.join("SAMUEL.exe"));
}
fs::create_dir_all(&version_root)?;
if !zip_path.is_file() {
console.log_info(format!(
"Downloading {} release archive: {}",
console.format_tool_name("SAMUEL"),
console.format_path(&zip_path)
));
download_to_path(&client, &asset.browser_download_url, &zip_path).await?;
}
if !extract_root.is_dir() || find_first_named_exe(&extract_root, "SAMUEL").is_none() {
if extract_root.exists() {
fs::remove_dir_all(&extract_root)?;
}
fs::create_dir_all(&extract_root)?;
extract_zip(&zip_path, &extract_root)?;
}
let exe_path = find_first_named_exe(&extract_root, "SAMUEL").ok_or_else(|| {
DoomError::message("Downloaded SAMUEL release did not contain a SAMUEL executable.")
})?;
console.log_success(format!(
"Ready {} executable: {}",
console.format_tool_name("SAMUEL"),
console.format_path(&exe_path)
));
Ok(exe_path)
}
pub async fn download_injector(
console: &Console,
tool_cache_root: &Path,
injector_zip: Option<&Path>,
item_id: i64,
dry_run: bool,
) -> Result<PathBuf> {
let archive_path =
download_injector_zip_to_cache(console, tool_cache_root, injector_zip, item_id, dry_run)
.await?;
console.log_success(format!(
"Ready {} archive: {}",
console.format_tool_name("EternalModInjector"),
console.format_path(&archive_path)
));
Ok(archive_path)
}
pub fn launch_samuel(console: &Console, exe_path: &Path, dry_run: bool) -> Result<()> {
if !exe_path.is_file() {
return Err(DoomError::message(format!(
"Missing SAMUEL executable: {}",
console.shorten_path(exe_path)
)));
}
if dry_run {
console.log_dry_run(format!("launch {}", console.format_path(exe_path)));
return Ok(());
}
console.log_info(format!(
"Launching {}...",
console.format_tool_name("SAMUEL")
));
Command::new(exe_path)
.current_dir(
exe_path
.parent()
.ok_or_else(|| DoomError::message("SAMUEL executable has no parent directory."))?,
)
.spawn()?;
Ok(())
}
fn discover_root_from_env<const N: usize>(variables: [&str; N]) -> Option<PathBuf> {
for variable in variables {
if let Some(value) = std::env::var_os(variable) {
return Some(PathBuf::from(value));
}
}
None
}
fn default_downloads_dir() -> Option<PathBuf> {
home_dir().map(|home| home.join("Downloads"))
}
fn cached_samuel_exe(tool_cache_root: &Path) -> Option<PathBuf> {
let samuel_root = tool_cache_root.join("samuel");
let mut versions = fs::read_dir(&samuel_root)
.ok()?
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().is_dir())
.collect::<Vec<_>>();
versions.sort_by_key(|entry| entry.file_name());
versions.reverse();
versions
.into_iter()
.find_map(|entry| find_first_named_exe(&entry.path(), "SAMUEL"))
}
fn cached_injector_zip(tool_cache_root: &Path) -> Option<PathBuf> {
let injector_root = tool_cache_root.join("injector");
let mut zips = list_files_with_extension(&injector_root, "zip");
zips.sort();
zips.pop()
}
fn list_files_with_extension(root: &Path, extension: &str) -> Vec<PathBuf> {
fs::read_dir(root)
.map(|entries| {
entries
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| {
path.is_file()
&& path
.extension()
.map(|value| value.to_string_lossy().eq_ignore_ascii_case(extension))
.unwrap_or(false)
})
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
fn find_first_named_exe(root: &Path, stem_prefix: &str) -> Option<PathBuf> {
if !root.exists() {
return None;
}
let mut stack = vec![root.to_path_buf()];
while let Some(path) = stack.pop() {
let entries = fs::read_dir(&path).ok()?;
for entry in entries.filter_map(|entry| entry.ok()) {
let child = entry.path();
if child.is_dir() {
stack.push(child);
continue;
}
let is_exe = child
.extension()
.map(|value| value.to_string_lossy().eq_ignore_ascii_case("exe"))
.unwrap_or(false);
let stem_matches = child
.file_stem()
.map(|value| value.to_string_lossy().starts_with(stem_prefix))
.unwrap_or(false);
if is_exe && stem_matches {
return Some(child);
}
}
}
None
}
fn extract_zip(zip_path: &Path, destination: &Path) -> Result<()> {
let file = File::open(zip_path)?;
let mut archive = zip::ZipArchive::new(file)?;
for index in 0..archive.len() {
let mut item = archive.by_index(index)?;
let enclosed = item
.enclosed_name()
.ok_or_else(|| DoomError::message("Archive contained an invalid path."))?;
let output_path = destination.join(enclosed);
if item.name().ends_with('/') {
fs::create_dir_all(&output_path)?;
continue;
}
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)?;
}
let mut output = File::create(&output_path)?;
io::copy(&mut item, &mut output)?;
output.flush()?;
}
Ok(())
}
fn github_client() -> Result<Client> {
Ok(Client::builder().user_agent("doom-eternal/0.1.0").build()?)
}
async fn download_to_path(client: &Client, url: &str, destination: &Path) -> Result<()> {
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
let mut response = client.get(url).send().await?.error_for_status()?;
let mut file = File::create(destination)?;
while let Some(chunk) = response.chunk().await? {
file.write_all(&chunk)?;
}
file.flush()?;
Ok(())
}