CVR Manager 1.0.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user