use std::{
fs,
io::Write,
path::{Path, PathBuf},
process::Command,
};
use chrono::Local;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use crate::{
console::{Console, ProgressFormat, ProgressReporter},
error::{DoomError, Result},
paths::{is_within, normalize_path},
};
const DEFAULT_INJECTOR_ITEM_ID: i64 = 7475;
const DEFAULT_INJECTOR_DOWNLOAD_API: &str =
"https://gamebanana.com/apiv7/Tool/{item_id}?_csvProperties=_aFiles";
const STREAM_CHUNK_SIZE: usize = 1024 * 1024;
const INJECTOR_NAME: &str = "EternalModInjector.bat";
const GAME_EXE_NAME: &str = "DOOMEternalx64vk.exe";
const COMMON_GAME_ROOTS: &[&str] = &[
"C:/Program Files (x86)/Steam/steamapps/common/DOOMEternal",
"C:/Program Files/Steam/steamapps/common/DOOMEternal",
"D:/SteamLibrary/steamapps/common/DOOMEternal",
"E:/SteamLibrary/steamapps/common/DOOMEternal",
"F:/SteamLibrary/steamapps/common/DOOMEternal",
];
#[derive(Debug, Clone)]
pub struct InstallRequest {
pub game_root: PathBuf,
pub mod_zip: PathBuf,
pub backup_root: PathBuf,
pub install_tools: bool,
pub injector_zip: Option<PathBuf>,
pub tool_cache_root: PathBuf,
pub injector_item_id: i64,
pub skip_injector: bool,
pub wait_for_injector: bool,
pub dry_run: bool,
}
#[derive(Debug, Clone)]
pub struct RestoreRequest {
pub manifest_path: PathBuf,
pub game_root_override: Option<PathBuf>,
pub skip_injector: bool,
pub wait_for_injector: bool,
pub dry_run: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BackupRecord {
original_path: String,
backup_path: String,
r#type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BackupManifest {
action: String,
created_at: String,
game_root: String,
mods_dir: String,
injector_path: String,
mod_zip_source: String,
installed_entries: Vec<String>,
backups: Vec<BackupRecord>,
tool_install: Option<ToolInstallRecord>,
notes: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ToolInstallRecord {
source: serde_json::Value,
archive_path: String,
}
pub fn default_injector_item_id() -> i64 {
DEFAULT_INJECTOR_ITEM_ID
}
pub fn discover_game_root() -> Option<PathBuf> {
let env_candidates = ["DOOM_ETERNAL_ROOT", "DOOM_ETERNAL_DIR", "DOOM_ROOT"];
for variable in env_candidates {
if let Some(raw_candidate) = std::env::var_os(variable) {
let candidate = PathBuf::from(raw_candidate);
if candidate.exists() {
if let Some(resolved) = try_resolve_game_root(&candidate) {
return Some(resolved);
}
}
}
}
for candidate in COMMON_GAME_ROOTS.iter().map(PathBuf::from) {
if candidate.exists() {
if let Some(resolved) = try_resolve_game_root(&candidate) {
return Some(resolved);
}
}
}
None
}
pub fn resolve_game_root(game_root: Option<&Path>, console: &Console) -> Result<PathBuf> {
if game_root.is_none() || game_root.is_some_and(|path| path.as_os_str().is_empty()) {
if let Some(discovered) = discover_game_root() {
return Ok(discovered);
}
let suggestions = COMMON_GAME_ROOTS
.iter()
.map(|candidate| format!("- {candidate}"))
.collect::<Vec<_>>()
.join("\n");
return Err(DoomError::message(format!(
"Could not auto-detect the DOOM Eternal install root.\nPass --game-root explicitly. Common locations:\n{suggestions}"
)));
}
let candidate = game_root.expect("checked above");
if !candidate.exists() {
return Err(DoomError::message(format!(
"Missing DOOM Eternal path: {}",
candidate.display()
)));
}
let resolved = try_resolve_game_root(candidate).ok_or_else(|| {
DoomError::message(format!(
"Expected a DOOM Eternal install root containing {INJECTOR_NAME} or {GAME_EXE_NAME}. Got: {}",
console.shorten_path(candidate)
))
})?;
Ok(resolved)
}
pub async fn install_mod(console: &Console, request: InstallRequest) -> Result<PathBuf> {
if !request.mod_zip.is_file() {
return Err(DoomError::message(format!(
"Missing mod zip: {}",
console.shorten_path(&request.mod_zip)
)));
}
let mods_dir = request.game_root.join("Mods");
let installed_mod_path = mods_dir.join(
request
.mod_zip
.file_name()
.map(|value| value.to_os_string())
.ok_or_else(|| DoomError::message(format!("Mod zip has no file name: {}", request.mod_zip.display())))?,
);
let backup_dir = make_backup_dir(&request.backup_root);
let manifest_path = backup_dir.join("backup-manifest.json");
console.log_info(format!(
"Installing mod zip {} into {}",
console.format_path(&request.mod_zip),
console.format_path(&mods_dir)
));
if request.dry_run {
console.log_dry_run(format!("ensure mods dir {}", console.format_path(&mods_dir)));
} else {
fs::create_dir_all(&mods_dir)?;
}
let mod_zip_name = request
.mod_zip
.file_name()
.map(|value| value.to_string_lossy().to_string())
.ok_or_else(|| DoomError::message(format!("Mod zip has no file name: {}", request.mod_zip.display())))?;
let mut backup_records = backup_existing_entries(console, &mods_dir, &mod_zip_name, &backup_dir, request.dry_run)?;
if backup_records.is_empty() {
console.log_info("No existing Xylex mod payload needed a backup.");
} else {
console.log_info(format!(
"Backed up {} before install.",
backup_records.len()
));
}
let mut installed_entries = Vec::new();
let tool_install = if request.install_tools {
Some(
install_injector_tools(
console,
&request.game_root,
&backup_dir,
&mut backup_records,
&mut installed_entries,
request.injector_zip.as_deref(),
&request.tool_cache_root,
request.injector_item_id,
request.dry_run,
)
.await?,
)
} else {
console.log_info("Skipping injector tool install.");
None
};
if request.dry_run {
console.log_dry_run(format!(
"copy {} -> {}",
console.format_path(&request.mod_zip),
console.format_path(&installed_mod_path)
));
} else {
fs::copy(&request.mod_zip, &installed_mod_path)?;
console.log_info(format!(
"Copied mod zip into Mods/: {}",
console.format_path(&installed_mod_path)
));
}
installed_entries.push(installed_mod_path.display().to_string());
let manifest = BackupManifest {
action: "install".to_string(),
created_at: Local::now().to_rfc3339(),
game_root: request.game_root.display().to_string(),
mods_dir: mods_dir.display().to_string(),
injector_path: request.game_root.join(INJECTOR_NAME).display().to_string(),
mod_zip_source: request.mod_zip.display().to_string(),
installed_entries,
backups: backup_records,
tool_install,
notes: "This manifest tracks the previous mod payload in DOOM Eternal's Mods folder and any EternalModInjector files that were overwritten. EternalModInjector manages the game's own backup/restore flow.".to_string(),
};
console.log_info(format!(
"Writing backup manifest: {}",
console.format_path(&manifest_path)
));
write_manifest(console, &manifest_path, &manifest, request.dry_run)?;
if request.skip_injector {
console.log_success(format!(
"Skipped {} launch.",
console.format_tool_name(INJECTOR_NAME)
));
} else {
launch_injector(console, &request.game_root, request.wait_for_injector, request.dry_run)?;
console.log_success(format!(
"Launched {}.",
console.format_tool_name(INJECTOR_NAME)
));
}
Ok(manifest_path)
}
pub fn resolve_restore_manifest(manifest: Option<&Path>, backup_root: &Path, console: &Console) -> Result<PathBuf> {
if let Some(manifest) = manifest {
if !manifest.as_os_str().is_empty() {
let candidate = manifest.to_path_buf();
if !candidate.is_file() {
return Err(DoomError::message(format!(
"Missing backup manifest: {}",
console.shorten_path(&candidate)
)));
}
return Ok(candidate);
}
}
let mut manifests = fs::read_dir(backup_root)?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path().join("backup-manifest.json"))
.filter(|path| path.is_file())
.collect::<Vec<_>>();
manifests.sort();
manifests
.pop()
.ok_or_else(|| DoomError::message(format!("No backup manifests found under {}", console.shorten_path(backup_root))))
}
pub fn restore_mod(console: &Console, request: RestoreRequest) -> Result<()> {
let manifest = load_backup_manifest(&request.manifest_path, console)?;
let manifest_game_root = PathBuf::from(&manifest.game_root);
let game_root = request
.game_root_override
.clone()
.map(Ok)
.unwrap_or_else(|| resolve_game_root(Some(&manifest_game_root), console))?;
let mods_dir = game_root.join("Mods");
if !is_within(&mods_dir, &game_root) {
return Err(DoomError::message(format!(
"Resolved Mods folder is outside the game root: {}",
console.shorten_path(&mods_dir)
)));
}
let backed_up_targets = manifest
.backups
.iter()
.map(|record| record.original_path.clone())
.collect::<std::collections::HashSet<_>>();
for installed_entry in &manifest.installed_entries {
if backed_up_targets.contains(installed_entry) {
continue;
}
let installed_path = PathBuf::from(installed_entry);
if !is_within(&installed_path, &game_root) {
return Err(DoomError::message(format!(
"Refusing to delete path outside the game root: {}",
console.shorten_path(&installed_path)
)));
}
if request.dry_run {
console.log_dry_run(format!(
"remove installed entry {}",
console.format_path(&installed_path)
));
} else {
remove_entry(&installed_path)?;
}
}
for record in &manifest.backups {
let backup_path = PathBuf::from(&record.backup_path);
let original_path = PathBuf::from(&record.original_path);
if !backup_path.exists() {
return Err(DoomError::message(format!(
"Missing backup entry: {}",
console.shorten_path(&backup_path)
)));
}
if !is_within(&original_path, &game_root) {
return Err(DoomError::message(format!(
"Refusing to restore path outside the game root: {}",
console.shorten_path(&original_path)
)));
}
if request.dry_run {
console.log_dry_run(format!(
"restore {} -> {}",
console.format_path(&backup_path),
console.format_path(&original_path)
));
} else {
remove_entry(&original_path)?;
copy_entry(&backup_path, &original_path)?;
}
}
if request.skip_injector {
console.log_success(format!(
"Skipped {} launch.",
console.format_tool_name(INJECTOR_NAME)
));
} else {
launch_injector(console, &game_root, request.wait_for_injector, request.dry_run)?;
console.log_success(format!(
"Launched {}.",
console.format_tool_name(INJECTOR_NAME)
));
}
Ok(())
}
fn try_resolve_game_root(path: &Path) -> Option<PathBuf> {
let resolved = normalize_path(path).ok()?;
if resolved.join(INJECTOR_NAME).is_file() || resolved.join(GAME_EXE_NAME).is_file() {
return Some(resolved);
}
if resolved
.file_name()
.map(|value| value.to_string_lossy().eq_ignore_ascii_case("base"))
.unwrap_or(false)
&& (resolved.parent()?.join(INJECTOR_NAME).is_file() || resolved.parent()?.join(GAME_EXE_NAME).is_file())
{
return resolved.parent().map(Path::to_path_buf);
}
None
}
fn make_backup_dir(backup_root: &Path) -> PathBuf {
let timestamp = Local::now().format("%Y%m%d-%H%M%S").to_string();
let mut backup_dir = backup_root.join(×tamp);
let mut suffix = 1;
while backup_dir.exists() {
backup_dir = backup_root.join(format!("{timestamp}-{suffix}"));
suffix += 1;
}
backup_dir
}
fn ensure_unique_backup_record(
records: &mut Vec<BackupRecord>,
original_path: &Path,
backup_path: &Path,
entry_type: &str,
) {
let original_as_str = original_path.display().to_string();
if records.iter().any(|record| record.original_path == original_as_str) {
return;
}
records.push(BackupRecord {
original_path: original_as_str,
backup_path: backup_path.display().to_string(),
r#type: entry_type.to_string(),
});
}
fn backup_existing_entries(
console: &Console,
mods_dir: &Path,
mod_zip_name: &str,
backup_dir: &Path,
dry_run: bool,
) -> Result<Vec<BackupRecord>> {
let candidates = vec![mods_dir.join(mod_zip_name), mods_dir.join(Path::new(mod_zip_name).file_stem().unwrap_or_default())];
let mut records = Vec::new();
let backup_mods_dir = backup_dir.join("mods");
for candidate in candidates {
if !candidate.exists() {
continue;
}
let relative_name = candidate
.file_name()
.map(|value| value.to_os_string())
.ok_or_else(|| DoomError::message(format!("Backup candidate has no file name: {}", candidate.display())))?;
let backup_path = backup_mods_dir.join(relative_name);
ensure_unique_backup_record(
&mut records,
&candidate,
&backup_path,
if candidate.is_dir() { "dir" } else { "file" },
);
if dry_run {
console.log_dry_run(format!(
"backup {} -> {}",
console.format_path(&candidate),
console.format_path(&backup_path)
));
} else {
copy_entry(&candidate, &backup_path)?;
}
}
Ok(records)
}
fn copy_entry(source: &Path, destination: &Path) -> Result<()> {
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
if source.is_dir() {
if destination.exists() {
fs::remove_dir_all(destination)?;
}
copy_dir_recursive(source, destination)?;
return Ok(());
}
fs::copy(source, destination)?;
Ok(())
}
fn copy_dir_recursive(source: &Path, destination: &Path) -> Result<()> {
fs::create_dir_all(destination)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
let child_source = entry.path();
let child_destination = destination.join(entry.file_name());
if child_source.is_dir() {
copy_dir_recursive(&child_source, &child_destination)?;
} else {
if let Some(parent) = child_destination.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&child_source, &child_destination)?;
}
}
Ok(())
}
fn remove_entry(path: &Path) -> Result<()> {
if !path.exists() {
return Ok(());
}
if path.is_dir() {
fs::remove_dir_all(path)?;
} else {
fs::remove_file(path)?;
}
Ok(())
}
fn write_manifest(console: &Console, manifest_path: &Path, payload: &BackupManifest, dry_run: bool) -> Result<()> {
if dry_run {
console.log_dry_run(format!("write manifest {}", console.format_path(manifest_path)));
return Ok(());
}
if let Some(parent) = manifest_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(manifest_path, serde_json::to_string_pretty(payload)?)?;
Ok(())
}
async fn fetch_injector_metadata(item_id: i64) -> Result<serde_json::Value> {
let api_url = DEFAULT_INJECTOR_DOWNLOAD_API.replace("{item_id}", &item_id.to_string());
let client = browserish_client()?;
let payload = client
.get(api_url)
.send()
.await?
.error_for_status()?
.json::<serde_json::Value>()
.await?;
let files = payload
.get("_aFiles")
.and_then(|value| value.as_array())
.ok_or_else(|| DoomError::message(format!("GameBanana item {item_id} did not expose any downloadable files.")))?;
let newest = files
.iter()
.max_by_key(|item| {
(
item.get("_tsDateAdded").and_then(|value| value.as_i64()).unwrap_or_default(),
item.get("_idRow").and_then(|value| value.as_i64()).unwrap_or_default(),
)
})
.ok_or_else(|| DoomError::message(format!("GameBanana item {item_id} did not expose any downloadable files.")))?;
Ok(newest.clone())
}
fn verify_md5(console: &Console, file_path: &Path, expected_md5: &str, label: &str) -> Result<()> {
let total_size = file_path.metadata().map(|metadata| metadata.len()).unwrap_or(0);
let mut reporter = ProgressReporter::new(console, label, total_size, ProgressFormat::Bytes);
reporter.update(0, true);
let mut digest = md5::Context::new();
let mut bytes_read = 0_u64;
let mut handle = fs::File::open(file_path)?;
loop {
let mut buffer = vec![0_u8; STREAM_CHUNK_SIZE];
let read = std::io::Read::read(&mut handle, &mut buffer)?;
if read == 0 {
break;
}
digest.consume(&buffer[..read]);
bytes_read += read as u64;
reporter.update(bytes_read, false);
}
reporter.finish();
let actual_md5 = format!("{:x}", digest.compute());
if !actual_md5.eq_ignore_ascii_case(expected_md5) {
return Err(DoomError::message(format!(
"Downloaded injector zip checksum mismatch for {}: expected {}, got {}",
console.shorten_path(file_path),
expected_md5,
actual_md5
)));
}
Ok(())
}
async fn obtain_injector_zip(
console: &Console,
injector_zip: Option<&Path>,
tool_cache_root: &Path,
item_id: i64,
dry_run: bool,
) -> Result<(PathBuf, serde_json::Value)> {
if let Some(injector_zip) = injector_zip {
if !injector_zip.is_file() {
return Err(DoomError::message(format!(
"Missing injector zip: {}",
console.shorten_path(injector_zip)
)));
}
console.log_info(format!(
"Using local injector zip: {}",
console.format_path(injector_zip)
));
return Ok((injector_zip.to_path_buf(), serde_json::json!({ "source": "local", "path": injector_zip })));
}
console.log_info(format!(
"Fetching {} metadata from {}...",
console.format_tool_name("EternalModInjector"),
console.ansi(&format!("GameBanana item {item_id}"), &["\u{1b}[1m"])
));
let metadata = fetch_injector_metadata(item_id).await?;
let download_url = metadata
.get("_sDownloadUrl")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| DoomError::message(format!("GameBanana item {item_id} is missing download metadata.")))?;
let file_name = metadata
.get("_sFile")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| DoomError::message(format!("GameBanana item {item_id} is missing download metadata.")))?;
let expected_md5 = metadata
.get("_sMd5Checksum")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty());
let cache_file = tool_cache_root.join(file_name);
if cache_file.is_file() {
console.log_info(format!(
"Using cached injector zip: {}",
console.format_path(&cache_file)
));
if let Some(expected_md5) = expected_md5 {
if verify_md5(console, &cache_file, expected_md5, "Verifying cached injector zip").is_ok() {
return Ok((cache_file, metadata));
}
console.log_info("Cached injector zip checksum mismatch. Re-downloading it.");
let _ = fs::remove_file(&cache_file);
} else {
return Ok((cache_file, metadata));
}
}
if dry_run {
console.log_dry_run(format!(
"download injector tools {download_url} -> {}",
console.format_path(&cache_file)
));
return Ok((cache_file, metadata));
}
fs::create_dir_all(tool_cache_root)?;
console.log_info(format!(
"Downloading injector zip to cache: {}",
console.format_path(&cache_file)
));
download_to_path(console, download_url, &cache_file, "Downloading injector zip").await?;
if let Some(expected_md5) = expected_md5 {
verify_md5(console, &cache_file, expected_md5, "Verifying downloaded injector zip")?;
}
Ok((cache_file, metadata))
}
fn iter_archive_files(archive_path: &Path) -> Result<Vec<(String, bool)>> {
let file = fs::File::open(archive_path)?;
let mut archive = zip::ZipArchive::new(file)?;
let mut members = Vec::new();
for index in 0..archive.len() {
let item = archive.by_index(index)?;
let member_path = PathBuf::from(item.name());
if item.name().is_empty() || member_path.is_absolute() || member_path.components().any(|component| component.as_os_str() == "..") {
return Err(DoomError::message(format!(
"Unsafe path in injector archive: {}",
item.name()
)));
}
members.push((item.name().to_string(), item.is_dir()));
}
Ok(members)
}
async fn install_injector_tools(
console: &Console,
game_root: &Path,
backup_dir: &Path,
backup_records: &mut Vec<BackupRecord>,
installed_entries: &mut Vec<String>,
injector_zip: Option<&Path>,
tool_cache_root: &Path,
injector_item_id: i64,
dry_run: bool,
) -> Result<ToolInstallRecord> {
let (archive_path, metadata) = obtain_injector_zip(console, injector_zip, tool_cache_root, injector_item_id, dry_run).await?;
let backup_tools_dir = backup_dir.join("tools");
console.log_info(format!(
"Preparing injector tool install from {}",
console.format_path(&archive_path)
));
if dry_run {
let member_descriptions = if archive_path.exists() {
iter_archive_files(&archive_path)?
} else {
Vec::new()
};
for (member_name, is_dir) in member_descriptions {
let target_path = game_root.join(Path::new(&member_name));
if is_dir {
console.log_dry_run(format!("ensure tool dir {}", console.format_path(&target_path)));
} else {
console.log_dry_run(format!(
"install tool file {} -> {}",
console.ansi(&member_name, &["\u{1b}[1m"]),
console.format_path(&target_path)
));
installed_entries.push(target_path.display().to_string());
}
}
return Ok(ToolInstallRecord {
source: metadata,
archive_path: archive_path.display().to_string(),
});
}
let temp_dir = tempfile::TempDir::new()?;
let file = fs::File::open(&archive_path)?;
let mut archive = zip::ZipArchive::new(file)?;
let file_members = (0..archive.len())
.filter_map(|index| archive.by_index(index).ok().map(|entry| (!entry.is_dir(), entry.name().to_string())))
.filter(|(is_file, _)| *is_file)
.collect::<Vec<_>>();
let mut extract_reporter = ProgressReporter::new(
console,
"Extracting injector archive",
file_members.len() as u64,
ProgressFormat::FileCount,
);
extract_reporter.update(0, true);
for index in 0..archive.len() {
let mut member = archive.by_index(index)?;
let member_path = PathBuf::from(member.name());
if member.name().is_empty() || member_path.is_absolute() || member_path.components().any(|component| component.as_os_str() == "..") {
return Err(DoomError::message(format!(
"Unsafe path in injector archive: {}",
member.name()
)));
}
let destination = temp_dir.path().join(&member_path);
if member.is_dir() {
fs::create_dir_all(&destination)?;
continue;
}
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
let mut output = fs::File::create(&destination)?;
std::io::copy(&mut member, &mut output)?;
output.flush()?;
extract_reporter.advance(1);
}
extract_reporter.finish();
let source_files = walkdir::WalkDir::new(temp_dir.path())
.into_iter()
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().is_file())
.map(|entry| entry.path().to_path_buf())
.collect::<Vec<_>>();
let mut install_reporter = ProgressReporter::new(
console,
"Installing injector tool files",
source_files.len() as u64,
ProgressFormat::FileCount,
);
install_reporter.update(0, true);
for source_file in source_files {
let relative_path = source_file.strip_prefix(temp_dir.path())?;
let target_path = game_root.join(relative_path);
if !is_within(&target_path, game_root) {
return Err(DoomError::message(format!(
"Refusing to write tool file outside game root: {}",
console.shorten_path(&target_path)
)));
}
if target_path.exists() {
let backup_path = backup_tools_dir.join(relative_path);
ensure_unique_backup_record(backup_records, &target_path, &backup_path, "file");
if !backup_path.exists() {
copy_entry(&target_path, &backup_path)?;
}
}
copy_entry(&source_file, &target_path)?;
installed_entries.push(target_path.display().to_string());
install_reporter.advance(1);
}
install_reporter.finish();
fs::create_dir_all(game_root.join("Mods"))?;
Ok(ToolInstallRecord {
source: metadata,
archive_path: archive_path.display().to_string(),
})
}
fn launch_injector(console: &Console, game_root: &Path, wait_for_injector: bool, dry_run: bool) -> Result<()> {
let injector_path = game_root.join(INJECTOR_NAME);
if !injector_path.is_file() {
return Err(DoomError::message(format!(
"Missing {INJECTOR_NAME} in {}. Run install with --install-tools or place EternalModInjector in the game root first.",
console.shorten_path(game_root)
)));
}
if dry_run {
let mode = if wait_for_injector { "wait" } else { "detach" };
console.log_dry_run(format!(
"launch {} ({mode})",
console.format_path(&injector_path)
));
return Ok(());
}
if wait_for_injector {
console.log_info(format!(
"Launching {} and waiting for it to exit...",
console.format_tool_name(INJECTOR_NAME)
));
let status = Command::new("cmd")
.args(["/c", INJECTOR_NAME])
.current_dir(game_root)
.status()?;
if !status.success() {
return Err(DoomError::message(format!(
"{INJECTOR_NAME} exited with {}",
status.code().unwrap_or(-1)
)));
}
return Ok(());
}
console.log_info(format!(
"Launching {} in a separate window...",
console.format_tool_name(INJECTOR_NAME)
));
Command::new("cmd")
.args(["/c", "start", "", INJECTOR_NAME])
.current_dir(game_root)
.spawn()?;
Ok(())
}
fn load_backup_manifest(path: &Path, console: &Console) -> Result<BackupManifest> {
let payload = serde_json::from_str::<BackupManifest>(&fs::read_to_string(path)?)?;
if payload.backups.is_empty() && payload.installed_entries.is_empty() {
return Err(DoomError::message(format!(
"Backup manifest is missing backups or installed entries: {}",
console.shorten_path(path)
)));
}
Ok(payload)
}
fn browserish_client() -> Result<Client> {
Ok(Client::builder()
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36")
.build()?)
}
async fn download_to_path(console: &Console, url: &str, destination: &Path, label: &str) -> Result<()> {
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
let client = browserish_client()?;
let mut response = client.get(url).send().await?.error_for_status()?;
let total_size = response.content_length().unwrap_or(0);
let mut reporter = ProgressReporter::new(console, label, total_size, ProgressFormat::Bytes);
reporter.update(0, true);
let mut file = fs::File::create(destination)?;
let mut bytes_written = 0_u64;
while let Some(chunk) = response.chunk().await? {
file.write_all(&chunk)?;
bytes_written += chunk.len() as u64;
reporter.update(bytes_written, false);
}
file.flush()?;
reporter.finish();
Ok(())
}
#[cfg(test)]
mod tests {
use std::{fs, path::PathBuf};
use tempfile::TempDir;
use super::{GAME_EXE_NAME, INJECTOR_NAME, try_resolve_game_root};
#[test]
fn resolves_direct_game_root() {
let temp_dir = TempDir::new().expect("tempdir");
let game_root = temp_dir.path().join("DOOMEternal");
fs::create_dir_all(&game_root).expect("game root");
fs::write(game_root.join(GAME_EXE_NAME), b"exe").expect("game exe");
let resolved = try_resolve_game_root(&game_root);
assert_eq!(resolved, Some(PathBuf::from(&game_root)));
}
#[test]
fn resolves_base_subfolder_back_to_game_root() {
let temp_dir = TempDir::new().expect("tempdir");
let game_root = temp_dir.path().join("DOOMEternal");
let base = game_root.join("base");
fs::create_dir_all(&base).expect("base");
fs::write(game_root.join(INJECTOR_NAME), b"bat").expect("injector");
let resolved = try_resolve_game_root(&base);
assert_eq!(resolved, Some(PathBuf::from(&game_root)));
}
}