using CVRManager.Models; namespace CVRManager.Services; public record Identified(AssemblyDetails File, string Folder, string Source, string Repo, string AssetName, CvrmgMod? Cvrmg, string Why); public class ModImportService { static readonly string[] Folders = { "Mods", "Plugins", "UserLibs" }; readonly GitHubService github; readonly CvrmgService cvrmg; readonly Dictionary> repoReleases = new(StringComparer.OrdinalIgnoreCase); public ModImportService(GitHubService github, CvrmgService cvrmg) { this.github = github; this.cvrmg = cvrmg; } public static string Norm(string s) => CvrmgService.Normalize(s); public async Task> IdentifyAsync(string gameDir, IEnumerable sources, Action log, CancellationToken ct) { var files = new List<(AssemblyDetails Details, string Folder)>(); foreach (var folder in Folders) { var dir = Path.Combine(gameDir, folder); if (!Directory.Exists(dir)) continue; foreach (var f in Directory.EnumerateFiles(dir, "*.dll", SearchOption.TopDirectoryOnly).OrderBy(f => f, StringComparer.OrdinalIgnoreCase)) files.Add((AssemblyReader.Read(f), folder)); foreach (var sub in Directory.EnumerateDirectories(dir)) log($"Skipping folder {folder}\\{Path.GetFileName(sub)} (only loose DLLs are imported)."); } log($"Found {files.Count} mod files."); List catalog = new(); try { catalog = await cvrmg.GetAllAsync(ct); } catch (Exception e) when (e is not OperationCanceledException) { log($"CVRMG unavailable ({e.Message}); matching without it."); } var index = new Dictionary>(); foreach (var src in sources.Where(s => s.Kind == "github" && s.Repo.Length > 0)) { var releases = await ReleasesAsync(src.Repo, log, ct); foreach (var r in releases) foreach (var a in r.Assets.Where(a => a.Name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))) { var key = Norm(a.Name); if (!index.TryGetValue(key, out var l)) index[key] = l = new(); if (l.All(x => !x.Repo.Equals(src.Repo, StringComparison.OrdinalIgnoreCase))) l.Add((src.Repo, a.Name)); } } var result = new List(); foreach (var (d, folder) in files) { ct.ThrowIfCancellationRequested(); var cm = catalog.FirstOrDefault(m => m.Hash.Length > 0 && m.Hash == d.Sha256); var cmWhy = cm != null ? "same file as the current CVRMG release" : ""; if (cm == null) { var keys = new[] { d.MelonName, d.AssemblyName, Path.GetFileNameWithoutExtension(d.FileName) } .Select(Norm).Where(k => k.Length > 0).Distinct().ToList(); cm = catalog.Where(m => new[] { m.Name }.Concat(m.Aliases).Select(Norm).Any(keys.Contains)) .OrderBy(m => m.ApprovalStatus == 1 ? 0 : 1).ThenByDescending(m => m.Updated).FirstOrDefault(); if (cm != null) cmWhy = "name match in CVRMG"; } string? repo = null, asset = null, why = ""; var link = GitHubService.ParseRepo(d.MelonLink.Contains("github.com", StringComparison.OrdinalIgnoreCase) ? d.MelonLink : ""); if (link != null) { var rel = await ReleasesAsync(link, log, ct); var hit = rel.SelectMany(r => r.Assets).FirstOrDefault(a => Norm(a.Name) == Norm(d.FileName)); if (hit != null) { repo = link; asset = hit.Name; why = "download link in the mod's own metadata"; } } if (repo == null && index.TryGetValue(Norm(d.FileName), out var cands)) { var pick = cands.FirstOrDefault(c => d.MelonAuthor.Length > 0 && c.Repo.Split('/')[0].Equals(d.MelonAuthor, StringComparison.OrdinalIgnoreCase)); if (pick == default) pick = cands[0]; repo = pick.Repo; asset = pick.Asset; why = "release assets of " + repo; } if (repo != null) result.Add(new Identified(d, folder, "github", repo, asset!, cm, why)); else if (cm != null) result.Add(new Identified(d, folder, "cvrmg", "", "", cm, cmWhy)); else result.Add(new Identified(d, folder, "local", "", "", null, d.MelonLink.Length > 0 ? $"no published release contains it; its metadata points at {d.MelonLink}" : "no known source")); } return result; } public void Invalidate() => repoReleases.Clear(); public async Task> ReleasesAsync(string repo, Action log, CancellationToken ct, bool force = false) { if (!force && repoReleases.TryGetValue(repo, out var cached)) return cached; var path = Path.Combine(Paths.Root, "sources", "gh_" + Paths.Safe(repo.Replace('/', '_')) + ".json"); try { if (!force && File.Exists(path) && DateTime.UtcNow - File.GetLastWriteTimeUtc(path) < TimeSpan.FromHours(1)) { var fromDisk = System.Text.Json.JsonSerializer.Deserialize>(await File.ReadAllTextAsync(path, ct)); if (fromDisk != null) return repoReleases[repo] = fromDisk; } var list = await github.GetReleasesAsync(repo, ct); Directory.CreateDirectory(Path.GetDirectoryName(path)!); await File.WriteAllTextAsync(path, System.Text.Json.JsonSerializer.Serialize(list), ct); return repoReleases[repo] = list; } catch (Exception e) when (e is not OperationCanceledException) { if (File.Exists(path)) { try { var stale = System.Text.Json.JsonSerializer.Deserialize>(await File.ReadAllTextAsync(path, ct)); if (stale != null) { log($"{repo}: using cached releases ({e.Message})"); return repoReleases[repo] = stale; } } catch (System.Text.Json.JsonException) { } } log($"{repo}: {e.Message}"); return repoReleases[repo] = new(); } } public static string SnapshotTag(AssemblyDetails d) => $"installed {d.Version} ({Convert.ToHexString(Convert.FromBase64String(d.Sha256))[..6].ToLowerInvariant()})"; public int Apply(AppConfig cfg, InstallInfo? install, IEnumerable found, Action log, string? gameDir = null) { var game = gameDir ?? Paths.GameDir(install!); var added = 0; foreach (var it in found) { var rel = $"{it.Folder}/{it.File.FileName}"; if (install != null && install.Deployed.Values.Any(l => l.Contains(rel, StringComparer.OrdinalIgnoreCase))) continue; var def = FindOrCreate(cfg, it); var tag = SnapshotTag(it.File); if (def.Releases.All(r => r.Tag != tag)) def.Releases.Add(new ReleaseInfo { Tag = tag, Snapshot = true, Published = File.GetLastWriteTime(Path.Combine(game, it.Folder, it.File.FileName)) }); var filesDir = Paths.ModFilesDir(def, tag); if (!ModStore.IsDownloaded(def, tag)) { Directory.CreateDirectory(filesDir); File.Copy(Path.Combine(game, it.Folder, it.File.FileName), Path.Combine(filesDir, it.File.FileName), true); File.WriteAllText(Path.Combine(Paths.ModVersionDir(def, tag), ".complete"), ""); } if (install != null) { install.Mods.RemoveAll(a => a.ModId == def.Id); install.Mods.Add(new ModAssignment { ModId = def.Id, Version = tag, Enabled = true }); install.Deployed[def.Id] = ModStore.MapFiles(def, tag).Keys.ToList(); } added++; log($"{def.Name} {it.File.Version}: {Describe(it)}"); } return added; } static string Describe(Identified it) => it.Source switch { "github" => $"GitHub {it.Repo} ({it.Why})", "cvrmg" => $"CVRMG ({it.Why})", _ => $"kept as a local snapshot ({it.Why})", }; ModDef FindOrCreate(AppConfig cfg, Identified it) { ModDef? def = it.Source switch { "github" => cfg.Mods.FirstOrDefault(m => m.Source == "github" && m.Repo.Equals(it.Repo, StringComparison.OrdinalIgnoreCase) && Norm(m.AssetFilter) == Norm(it.AssetName)), "cvrmg" => cfg.Mods.FirstOrDefault(m => m.CvrmgId == it.Cvrmg!.Id && m.Source == "cvrmg"), _ => cfg.Mods.FirstOrDefault(m => m.Local && Norm(m.FileName) == Norm(it.File.FileName)), }; if (def != null) return def; def = new ModDef { Name = it.Cvrmg?.Name is { Length: > 0 } n ? n : it.File.MelonName.Length > 0 ? it.File.MelonName : it.File.AssemblyName, Type = it.Folder, FileName = it.File.FileName, Author = it.File.MelonAuthor, Source = it.Source, Local = it.Source == "local", }; if (it.Cvrmg != null) CvrmgService.ApplyMeta(def, it.Cvrmg); if (def.SourceLink.Length == 0 && it.File.MelonLink.Length > 0) def.SourceLink = it.File.MelonLink; switch (it.Source) { case "github": def.Repo = it.Repo; def.AssetFilter = it.AssetName; def.SourceLink = $"https://github.com/{it.Repo}"; def.Releases = FilterReleases(repoReleases.GetValueOrDefault(it.Repo) ?? new(), def.AssetFilter); break; case "cvrmg": def.Releases = new() { CvrmgService.ToRelease(it.Cvrmg!, it.File.FileName) }; break; } cfg.Mods.Add(def); return def; } public static List FilterReleases(IEnumerable releases, string assetFilter) => string.IsNullOrWhiteSpace(assetFilter) ? releases.ToList() : releases.Where(r => r.Assets.Any(a => ModStore.AssetMatches(a.Name, assetFilter))).ToList(); }