mod cache; mod config; mod extend; mod github; mod global; mod http_async; mod iw4x; mod misc; mod self_update; mod structs; #[cfg(test)] mod tests; use extend::*; use global::*; use structs::*; #[macro_use] extern crate simple_log; use colored::Colorize; use indicatif::ProgressBar; #[cfg(windows)] use mslnk::ShellLink; use simple_log::LogConfigBuilder; use std::{ borrow::Cow, collections::HashMap, env, fs, path::{Path, PathBuf}, }; #[cfg(windows)] use steamlocate::SteamDir; #[cfg(windows)] fn get_installed_games(games: &Vec) -> Vec<(u32, PathBuf)> { let mut installed_games = Vec::new(); let steamdir_result = SteamDir::locate(); let steamdir = match steamdir_result { Ok(steamdir) => steamdir, Err(error) => { crate::println_error!("Error locating Steam: {error}"); return installed_games; } }; for game in games { if let Ok(Some((app, library))) = steamdir.find_app(game.app_id) { let game_path = library .path() .join("steamapps") .join("common") .join(&app.install_dir); installed_games.push((game.app_id, game_path)); } } installed_games } #[cfg(windows)] fn create_shortcut(path: &Path, target: &Path, icon: String, args: String) { if let Ok(mut sl) = ShellLink::new(target) { sl.set_arguments(Some(args)); sl.set_icon_location(Some(icon)); sl.create_lnk(path).unwrap_or_else(|error| { crate::println_error!("Error creating shortcut.\n{error}"); }); } else { crate::println_error!("Error creating shortcut."); } } #[cfg(windows)] fn setup_client_links(game: &Game, game_dir: &Path) { if game.client.len() > 1 { println!("Multiple clients installed, use the shortcuts (launch-.lnk in the game directory or on the desktop) to launch a specific client."); } for c in game.client.iter() { create_shortcut( &game_dir.join(format!("launch-{c}.lnk")), &game_dir.join("alterware-launcher.exe"), game_dir .join(format!("{c}.exe")) .to_string_lossy() .into_owned(), c.to_string(), ); } } #[cfg(windows)] fn setup_desktop_links(path: &Path, game: &Game) { println!("Create Desktop shortcut? (Y/n)"); let input = misc::stdin().to_ascii_lowercase(); if input != "n" { let desktop = PathBuf::from(&format!("{}\\Desktop", env::var("USERPROFILE").unwrap())); for c in game.client.iter() { create_shortcut( &desktop.join(format!("{c}.lnk")), &path.join("alterware-launcher.exe"), path.join(format!("{c}.exe")).to_string_lossy().into_owned(), c.to_string(), ); } } } #[cfg(windows)] async fn auto_install(path: &Path, game: &Game<'_>) { setup_client_links(game, path); setup_desktop_links(path, game); update(game, path, false, false, None, None).await; } #[cfg(windows)] async fn windows_launcher_install(games: &Vec>) { crate::println_info!( "{}", "No game specified/found. Checking for installed Steam games..".yellow() ); let installed_games = get_installed_games(games); if !installed_games.is_empty() { let current_dir = env::current_dir().unwrap(); for (id, path) in installed_games.iter() { if current_dir.starts_with(path) { crate::println_info!("Found game in current directory."); crate::println_info!("Installing AlterWare client for {}.", id); let game = games.iter().find(|&g| g.app_id == *id).unwrap(); auto_install(path, game).await; crate::println_info!("Installation complete. Please run the launcher again or use a shortcut to launch the game."); std::io::stdin().read_line(&mut String::new()).unwrap(); std::process::exit(0); } } println!("Installed games:"); for (id, path) in installed_games.iter() { println!("{id}: {}", path.display()); } println!("Enter the ID of the game you want to install the AlterWare client for:"); let input: u32 = misc::stdin().parse().unwrap(); for (id, path) in installed_games.iter() { if *id == input { let game = games.iter().find(|&g| g.app_id == input).unwrap(); let launcher_path = env::current_exe().unwrap(); let target_path = path.join("alterware-launcher.exe"); if launcher_path != target_path { fs::copy(launcher_path, &target_path).unwrap(); crate::println_info!("Launcher copied to {}", path.display()); } auto_install(path, game).await; crate::println_info!("Installation complete."); crate::println_info!("Please use one of the shortcuts (on your Desktop or in the game folder) to play."); crate::println_info!( "Alternatively run the launcher again from the game folder {}", target_path.display() ); std::io::stdin().read_line(&mut String::new()).unwrap(); break; } } std::process::exit(0); } else { println!( "No installed games found. Make sure to place the launcher in the game directory." ); std::io::stdin().read_line(&mut String::new()).unwrap(); std::process::exit(0); } } fn total_download_size(cdn_info: &Vec, remote_dir: &str) -> u64 { let remote_dir = format!("{remote_dir}/"); let mut size: u64 = 0; for file in cdn_info { if !file.name.starts_with(&remote_dir) || file.name == "iw4/iw4x.dll" { continue; } size += file.size as u64; } size } async fn update_dir( cdn_info: &Vec, remote_dir: &str, dir: &Path, hashes: &mut HashMap, pb: &ProgressBar, skip_iw4x_sp: bool, ) { misc::pb_style_download(pb, false); let remote_dir_pre = format!("{remote_dir}/"); let mut files_to_download: Vec = vec![]; for file in cdn_info { if !file.name.starts_with(&remote_dir_pre) || file.name == "iw4/iw4x.dll" { continue; } if skip_iw4x_sp && file.name == "iw4/iw4x-sp.exe" { continue; } let hash_remote = file.blake3.to_lowercase(); let file_name = &file.name.replace(remote_dir_pre.as_str(), ""); let file_path = dir.join(file_name); if file_path.exists() { let hash_local = hashes .get(file_name) .map(Cow::Borrowed) .unwrap_or_else(|| Cow::Owned(file_path.get_blake3().unwrap())) .to_string(); if hash_local != hash_remote { files_to_download.push(file.clone()); } else { let msg = format!("{}{}", misc::prefix("checked"), file_path.cute_path()); pb.println(&msg); info!("{msg}"); hashes.insert(file_name.to_owned(), file.blake3.to_lowercase()); } } else { files_to_download.push(file.clone()); } } if files_to_download.is_empty() { let msg = format!( "{}No files to download for {}", misc::prefix("info"), remote_dir ); pb.println(&msg); info!("{msg}"); return; } let msg = format!( "{}Downloading outdated or missing files for {remote_dir}, {}", misc::prefix("info"), misc::human_readable_bytes(total_download_size(&files_to_download, remote_dir)) ); pb.println(&msg); info!("{msg}"); misc::pb_style_download(pb, true); let client = reqwest::Client::new(); for file in files_to_download { let file_name = &file.name.replace(&remote_dir_pre, ""); let file_path = dir.join(file_name); if let Some(parent) = file_path.parent() { if !parent.exists() { fs::create_dir_all(parent).unwrap(); } } // Prompt user to retry downloads if they fail let mut download_complete = false; let mut bust_cache = false; let mut local_hash = String::default(); while !download_complete { let url = format!("{}/{}", MASTER.lock().unwrap(), file.name); let url = if bust_cache { bust_cache = false; format!("{}?{}", url, misc::random_string(6)) } else { url }; if let Err(err) = http_async::download_file_progress(&client, pb, &url, &file_path, file.size as u64) .await { let file_name = file_path.clone().cute_path(); println_error!("{err}"); println!("Failed to download file {file_name}, retry? (Y/n)"); let input = misc::stdin().to_ascii_lowercase(); if input == "n" { error!("Download for file {file_name} failed with {err}"); panic!("{err}"); } else { warn!( "Download for file {file_name} failed with {err} user chose to retry download" ); } }; local_hash = file_path.get_blake3().unwrap().to_lowercase(); let remote = file.blake3.to_lowercase(); if local_hash != remote { println_error!("Downloaded file hash does not match remote!\nRemote {remote}, local {local_hash}, {}\nIf this issue persists please try again in 15 minutes.", file_path.cute_path()); println!("Retry download? (Y/n)"); let input = misc::stdin().to_ascii_lowercase(); if input != "n" { println_info!( "Retrying download for {} due to hash mismatch", file_path.cute_path() ); bust_cache = true; continue; } } download_complete = true; } hashes.insert(file_name.to_owned(), local_hash); #[cfg(unix)] if file_name.ends_with(".exe") { let perms = std::os::unix::fs::PermissionsExt::from_mode(0o755); fs::set_permissions(&file_path, perms).unwrap_or_else(|error| { crate::println_error!("Error setting permissions for {file_name}: {error}"); }) } } misc::pb_style_download(pb, false); } async fn update( game: &Game<'_>, dir: &Path, bonus_content: bool, force: bool, skip_iw4x_sp: Option, ignore_required_files: Option, ) { info!("Starting update for game engine: {}", game.engine); info!("Update path: {}", dir.display()); debug!("Bonus content: {}, Force update: {}", bonus_content, force); let skip_iw4x_sp = skip_iw4x_sp.unwrap_or(false); let ignore_required_files = ignore_required_files.unwrap_or(false); let res = http_async::get_body_string(format!("{}/files.json", MASTER.lock().unwrap()).as_str()) .await .unwrap(); debug!("Retrieved files.json from server"); let cdn_info: Vec = serde_json::from_str(&res).unwrap(); if !ignore_required_files && !game.required_files_exist(dir) { error!("Critical game files missing. Required files check failed."); println!( "{}\nVerify game file integrity on Steam or reinstall the game.", "Critical game files missing.".bright_red() ); std::io::stdin().read_line(&mut String::new()).unwrap(); std::process::exit(0); } let old_files = [".sha-sums", ".iw4xrevision"]; for f in old_files { if dir.join(f).exists() { match fs::remove_file(dir.join(f)) { Ok(_) => {} Err(error) => { crate::println_error!("Error removing {f}: {error}"); } } } } let mut cache = if force { structs::Cache::default() } else { cache::get_cache(dir) }; if game.engine == "iw4" { iw4x::update(dir, &mut cache).await; let iw4x_dirs = vec!["iw4x", "zone/patch"]; for d in &iw4x_dirs { if let Ok(dir_iter) = dir.join(d).read_dir() { 'outer: for file in dir_iter.filter_map(|entry| entry.ok()) { let file_path = file.path(); if file_path.is_dir() { continue; } let file_path_rel = match file_path.strip_prefix(dir) { Ok(rel) => rel.to_path_buf(), Err(_) => continue, }; if iw4x_dirs .iter() .any(|prefix| file_path_rel.starts_with(Path::new(prefix))) { if !cdn_info .iter() .any(|cdn_file| cdn_file.name.starts_with("iw4")) { continue; } let should_continue = cdn_info.iter().any(|cdn_file| { let path_rem = Path::new(&cdn_file.name) .strip_prefix(Path::new("iw4")) .unwrap_or_else(|_| Path::new(&cdn_file.name)); path_rem == file_path_rel }); if should_continue { continue 'outer; } crate::println_info!( "{}{}", misc::prefix("removed"), file_path.cute_path() ); if fs::remove_file(&file_path).is_err() { crate::println_error!( "{}Couldn't delete {}", misc::prefix("error"), file_path.cute_path() ); } } } } } } let pb = ProgressBar::new(0); update_dir( &cdn_info, game.engine, dir, &mut cache.hashes, &pb, skip_iw4x_sp, ) .await; if bonus_content && !game.bonus.is_empty() { for bonus in game.bonus.iter() { update_dir(&cdn_info, bonus, dir, &mut cache.hashes, &pb, skip_iw4x_sp).await; } } pb.finish(); for f in game.delete.iter() { let file_path = dir.join(f); if file_path.is_file() { if fs::remove_file(&file_path).is_err() { println!( "{}Couldn't delete {}", misc::prefix("error"), file_path.cute_path() ); } else { println!("{}{}", misc::prefix("removed"), file_path.cute_path()); } } else if file_path.is_dir() { if fs::remove_dir_all(&file_path).is_err() { println!( "{}Couldn't delete {}", misc::prefix("error"), file_path.cute_path() ); } else { println!("{}{}", misc::prefix("removed"), file_path.cute_path()); } } } cache::save_cache(dir, cache); // Store game data for offline mode let mut stored_data = cache::get_stored_data().unwrap_or_default(); stored_data.game_path = dir.to_string_lossy().into_owned(); // Store available clients for this engine stored_data.clients.insert( game.engine.to_string(), game.client.iter().map(|s| s.to_string()).collect(), ); if let Err(e) = cache::store_game_data(&stored_data) { println!( "{} Failed to store game data: {}", PREFIXES.get("error").unwrap().formatted(), e ); } } #[cfg(windows)] fn launch(file_path: &PathBuf, args: &str) { info!( "Launching game on Windows: {} {}", file_path.display(), args ); println!("\n\nJoin the AlterWare Discord server:\nhttps://discord.gg/2ETE8engZM\n\n"); crate::println_info!("Launching {} {args}", file_path.display()); let exit_status = std::process::Command::new(file_path) .args(args.trim().split(' ')) .current_dir(file_path.parent().unwrap()) .spawn() .expect("Failed to launch the game") .wait() .expect("Failed to wait for the game process to finish"); if exit_status.success() { info!("Game exited successfully with status: {}", exit_status); } else { error!("Game exited with error status: {}", exit_status); } crate::println_error!("Game exited with {exit_status}"); if !exit_status.success() { misc::stdin(); } } #[cfg(unix)] fn launch(file_path: &PathBuf, args: &str) { println!("\n\nJoin the AlterWare Discord server:\nhttps://discord.gg/2ETE8engZM\n\n"); crate::println_info!("Launching {} {args}", file_path.display()); let exit_status = if misc::is_program_in_path("wine") { println!("Found wine, launching game using wine.\nIf you run into issues or want to launch a different way, run {} manually.", file_path.display()); std::process::Command::new("wine") .args([file_path.to_str().unwrap(), args.trim()]) .current_dir(file_path.parent().unwrap()) .spawn() .expect("Failed to launch the game") .wait() .expect("Failed to wait for the game process to finish") } else { std::process::Command::new(file_path) .args(args.trim().split(' ')) .current_dir(file_path.parent().unwrap()) .spawn() .expect("Failed to launch the game") .wait() .expect("Failed to wait for the game process to finish") }; crate::println_error!("Game exited with {exit_status}"); if !exit_status.success() { misc::stdin(); } } #[cfg(windows)] fn setup_env() { colored::control::set_virtual_terminal(true).unwrap_or_else(|error| { crate::println_error!("{:#?}", error); colored::control::SHOULD_COLORIZE.set_override(false); }); if let Ok(system_root) = env::var("SystemRoot") { if let Ok(current_dir) = env::current_dir() { if current_dir.starts_with(system_root) { if let Ok(current_exe) = env::current_exe() { if let Some(parent) = current_exe.parent() { if let Err(error) = env::set_current_dir(parent) { crate::println_error!("{:#?}", error); } else { crate::println_info!("Running from the system directory. Changed working directory to the executable location."); } } } } } } } fn arg_value(args: &[String], arg: &str) -> Option { if let Some(e) = args.iter().position(|r| r == arg) { if e + 1 < args.len() { return Some(args[e + 1].clone()); } } None } fn arg_bool(args: &[String], arg: &str) -> bool { args.iter().any(|r| r == arg) } fn arg_remove(args: &mut Vec, arg: &str) { args.iter().position(|r| r == arg).map(|e| args.remove(e)); } fn arg_remove_value(args: &mut Vec, arg: &str) { if let Some(e) = args.iter().position(|r| r == arg) { args.remove(e); args.remove(e); }; } #[tokio::main] async fn main() { #[cfg(windows)] let log_file = env::current_exe() .unwrap_or(PathBuf::from("alterware-launcher")) .with_extension("log"); #[cfg(unix)] let log_file = PathBuf::from("/var/log/alterware-launcher.log"); if log_file.exists() && fs::remove_file(&log_file).is_err() { println!("Couldn't clear log file, make sure target directory is writable."); } let logger_config = LogConfigBuilder::builder() .path(log_file.to_str().unwrap()) .time_format("%Y-%m-%d %H:%M:%S.%f") .level("debug") .unwrap() .output_file() .build(); let _ = simple_log::new(logger_config); #[cfg(windows)] setup_env(); let mut args: Vec = env::args().collect(); if arg_bool(&args, "--help") { println!("CLI Args:"); println!(" : Specify the client to launch"); println!(" --help: Display this help message"); println!(" --version: Display the launcher version"); println!(" --path/-p : Specify the game directory"); println!(" --update/-u: Update only, don't launch the game"); println!(" --bonus: Download bonus content"); println!(" --skip-bonus: Don't download bonus content"); println!(" --force/-f: Force file hash recheck"); println!(" --pass : Pass arguments to the game"); println!(" --skip-launcher-update: Skip launcher self-update"); println!(" --ignore-required-files: Skip required files check"); println!(" --skip-redist: Skip redistributable installation"); println!(" --redist: (Re-)Install redistributables"); println!( "\nExample:\n alterware-launcher.exe iw4x --bonus --pass \"-console -nointro\"" ); return; } if arg_bool(&args, "--version") || arg_bool(&args, "-v") { println!( "{} v{}", "AlterWare Launcher".bright_green(), env!("CARGO_PKG_VERSION") ); println!("https://github.com/{GH_OWNER}/{GH_REPO}"); println!( "\n{}{}{}{}{}{}{}", "For ".on_black(), "Alter".bright_blue().on_black().underline(), "Ware".white().on_black().underline(), ".dev".on_black().underline(), " by ".on_black(), "mxve".bright_magenta().on_black().underline(), ".de".on_black().underline() ); return; } let offline_mode = !global::check_connectivity().await; if offline_mode { // Check if this is a first-time run (no stored data) let stored_data = cache::get_stored_data(); if stored_data.is_none() { println!( "{} Internet connection is required for first-time installation.", PREFIXES.get("error").unwrap().formatted() ); error!("Internet connection required for first-time installation"); println!("Please connect to the internet and try again."); println!("Press enter to exit..."); misc::stdin(); std::process::exit(1); } println!( "{} No internet connection or MASTER server is unreachable. Running in offline mode...", PREFIXES.get("error").unwrap().formatted() ); warn!("No internet connection or MASTER server is unreachable. Running in offline mode..."); // Handle path the same way as online mode let install_path: PathBuf; if let Some(path) = arg_value(&args, "--path") { install_path = PathBuf::from(path); arg_remove_value(&mut args, "--path"); } else if let Some(path) = arg_value(&args, "-p") { install_path = PathBuf::from(path); arg_remove_value(&mut args, "-p"); } else { install_path = env::current_dir().unwrap(); } let cfg = config::load(install_path.join("alterware-launcher.json")); // Try to get stored game data let stored_data = cache::get_stored_data(); if let Some(ref data) = stored_data { info!("Found stored game data for path: {}", data.game_path); } else { warn!("No stored game data found"); } // Get client from args, config, or prompt user let client = if args.len() > 1 { args[1].clone() } else if let Some(engine) = stored_data .as_ref() .and_then(|d| d.clients.get(&cfg.engine)) { if engine.len() > 1 { println!("Multiple clients available, select one to launch:"); for (i, c) in engine.iter().enumerate() { println!("{i}: {c}"); } info!("Multiple clients available, prompting user for selection"); engine[misc::stdin().parse::().unwrap()].clone() } else if !engine.is_empty() { info!("Using single available client: {}", engine[0]); engine[0].clone() } else { println!( "{} No client specified and no stored clients available.", PREFIXES.get("error").unwrap().formatted() ); error!("No client specified and no stored clients available"); std::process::exit(1); } } else { println!( "{} No client specified and no stored data available.", PREFIXES.get("error").unwrap().formatted() ); error!("No client specified and no stored data available"); std::process::exit(1); }; info!("Launching game in offline mode with client: {}", client); // Launch game without updates launch(&install_path.join(format!("{}.exe", client)), &cfg.args); return; } let install_path: PathBuf; if let Some(path) = arg_value(&args, "--path") { install_path = PathBuf::from(path); arg_remove_value(&mut args, "--path"); } else if let Some(path) = arg_value(&args, "-p") { install_path = PathBuf::from(path); arg_remove_value(&mut args, "-p"); } else { install_path = env::current_dir().unwrap(); } let mut cfg = config::load(install_path.join("alterware-launcher.json")); if !cfg.use_https { let mut master_url = MASTER.lock().unwrap(); *master_url = master_url.replace("https://", "http://"); }; if !arg_bool(&args, "--skip-launcher-update") && !cfg.skip_self_update { self_update::run(cfg.update_only).await; } else { arg_remove(&mut args, "--skip-launcher-update"); } if arg_bool(&args, "--update") || arg_bool(&args, "-u") { cfg.update_only = true; arg_remove(&mut args, "--update"); arg_remove(&mut args, "-u"); } if arg_bool(&args, "--bonus") { cfg.download_bonus_content = true; cfg.ask_bonus_content = false; arg_remove(&mut args, "--bonus"); } else if arg_bool(&args, "--skip-bonus") { cfg.download_bonus_content = false; cfg.ask_bonus_content = false; arg_remove(&mut args, "--skip-bonus") } if arg_bool(&args, "--force") || arg_bool(&args, "-f") { cfg.force_update = true; arg_remove(&mut args, "--force"); arg_remove(&mut args, "-f"); } let ignore_required_files = arg_bool(&args, "--ignore-required-files"); if ignore_required_files { arg_remove(&mut args, "--ignore-required-files"); } if let Some(pass) = arg_value(&args, "--pass") { cfg.args = pass; arg_remove_value(&mut args, "--pass"); } else if cfg.args.is_empty() { cfg.args = String::default(); } if arg_bool(&args, "--skip-redist") { cfg.skip_redist = true; arg_remove(&mut args, "--skip-redist"); } #[cfg(windows)] if arg_bool(&args, "--redist") { arg_remove(&mut args, "--redist"); misc::install_dependencies(&install_path).await; println_info!("Redistributables installation finished. Press enter to exit..."); misc::stdin(); std::process::exit(0); } let games_json = http_async::get_body_string(format!("{}/games.json", MASTER.lock().unwrap()).as_str()) .await .unwrap_or_else(|error| { crate::println_error!("Failed to get games.json: {:#?}", error); misc::stdin(); std::process::exit(1); }); let games: Vec = serde_json::from_str(&games_json).unwrap_or_else(|error| { crate::println_error!("Error parsing games.json: {:#?}", error); misc::stdin(); std::process::exit(1); }); let mut game: String = String::new(); if args.len() > 1 { game = String::from(&args[1]); } else { 'main: for g in games.iter() { for r in g.references.iter() { if install_path.join(r).exists() { if g.client.len() > 1 { if cfg.update_only { game = String::from(g.client[0]); break 'main; } #[cfg(windows)] setup_client_links(g, &env::current_dir().unwrap()); #[cfg(not(windows))] println!("Multiple clients installed, set the client as the first argument to launch a specific client."); println!("Select a client to launch:"); for (i, c) in g.client.iter().enumerate() { println!("{i}: {c}"); } game = String::from(g.client[misc::stdin().parse::().unwrap()]); break 'main; } game = String::from(g.client[0]); break 'main; } } } } for g in games.iter() { for c in g.client.iter() { if c == &game { if cfg.engine.is_empty() { cfg.engine = String::from(g.engine); config::save_value_s( install_path.join("alterware-launcher.json"), "engine", cfg.engine.clone(), ); if cfg.engine == "iw4" && cfg.args.is_empty() { cfg.args = String::from("-stdout"); config::save_value_s( install_path.join("alterware-launcher.json"), "args", cfg.args.clone(), ); } #[cfg(windows)] if !cfg.skip_redist { misc::install_dependencies(&install_path).await; } } if cfg.ask_bonus_content && !g.bonus.is_empty() { println!("Download bonus content? (Y/n)"); let input = misc::stdin().to_ascii_lowercase(); cfg.download_bonus_content = input != "n"; config::save_value( install_path.join("alterware-launcher.json"), "download_bonus_content", cfg.download_bonus_content, ); config::save_value( install_path.join("alterware-launcher.json"), "ask_bonus_content", false, ); } update( g, install_path.as_path(), cfg.download_bonus_content, cfg.force_update, Some(&game != "iw4x-sp"), Some(ignore_required_files), ) .await; if !cfg.update_only { launch(&install_path.join(format!("{c}.exe")), &cfg.args); } // Store game data for offline mode let mut stored_data = cache::get_stored_data().unwrap_or_default(); stored_data.game_path = install_path.to_string_lossy().into_owned(); // Store available clients for this engine stored_data.clients.insert( g.engine.to_string(), g.client.iter().map(|s| s.to_string()).collect(), ); if let Err(e) = cache::store_game_data(&stored_data) { println!( "{} Failed to store game data: {}", PREFIXES.get("error").unwrap().formatted(), e ); } return; } } } #[cfg(windows)] windows_launcher_install(&games).await; crate::println_error!("Game not found!"); println!("Place the launcher in the game folder, if that doesn't work specify the client on the command line (ex. alterware-launcher.exe iw4-sp)"); println!("Press enter to exit..."); std::io::stdin().read_line(&mut String::new()).unwrap(); }