using System.IO.Compression; using System.Security.Cryptography; using CVRManager.Models; namespace CVRManager.Services; public class ModStore { static readonly string[] KnownRoots = { "Mods", "Plugins", "UserLibs", "UserData", "MelonLoader" }; const string Marker = ".complete"; readonly GitHubService github; public ModStore(GitHubService github) => this.github = github; public static string ResolveTag(ModDef mod, string version) => version == "latest" ? mod.LatestTag ?? "" : version; public static bool IsDownloaded(ModDef mod, string tag) => tag.Length > 0 && File.Exists(Path.Combine(Paths.ModVersionDir(mod, tag), Marker)); public async Task EnsureDownloadedAsync(ModDef mod, string tag, Action log, CancellationToken ct) { if (IsDownloaded(mod, tag)) return; if (mod.Local) throw new InvalidOperationException($"Local files for {mod.Name} are missing from the store."); var rel = mod.Releases.FirstOrDefault(r => r.Tag == tag) ?? throw new InvalidOperationException($"{mod.Name} has no release {tag}; refresh versions."); var assets = PickAssets(mod, rel); if (assets.Count == 0) throw new InvalidOperationException( $"{mod.Name} {tag}: no downloadable .dll or .zip asset matched. Assets: {string.Join(", ", rel.Assets.Select(a => a.Name))}. Set an asset filter on the mod."); var dir = Paths.ModVersionDir(mod, tag); if (Directory.Exists(dir)) Directory.Delete(dir, true); var files = Paths.ModFilesDir(mod, tag); Directory.CreateDirectory(files); foreach (var a in assets) { log($"Downloading {a.Name} ({a.Size / 1024} KB)..."); var tmp = Path.Combine(dir, Paths.Safe(a.Name)); var served = await github.DownloadAsync(a.Url, tmp, ct); if (a.Hash.Length > 0) { var actual = Convert.ToBase64String(SHA256.HashData(await File.ReadAllBytesAsync(tmp, ct))); if (actual != a.Hash) { File.Delete(tmp); throw new InvalidOperationException($"{a.Name}: the downloaded file does not match the hash its source published."); } } var isZip = a.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) || served.EndsWith(".zip", StringComparison.OrdinalIgnoreCase); if (isZip) { ZipFile.ExtractToDirectory(tmp, files, true); File.Delete(tmp); } else { var name = mod.Source == "cvrmg" && served.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ? served : a.Name; File.Move(tmp, Path.Combine(files, Paths.Safe(name)), true); } } File.WriteAllText(Path.Combine(dir, Marker), ""); } static List PickAssets(ModDef mod, ReleaseInfo rel) { if (mod.Source == "cvrmg") return rel.Assets.ToList(); if (!string.IsNullOrWhiteSpace(mod.AssetFilter)) { return rel.Assets.Where(a => AssetMatches(a.Name, mod.AssetFilter)).ToList(); } var dlls = rel.Assets.Where(a => a.Name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)).ToList(); if (dlls.Count > 0) return dlls; return rel.Assets.Where(a => a.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)).Take(1).ToList(); } public static bool AssetMatches(string assetName, string filter) { foreach (var f in filter.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { if (f.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) { if (CvrmgService.Normalize(assetName) == CvrmgService.Normalize(f)) return true; } else if (assetName.Contains(f, StringComparison.OrdinalIgnoreCase)) return true; } return false; } public void ImportLocal(ModDef mod, IEnumerable paths) { var dir = Paths.ModVersionDir(mod, "local"); if (Directory.Exists(dir)) Directory.Delete(dir, true); var files = Paths.ModFilesDir(mod, "local"); Directory.CreateDirectory(files); foreach (var p in paths) { if (p.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) ZipFile.ExtractToDirectory(p, files, true); else File.Copy(p, Path.Combine(files, Path.GetFileName(p)), true); } File.WriteAllText(Path.Combine(dir, Marker), ""); } public static void DeleteVersion(ModDef mod, string tag) { var dir = Paths.ModVersionDir(mod, tag); if (Directory.Exists(dir)) Directory.Delete(dir, true); } public static Dictionary MapFiles(ModDef mod, string tag) { var root = Paths.ModFilesDir(mod, tag); var map = new Dictionary(StringComparer.OrdinalIgnoreCase); if (!Directory.Exists(root)) return map; foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) { var rel = Path.GetRelativePath(root, file).Replace('\\', '/'); map[Target(mod, rel)] = file; } return map; } static string Target(ModDef mod, string rel) { if (mod.Type == "Root") return rel; var first = rel.Split('/')[0]; if (rel.Contains('/') && KnownRoots.Any(k => k.Equals(first, StringComparison.OrdinalIgnoreCase))) return rel; if (rel.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) return $"{mod.Type}/{Path.GetFileName(rel)}"; return $"{mod.Type}/{rel}"; } public async Task ApplyAsync(InstallInfo install, IReadOnlyList catalog, Action log, CancellationToken ct) { var game = Paths.GameDir(install); if (!Directory.Exists(game)) throw new InvalidOperationException("The install has no game files yet. Update it first."); var desired = new Dictionary>(); foreach (var a in install.Mods.Where(a => a.Enabled)) { var mod = catalog.FirstOrDefault(m => m.Id == a.ModId); if (mod == null) continue; var tag = ResolveTag(mod, a.Version); if (tag.Length == 0) throw new InvalidOperationException($"{mod.Name} has no versions; refresh versions in the Mods tab."); await EnsureDownloadedAsync(mod, tag, log, ct); desired[mod.Id] = MapFiles(mod, tag); } foreach (var modId in install.Deployed.Keys.ToList()) { var keep = desired.TryGetValue(modId, out var map) ? map.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase) : new HashSet(); foreach (var rel in install.Deployed[modId].Where(r => !keep.Contains(r))) DeleteTracked(game, rel); if (!desired.ContainsKey(modId)) install.Deployed.Remove(modId); } foreach (var (modId, map) in desired) { foreach (var (rel, src) in map) { var dest = Path.Combine(game, rel.Replace('/', Path.DirectorySeparatorChar)); Directory.CreateDirectory(Path.GetDirectoryName(dest)!); File.Copy(src, dest, true); } install.Deployed[modId] = map.Keys.ToList(); log($"Deployed {catalog.First(m => m.Id == modId).Name} ({map.Count} files)"); } } static void DeleteTracked(string game, string rel) { var path = Path.Combine(game, rel.Replace('/', Path.DirectorySeparatorChar)); if (File.Exists(path)) File.Delete(path); var dir = Path.GetDirectoryName(path); while (!string.IsNullOrEmpty(dir) && dir.Length > game.Length && Directory.Exists(dir) && !Directory.EnumerateFileSystemEntries(dir).Any()) { Directory.Delete(dir); dir = Path.GetDirectoryName(dir); } } }