9bb4f85802
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
218 lines
10 KiB
C#
218 lines
10 KiB
C#
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<string, List<ReleaseInfo>> 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<List<Identified>> IdentifyAsync(string gameDir, IEnumerable<SourceDef> sources, Action<string> 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<CvrmgMod> 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<string, List<(string Repo, string Asset)>>();
|
|
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<Identified>();
|
|
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<List<ReleaseInfo>> ReleasesAsync(string repo, Action<string> 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<List<ReleaseInfo>>(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<List<ReleaseInfo>>(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<Identified> found, Action<string> 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<ReleaseInfo> FilterReleases(IEnumerable<ReleaseInfo> releases, string assetFilter) =>
|
|
string.IsNullOrWhiteSpace(assetFilter)
|
|
? releases.ToList()
|
|
: releases.Where(r => r.Assets.Any(a => ModStore.AssetMatches(a.Name, assetFilter))).ToList();
|
|
}
|