Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable caching of downloading MelonLoader archive #38

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 8 additions & 9 deletions MelonLoader.Installer/GameManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
{
Expand All @@ -124,21 +124,20 @@ 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)))
{
errorMessage = "Game is already listed.";
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
{
Expand All @@ -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;
Expand All @@ -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);
Expand Down
91 changes: 77 additions & 14 deletions MelonLoader.Installer/InstallerUtils.cs
Original file line number Diff line number Diff line change
@@ -1,19 +1,40 @@
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()
{
Http = new();
Http.DefaultRequestHeaders.Add("User-Agent", $"MelonLoader Installer v{Program.VersionName}");
}

public static async Task<string?> DownloadFileAsync(string url, Stream destination, InstallProgressEventHandler? onProgress)
private static async Task<string?> 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));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assumes the file name is unique for every download, which already breaks everything. ML versions do not have unique file names per version, so only one version will be cached.


// 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
{
Expand All @@ -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<string?> 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)";
}
Comment on lines +99 to +126
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assumes that the downloaded file is a zip and that a hash is present. The purpose of this function is to download any file the caller may request. The checksum seems unnecessary, as the only possibility of a "corruption" would be a returned error message (which should be indicated by the response status code).


public static string? Extract(Stream archiveStream, string destination, InstallProgressEventHandler? onProgress)
{
Directory.CreateDirectory(destination);
Expand Down
23 changes: 15 additions & 8 deletions MelonLoader.Installer/MLManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand All @@ -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
};

Expand All @@ -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;
}

Expand Down Expand Up @@ -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);
Expand Down
50 changes: 42 additions & 8 deletions MelonLoader.Installer/MLVersion.cs
Original file line number Diff line number Diff line change
@@ -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,
}
winterheart marked this conversation as resolved.
Show resolved Hide resolved

public static class EnumHelper
{
public static string? GetDescription<T>(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;
winterheart marked this conversation as resolved.
Show resolved Hide resolved
var attrs = fieldInfo.GetCustomAttributes(typeof(DescriptionAttribute), true);
if (attrs.Length > 0)
{
description = ((DescriptionAttribute)attrs[0]).Description;
}
return description;
}
}


winterheart marked this conversation as resolved.
Show resolved Hide resolved
public class MLVersion
{
public string? DownloadUrlWin { get; init; }
Expand All @@ -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))
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion MelonLoader.Installer/Updater.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 5 additions & 5 deletions MelonLoader.Installer/ViewModels/GameModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Contributor

@slxdy slxdy Dec 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The IsLinux property seems unnecessary now, so it can be removed

public GameLauncher? Launcher => launcher;
public Bitmap? Icon => icon;
public string? MLVersionText => mlVersion != null ? 'v' + mlVersion.ToString() : null;
Expand Down Expand Up @@ -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)
Expand Down
Loading