wabbajack/Wabbajack.Installer/AInstaller.cs
2021-09-27 06:42:46 -06:00

467 lines
19 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Wabbajack.Common;
using Wabbajack.Downloaders;
using Wabbajack.DTOs;
using Wabbajack.DTOs.Directives;
using Wabbajack.DTOs.DownloadStates;
using Wabbajack.DTOs.JsonConverters;
using Wabbajack.Hashing.PHash;
using Wabbajack.Hashing.xxHash64;
using Wabbajack.Installer.Utilities;
using Wabbajack.Networking.WabbajackClientApi;
using Wabbajack.Paths;
using Wabbajack.Paths.IO;
using Wabbajack.RateLimiter;
using Wabbajack.VFS;
namespace Wabbajack.Installer
{
public record StatusUpdate(string StatusText, Percent StepsProgress, Percent StepProgress) {}
public abstract class AInstaller<T>
where T : AInstaller<T>
{
private static readonly Regex NoDeleteRegex = new(@"(?i)[\\\/]\[NoDelete\]", RegexOptions.Compiled);
protected readonly InstallerConfiguration _configuration;
protected readonly DownloadDispatcher _downloadDispatcher;
private readonly FileExtractor.FileExtractor _extractor;
private readonly FileHashCache _fileHashCache;
protected readonly IGameLocator _gameLocator;
private readonly DTOSerializer _jsonSerializer;
protected readonly ParallelOptions _parallelOptions;
protected readonly ILogger<T> _logger;
protected readonly TemporaryFileManager _manager;
private readonly Context _vfs;
protected readonly Client _wjClient;
private long _maxStepProgress = 0;
private int _currentStep = 0;
private string _statusText;
private long _currentStepProgress;
private Stopwatch _updateStopWatch = new();
protected long MaxSteps { get; set; }
public event EventHandler<StatusUpdate> OnStatusUpdate;
public AInstaller(ILogger<T> logger, InstallerConfiguration config, IGameLocator gameLocator,
FileExtractor.FileExtractor extractor,
DTOSerializer jsonSerializer, Context vfs, FileHashCache fileHashCache,
DownloadDispatcher downloadDispatcher,
ParallelOptions parallelOptions, Client wjClient)
{
_manager = new TemporaryFileManager(config.Install.Combine("__temp__"));
ExtractedModlistFolder = _manager.CreateFolder();
_configuration = config;
_logger = logger;
_extractor = extractor;
_jsonSerializer = jsonSerializer;
_vfs = vfs;
_fileHashCache = fileHashCache;
_downloadDispatcher = downloadDispatcher;
_parallelOptions = parallelOptions;
_gameLocator = gameLocator;
_wjClient = wjClient;
}
public void NextStep(string statusText, long maxStepProgress)
{
_updateStopWatch.Restart();
_maxStepProgress = maxStepProgress;
_currentStep += 1;
_statusText = statusText;
_logger.LogInformation("Next Step: {Step}", statusText);
if (OnStatusUpdate != null)
{
OnStatusUpdate(this, new StatusUpdate(statusText, Percent.FactoryPutInRange(_currentStep, MaxSteps),
Percent.Zero));
}
}
public void UpdateProgress(long stepProgress)
{
Interlocked.Add(ref _currentStepProgress, stepProgress);
lock (_updateStopWatch)
{
if (_updateStopWatch.ElapsedMilliseconds < 100) return;
_updateStopWatch.Restart();
}
if (OnStatusUpdate != null)
{
OnStatusUpdate(this, new StatusUpdate(_statusText, Percent.FactoryPutInRange(_currentStep, MaxSteps),
Percent.FactoryPutInRange(_currentStepProgress, _maxStepProgress)));
}
}
public Dictionary<Hash, AbsolutePath> HashedArchives { get; set; } = new();
public bool UseCompression { get; set; }
public TemporaryPath ExtractedModlistFolder { get; set; }
public ModList ModList => _configuration.ModList;
public abstract Task<bool> Begin(CancellationToken token);
public async Task ExtractModlist(CancellationToken token)
{
ExtractedModlistFolder = _manager.CreateFolder();
await _extractor.ExtractAll(_configuration.ModlistArchive, ExtractedModlistFolder, token);
}
public async Task<byte[]> LoadBytesFromPath(RelativePath path)
{
var fullPath = ExtractedModlistFolder.Path.Combine(path);
if (!fullPath.FileExists())
throw new Exception($"Cannot load inlined data {path} file does not exist");
return await fullPath.ReadAllBytesAsync();
}
public async Task<Stream> InlinedFileStream(RelativePath inlinedFile)
{
var fullPath = ExtractedModlistFolder.Path.Combine(inlinedFile);
return fullPath.Open(FileMode.Open, FileAccess.Read, FileShare.Read);
}
public static async Task<ModList> LoadFromFile(DTOSerializer serializer, AbsolutePath path)
{
await using var fs = path.Open(FileMode.Open, FileAccess.Read, FileShare.Read);
using var ar = new ZipArchive(fs, ZipArchiveMode.Read);
var entry = ar.GetEntry("modlist");
if (entry == null)
{
entry = ar.GetEntry("modlist.json");
if (entry == null)
throw new Exception("Invalid Wabbajack Installer");
await using var e = entry.Open();
return (await serializer.DeserializeAsync<ModList>(e))!;
}
await using (var e = entry.Open())
{
return (await serializer.DeserializeAsync<ModList>(e))!;
}
}
/// <summary>
/// We don't want to make the installer index all the archives, that's just a waste of time, so instead
/// we'll pass just enough information to VFS to let it know about the files we have.
/// </summary>
protected async Task PrimeVFS()
{
_vfs.AddKnown(_configuration.ModList.Directives.OfType<FromArchive>().Select(d => d.ArchiveHashPath),
HashedArchives);
await _vfs.BackfillMissing();
}
public void BuildFolderStructure()
{
_logger.LogInformation("Building Folder Structure");
ModList.Directives
.Where(d => d.To.Depth > 1)
.Select(d => _configuration.Install.Combine(d.To.Parent))
.Distinct()
.Do(f => f.CreateDirectory());
}
public async Task InstallArchives(CancellationToken token)
{
NextStep("Installing files", ModList.Directives.Sum(d => d.Size));
var grouped = ModList.Directives
.OfType<FromArchive>()
.Select(a => new { VF = _vfs.Index.FileForArchiveHashPath(a.ArchiveHashPath), Directive = a })
.GroupBy(a => a.VF)
.ToDictionary(a => a.Key);
if (grouped.Count == 0) return;
await _vfs.Extract(grouped.Keys.ToHashSet(), async (vf, sf) =>
{
foreach (var directive in grouped[vf])
{
var file = directive.Directive;
UpdateProgress(file.Size);
switch (file)
{
case PatchedFromArchive pfa:
{
await using var s = await sf.GetStream();
s.Position = 0;
await using var patchDataStream = await InlinedFileStream(pfa.PatchID);
var toFile = file.To.RelativeTo(_configuration.Install);
{
await using var os = toFile.Open(FileMode.Create, FileAccess.ReadWrite, FileShare.None);
await BinaryPatching.ApplyPatch(s, patchDataStream, os);
}
}
break;
case TransformedTexture tt:
{
await using var s = await sf.GetStream();
await using var of = directive.Directive.To.RelativeTo(_configuration.Install)
.Open(FileMode.Create, FileAccess.Write);
await ImageLoader.Recompress(s, tt.ImageState.Width, tt.ImageState.Height, tt.ImageState.Format, of, token);
}
break;
case FromArchive _:
if (grouped[vf].Count() == 1)
{
await sf.Move(directive.Directive.To.RelativeTo(_configuration.Install), token);
}
else
{
await using var s = await sf.GetStream();
await directive.Directive.To.RelativeTo(_configuration.Install)
.WriteAllAsync(s, token, false);
}
break;
default:
throw new Exception($"No handler for {directive}");
}
}
}, token);
}
public async Task DownloadArchives(CancellationToken token)
{
var missing = ModList.Archives.Where(a => !HashedArchives.ContainsKey(a.Hash)).ToList();
_logger.LogInformation("Missing {count} archives", missing.Count);
var dispatchers = missing.Select(m => _downloadDispatcher.Downloader(m))
.Distinct()
.ToList();
await Task.WhenAll(dispatchers.Select(d => d.Prepare()));
var validationData = await _wjClient.LoadDownloadAllowList();
foreach (var archive in missing.Where(archive =>
!_downloadDispatcher.Downloader(archive).IsAllowed(validationData, archive.State)))
{
_logger.LogCritical("File {primaryKeyString} failed validation", archive.State.PrimaryKeyString);
return;
}
await DownloadMissingArchives(missing, token);
}
public async Task DownloadMissingArchives(List<Archive> missing, CancellationToken token, bool download = true)
{
if (download)
{
var result = SendDownloadMetrics(missing);
foreach (var a in missing.Where(a => a.State is Manual))
{
var outputPath = _configuration.Downloads.Combine(a.Name);
await _downloadDispatcher.Download(a, outputPath, token);
}
}
_logger.LogInformation("Downloading {count} archives", missing.Count);
await missing.Where(a => a.State is not Manual)
.PDo(_parallelOptions, async archive =>
{
_logger.LogInformation("Downloading {archive}", archive.Name);
var outputPath = _configuration.Downloads.Combine(archive.Name);
if (download)
if (outputPath.FileExists())
{
var origName = Path.GetFileNameWithoutExtension(archive.Name);
var ext = Path.GetExtension(archive.Name);
var uniqueKey = archive.State.PrimaryKeyString.StringSha256Hex();
outputPath = _configuration.Downloads.Combine(origName + "_" + uniqueKey + "_" + ext);
outputPath.Delete();
}
await DownloadArchive(archive, download, token, outputPath);
});
}
private async Task SendDownloadMetrics(List<Archive> missing)
{
var grouped = missing.GroupBy(m => m.State.GetType());
foreach (var group in grouped)
await _wjClient.SendMetric($"downloading_{@group.Key.FullName!.Split(".").Last().Split("+").First()}",
@group.Sum(g => (long)g.Size).ToString());
}
public async Task<bool> DownloadArchive(Archive archive, bool download, CancellationToken token,
AbsolutePath? destination = null)
{
try
{
destination ??= _configuration.Downloads.Combine(archive.Name);
var (result, hash) =
await _downloadDispatcher.DownloadWithPossibleUpgrade(archive, destination.Value, token);
if (hash != default)
_fileHashCache.FileHashWriteCache(destination.Value, hash);
if (result == DownloadResult.Update)
await destination.Value.MoveToAsync(destination.Value.Parent.Combine(archive.Hash.ToHex()), true,
token);
}
catch (Exception ex)
{
_logger.LogError(ex, "Download error for file {name}", archive.Name);
return false;
}
return false;
}
public async Task HashArchives(CancellationToken token)
{
_logger.LogInformation("Looking for files to hash");
var allFiles = _configuration.Downloads.EnumerateFiles()
.Concat(_gameLocator.GameLocation(_configuration.Game).EnumerateFiles())
.ToList();
var hashDict = allFiles.GroupBy(f => f.Size()).ToDictionary(g => g.Key);
var toHash = ModList.Archives.Where(a => hashDict.ContainsKey((long)a.Size))
.SelectMany(a => hashDict[(long)a.Size]).ToList();
_logger.LogInformation("Found {count} total files, {hashedCount} matching filesize", allFiles.Count,
toHash.Count);
var hashResults = await
toHash
.PMap(_parallelOptions, async e => (await _fileHashCache.FileHashCachedAsync(e, token), e))
.ToList();
HashedArchives = hashResults
.OrderByDescending(e => e.Item2.LastModified())
.GroupBy(e => e.Item1)
.Select(e => e.First())
.Where(x => x.Item1 != default)
.ToDictionary(kv => kv.Item1, kv => kv.e);
}
/// <summary>
/// The user may already have some files in the _configuration.Install. If so we can go through these and
/// figure out which need to be updated, deleted, or left alone
/// </summary>
protected async Task OptimizeModlist(CancellationToken token)
{
_logger.LogInformation("Optimizing ModList directives");
var indexed = ModList.Directives.ToDictionary(d => d.To);
var profileFolder = _configuration.Install.Combine("profiles");
var savePath = (RelativePath)"saves";
var existingFiles = _configuration.Install.EnumerateFiles().ToList();
NextStep("Optimizing Modlist: Looking for files to delete", existingFiles.Count);
await existingFiles
.PDo(_parallelOptions, async f =>
{
UpdateProgress(1);
var relativeTo = f.RelativeTo(_configuration.Install);
if (indexed.ContainsKey(relativeTo) || f.InFolder(_configuration.Downloads))
return;
if (f.InFolder(profileFolder) && f.Parent.FileName == savePath) return;
if (NoDeleteRegex.IsMatch(f.ToString()))
return;
_logger.LogInformation("Deleting {relativeTo} it's not part of this ModList", relativeTo);
f.Delete();
});
_logger.LogInformation("Cleaning empty folders");
NextStep("Optimizing Modlist: Cleaning empty folders", indexed.Keys.Count);
var expectedFolders = indexed.Keys
.Select(f => f.RelativeTo(_configuration.Install))
// We ignore the last part of the path, so we need a dummy file name
.Append(_configuration.Downloads.Combine("_"))
.OnEach(_ => UpdateProgress(1))
.Where(f => f.InFolder(_configuration.Install))
.SelectMany(path =>
{
// Get all the folders and all the folder parents
// so for foo\bar\baz\qux.txt this emits ["foo", "foo\\bar", "foo\\bar\\baz"]
var split = ((string)path.RelativeTo(_configuration.Install)).Split('\\');
return Enumerable.Range(1, split.Length - 1).Select(t => string.Join("\\", split.Take(t)));
})
.Distinct()
.Select(p => _configuration.Install.Combine(p))
.ToHashSet();
try
{
var toDelete = _configuration.Install.EnumerateDirectories()
.Where(p => !expectedFolders.Contains(p))
.OrderByDescending(p => p.ToString().Length)
.ToList();
foreach (var dir in toDelete) dir.DeleteDirectory(true);
}
catch (Exception)
{
// ignored because it's not worth throwing a fit over
_logger.LogWarning("Error when trying to clean empty folders. This doesn't really matter.");
}
var existingfiles = _configuration.Install.EnumerateFiles().ToHashSet();
NextStep("Optimizing Modlist: Removing redundant directives", indexed.Count);
await indexed.Values.PMap<Directive, Directive?>(_parallelOptions, async d =>
{
UpdateProgress(1);
// Bit backwards, but we want to return null for
// all files we *want* installed. We return the files
// to remove from the install list.
var path = _configuration.Install.Combine(d.To);
if (!existingfiles.Contains(path)) return null;
return await _fileHashCache.FileHashCachedAsync(path, token) == d.Hash ? d : null;
})
.Do(d =>
{
if (d != null) indexed.Remove(d.To);
});
_logger.LogInformation("Optimized {optimized} directives to {indexed} required", ModList.Directives.Length,
indexed.Count);
var requiredArchives = indexed.Values.OfType<FromArchive>()
.GroupBy(d => d.ArchiveHashPath.Hash)
.Select(d => d.Key)
.ToHashSet();
ModList.Archives = ModList.Archives.Where(a => requiredArchives.Contains(a.Hash)).ToArray();
ModList.Directives = indexed.Values.ToArray();
}
}
}