Files
Exterrata 9bb4f85802 CVR Manager 1.0.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 02:46:35 -04:00

184 lines
7.9 KiB
C#

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<string> 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<AssetInfo> 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<string> 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<string, string> MapFiles(ModDef mod, string tag)
{
var root = Paths.ModFilesDir(mod, tag);
var map = new Dictionary<string, string>(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<ModDef> catalog, Action<string> 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<string, Dictionary<string, string>>();
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<string>();
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);
}
}
}