diff --git a/MelonLoader.Installer/GameManager.cs b/MelonLoader.Installer/GameManager.cs index 28d5065..a3f89b2 100644 --- a/MelonLoader.Installer/GameManager.cs +++ b/MelonLoader.Installer/GameManager.cs @@ -100,7 +100,7 @@ public static void RemoveGame(GameModel game) path = Path.GetFullPath(path); - var linux = false; + var arch = Architecture.Unknown; var rawDataDirs = Directory.GetDirectories(path, "*_Data"); var dataDirs = rawDataDirs.Where(x => File.Exists(x[..^5] + ".exe")).ToArray(); @@ -109,7 +109,7 @@ public static void RemoveGame(GameModel game) dataDirs = rawDataDirs.Where(x => File.Exists(x[..^5] + ".x86_64")).ToArray(); if (dataDirs.Length != 0) { - linux = true; + arch = Architecture.LinuxX64; } else { @@ -124,7 +124,7 @@ public static void RemoveGame(GameModel game) return null; } - var exe = dataDirs[0][..^5] + (linux ? ".x86_64" : ".exe"); + var exe = dataDirs[0][..^5] + (arch == Architecture.LinuxX64 ? ".x86_64" : ".exe"); if (Games.Any(x => x.Path.Equals(exe, StringComparison.OrdinalIgnoreCase))) { @@ -132,13 +132,12 @@ public static void RemoveGame(GameModel game) return null; } - var is64 = true; - if (!linux) + if (arch == Architecture.Unknown) { try { using var pe = new PEReader(File.OpenRead(exe)); - is64 = pe.PEHeaders.CoffHeader.Machine == Machine.Amd64; + arch = pe.PEHeaders.CoffHeader.Machine == Machine.Amd64 ? Architecture.WindowsX64 : Architecture.WindowsX86; } catch { @@ -147,8 +146,8 @@ public static void RemoveGame(GameModel game) } } - var mlVersion = MLVersion.GetMelonLoaderVersion(path, out var ml86, out var mlLinux); - if (mlVersion != null && (is64 == ml86 || linux != mlLinux)) + var mlVersion = MLVersion.GetMelonLoaderVersion(path, out var mlArch); + if (mlVersion != null && (mlArch != arch)) mlVersion = null; Bitmap? icon = null; @@ -169,7 +168,7 @@ public static void RemoveGame(GameModel game) var isProtected = Directory.Exists(Path.Combine(path, "EasyAntiCheat")); - var result = new GameModel(exe, customName ?? Path.GetFileNameWithoutExtension(exe), !is64, linux, launcher, icon, mlVersion, isProtected); + var result = new GameModel(exe, customName ?? Path.GetFileNameWithoutExtension(exe), arch, launcher, icon, mlVersion, isProtected); errorMessage = null; AddGameSorted(result); diff --git a/MelonLoader.Installer/InstallerUtils.cs b/MelonLoader.Installer/InstallerUtils.cs index e69c267..7442388 100644 --- a/MelonLoader.Installer/InstallerUtils.cs +++ b/MelonLoader.Installer/InstallerUtils.cs @@ -1,10 +1,12 @@ using System.IO.Compression; +using System.Security.Cryptography; namespace MelonLoader.Installer; public static class InstallerUtils { public static HttpClient Http { get; } + private static readonly SHA512 Hasher = SHA512.Create(); static InstallerUtils() { @@ -12,8 +14,27 @@ static InstallerUtils() Http.DefaultRequestHeaders.Add("User-Agent", $"MelonLoader Installer v{Program.VersionName}"); } - public static async Task DownloadFileAsync(string url, Stream destination, InstallProgressEventHandler? onProgress) + private static async Task FetchFile(string url, Stream destination, bool useCache, InstallProgressEventHandler? onProgress) { + // Cache preparation + var parentDirectory = Path.GetFileName(Path.GetDirectoryName(url)) ?? ""; + var fileCache = Path.Combine(Config.CacheDir, "Cache", parentDirectory, Path.GetFileName(url)); + + // Cache hits + if (useCache && File.Exists(fileCache)) + { + try + { + await using var fsOut = File.OpenRead(fileCache); + await fsOut.CopyToAsync(destination); + return null; + } + catch + { + return $"Failed to read the cache file {fileCache}"; + } + } + HttpResponseMessage response; try { @@ -33,35 +54,77 @@ static InstallerUtils() return response.ReasonPhrase; } - using var content = await response.Content.ReadAsStreamAsync(); + await using var content = await response.Content.ReadAsStreamAsync(); var length = response.Content.Headers.ContentLength ?? 0; if (length > 0) { destination.SetLength(length); + var position = 0; + var buffer = new byte[1024 * 16]; + while (position < destination.Length - 1) + { + var read = await content.ReadAsync(buffer, 0, buffer.Length); + await destination.WriteAsync(buffer, 0, read); + + position += read; + + onProgress?.Invoke(position / (double)(destination.Length - 1), null); + } } else { await content.CopyToAsync(destination); - return null; } - - var position = 0; - var buffer = new byte[1024 * 16]; - while (position < destination.Length - 1) + + // Save cache + if (useCache) { - var read = await content.ReadAsync(buffer, 0, buffer.Length); - await destination.WriteAsync(buffer, 0, read); - - position += read; - - onProgress?.Invoke(position / (double)(destination.Length - 1), null); + try + { + Directory.CreateDirectory(Path.Combine(Config.CacheDir, "Cache", parentDirectory)); + await using var fsIn = File.OpenWrite(fileCache); + destination.Seek(0, SeekOrigin.Begin); + await destination.CopyToAsync(fsIn); + } + catch + { + // Failed to save cache + } } - return null; } + public static async Task DownloadFileAsync(string url, Stream destination, bool useCache, InstallProgressEventHandler? onProgress) + { + // Get archive + var result = await FetchFile(url, destination, useCache, onProgress); + if (result != null) + return $"Failed to fetch file from {url}: {result}"; + + destination.Seek(0, SeekOrigin.Begin); + + // Get checksum + var checksumUrl = url.Replace(".zip", ".sha512"); + using var checksumStr = new MemoryStream(); + result = await FetchFile(checksumUrl, checksumStr, useCache, onProgress); + + // Checksum fetch failed, skip verification + if (result != null) return null; + + var checksumDownload = System.Text.Encoding.UTF8.GetString(checksumStr.ToArray()); + var checksumCompute = Convert.ToHexString(await Hasher.ComputeHashAsync(destination)); + + // Verification successful + if (checksumCompute == checksumDownload) return null; + // Verification failed, remove corrupted files + var parentDirectory = Path.GetFileName(Path.GetDirectoryName(url)) ?? ""; + File.Delete(Path.Combine(Config.CacheDir, "Cache", parentDirectory, Path.GetFileName(url))); + File.Delete(Path.Combine(Config.CacheDir, "Cache", parentDirectory, Path.GetFileName(checksumUrl))); + return "Fetched corrupted file (checksum mismatch)"; + } + public static string? Extract(Stream archiveStream, string destination, InstallProgressEventHandler? onProgress) { Directory.CreateDirectory(destination); diff --git a/MelonLoader.Installer/MLManager.cs b/MelonLoader.Installer/MLManager.cs index e7c9500..8d1aecb 100644 --- a/MelonLoader.Installer/MLManager.cs +++ b/MelonLoader.Installer/MLManager.cs @@ -310,7 +310,7 @@ public static void SetLocalZip(string zipPath, InstallProgressEventHandler? onPr return; } - var mlVer = MLVersion.GetMelonLoaderVersion(Config.LocalZipCache, out var x86, out var linux); + var mlVer = MLVersion.GetMelonLoaderVersion(Config.LocalZipCache, out var arch); if (mlVer == null) { onFinished?.Invoke("The selected zip archive does not contain a valid MelonLoader build."); @@ -320,9 +320,9 @@ public static void SetLocalZip(string zipPath, InstallProgressEventHandler? onPr var version = new MLVersion() { Version = mlVer, - DownloadUrlWin = !linux ? (!x86 ? Config.LocalZipCache : null) : null, - DownloadUrlWinX86 = !linux ? (x86 ? Config.LocalZipCache : null) : null, - DownloadUrlLinux = linux ? Config.LocalZipCache : null, + DownloadUrlWin = arch == Architecture.WindowsX64 ? Config.LocalZipCache : null, + DownloadUrlWinX86 = arch == Architecture.WindowsX86 ? Config.LocalZipCache : null, + DownloadUrlLinux = arch == Architecture.LinuxX64 ? Config.LocalZipCache : null, IsLocalPath = true }; @@ -332,12 +332,19 @@ public static void SetLocalZip(string zipPath, InstallProgressEventHandler? onPr onFinished?.Invoke(null); } - public static async Task InstallAsync(string gameDir, bool removeUserFiles, MLVersion version, bool linux, bool x86, InstallProgressEventHandler? onProgress, InstallFinishedEventHandler? onFinished) + public static async Task InstallAsync(string gameDir, bool removeUserFiles, MLVersion version, Architecture arch, InstallProgressEventHandler? onProgress, InstallFinishedEventHandler? onFinished) { - var downloadUrl = linux ? (!x86 ? version.DownloadUrlLinux : null) : (x86 ? version.DownloadUrlWinX86 : version.DownloadUrlWin); + var downloadUrl = arch switch + { + Architecture.LinuxX64 => version.DownloadUrlLinux, + Architecture.WindowsX64 => version.DownloadUrlWin, + Architecture.WindowsX86 => version.DownloadUrlWinX86, + _ => null + }; + if (downloadUrl == null) { - onFinished?.Invoke($"The selected version does not support the selected architecture: {(linux ? "linux" : "win")}-{(x86 ? "x86" : "x64")}"); + onFinished?.Invoke($"The selected version does not support the selected architecture: {arch.GetDescription()}"); return; } @@ -381,7 +388,7 @@ void SetProgress(double progress, string? newStatus = null) SetProgress(0, "Downloading MelonLoader " + version); using var bufferStr = new MemoryStream(); - var result = await InstallerUtils.DownloadFileAsync(downloadUrl, bufferStr, SetProgress); + var result = await InstallerUtils.DownloadFileAsync(downloadUrl, bufferStr, true, SetProgress); if (result != null) { onFinished?.Invoke("Failed to download MelonLoader: " + result); diff --git a/MelonLoader.Installer/MLVersion.cs b/MelonLoader.Installer/MLVersion.cs index f6e9900..3081cc1 100644 --- a/MelonLoader.Installer/MLVersion.cs +++ b/MelonLoader.Installer/MLVersion.cs @@ -1,9 +1,44 @@ using Semver; +using System.ComponentModel; using System.Diagnostics; using System.Reflection.PortableExecutable; namespace MelonLoader.Installer; +public enum Architecture +{ + [Description("unknown")] + Unknown, + [Description("win-x86")] + WindowsX86, + [Description("win-x64")] + WindowsX64, + [Description("linux-x64")] + LinuxX64, +} + +public static class EnumHelper +{ + public static string? GetDescription(this T enumValue) + where T : struct, IConvertible + { + if (!typeof(T).IsEnum) + return null; + + var description = enumValue.ToString(); + var fieldInfo = enumValue.GetType().GetField(enumValue.ToString()); + + if (fieldInfo == null) return description; + var attrs = fieldInfo.GetCustomAttributes(typeof(DescriptionAttribute), true); + if (attrs.Length > 0) + { + description = ((DescriptionAttribute)attrs[0]).Description; + } + return description; + } +} + + public class MLVersion { public string? DownloadUrlWin { get; init; } @@ -12,10 +47,9 @@ public class MLVersion public required SemVersion Version { get; init; } public bool IsLocalPath { get; init; } - public static SemVersion? GetMelonLoaderVersion(string gameDir, out bool x86, out bool linux) + public static SemVersion? GetMelonLoaderVersion(string gameDir, out Architecture architecture) { - x86 = false; - linux = false; + architecture = Architecture.Unknown; var mlDir = Path.Combine(gameDir, "MelonLoader"); if (!Directory.Exists(mlDir)) @@ -57,17 +91,17 @@ public class MLVersion return null; proxyPath = Path.Combine(gameDir, proxyPath); - - linux = proxyPath.EndsWith(".so"); - - if (linux) + if (proxyPath.EndsWith(".so")) + { + architecture = Architecture.LinuxX64; return version; + } try { using var proxyStr = File.OpenRead(proxyPath); var pe = new PEReader(proxyStr); - x86 = pe.PEHeaders.CoffHeader.Machine != Machine.Amd64; + architecture = pe.PEHeaders.CoffHeader.Machine == Machine.Amd64 ? Architecture.WindowsX64 : Architecture.WindowsX86; return version; } catch diff --git a/MelonLoader.Installer/Updater.cs b/MelonLoader.Installer/Updater.cs index 046257e..538e224 100644 --- a/MelonLoader.Installer/Updater.cs +++ b/MelonLoader.Installer/Updater.cs @@ -86,7 +86,7 @@ private static async Task UpdateAsync(string downloadUrl) await using (var newStr = File.OpenWrite(newPath)) { - var result = await InstallerUtils.DownloadFileAsync(downloadUrl, newStr, (progress, newStatus) => Progress?.Invoke(progress, newStatus)); + var result = await InstallerUtils.DownloadFileAsync(downloadUrl, newStr, false, (progress, newStatus) => Progress?.Invoke(progress, newStatus)); if (result != null) { throw new Exception("Failed to download the latest installer version: " + result); diff --git a/MelonLoader.Installer/ViewModels/GameModel.cs b/MelonLoader.Installer/ViewModels/GameModel.cs index 1bfd0b3..d34d3c1 100644 --- a/MelonLoader.Installer/ViewModels/GameModel.cs +++ b/MelonLoader.Installer/ViewModels/GameModel.cs @@ -4,12 +4,12 @@ namespace MelonLoader.Installer.ViewModels; -public class GameModel(string path, string name, bool is32Bit, bool isLinux, GameLauncher? launcher, Bitmap? icon, SemVersion? mlVersion, bool isProtected) : ViewModelBase +public class GameModel(string path, string name, Architecture architecture, GameLauncher? launcher, Bitmap? icon, SemVersion? mlVersion, bool isProtected) : ViewModelBase { public string Path => path; public string Name => name; - public bool Is32Bit => is32Bit; - public bool IsLinux => isLinux; + public Architecture Arch => architecture; + public bool IsLinux => architecture == Architecture.LinuxX64; public GameLauncher? Launcher => launcher; public Bitmap? Icon => icon; public string? MLVersionText => mlVersion != null ? 'v' + mlVersion.ToString() : null; @@ -47,8 +47,8 @@ public bool ValidateGame() return false; } - var newMlVersion = Installer.MLVersion.GetMelonLoaderVersion(Dir, out var ml86, out var mlLinux); - if (newMlVersion != null && (ml86 != Is32Bit || mlLinux != IsLinux)) + var newMlVersion = Installer.MLVersion.GetMelonLoaderVersion(Dir, out var arch); + if (newMlVersion != null && arch != Arch) newMlVersion = null; if (newMlVersion == MLVersion) diff --git a/MelonLoader.Installer/Views/DetailsView.axaml.cs b/MelonLoader.Installer/Views/DetailsView.axaml.cs index e3e9320..0cd84d1 100644 --- a/MelonLoader.Installer/Views/DetailsView.axaml.cs +++ b/MelonLoader.Installer/Views/DetailsView.axaml.cs @@ -44,7 +44,7 @@ protected override async void OnDataContextChanged(EventArgs e) return; #if LINUX - if (Model.Game.IsLinux) + if (Model.Game.Arch == Architecture.LinuxX64) { LdLibPathVar.Text = $"LD_LIBRARY_PATH=\"{Model.Game.Dir}:$LD_LIBRARY_PATH\""; SteamLaunchOptions.Text = $"{LdLibPathVar.Text} {LdPreloadVar.Text} %command%"; @@ -74,7 +74,13 @@ public void UpdateVersionList() if (Model == null) return; - var en = MLManager.Versions.Where(x => (Model.Game.IsLinux ? x.DownloadUrlLinux : (Model.Game.Is32Bit ? x.DownloadUrlWinX86 : x.DownloadUrlWin)) != null); + var en = MLManager.Versions.Where(x => + Model.Game.Arch switch + { + Architecture.LinuxX64 => x.DownloadUrlLinux, + Architecture.WindowsX86 => x.DownloadUrlWinX86, + _ => x.DownloadUrlWin + } != null); if (NightlyCheck.IsChecked != true) en = en.Where(x => !x.Version.IsPrerelease || x.IsLocalPath); @@ -139,7 +145,7 @@ private void InstallHandler(object sender, RoutedEventArgs args) ShowLinuxInstructions.IsVisible = false; _ = MLManager.InstallAsync(Path.GetDirectoryName(Model.Game.Path)!, Model.Game.MLInstalled && !KeepFilesCheck.IsChecked!.Value, - (MLVersion)VersionCombobox.SelectedItem!, Model.Game.IsLinux, Model.Game.Is32Bit, + (MLVersion)VersionCombobox.SelectedItem!, Model.Game.Arch, (progress, newStatus) => Dispatcher.UIThread.Post(() => OnInstallProgress(progress, newStatus)), (errorMessage) => Dispatcher.UIThread.Post(() => OnOperationFinished(errorMessage))); } @@ -253,9 +259,16 @@ private async void SelectZipHandler(object sender, TappedEventArgs args) if (errorMessage == null) { var ver = MLManager.Versions[0]; - if ((Model.Game.IsLinux ? ver.DownloadUrlLinux : (Model.Game.Is32Bit ? ver.DownloadUrlWinX86 : ver.DownloadUrlWin)) == null) + var downloadUrl = Model.Game.Arch switch + { + Architecture.LinuxX64 => ver.DownloadUrlLinux, + Architecture.WindowsX64 => ver.DownloadUrlWin, + Architecture.WindowsX86 => ver.DownloadUrlWinX86, + _ => null + }; + if (downloadUrl == null) { - DialogBox.ShowError($"The selected version does not support the architechture of the current game: {(Model.Game.IsLinux ? "linux" : "win")}-{(Model.Game.Is32Bit ? "x86" : "x64")}"); + DialogBox.ShowError($"The selected version does not support the architecture of the current game: {Model.Game.Arch.GetDescription()}"); } } diff --git a/MelonLoader.Installer/Views/GameControl.axaml.cs b/MelonLoader.Installer/Views/GameControl.axaml.cs index 6daa76e..554c471 100644 --- a/MelonLoader.Installer/Views/GameControl.axaml.cs +++ b/MelonLoader.Installer/Views/GameControl.axaml.cs @@ -29,7 +29,7 @@ protected override void OnDataContextChanged(EventArgs e) var showWine = #if LINUX - !Model.IsLinux; + Model.Arch != Architecture.LinuxX64; #else false; #endif