const TEXT_SERVER_ROOTS: [&str; 2] = [
"https://raw.githubusercontent.com/solsticegamestudios/GModPatchTool/refs/heads/files/",
"https://solsticegamestudios.com/gmodpatchtool/"
];
const BINARY_SERVER_ROOTS: [&str; 2] = [
"https://media.githubusercontent.com/media/solsticegamestudios/GModPatchTool/refs/heads/files/",
"https://solsticegamestudios.com/gmodpatchtool/"
];
const BLANK_FILE_HASH: &str = "null";
const SPINNER_FRAMES: &[&str] = &[
"⠄", "⡀", "⡠", "⣀", "⣂", "⣄", "⣔",
"⣤", "⣥", "⣦", "⣮", "⣶", "⣶", "\u{2800}", "⣶",
];
const SPINNER_TICK: time::Duration = time::Duration::from_millis(100);
use crate::*;
use serde::Deserialize;
use tracing::error;
use tracing_subscriber::filter::EnvFilter;
use clap::Parser;
use std::io::IsTerminal;
use phf::phf_map;
use phf::Map;
use std::time;
use steamid::SteamId;
use sysinfo::System;
use std::fs::File;
use std::io;
use bytes::Bytes;
use reqwest::Response;
use tokio::time::Instant;
use tokio::task::JoinSet;
use qbsdiff::Bspatch;
use regex::Regex;
use std::sync::OnceLock;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle, ProgressDrawTarget};
use super::vdf;
static UI: OnceLock<MultiProgress> = OnceLock::new();
fn spinner_style(template: &str) -> ProgressStyle {
ProgressStyle::with_template(template).unwrap().tick_strings(SPINNER_FRAMES).progress_chars("#>-")
}
#[cfg(windows)]
use is_elevated::is_elevated;
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
#[derive(Parser)]
#[command(version)]
struct Args {
#[arg(short, long)]
launch_gmod: bool,
#[arg(short, long)]
skip_exit_prompt: bool,
#[arg(long)]
steam_path: Option<PathBuf>,
#[arg(long)]
no_sourcescheme: bool,
#[arg(long)]
skip_clear_chromiumcache: bool,
#[cfg(target_os = "macos")]
#[arg(long)]
skip_rosetta_prewarm: bool,
#[arg(long)]
disable_cache: bool,
#[arg(long)]
no_system_proxy: bool,
#[arg(long)]
ignore_gmod_running: bool,
#[arg(long)]
run_as_root_with_security_risk: bool
}
const COLOR_LOOKUP: Map<&'static str, &'static str> =
phf_map! {
"red" => "\x1B[1;31m",
"green" => "\x1B[1;32m",
"yellow" => "\x1B[1;33m",
"magenta" => "\x1B[1;35m",
"cyan" => "\x1B[1;36m"
};
use thiserror::Error;
#[derive(Debug, Error)]
enum AlmightyError {
#[error("HTTP Error: {0}")]
Http(#[from] reqwest::Error),
#[error("Remote Version parsing error: {0}")]
Parse(#[from] std::num::ParseIntError),
#[error("{0}")]
Generic(String)
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct SteamUser {
#[serde(alias = "accountname")]
account_name: String,
#[serde(alias = "personaname")]
persona_name: String,
#[serde(alias = "mostrecent", default)]
most_recent: bool,
#[serde(alias = "timestamp", default)]
timestamp: u64
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct SteamLibraryFolder {
#[serde(alias = "path")]
path: String,
}
#[derive(Deserialize, Debug)]
#[serde(untagged)]
enum SteamLibraryFolderEntry {
Folder(SteamLibraryFolder),
Other(serde::de::IgnoredAny)
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct SteamAppManifest {
#[serde(alias = "stateflags")]
state_flags: u32,
#[serde(alias = "installdir")]
install_dir: String,
#[serde(alias = "bytestodownload", default)]
bytes_to_download: u64,
#[serde(alias = "bytesdownloaded", default)]
bytes_downloaded: u64,
#[serde(alias = "bytestostage", default)]
bytes_to_stage: u64,
#[serde(alias = "bytesstaged", default)]
bytes_staged: u64,
#[serde(alias = "scheduledautoupdate")]
scheduled_auto_update: u64,
#[serde(alias = "fullvalidatebeforenextupdate")]
full_validate_before_next_update: Option<bool>,
#[serde(alias = "mountedconfig")]
mounted_config: SteamAppConfig
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct SteamAppConfig {
#[serde(alias = "betakey")]
beta_key: Option<String>,
}
#[cfg(target_os = "linux")]
#[derive(Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct SteamConfig {
#[serde(alias = "software")]
software: SteamConfigSoftware
}
#[cfg(target_os = "linux")]
#[derive(Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct SteamConfigSoftware {
#[serde(alias = "valve")]
valve: SteamConfigValve
}
#[cfg(target_os = "linux")]
#[derive(Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct SteamConfigValve {
#[serde(alias = "steam")]
steam: SteamConfigSteam
}
#[cfg(target_os = "linux")]
#[derive(Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct SteamConfigSteam {
#[serde(alias = "compattoolmapping")]
compat_tool_mapping: Option<SteamConfigCompatToolMappingApps>
}
#[cfg(target_os = "linux")]
#[derive(Deserialize, Debug)]
struct SteamConfigCompatToolMappingApps {
#[serde(rename = "4000")]
gmod: Option<SteamCompatToolMapping>
}
#[cfg(target_os = "linux")]
#[derive(Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct SteamCompatToolMapping {
#[serde(alias = "name")]
name: String,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct SteamUserLocalConfig {
#[serde(alias = "software")]
software: SteamUserLocalConfigSoftware,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct SteamUserLocalConfigSoftware {
#[serde(alias = "valve")]
valve: SteamUserLocalConfigValve
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct SteamUserLocalConfigValve {
#[serde(alias = "steam")]
steam: SteamUserLocalConfigSteam
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct SteamUserLocalConfigSteam {
#[serde(alias = "apps")]
apps: SteamUserLocalConfigApps
}
#[derive(Deserialize, Debug)]
struct SteamUserLocalConfigApps {
#[serde(rename = "4000")]
gmod: Option<SteamUserLocalConfigApp>
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct SteamUserLocalConfigApp {
#[serde(alias = "launchoptions")]
launch_options: Option<String>,
}
fn terminal_write<W>(writer: fn() -> W, output: &str, newline: bool, color: Option<&str>)
where
W: std::io::Write + 'static
{
let write_now = || {
if let Some(color) = color && COLOR_LOOKUP.contains_key(color) {
write!(writer(), "{}", COLOR_LOOKUP[color]).unwrap();
}
if newline {
writeln!(writer(), "{output}").unwrap();
} else {
write!(writer(), "{output}").unwrap();
}
if color.is_some() {
write!(writer(), "\x1B[0m").unwrap();
}
};
if let Some(ui) = UI.get() {
ui.suspend(write_now);
} else {
write_now();
}
}
async fn get_http_response<W>(writer: fn() -> W, writer_is_interactive: bool, servers: &[&str], filename: &str, mut server_id: u8, mut try_count: u8, no_system_proxy: bool) -> Option<Response>
where
W: std::io::Write + 'static
{
let mut response = None;
while (server_id as usize) < servers.len() {
let url = servers[server_id as usize].to_string() + filename;
let mut builder = reqwest::Client::builder();
if no_system_proxy {
builder = builder.no_proxy();
}
let client = builder
.https_only(true)
.connect_timeout(std::time::Duration::new(10, 0))
.read_timeout(std::time::Duration::new(10, 0))
.build();
let response_result = match client {
Ok(client) => client.get(url.clone()).send().await,
Err(error) => Err(error)
};
match response_result {
Ok(response_unwrapped) => {
let response_status_code = response_unwrapped.status().as_u16();
if response_status_code == 200 {
response = Some(response_unwrapped);
break;
} else {
terminal_write(writer, format!("\n{url}\n\tBad HTTP Status Code: {response_status_code}").as_str(), true, if writer_is_interactive { Some("red") } else { None });
response = None;
server_id += 1;
try_count = 0;
}
},
Err(error) => {
let error = error.without_url();
terminal_write(writer, format!("\n{url}\n\tHTTP Error: {error}").as_str(), true, if writer_is_interactive { Some("red") } else { None });
response = None;
try_count += 1;
if try_count >= 3 {
server_id += 1;
try_count = 0;
}
}
}
}
response
}
async fn get_http_response_bytes<W>(writer: fn() -> W, writer_is_interactive: bool, bar: &ProgressBar, servers: &[&str], filename: &str, no_system_proxy: bool) -> Option<Bytes>
where
W: std::io::Write + 'static
{
let mut server_id: u8 = 0;
let mut try_count: u8 = 0;
let mut response_bytes = None;
let mut length_counted = false;
while (server_id as usize) < servers.len() {
let response = get_http_response(writer, writer_is_interactive, servers, filename, server_id, try_count, no_system_proxy).await;
if let Some(mut response) = response {
if !length_counted {
bar.inc_length(response.content_length().unwrap_or(0));
length_counted = true;
}
let mut buf: Vec<u8> = Vec::with_capacity(response.content_length().unwrap_or(0) as usize);
let mut stream_error = None;
loop {
match response.chunk().await {
Ok(Some(chunk)) => {
bar.inc(chunk.len() as u64);
buf.extend_from_slice(&chunk);
},
Ok(None) => break,
Err(error) => {
stream_error = Some(error);
break;
}
}
}
match stream_error {
None => {
response_bytes = Some(Bytes::from(buf));
break;
},
Some(error) => {
terminal_write(writer, format!("\nHTTP Bytes Error: {error}").as_str(), true, if writer_is_interactive { Some("red") } else { None });
response_bytes = None;
try_count += 1;
if try_count >= 3 {
server_id += 1;
try_count = 0;
}
}
}
} else {
break;
}
}
response_bytes
}
async fn get_http_response_text<W>(writer: fn() -> W, writer_is_interactive: bool, servers: &[&str], filename: &str, no_system_proxy: bool) -> Option<String>
where
W: std::io::Write + 'static
{
let mut server_id: u8 = 0;
let mut try_count: u8 = 0;
let mut response_text = None;
while (server_id as usize) < servers.len() {
let response = get_http_response(writer, writer_is_interactive, servers, filename, server_id, try_count, no_system_proxy).await;
if let Some(response) = response {
let response_text_raw = response.text().await;
match response_text_raw {
Ok(response_text_raw) => {
response_text = Some(response_text_raw);
break;
},
Err(error) => {
terminal_write(writer, format!("\nHTTP Text Error: {error}").as_str(), true, if writer_is_interactive { Some("red") } else { None });
response_text = None;
try_count += 1;
if try_count >= 3 {
server_id += 1;
try_count = 0;
}
}
}
} else {
break;
}
}
response_text
}
async fn get_http_response_json<W, T>(writer: fn() -> W, writer_is_interactive: bool, servers: &[&str], filename: &str, no_system_proxy: bool) -> Option<T>
where
W: std::io::Write + 'static,
T: serde::de::DeserializeOwned
{
let mut server_id: u8 = 0;
let mut try_count: u8 = 0;
let mut response_json = None;
while (server_id as usize) < servers.len() {
let response = get_http_response(writer, writer_is_interactive, servers, filename, server_id, try_count, no_system_proxy).await;
if let Some(response) = response {
let response_json_raw = response.json::<T>().await;
match response_json_raw {
Ok(response_json_raw) => {
response_json = Some(response_json_raw);
break;
},
Err(error) => {
terminal_write(writer, format!("\nHTTP JSON Error: {error}").as_str(), true, if writer_is_interactive { Some("red") } else { None });
response_json = None;
try_count += 1;
if try_count >= 3 {
server_id += 1;
try_count = 0;
}
}
}
} else {
break;
}
}
response_json
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum IntegrityStatus {
NeedDelete = 0,
NeedOriginal = 1,
NeedWipeFix = 2,
NeedFix = 3,
Fixed = 4
}
fn determine_file_integrity_status(gmod_path: PathBuf, filename: &str, hashes: &IndexMap<String, String>) -> Result<IntegrityStatus, String> {
let file_parts: Vec<&str> = filename.split("/").collect();
let file_path = path_to_canonical_pathbuf(extend_pathbuf_and_return(gmod_path, &file_parts[..]), false);
let mut file_hash = BLANK_FILE_HASH.to_string();
if let Ok(file_path) = file_path {
file_hash = get_file_hash(&file_path)?;
}
if file_hash == hashes["fixed"] {
Ok(IntegrityStatus::Fixed)
} else {
if hashes["fixed"] == BLANK_FILE_HASH {
Ok(IntegrityStatus::NeedDelete)
} else if hashes["original"] == BLANK_FILE_HASH {
Ok(IntegrityStatus::NeedWipeFix)
} else if file_hash == hashes["original"] {
Ok(IntegrityStatus::NeedFix)
} else {
Ok(IntegrityStatus::NeedOriginal)
}
}
}
async fn download_file_to_cache<W>(writer: fn() -> W, writer_is_interactive: bool, bar: ProgressBar, cache_dir: PathBuf, filename: String, target_hash: String, no_system_proxy: bool) -> Result<(), ()>
where
W: std::io::Write + 'static
{
let filename_no_zst = if filename.ends_with(".zst") {
let len = filename.len() - 4;
filename[..len].to_string()
} else {
filename.clone()
};
let file_parts: Vec<&str> = filename_no_zst.split("/").collect();
let cache_file_path = extend_pathbuf_and_return(cache_dir, &file_parts[..]);
let cache_file_path_result = path_to_canonical_pathbuf(&cache_file_path, false);
terminal_write(writer, format!("\tDownloading: {filename} ...").as_str(), true, None);
if cache_file_path_result.is_ok() {
let file_hash_result = get_file_hash(&cache_file_path);
if let Ok(file_hash) = file_hash_result
&& file_hash == target_hash {
terminal_write(writer, format!("\tDownloaded (From Cache): {filename}").as_str(), true, None);
return Ok(());
}
}
let response_bytes = get_http_response_bytes(writer, writer_is_interactive, &bar, &BINARY_SERVER_ROOTS, filename.as_str(), no_system_proxy).await;
if let Some(response_bytes) = response_bytes {
let mut cache_file_path_dir = cache_file_path.clone();
cache_file_path_dir.pop();
let cache_file_path_dir_canonical = path_to_canonical_pathbuf(&cache_file_path_dir, false);
if cache_file_path_dir_canonical.is_err() {
let create_dir_result = tokio::fs::create_dir_all(cache_file_path_dir).await;
if let Err(error) = create_dir_result {
terminal_write(writer, format!("\tFailed to Download: {filename} | Step 1: {error}").as_str(), true, if writer_is_interactive { Some("red") } else { None });
return Err(());
}
}
let mut bytes: Vec<u8> = if filename.ends_with(".zst") { Vec::new() } else { response_bytes.to_vec() };
if filename.ends_with(".zst") {
terminal_write(writer, format!("\tDecompressing: {filename} ...").as_str(), true, None);
let decompress_result = zstd::stream::copy_decode(&response_bytes[..], &mut bytes);
if let Err(error) = decompress_result {
terminal_write(writer, format!("\tFailed to Decompress: {filename} | {error}").as_str(), true, if writer_is_interactive { Some("red") } else { None });
return Err(());
}
terminal_write(writer, format!("\tDecompressed: {filename}").as_str(), true, None);
}
let write_result = tokio::fs::write(cache_file_path.clone(), bytes).await;
if let Err(error) = write_result {
terminal_write(writer, format!("\tFailed to Download: {filename} | Step 2: {error}").as_str(), true, if writer_is_interactive { Some("red") } else { None });
return Err(());
}
let file_hash_result = get_file_hash(&cache_file_path);
match file_hash_result {
Ok(file_hash) => {
if file_hash == target_hash {
let size_mib = response_bytes.len() as f64 / 0x100000 as f64;
terminal_write(writer, format!("\tDownloaded [{size_mib:.2} MiB]: {filename}").as_str(), true, None);
return Ok(());
} else {
terminal_write(writer, format!("\tFailed to Download: {filename} | Step 4: Checksum mismatch").as_str(), true, if writer_is_interactive { Some("red") } else { None });
}
},
Err(error) => {
terminal_write(writer, format!("\tFailed to Download: {filename} | Step 3: {error}").as_str(), true, if writer_is_interactive { Some("red") } else { None });
}
}
}
Err(())
}
#[allow(clippy::too_many_arguments)]
fn patch_file<W>(
writer: fn() -> W,
writer_is_interactive: bool,
integrity_status_strings: &HashMap<IntegrityStatus, &str>,
gmod_path: &Path,
platform_masked: &str,
gmod_branch: &String,
cache_dir: &Path,
filename: &&String,
integrity_status: &IntegrityStatus,
hashes: &&IndexMap<String, String>
) -> IntegrityStatus
where
W: std::io::Write + 'static
{
terminal_write(writer, format!("\tPatching: {filename} ...").as_str(), true, None);
let mut new_integrity_status: IntegrityStatus = *integrity_status;
let mut integrity_status_string = integrity_status_strings[&new_integrity_status];
let gmod_file_parts: Vec<&str> = filename.split("/").collect();
let gmod_file_path = extend_pathbuf_and_return(gmod_path.to_path_buf(), &gmod_file_parts[..]);
if new_integrity_status == IntegrityStatus::NeedDelete {
if let Err(delete_error) = std::fs::remove_file(&gmod_file_path)
&& let Err(truncate_error) = File::create(&gmod_file_path) {
terminal_write(writer, format!("\tFailed to Patch: {filename} | {integrity_status_string}:\n\tDelete: {delete_error}\n\tTruncate: {truncate_error}").as_str(), true, if writer_is_interactive { Some("red") } else { None });
return new_integrity_status;
}
terminal_write(writer, format!("\tPatched: {filename}").as_str(), true, None);
new_integrity_status = IntegrityStatus::Fixed;
integrity_status_string = integrity_status_strings[&new_integrity_status];
}
if new_integrity_status == IntegrityStatus::NeedOriginal {
let gmod_file_path_dir = gmod_file_path.parent().unwrap().to_path_buf();
let gmod_file_path_dir_path = path_to_canonical_pathbuf(&gmod_file_path_dir, false);
if gmod_file_path_dir_path.is_err() {
let create_dir_result = std::fs::create_dir_all(gmod_file_path_dir);
if let Err(error) = create_dir_result {
terminal_write(writer, format!("\tFailed to Patch: {filename} | {integrity_status_string}: {error}").as_str(), true, if writer_is_interactive { Some("red") } else { None });
return new_integrity_status;
}
}
let original_filename = format!("originals/{platform_masked}/{gmod_branch}/{filename}");
let original_file_parts: Vec<&str> = original_filename.split("/").collect();
let original_cache_file_path = path_to_canonical_pathbuf(extend_pathbuf_and_return(cache_dir.to_path_buf(), &original_file_parts[..]), false);
match original_cache_file_path {
Ok(original_cache_file_path) => {
let copy_result = std::fs::copy(original_cache_file_path, &gmod_file_path);
if let Err(error) = copy_result {
terminal_write(writer, format!("\tFailed to Patch: {filename} | {integrity_status_string}: {error}").as_str(), true, if writer_is_interactive { Some("red") } else { None });
return new_integrity_status;
}
new_integrity_status = IntegrityStatus::NeedFix;
integrity_status_string = integrity_status_strings[&new_integrity_status];
},
Err(error) => {
terminal_write(writer, format!("\tFailed to Patch: {filename} | {integrity_status_string}: {error}").as_str(), true, if writer_is_interactive { Some("red") } else { None });
return new_integrity_status;
}
}
}
if new_integrity_status == IntegrityStatus::NeedWipeFix {
let gmod_file_path_dir = gmod_file_path.parent().unwrap().to_path_buf();
let gmod_file_path_dir_path = path_to_canonical_pathbuf(&gmod_file_path_dir, false);
if gmod_file_path_dir_path.is_err() {
let create_dir_result = std::fs::create_dir_all(gmod_file_path_dir);
if let Err(error) = create_dir_result {
terminal_write(writer, format!("\tFailed to Patch: {filename} | {integrity_status_string}: {error}").as_str(), true, if writer_is_interactive { Some("red") } else { None });
return new_integrity_status;
}
}
let create_result = File::create(&gmod_file_path);
if let Err(error) = create_result {
terminal_write(writer, format!("\tFailed to Patch: {filename} | {integrity_status_string}: {error}").as_str(), true, if writer_is_interactive { Some("red") } else { None });
return new_integrity_status;
}
new_integrity_status = IntegrityStatus::NeedFix;
integrity_status_string = integrity_status_strings[&new_integrity_status];
}
if new_integrity_status == IntegrityStatus::NeedFix {
let gmod_file_path = match path_to_canonical_pathbuf(gmod_file_path, false) {
Ok(gmod_file_path) => gmod_file_path,
Err(error) => {
terminal_write(writer, format!("\tFailed to Patch: {filename} | {integrity_status_string} / Step 1: {error}").as_str(), true, if writer_is_interactive { Some("red") } else { None });
return new_integrity_status;
}
};
let patch_filename = format!("patches/{platform_masked}/{gmod_branch}/{filename}.bsdiff");
let patch_file_parts: Vec<&str> = patch_filename.split("/").collect();
let patch_file_path = match path_to_canonical_pathbuf(extend_pathbuf_and_return(cache_dir.to_path_buf(), &patch_file_parts[..]), false) {
Ok(patch_file_path) => patch_file_path,
Err(error) => {
terminal_write(writer, format!("\tFailed to Patch: {filename} | {integrity_status_string} / Step 2: {error}").as_str(), true, if writer_is_interactive { Some("red") } else { None });
return new_integrity_status;
}
};
let gmod_file = match std::fs::read(gmod_file_path.clone()) {
Ok(gmod_file) => gmod_file,
Err(error) => {
terminal_write(writer, format!("\tFailed to Patch: {filename} | {integrity_status_string} / Step 3: {error}").as_str(), true, if writer_is_interactive { Some("red") } else { None });
return new_integrity_status;
}
};
let patch_file = match std::fs::read(patch_file_path) {
Ok(patch_file) => patch_file,
Err(error) => {
terminal_write(writer, format!("\tFailed to Patch: {filename} | {integrity_status_string} / Step 4: {error}").as_str(), true, if writer_is_interactive { Some("red") } else { None });
return new_integrity_status;
}
};
let patcher = match Bspatch::new(&patch_file) {
Ok(patcher) => patcher,
Err(error) => {
terminal_write(writer, format!("\tFailed to Patch: {filename} | {integrity_status_string} / Step 5: {error}").as_str(), true, if writer_is_interactive { Some("red") } else { None });
return new_integrity_status;
}
};
let mut new_gmod_file = Vec::with_capacity(patcher.hint_target_size() as usize);
let patch_result = patcher.apply(&gmod_file, io::Cursor::new(&mut new_gmod_file));
if let Err(error) = patch_result {
terminal_write(writer, format!("\tFailed to Patch: {filename} | {integrity_status_string} / Step 6: {error}").as_str(), true, if writer_is_interactive { Some("red") } else { None });
return new_integrity_status;
}
let file_hash = format!("{}", blake3::hash(&new_gmod_file));
if file_hash != hashes["fixed"] {
terminal_write(writer, format!("\tFailed to Patch: {filename} | {integrity_status_string} / Step 7: Checksum mismatch").as_str(), true, if writer_is_interactive { Some("red") } else { None });
return new_integrity_status;
}
let write_result = std::fs::write(&gmod_file_path, &new_gmod_file);
if let Err(error) = write_result {
terminal_write(writer, format!("\tFailed to Patch: {filename} | {integrity_status_string} / Step 8: {error}").as_str(), true, if writer_is_interactive { Some("red") } else { None });
return new_integrity_status;
}
terminal_write(writer, format!("\tPatched: {filename}").as_str(), true, None);
new_integrity_status = IntegrityStatus::Fixed;
}
new_integrity_status
}
#[cfg(unix)]
#[link(name = "c")]
unsafe extern "C" {
safe fn geteuid() -> u32;
}
fn strip_localconfig_webstorage(localconfig: String) -> String {
let webstorage_start_regex = Regex::new(r"WebStorage.+\s+\{").unwrap();
let webstorage_start_match = webstorage_start_regex.find(&localconfig);
if let Some(webstorage_start_match) = webstorage_start_match {
let webstorage_open_bracket = webstorage_start_match.end();
let mut next_char_escaped = false;
let mut in_string = false;
let mut open_bracket_count: usize = 1;
let mut webstorage_close_bracket_offset: Option<usize> = None;
for (offset, byte) in (0_usize..).zip(localconfig[webstorage_open_bracket..].bytes()) {
if next_char_escaped {
next_char_escaped = false;
} else if byte == b'\\' {
next_char_escaped = true;
} else if byte == b'"' {
in_string = !in_string;
} else if !in_string {
if byte == b'{' {
open_bracket_count += 1;
} else if byte == b'}' {
open_bracket_count -= 1;
}
if open_bracket_count == 0 {
webstorage_close_bracket_offset = Some(offset);
break;
}
}
}
if let Some(webstorage_close_bracket_offset) = webstorage_close_bracket_offset {
let webstorage_close_bracket = webstorage_open_bracket + webstorage_close_bracket_offset;
return format!("{}{}", &localconfig[..webstorage_open_bracket], &localconfig[webstorage_close_bracket..]);
}
}
localconfig
}
async fn main_script_internal<W>(writer: fn() -> W, writer_is_interactive: bool, args: Args) -> Result<(), AlmightyError>
where
W: std::io::Write + 'static
{
let now = Instant::now();
let mut sys = System::new_all();
// Set up the live progress UI; hidden when output isn't a terminal so we don't spam control codes
let _ = UI.set(MultiProgress::with_draw_target(if writer_is_interactive {
ProgressDrawTarget::stdout()
} else {
ProgressDrawTarget::hidden()
}));
// Figure out where our cache should go based on OS
let os_cache_dir = if let Some(dirs_cache_dir) = dirs::cache_dir() { dirs_cache_dir } else { std::env::temp_dir() };
// The cache base (ex. ~/.cache) may not exist on a fresh account; the lockfile create reports any real failure
let _ = std::fs::create_dir_all(&os_cache_dir);
// Take the PID lockfile atomically (O_EXCL); a pre-existing file only counts as running if it points at a live gmodpatchtool
let pid_path = extend_pathbuf_and_return(os_cache_dir.clone(), &["gmodpatchtool.pid"]);
let mut lock_attempts = 0;
loop {
match std::fs::OpenOptions::new().write(true).create_new(true).open(&pid_path) {
Ok(mut pid_file) => {
use std::io::Write;
if let Err(error) = pid_file.write_all(std::process::id().to_string().as_bytes()) {
return Err(AlmightyError::Generic(format!("Failed to create gmodpatchtool.pid: {error}")));
}
break;
},
Err(error) if error.kind() == io::ErrorKind::AlreadyExists && lock_attempts < 2 => {
lock_attempts += 1;
let existing_pid = std::fs::read_to_string(&pid_path).ok().and_then(|pid| pid.trim().parse::<usize>().ok());
if let Some(existing_pid) = existing_pid
&& let Some(process) = sys.process(sysinfo::Pid::from(existing_pid))
&& process.name().to_string_lossy().starts_with("gmodpatchtool") {
return Err(AlmightyError::Generic(format!("Another instance of GModPatchTool is already running ({existing_pid}).")));
}
// Stale lockfile (crashed run or recycled PID); remove it and retry
let _ = std::fs::remove_file(&pid_path);
},
Err(error) => {
return Err(AlmightyError::Generic(format!("Failed to create gmodpatchtool.pid: {error}")));
}
}
}
// Get local version
let local_version: u32 = env!("CARGO_PKG_VERSION_MAJOR").parse().unwrap();
// Get remote version
terminal_write(writer, "Getting remote version...", true, None);
let remote_version = get_http_response_text(writer, writer_is_interactive, &TEXT_SERVER_ROOTS, "version.txt", args.no_system_proxy).await;
if remote_version.is_none() {
return Err(AlmightyError::Generic("Couldn't get remote version. Please check your internet connection!".to_string()));
}
let remote_version = remote_version.unwrap();
let remote_version: u32 = remote_version
.trim()
.parse()?;
if local_version >= remote_version {
terminal_write(writer, format!("You are running the latest version of GModPatchTool [Local: {local_version} / Remote: {remote_version}]!\n").as_str(), true, if writer_is_interactive { Some("green") } else { None });
} else {
terminal_write(writer, "WARNING: GModPatchTool is out of date! Please get the latest version at\nhttps:
let mut secs_to_continue: u8 = 5;
while secs_to_continue > 0 {
terminal_write(writer, format!("\tContinuing in {secs_to_continue} second(s)...\r").as_str(), false, if writer_is_interactive { Some("yellow") } else { None });
writer().flush().unwrap();
tokio::time::sleep(time::Duration::from_secs(1)).await;
secs_to_continue -= 1;
}
if writer_is_interactive {
terminal_write(writer, "\x1B[0K\n", false, None);
}
}
#[cfg(windows)]
let root = is_elevated();
#[cfg(unix)]
let root = geteuid() == 0;
if root {
if args.run_as_root_with_security_risk {
terminal_write(writer, "WARNING: You are running GModPatchTool as root/with admin privileges. This may cause issues and is not typically necessary.", true, if writer_is_interactive { Some("red") } else { None });
let mut secs_to_continue: u8 = 10;
while secs_to_continue > 0 {
terminal_write(writer, format!("\tContinuing in {secs_to_continue} second(s)...\r").as_str(), false, if writer_is_interactive { Some("yellow") } else { None });
writer().flush().unwrap();
tokio::time::sleep(time::Duration::from_secs(1)).await;
secs_to_continue -= 1;
}
if writer_is_interactive {
terminal_write(writer, "\x1B[0K\n", false, None);
}
} else {
let elevated_msg = format!("You are running GModPatchTool as root/with admin privileges{}. This may cause issues and is not typically necessary.\n\nIF YOU KNOW WHAT YOU'RE DOING, you can allow this by running the tool with --run-as-root-with-security-risk. Aborting...", if cfg!(windows) { " (is User Account Control turned off?)" } else { "" });
return Err(AlmightyError::Generic(elevated_msg));
}
}
if !args.ignore_gmod_running && (sys.processes_by_exact_name("gmod.exe".as_ref()).next().is_some() || sys.processes_by_exact_name("gmod".as_ref()).next().is_some()) {
return Err(AlmightyError::Generic("Garry's Mod is currently running. Please close it before running this tool.".to_string()));
}
#[cfg(target_os = "macos")]
{
terminal_write(writer, "WARNING: Garry's Mod is not well supported on macOS and may not be supported at all in the future:", true, if writer_is_interactive { Some("red") } else { None });
terminal_write(writer, "\thttps://winteris.moe/share/2025-08-07_20-53-45.png", true, None);
terminal_write(writer, "\nOptions for playing the Windows version of GMod on macOS are located here:", true, if writer_is_interactive { Some("red") } else { None });
terminal_write(writer, "\thttps://github.com/solsticegamestudios/GModPatchTool/issues/170", true, None);
let mut secs_to_continue: u8 = 10;
while secs_to_continue > 0 {
terminal_write(writer, format!("\tContinuing in {secs_to_continue} second(s)...\r").as_str(), false, if writer_is_interactive { Some("yellow") } else { None });
writer().flush().unwrap();
tokio::time::sleep(time::Duration::from_secs(1)).await;
secs_to_continue -= 1;
}
if writer_is_interactive {
terminal_write(writer, "\x1B[0K\n", false, None);
}
}
#[allow(unused_assignments)]
let mut steam_path = None;
if let Some(steam_path_arg) = args.steam_path {
let steam_path_arg_pathbuf = path_to_canonical_pathbuf(&steam_path_arg, true);
steam_path = match steam_path_arg_pathbuf {
Ok(steam_path) => Some(steam_path),
Err(error) => {
return Err(AlmightyError::Generic(format!("Please check the --steam-path argument is pointing to a valid path:\n\t{error}")));
}
}
} else {
#[cfg(windows)]
{
if let Ok(steam_reg_key) = windows_registry::CURRENT_USER.open("Software\\Valve\\Steam") {
if let Ok(steam_reg_path) = steam_reg_key.get_string("SteamPath") {
steam_path = path_to_canonical_pathbuf(steam_reg_path, true).ok();
}
}
}
#[cfg(target_os = "macos")]
{
let mut steam_data_path = dirs::data_dir().unwrap();
steam_data_path.push("Steam");
steam_path = path_to_canonical_pathbuf(steam_data_path, true).ok();
}
#[cfg(not(any(windows, target_os = "macos")))]
{
let home_dir = dirs::home_dir().unwrap();
let possible_steam_paths = vec![
extend_pathbuf_and_return(home_dir.clone(), &["snap", "steam", "common", ".local", "share", "Steam"]),
extend_pathbuf_and_return(home_dir.clone(), &["snap", "steam", "common", ".steam", "steam"]),
extend_pathbuf_and_return(home_dir.clone(), &[".var", "app", "com.valvesoftware.Steam", ".local", "share", "Steam"]),
extend_pathbuf_and_return(home_dir.clone(), &[".var", "app", "com.valvesoftware.Steam", ".steam", "steam"]),
extend_pathbuf_and_return(home_dir.clone(), &[".steam", "steam"]),
];
let mut valid_steam_paths = vec![];
for pathbuf in possible_steam_paths {
if let Ok(pathbuf) = path_to_canonical_pathbuf(pathbuf, true) {
if !valid_steam_paths.contains(&pathbuf) {
valid_steam_paths.push(pathbuf);
}
}
}
if let Some(steam_xdg_path) = dirs::data_dir() {
if let Ok(steam_xdg_pathbuf) = path_to_canonical_pathbuf(extend_pathbuf_and_return(steam_xdg_path, &["Steam"]), true) {
if !valid_steam_paths.contains(&steam_xdg_pathbuf) {
valid_steam_paths.push(steam_xdg_pathbuf);
}
}
}
if !valid_steam_paths.is_empty() {
if valid_steam_paths.len() > 1 {
let mut valid_steam_paths_str: String = "".to_string();
for pathbuf in &valid_steam_paths {
valid_steam_paths_str += "\n\t- ";
valid_steam_paths_str += &pathbuf.to_string_lossy();
}
terminal_write(writer, format!("Warning: Multiple Steam Installations Detected! This may cause issues:{valid_steam_paths_str}").as_str(), true, if writer_is_interactive { Some("yellow") } else { None });
let mut secs_to_continue: u8 = 5;
while secs_to_continue > 0 {
terminal_write(writer, format!("\tContinuing in {secs_to_continue} second(s)...\r").as_str(), false, if writer_is_interactive { Some("yellow") } else { None });
writer().flush().unwrap();
tokio::time::sleep(time::Duration::from_secs(1)).await;
secs_to_continue -= 1;
}
if writer_is_interactive {
terminal_write(writer, "\x1B[0K\n", false, None);
}
}
steam_path = Some(valid_steam_paths[0].clone());
}
}
}
if steam_path.is_none() {
return Err(AlmightyError::Generic("Couldn't find Steam. If it's installed, try using the --steam-path argument to force a specific path.".to_string()));
}
let steam_path = steam_path.unwrap();
let steam_path_str = steam_path.to_string_lossy();
terminal_write(writer, format!("Steam Path: {steam_path_str}\n").as_str(), true, None);
let steam_loginusers_path = extend_pathbuf_and_return(steam_path.clone(), &["config", "loginusers.vdf"]);
let steam_loginusers_bytes = tokio::fs::read(steam_loginusers_path).await;
if steam_loginusers_bytes.is_err() {
return Err(AlmightyError::Generic("Couldn't find Steam loginusers.vdf. Have you ever launched/signed in to Steam?".to_string()));
}
let steam_loginusers_str = String::from_utf8_lossy(&steam_loginusers_bytes.unwrap()).into_owned();
let steam_loginusers = vdf::from_str(steam_loginusers_str.as_str());
if let Err(error) = steam_loginusers {
return Err(AlmightyError::Generic(format!("Couldn't parse Steam loginusers.vdf. Is the file corrupt?\n\t{error}")));
}
let mut steam_user: HashMap<&str, String> = HashMap::new();
let mut steam_user_is_most_recent = false;
let steam_loginusers: HashMap<&str, SteamUser> = steam_loginusers.unwrap();
for (other_steam_id_64, other_steam_user) in steam_loginusers {
let mostrecent = other_steam_user.most_recent;
let timestamp = other_steam_user.timestamp;
let take = !steam_user.contains_key("Timestamp")
|| mostrecent
|| (!steam_user_is_most_recent && timestamp > steam_user.get("Timestamp").unwrap().parse::<u64>().unwrap());
if take {
steam_user_is_most_recent = mostrecent;
steam_user.insert("SteamID64", other_steam_id_64.to_string());
steam_user.insert("Timestamp", timestamp.to_string());
steam_user.insert("AccountName", other_steam_user.account_name);
steam_user.insert("PersonaName", other_steam_user.persona_name);
}
}
if !steam_user.contains_key("Timestamp") {
return Err(AlmightyError::Generic("Couldn't find Steam User. Have you ever launched/signed in to Steam?".to_string()));
}
let steam_id = steam_user.get("SteamID64").unwrap().parse::<u64>().ok().and_then(|steam_id_64| SteamId::new(steam_id_64).ok());
let Some(steam_id) = steam_id else {
return Err(AlmightyError::Generic("Couldn't parse Steam loginusers.vdf. Is the file corrupt?".to_string()));
};
terminal_write(writer, format!("Steam User: {} ({} / {})\n", steam_user.get("PersonaName").unwrap(), steam_user.get("SteamID64").unwrap(), steam_id.steam3id()).as_str(), true, None);
let mut steam_libraryfolders_path = extend_pathbuf_and_return(steam_path.clone(), &["config", "libraryfolders.vdf"]);
let mut steam_libraryfolders_str = tokio::fs::read_to_string(steam_libraryfolders_path).await;
if steam_libraryfolders_str.is_err() {
steam_libraryfolders_path = extend_pathbuf_and_return(steam_path.clone(), &["steamapps", "libraryfolders.vdf"]);
steam_libraryfolders_str = tokio::fs::read_to_string(steam_libraryfolders_path).await;
}
if steam_libraryfolders_str.is_err() {
steam_libraryfolders_path = extend_pathbuf_and_return(steam_path.clone(), &["SteamApps", "libraryfolders.vdf"]);
steam_libraryfolders_str = tokio::fs::read_to_string(steam_libraryfolders_path).await;
}
if steam_libraryfolders_str.is_err() {
return Err(AlmightyError::Generic("Couldn't find Steam libraryfolders.vdf. Have you ever launched/signed in to Steam?".to_string()));
}
let steam_libraryfolders_str = steam_libraryfolders_str.unwrap();
let steam_libraryfolders = vdf::from_str(steam_libraryfolders_str.as_str());
if let Err(error) = steam_libraryfolders {
return Err(AlmightyError::Generic(format!("Couldn't parse Steam libraryfolders.vdf. Is the file corrupt?\n\t{error}")));
}
let mut gmod_steam_library_path = None;
let mut gmod_manifest_str = None;
let steam_libraryfolders: IndexMap<&str, SteamLibraryFolderEntry> = steam_libraryfolders.unwrap();
for (_, steam_library) in steam_libraryfolders {
let SteamLibraryFolderEntry::Folder(steam_library) = steam_library else {
continue;
};
let new_gmod_steam_library_path = path_to_canonical_pathbuf(steam_library.path, true);
if let Ok(new_gmod_steam_library_path) = new_gmod_steam_library_path {
let mut new_gmod_manifest_path = extend_pathbuf_and_return(new_gmod_steam_library_path.to_path_buf(), &["steamapps", "appmanifest_4000.acf"]);
let mut new_gmod_manifest_str = tokio::fs::read_to_string(new_gmod_manifest_path).await;
if new_gmod_manifest_str.is_err() {
new_gmod_manifest_path = extend_pathbuf_and_return(new_gmod_steam_library_path.to_path_buf(), &["SteamApps", "appmanifest_4000.acf"]);
new_gmod_manifest_str = tokio::fs::read_to_string(new_gmod_manifest_path).await;
}
if new_gmod_manifest_str.is_ok() {
gmod_steam_library_path = Some(new_gmod_steam_library_path);
gmod_manifest_str = new_gmod_manifest_str.ok();
break;
}
}
}
if gmod_manifest_str.is_none() {
return Err(AlmightyError::Generic("Couldn't find GMod's appmanifest_4000.acf. Is Garry's Mod installed?".to_string()));
}
let gmod_manifest_str = gmod_manifest_str.unwrap();
let gmod_manifest = vdf::from_str(gmod_manifest_str.as_str());
if let Err(error) = gmod_manifest {
return Err(AlmightyError::Generic(format!("Couldn't parse GMod's appmanifest_4000.acf. Is the file corrupt?\n\t{error}")));
}
let gmod_steam_library_path = gmod_steam_library_path.unwrap();
let gmod_steam_library_path_str = gmod_steam_library_path.to_string_lossy();
terminal_write(writer, format!("GMod Steam Library: {gmod_steam_library_path_str}\n").as_str(), true, None);
let gmod_manifest: SteamAppManifest = gmod_manifest.unwrap();
let gmod_stateflags = gmod_manifest.state_flags;
let gmod_scheduledautoupdate = gmod_manifest.scheduled_auto_update;
let gmod_fullvalidatebeforenextupdate: bool = gmod_manifest.full_validate_before_next_update.unwrap_or_default();
let gmod_bytesdownloaded = gmod_manifest.bytes_downloaded;
let gmod_bytestodownload = gmod_manifest.bytes_to_download;
let gmod_bytesstaged = gmod_manifest.bytes_staged;
let gmod_bytestostage = gmod_manifest.bytes_to_stage;
terminal_write(writer, format!("GMod App State: {gmod_stateflags} | {gmod_scheduledautoupdate} | {gmod_fullvalidatebeforenextupdate} | {gmod_bytesdownloaded}/{gmod_bytestodownload} | {gmod_bytesstaged}/{gmod_bytestostage} \n").as_str(), true, None);
if gmod_stateflags != 4 || gmod_scheduledautoupdate != 0 || gmod_fullvalidatebeforenextupdate || gmod_bytesdownloaded != gmod_bytestodownload || gmod_bytesstaged != gmod_bytestostage {
return Err(AlmightyError::Generic("Garry's Mod is Not Ready. Check Steam > Downloads and make sure it is fully installed and up to date. If that doesn't work, try launching the game, closing it, then running the tool again.".to_string()));
}
let gmod_mountedconfig = gmod_manifest.mounted_config;
let gmod_branch = gmod_mountedconfig.beta_key;
let gmod_branch = match gmod_branch { Some(gmod_branch) if !gmod_branch.is_empty() => gmod_branch, _ => "public".to_string() };
terminal_write(writer, format!("GMod Beta Branch: {gmod_branch}\n").as_str(), true, None);
let gmod_path_config = gmod_manifest.install_dir;
let mut gmod_path = path_to_canonical_pathbuf(extend_pathbuf_and_return(gmod_steam_library_path.clone(), &["steamapps", "common", &gmod_path_config]), true);
if gmod_path.is_err() {
gmod_path = path_to_canonical_pathbuf(extend_pathbuf_and_return(gmod_steam_library_path.clone(), &["SteamApps", "common", &gmod_path_config]), true);
}
if gmod_path.is_err() {
return Err(AlmightyError::Generic("Couldn't find Garry's Mod directory. Is Garry's Mod installed?".to_string()));
}
let gmod_path = gmod_path.unwrap();
let gmod_path_str = gmod_path.to_string_lossy();
terminal_write(writer, format!("GMod Path: {gmod_path_str}\n").as_str(), true, None);
#[cfg(unix)]
if root
&& let Ok(gmod_dir_meta) = tokio::fs::metadata(&gmod_path).await
&& gmod_dir_meta.uid() != 0 {
return Err(AlmightyError::Generic("You are running GModPatchTool as root, but the Garry's Mod directory isn't owned by root. Either fix your permissions or don't run as root! Aborting...".to_string()));
}
let platform = if cfg!(windows) { "windows" } else if cfg!(target_os = "macos") { "macos" } else { "linux" };
#[cfg_attr(not(target_os = "linux"), expect(unused_mut, reason = "used on linux"))]
let mut platform_masked = platform;
#[cfg_attr(not(target_os = "linux"), expect(unused_mut, reason = "used on linux"))]
let mut gmod_compattool = "none".to_string();
#[cfg(target_os = "linux")]
{
let steam_config_path = extend_pathbuf_and_return(steam_path.clone(), &["config", "config.vdf"]);
let steam_config_str = tokio::fs::read_to_string(steam_config_path).await;
if steam_config_str.is_err() {
return Err(AlmightyError::Generic("Couldn't find Steam config.vdf. Have you ever launched/signed in to Steam?".to_string()));
}
let steam_config_str = steam_config_str.unwrap();
let steam_config = vdf::from_str(steam_config_str.as_str());
if steam_config.is_err() {
return Err(AlmightyError::Generic("Couldn't parse Steam config.vdf. Is the file corrupt?".to_string()));
}
let steam_config: SteamConfig = steam_config.unwrap();
let steam_config = steam_config.software.valve.steam;
if let Some(steam_config_compat_tool_mapping) = steam_config.compat_tool_mapping {
if let Some(steam_config_compat_tool_mapping_gmod) = steam_config_compat_tool_mapping.gmod {
let compattool = steam_config_compat_tool_mapping_gmod.name.to_lowercase();
if compattool.contains("proton") {
platform_masked = "windows";
}
gmod_compattool = compattool;
}
}
}
terminal_write(writer, format!("Target Platform: {platform_masked} ({gmod_compattool})\n").as_str(), true, None);
let steam_user_localconfig_path = extend_pathbuf_and_return(steam_path.clone(), &["userdata", steam_id.account_id().into_u32().to_string().as_str(), "config", "localconfig.vdf"]);
let steam_user_localconfig_bytes = tokio::fs::read(steam_user_localconfig_path).await;
if let Err(error) = steam_user_localconfig_bytes {
return Err(AlmightyError::Generic(format!("Couldn't find/read Steam localconfig.vdf. Have you ever launched/signed in to Steam?\n\t{error}")));
}
let steam_user_localconfig_str = strip_localconfig_webstorage(String::from_utf8_lossy(&steam_user_localconfig_bytes.unwrap()).into_owned());
let steam_user_localconfig = vdf::from_str(steam_user_localconfig_str.as_str());
if let Err(error) = steam_user_localconfig {
return Err(AlmightyError::Generic(format!("Couldn't parse Steam localconfig.vdf. Is the file corrupt?\n\t{error}")));
}
let steam_user_localconfig: SteamUserLocalConfig = steam_user_localconfig.unwrap();
let steam_user_localconfig_gmod = steam_user_localconfig.software.valve.steam.apps.gmod;
if let Some(steam_user_localconfig_gmod) = steam_user_localconfig_gmod {
if let Some(steam_user_localconfig_gmod_launchopts) = &steam_user_localconfig_gmod.launch_options
&& steam_user_localconfig_gmod_launchopts.contains("-nochromium") {
terminal_write(writer, "WARNING: -nochromium is in GMod's Launch Options! CEF will not work with this.\n\tPlease go to Steam > Garry's Mod > Properties > General and remove it.\n\tAdditionally, if you have gmod-lua-menu installed, uninstall it.", true, if writer_is_interactive { Some("yellow") } else { None });
let mut secs_to_continue: u8 = 5;
while secs_to_continue > 0 {
terminal_write(writer, format!("\tContinuing in {secs_to_continue} second(s)...\r").as_str(), false, if writer_is_interactive { Some("yellow") } else { None });
writer().flush().unwrap();
tokio::time::sleep(time::Duration::from_secs(1)).await;
secs_to_continue -= 1;
}
if writer_is_interactive {
terminal_write(writer, "\x1B[0K\n", false, None);
}
}
} else {
return Err(AlmightyError::Generic("Couldn't find Garry's Mod in user localconfig.vdf. Is Garry's Mod installed?".to_string()));
}
terminal_write(writer, "Getting remote manifest...", true, None);
let remote_manifest = get_http_response_json::<_, Manifest>(writer, writer_is_interactive, &TEXT_SERVER_ROOTS, "manifest.json", args.no_system_proxy).await;
if remote_manifest.is_none() {
terminal_write(writer, "", true, None);
return Err(AlmightyError::Generic("Couldn't get remote manifest. Please check your internet connection!".to_string()));
}
let remote_manifest = remote_manifest.unwrap();
terminal_write(writer, "GModPatchTool Manifest Loaded!\n", true, None);
let platform_branches = remote_manifest.get(platform_masked);
if platform_branches.is_none() {
return Err(AlmightyError::Generic(format!("This operating system ({platform_masked}) is not supported!")));
}
let platform_branch_files = platform_branches.unwrap().get(&gmod_branch);
if platform_branch_files.is_none() {
return Err(AlmightyError::Generic(format!("This Beta Branch of Garry's Mod ({gmod_branch}) is not supported! Please go to Steam > Garry's Mod > Properties > Betas, select the x86-64 beta, then try again.")));
}
let platform_branch_files = platform_branch_files.unwrap();
for filename in platform_branch_files.keys() {
let path_is_safe = std::path::Path::new(filename).components().all(|component| matches!(component, std::path::Component::Normal(_)));
if !path_is_safe || filename.chars().any(|character| character.is_control()) {
return Err(AlmightyError::Generic(format!("Manifest contains an unsafe file path: {filename:?}")));
}
}
terminal_write(writer, "Determining file integrity status...", true, None);
let integrity_status_strings = HashMap::from([
(IntegrityStatus::NeedDelete, "Needs Delete"),
(IntegrityStatus::NeedOriginal, "Needs Original + Fix"),
(IntegrityStatus::NeedWipeFix, "Needs Wipe + Fix"),
(IntegrityStatus::NeedFix, "Needs Fix"),
(IntegrityStatus::Fixed, "Already Fixed")
]);
#[allow(clippy::type_complexity)]
let integrity_results: Vec<(&String, Result<IntegrityStatus, String>, &IndexMap<String, String>)> = platform_branch_files.par_iter()
.map(|(filename, hashes)| {
let integrity_result;
if args.no_sourcescheme && filename.ends_with(".res") {
terminal_write(writer, format!("\t{filename}: Skipping due to --no-sourcescheme").as_str(), true, if writer_is_interactive { Some("yellow") } else { None });
integrity_result = Ok(IntegrityStatus::Fixed);
} else {
integrity_result = determine_file_integrity_status(gmod_path.clone(), filename, hashes);
let integrity_result_clone = integrity_result.clone();
match integrity_result_clone {
Ok(integrity_result_clone) => {
let integrity_status_string = integrity_status_strings[&integrity_result_clone];
terminal_write(writer, format!("\t{filename}: {integrity_status_string}").as_str(), true, None);
},
Err(error) => {
terminal_write(writer, format!("\t{filename}: {error}").as_str(), true, if writer_is_interactive { Some("red") } else { None });
}
}
}
(filename, integrity_result, hashes)
}).collect();
let mut pending_files: Vec<(&String, IntegrityStatus, &IndexMap<String, String>)> = vec![];
for (filename, result, hashes) in integrity_results {
match result {
Ok(result) => {
if result != IntegrityStatus::Fixed {
pending_files.push((filename, result, hashes));
}
},
Err(_) => {
return Err(AlmightyError::Generic("Failed to get integrity status of one or more files!".to_string()));
}
}
}
let pending_files_len = pending_files.len();
if pending_files_len > 0 {
#[cfg(windows)]
let old_cache_dir = path_to_canonical_pathbuf(extend_pathbuf_and_return(os_cache_dir.clone(), &["Temp", "GModCEFCodecFix"]), false);
#[cfg(not(windows))]
let old_cache_dir = path_to_canonical_pathbuf(extend_pathbuf_and_return(os_cache_dir.clone(), &["GModCEFCodecFix"]), false);
if let Ok(old_cache_dir) = old_cache_dir {
let old_cache_dir_result = tokio::fs::remove_dir_all(old_cache_dir).await;
match old_cache_dir_result {
Ok(_) => {
terminal_write(writer,"Successfully removed old GModCEFCodecFix cache directory.", true, None);
},
Err(error) => {
terminal_write(writer, format!("Failed to remove old GModCEFCodecFix cache directory: {error}").as_str(), true, if writer_is_interactive { Some("yellow") } else { None });
}
}
}
let cache_path = extend_pathbuf_and_return(os_cache_dir, &["GModPatchTool"]);
let mut cache_path_str = cache_path.to_string_lossy();
let mut cache_dir = path_to_canonical_pathbuf(&cache_path, false);
if args.disable_cache {
if let Ok(cache_dir) = cache_dir {
let remove_result = tokio::fs::remove_dir_all(cache_dir).await;
match remove_result {
Ok(_) => {
terminal_write(writer,"\n[disable-cache:Pre] Successfully cleared GModPatchTool cache directory.", true, None);
},
Err(error) => {
terminal_write(writer, format!("\n[disable-cache:Pre] Failed to clear GModPatchTool cache directory: {error}").as_str(), true, if writer_is_interactive { Some("yellow") } else { None });
}
}
}
cache_dir = path_to_canonical_pathbuf(&cache_path, false);
}
if cache_dir.is_err() {
let create_result = tokio::fs::create_dir_all(cache_path.clone()).await;
if create_result.is_ok() {
cache_dir = path_to_canonical_pathbuf(&cache_path, false);
}
}
if let Err(error) = cache_dir {
return Err(AlmightyError::Generic(format!("Failed to create cache directory ({error}):\n\t{cache_path_str}")));
}
let cache_dir = cache_dir.unwrap();
cache_path_str = cache_dir.to_string_lossy();
terminal_write(writer, format!("\nGModPatchTool Cache Directory: {cache_path_str}\n").as_str(), true, None);
terminal_write(writer, "Downloading patch files...", true, None);
let download_bar = UI.get().unwrap().add(ProgressBar::new(1));
download_bar.set_style(spinner_style("{spinner} Downloading {msg} file(s) [{bar:25}] {percent}% \u{B7} {bytes}/{total_bytes} ({binary_bytes_per_sec})"));
download_bar.enable_steady_tick(SPINNER_TICK);
let mut download_futures = JoinSet::new();
for (filename, integrity_status, hashes) in &pending_files {
if *integrity_status == IntegrityStatus::NeedOriginal {
download_futures.spawn(download_file_to_cache(writer, writer_is_interactive, download_bar.clone(), cache_dir.clone(), format!("originals/{platform_masked}/{gmod_branch}/{filename}.zst"), hashes["original"].clone(), args.no_system_proxy));
}
if *integrity_status != IntegrityStatus::NeedDelete {
download_futures.spawn(download_file_to_cache(writer, writer_is_interactive, download_bar.clone(), cache_dir.clone(), format!("patches/{platform_masked}/{gmod_branch}/{filename}.bsdiff"), hashes["patch"].clone(), args.no_system_proxy));
}
}
let download_files_total = download_futures.len();
let mut download_files_done: usize = 0;
download_bar.set_message(format!("0/{download_files_total}"));
while let Some(download_result) = download_futures.join_next().await {
if !matches!(download_result, Ok(Ok(()))) {
download_bar.finish_and_clear();
return Err(AlmightyError::Generic("Failed to download one or more patch files!".to_string()));
}
download_files_done += 1;
download_bar.set_message(format!("{download_files_done}/{download_files_total}"));
}
download_bar.finish_and_clear();
sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
if !args.ignore_gmod_running && (sys.processes_by_exact_name("gmod.exe".as_ref()).next().is_some() || sys.processes_by_exact_name("gmod".as_ref()).next().is_some()) {
return Err(AlmightyError::Generic("Garry's Mod is currently running. Please close it before running this tool.".to_string()));
}
terminal_write(writer, format!("\nPatching {pending_files_len} file(s)...").as_str(), true, None);
let patch_bar = UI.get().unwrap().add(ProgressBar::new(pending_files_len as u64));
patch_bar.set_style(spinner_style("{spinner} Patching {pos}/{len} file(s) [{bar:25}] {percent}%"));
patch_bar.enable_steady_tick(SPINNER_TICK);
let patch_results: Vec<(&String, IntegrityStatus)> = pending_files.par_iter()
.map(|(filename, integrity_status, hashes)| {
let new_integrity_status = patch_file(
writer,
writer_is_interactive,
&integrity_status_strings,
&gmod_path,
platform_masked,
&gmod_branch,
&cache_dir,
filename,
integrity_status,
hashes
);
patch_bar.inc(1);
(*filename, new_integrity_status)
}).collect();
patch_bar.finish_and_clear();
for (_, integrity_status) in patch_results {
if integrity_status != IntegrityStatus::Fixed {
return Err(AlmightyError::Generic("Failed to patch one or more files!".to_string()));
}
}
if args.disable_cache {
let remove_result = tokio::fs::remove_dir_all(cache_dir).await;
match remove_result {
Ok(_) => {
terminal_write(writer,"\n[disable-cache:Post] Successfully cleared GModPatchTool cache directory.", true, None);
},
Err(error) => {
terminal_write(writer, format!("\n[disable-cache:Post] Failed to clear GModPatchTool cache directory: {error}").as_str(), true, if writer_is_interactive { Some("yellow") } else { None });
}
}
}
} else {
terminal_write(writer, "No files need patching!", true, None);
}
#[cfg(unix)]
{
terminal_write(writer, "\nApplying file permissions...", true, None);
let mut exec_bit_failed = false;
for (filename, fileinfo) in platform_branch_files {
let executable = fileinfo.get("executable");
if let Some(executable) = executable
&& executable == "true" {
let gmod_file_parts: Vec<&str> = filename.split("/").collect();
let gmod_file_path = path_to_canonical_pathbuf(extend_pathbuf_and_return(gmod_path.clone(), &gmod_file_parts[..]), true);
if let Ok(gmod_file_path) = gmod_file_path {
let metadata = tokio::fs::metadata(&gmod_file_path).await;
match metadata {
Ok(metadata) => {
let mut perms = metadata.permissions();
let already_executable = perms.mode() & 0o111 != 0;
perms.set_mode(perms.mode() | 0o111);
let perms_result: Result<(), io::Error> = tokio::fs::set_permissions(&gmod_file_path, perms).await;
match perms_result {
Ok(_) => {
terminal_write(writer, format!("\t{filename}").as_str(), true, None);
},
Err(error) => {
terminal_write(writer, format!("\tFailed to Apply Permissions: {filename} | {error}").as_str(), true, if writer_is_interactive { Some("red") } else { None });
if !already_executable {
exec_bit_failed = true;
}
}
}
},
Err(error) => {
terminal_write(writer, format!("\tFailed to Apply Permissions: {filename} | {error}").as_str(), true, if writer_is_interactive { Some("red") } else { None });
}
}
}
}
}
if exec_bit_failed {
return Err(AlmightyError::Generic("Failed to make one or more files executable!".to_string()));
}
}
#[cfg(target_os = "macos")]
if !args.skip_rosetta_prewarm {
if std::path::Path::new("/Library/Apple/usr/share/rosetta/rosetta").is_file() {
let mut prewarm_paths = Vec::new();
for (filename, fileinfo) in platform_branch_files {
let executable = fileinfo.get("executable");
if let Some(executable) = executable
&& executable == "true"
&& (filename.ends_with(".dylib") || filename.ends_with("Chromium Embedded Framework")) {
let gmod_file_parts: Vec<&str> = filename.split("/").collect();
let gmod_file_path = path_to_canonical_pathbuf(extend_pathbuf_and_return(gmod_path.clone(), &gmod_file_parts[..]), true);
if let Ok(gmod_file_path) = gmod_file_path {
prewarm_paths.push(gmod_file_path);
}
}
}
prewarm_paths.sort_by_key(|prewarm_path| !prewarm_path.ends_with("Chromium Embedded Framework"));
if !prewarm_paths.is_empty() {
terminal_write(writer, "\nPre-warming Rosetta translations (this can take a few minutes on slower Macs)...", true, None);
match std::env::current_exe() {
Ok(current_exe) => {
let mut prewarm_command = tokio::process::Command::new("/usr/bin/arch");
prewarm_command.arg("-x86_64")
.arg(current_exe)
.arg("--rosetta-prewarm")
.args(&prewarm_paths)
.kill_on_drop(true);
let prewarm_result = tokio::time::timeout(time::Duration::from_secs(300), prewarm_command.status()).await;
match prewarm_result {
Ok(Ok(prewarm_status)) if prewarm_status.success() => {
terminal_write(writer, "Done!", true, None);
},
Ok(Ok(_)) => {
terminal_write(writer, "\tSome libraries failed to prewarm! The first launch may be slow while macOS translates the new files.", true, if writer_is_interactive { Some("yellow") } else { None });
},
Ok(Err(error)) => {
terminal_write(writer, format!("\tFailed to prewarm: {error}\n\tThe first launch may be slow while macOS translates the new files.").as_str(), true, if writer_is_interactive { Some("yellow") } else { None });
},
Err(_) => {
terminal_write(writer, "\tTimed out! The first launch may be slow while macOS translates the new files.", true, if writer_is_interactive { Some("yellow") } else { None });
}
}
},
Err(error) => {
terminal_write(writer, format!("\tFailed to prewarm: {error}").as_str(), true, if writer_is_interactive { Some("yellow") } else { None });
}
}
}
} else if std::env::consts::ARCH == "aarch64" {
terminal_write(writer, "\nRosetta 2 is not installed! Garry's Mod needs it to run on Apple Silicon:\n\tsoftwareupdate --install-rosetta --agree-to-license", true, if writer_is_interactive { Some("yellow") } else { None });
}
}
if !args.skip_clear_chromiumcache {
let gmod_chromiumcache_path = path_to_canonical_pathbuf(extend_pathbuf_and_return(gmod_path.clone(), &["ChromiumCache"]), false);
if let Ok(gmod_chromiumcache_path) = gmod_chromiumcache_path {
terminal_write(writer, "\nClearing ChromiumCache...", true, None);
if let Err(error) = tokio::fs::remove_dir_all(gmod_chromiumcache_path).await {
terminal_write(writer, format!("\tFailed: {error}\n\tYou may want to delete ChromiumCache from the GarrysMod directory manually!").as_str(), true, if writer_is_interactive { Some("yellow") } else { None });
} else {
terminal_write(writer, "Done!", true, None);
}
}
let gmod_chromiumcachemultirun_path = path_to_canonical_pathbuf(extend_pathbuf_and_return(gmod_path.clone(), &["ChromiumCacheMultirun"]), false);
if let Ok(gmod_chromiumcachemultirun_path) = gmod_chromiumcachemultirun_path {
terminal_write(writer, "\nClearing ChromiumCacheMultirun...", true, None);
if let Err(error) = tokio::fs::remove_dir_all(gmod_chromiumcachemultirun_path).await {
terminal_write(writer, format!("\tFailed: {error}\n\tYou may want to delete ChromiumCacheMultirun from the GarrysMod directory manually!").as_str(), true, if writer_is_interactive { Some("yellow") } else { None });
} else {
terminal_write(writer, "Done!", true, None);
}
}
let gmod_chromiumlog_path = path_to_canonical_pathbuf(extend_pathbuf_and_return(gmod_path.clone(), &["chromium.log"]), false);
if let Ok(gmod_chromiumlog_path) = gmod_chromiumlog_path {
terminal_write(writer, "\nRemoving chromium.log...", true, None);
if let Err(error) = tokio::fs::remove_file(gmod_chromiumlog_path).await {
terminal_write(writer, format!("\tFailed: {error}\n\tYou may want to delete chromium.log from the GarrysMod directory manually!").as_str(), true, if writer_is_interactive { Some("yellow") } else { None });
} else {
terminal_write(writer, "Done!", true, None);
}
}
}
let now = now.elapsed().as_secs_f64();
terminal_write(writer, format!("\nGModPatchTool applied successfully! Took {now} second(s).").as_str(), true, if writer_is_interactive { Some("green") } else { None });
if args.launch_gmod {
terminal_write(writer, "Launching Garry's Mod...", true, if writer_is_interactive { Some("green") } else { None });
let open_result = open::that_detached("steam://rungameid/4000");
if let Err(error) = open_result {
terminal_write(writer, format!("\tFailed: {error}").as_str(), true, if writer_is_interactive { Some("yellow") } else { None });
}
} else {
terminal_write(writer, "You can now launch Garry's Mod in Steam.", true, if writer_is_interactive { Some("green") } else { None });
}
terminal_write(writer, "\n💖 ", false, None);
terminal_write(writer, "Did you find this tool useful? Please consider donating a few dollars to help support it:", true, if writer_is_interactive { Some("magenta") } else { None });
terminal_write(writer, "\thttps://solsticegamestudios.com/donate/", true, None);
Ok(())
}
#[cfg(target_os = "macos")]
fn rosetta_prewarm_child() {
let mut args = std::env::args_os();
if args.nth(1).as_deref() != Some(std::ffi::OsStr::new("--rosetta-prewarm")) {
return;
}
unsafe extern "C" {
fn dlopen(filename: *const std::ffi::c_char, flags: std::ffi::c_int) -> *mut std::ffi::c_void;
fn dlerror() -> *const std::ffi::c_char;
}
const RTLD_NOW: std::ffi::c_int = 0x2;
let mut prewarm_failed = false;
for path in args {
let path_cstring = std::ffi::CString::new(path.as_encoded_bytes());
match path_cstring {
Ok(path_cstring) => {
let dlopen_result = unsafe { dlopen(path_cstring.as_ptr(), RTLD_NOW) };
if dlopen_result.is_null() {
let dlopen_error = unsafe { dlerror() };
let dlopen_error = if dlopen_error.is_null() { std::borrow::Cow::from("Unknown Error") } else { unsafe { std::ffi::CStr::from_ptr(dlopen_error) }.to_string_lossy() };
println!("\tFailed to translate: {} | {dlopen_error}", path.to_string_lossy());
prewarm_failed = true;
} else {
println!("\tTranslated: {}", path.to_string_lossy());
}
},
Err(error) => {
println!("\tFailed to translate: {} | {error}", path.to_string_lossy());
prewarm_failed = true;
}
}
}
std::process::exit(if prewarm_failed { 1 } else { 0 });
}
fn delete_pid_lockfile() {
let os_cache_dir = if let Some(dirs_cache_dir) = dirs::cache_dir() { dirs_cache_dir } else { std::env::temp_dir() };
let pid_path = extend_pathbuf_and_return(os_cache_dir.clone(), &["gmodpatchtool.pid"]);
let running_instance_pid = std::fs::read_to_string(&pid_path);
if let Ok(pid) = running_instance_pid
&& let Ok(pid) = pid.parse::<u32>()
&& pid == std::process::id() {
let pid_remove_result = std::fs::remove_file(&pid_path);
if let Err(error) = pid_remove_result {
println!("Failed to remove gmodpatchtool.pid: {error}");
}
}
}
fn terminal_exit_handler() {
println!("\nPress Enter to exit...");
std::io::stdin().read_line(&mut String::new()).unwrap();
delete_pid_lockfile();
}
fn main_script<W>(writer: fn() -> W, writer_is_interactive: bool, args: Args) -> Result<(), AlmightyError>
where
W: std::io::Write + 'static
{
std::thread::Builder::new()
.stack_size(0x800000)
.spawn(move || {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.thread_stack_size(0x800000)
.build()
.map_err(|error| AlmightyError::Generic(format!("Failed to create Tokio runtime: {error}")))?
.block_on(
main_script_internal(writer, writer_is_interactive, args)
)
})
.map_err(|error| AlmightyError::Generic(format!("Failed to spawn patch thread: {error}")))?
.join()
.map_err(|_| AlmightyError::Generic("Patch thread panicked".to_string()))?
}
fn init_logger<W>(ansi: bool, writer: fn() -> W)
where
W: std::io::Write + 'static
{
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.with_ansi(ansi)
.without_time()
.with_target(false)
.with_writer(writer)
.init();
}
pub fn main() {
#[cfg(target_os = "macos")]
rosetta_prewarm_child();
#[cfg(target_os = "windows")]
use crossterm::ansi_support::supports_ansi;
#[cfg(not(target_os = "windows"))]
fn supports_ansi() -> bool { true }
let is_terminal = io::stdout().is_terminal();
let is_ansi = is_terminal && supports_ansi();
init_logger(is_ansi, std::io::stdout);
{
use std::{env, process};
#[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
let mut force_gui = match env::var("FORCE_GUI") {
Ok(value) => Some(value.trim() == "1"),
Err(env::VarError::NotPresent) => None,
Err(error) => {
error!("FORCE_GUI is invalid: {error}");
process::exit(1);
},
};
#[cfg(target_os = "windows")]
{
use win32console::console::WinConsole;
match WinConsole::get_process_list() {
Ok(list) if list.len() == 1 => {
force_gui = Some(true);
if let Err(error) = WinConsole::free_console() {
tracing::warn!("GUI | {error}");
}
},
Ok(_) => {},
Err(error) => {
tracing::warn!("GUI | {error}");
}
}
}
if force_gui.unwrap_or(!is_terminal || !is_ansi) {
unsafe {
env::set_var("FORCE_GUI", "0");
}
if let Err(error) = gui::main() {
error!("GUI | {error}");
process::exit(1);
}
process::exit(0);
}
}
if is_ansi {
print!("\x1B]0;GModPatchTool\x07");
}
let writer = std::io::stdout;
let writer_is_interactive = is_terminal;
terminal_write(writer, ABOUT, true, if writer_is_interactive { Some("cyan") } else { None });
let args = match Args::try_parse() {
Ok(args) => args,
Err(error) => {
let _ = error.print();
terminal_exit_handler();
std::process::exit(error.exit_code());
},
};
let skip_exit_prompt = args.skip_exit_prompt;
let script_result = main_script(writer, writer_is_interactive, args);
if let Err(error) = &script_result {
error!("{error}");
}
if is_terminal && !skip_exit_prompt {
terminal_exit_handler();
} else {
delete_pid_lockfile();
}
if script_result.is_err() {
std::process::exit(1);
}
}
#[cfg(test)]
mod tests;