diff --git a/src/CLI/CLI.csproj b/src/CLI/CLI.csproj index db5310e..9c44535 100644 --- a/src/CLI/CLI.csproj +++ b/src/CLI/CLI.csproj @@ -27,13 +27,4 @@ Always - - - - - - - - - diff --git a/src/CLI/ConsoleEventLogger.cs b/src/CLI/ConsoleEventLogger.cs new file mode 100644 index 0000000..4761a17 --- /dev/null +++ b/src/CLI/ConsoleEventLogger.cs @@ -0,0 +1,12 @@ +using Core.Utils; +using Core; + +namespace AMS2CM.CLI; + +internal class ConsoleEventLogger : BaseEventLogger +{ + public override void ProgressUpdate(IPercent? value) + { + } + protected override void LogMessage(string message) => Console.WriteLine(message); +} \ No newline at end of file diff --git a/src/CLI/Program.cs b/src/CLI/Program.cs index 83366f6..d06c0f9 100644 --- a/src/CLI/Program.cs +++ b/src/CLI/Program.cs @@ -1,11 +1,11 @@ -using Core; +using AMS2CM.CLI; +using Core; try { var config = Config.Load(args); var modManager = Init.CreateModManager(config); - modManager.Logs += Console.WriteLine; - modManager.InstallEnabledMods(); + modManager.InstallEnabledMods(new ConsoleEventLogger()); } catch (Exception e) { diff --git a/src/Core/Backup/IBackupStrategy.cs b/src/Core/Backup/IBackupStrategy.cs new file mode 100644 index 0000000..8e7a6fa --- /dev/null +++ b/src/Core/Backup/IBackupStrategy.cs @@ -0,0 +1,9 @@ +namespace Core.Backup; + +public interface IBackupStrategy +{ + public void PerformBackup(string fullPath); + public void RestoreBackup(string fullPath); + public void DeleteBackup(string fullPath); + public bool IsBackupFile(string fullPath); +} \ No newline at end of file diff --git a/src/Core/Backup/MoveFileBackupStrategy.cs b/src/Core/Backup/MoveFileBackupStrategy.cs new file mode 100644 index 0000000..f00d36d --- /dev/null +++ b/src/Core/Backup/MoveFileBackupStrategy.cs @@ -0,0 +1,52 @@ +using System.IO.Abstractions; + +namespace Core.Backup; + +public class MoveFileBackupStrategy +{ + + private readonly IFileSystem fs; + private readonly Func generateBackupFilePath; + + public MoveFileBackupStrategy(IFileSystem fs, Func generateBackupFilePath) + { + this.fs = fs; + this.generateBackupFilePath = generateBackupFilePath; + } + + public void PerformBackup(string fullPath) + { + if (!fs.File.Exists(fullPath)) + { + return; + } + + var backupFilePath = generateBackupFilePath(fullPath); + if (fs.File.Exists(backupFilePath)) + { + fs.File.Delete(fullPath); + } + else + { + fs.File.Move(fullPath, backupFilePath); + } + } + + public void RestoreBackup(string fullPath) + { + var backupFilePath = generateBackupFilePath(fullPath); + if (fs.File.Exists(backupFilePath)) + { + fs.File.Move(backupFilePath, fullPath); + } + } + + public void DeleteBackup(string fullPath) + { + var backupFilePath = generateBackupFilePath(fullPath); + if (fs.File.Exists(backupFilePath)) + { + fs.File.Delete(backupFilePath); + } + } +} \ No newline at end of file diff --git a/src/Core/Backup/SuffixBackupStrategy.cs b/src/Core/Backup/SuffixBackupStrategy.cs new file mode 100644 index 0000000..018812c --- /dev/null +++ b/src/Core/Backup/SuffixBackupStrategy.cs @@ -0,0 +1,16 @@ +using System.IO.Abstractions; + +namespace Core.Backup; + +public class SuffixBackupStrategy : MoveFileBackupStrategy, IBackupStrategy +{ + public const string BackupSuffix = ".orig"; + + public SuffixBackupStrategy() : + base(new FileSystem(), _ => $"{_}{BackupSuffix}") + { + } + + public bool IsBackupFile(string fullPath) => + fullPath.EndsWith(BackupSuffix); +} \ No newline at end of file diff --git a/src/Core/BaseEventLogger.cs b/src/Core/BaseEventLogger.cs new file mode 100644 index 0000000..5766110 --- /dev/null +++ b/src/Core/BaseEventLogger.cs @@ -0,0 +1,59 @@ +using Core.Utils; + +namespace Core; + +/// +/// This class is here because of the CLI. Move it into the GUI once the CLI +/// can be decommissioned or into a shared module that handles localisation. +/// +public abstract class BaseEventLogger : IModManager.IEventHandler +{ + public abstract void ProgressUpdate(IPercent? progress); + protected abstract void LogMessage(string message); + + public void InstallNoMods() => + LogMessage($"No mod archives to install"); + public void InstallStart() => + LogMessage("Installing mods:"); + public void InstallCurrent(string packageName) => + LogMessage($"- {packageName}"); + public void InstallEnd() + { + } + + public void PostProcessingNotRequired() => + LogMessage("Post-processing not required"); + public void PostProcessingStart() => + LogMessage("Post-processing:"); + public void ExtractingBootfiles(string? packageName) => + LogMessage($"Extracting bootfiles from {packageName ?? "game"}"); + public void ExtractingBootfilesErrorMultiple(IReadOnlyCollection bootfilesPackageNames) + { + LogMessage("Multiple bootfiles found:"); + foreach (var packageName in bootfilesPackageNames) + { + LogMessage($"- {packageName}"); + } + } + public void PostProcessingVehicles() => + LogMessage("- Appending crd file entries"); + public void PostProcessingTracks() => + LogMessage("- Appending trd file entries"); + public void PostProcessingDrivelines() => + LogMessage("- Appending driveline records"); + public void PostProcessingEnd() + { + } + + public void UninstallNoMods() => + LogMessage("No previously installed mods found. Skipping uninstall phase."); + public void UninstallStart() => + LogMessage($"Uninstalling mods:"); + public void UninstallCurrent(string packageName) => + LogMessage($"- {packageName}"); + public void UninstallSkipModified(string filePath) => + LogMessage($" Skipping modified file {filePath}"); + public void UninstallEnd() + { + } +} \ No newline at end of file diff --git a/src/Core/BootfilesManager.cs b/src/Core/BootfilesManager.cs new file mode 100644 index 0000000..0f95d93 --- /dev/null +++ b/src/Core/BootfilesManager.cs @@ -0,0 +1,10 @@ +namespace Core; + +public class BootfilesManager +{ + public const string BootfilesPrefix = "__bootfiles"; + + // TODO Make it an instance method when not called all over the place + internal static bool IsBootFiles(string packageName) => + packageName.StartsWith(BootfilesPrefix); +} \ No newline at end of file diff --git a/src/Core/Config.cs b/src/Core/Config.cs index 9a74213..ed800d8 100644 --- a/src/Core/Config.cs +++ b/src/Core/Config.cs @@ -34,7 +34,7 @@ public class GameConfig : Game.IConfig public string ProcessName { get; set; } = "Undefined"; } -public class ModInstallConfig : ModFactory.IConfig +public class ModInstallConfig : ModInstaller.IConfig, InstallationFactory.IConfig { public IEnumerable DirsAtRoot { get; set; } = Array.Empty(); public IEnumerable ExcludedFromInstall { get; set; } = Array.Empty(); diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 4766777..33fe6e5 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -26,13 +26,12 @@ - - - - - + + + + diff --git a/src/Core/IInstallationFactory.cs b/src/Core/IInstallationFactory.cs new file mode 100644 index 0000000..caf41a6 --- /dev/null +++ b/src/Core/IInstallationFactory.cs @@ -0,0 +1,9 @@ +using Core.Mods; + +namespace Core; + +public interface IInstallationFactory +{ + IInstaller GeneratedBootfilesInstaller(); + IInstaller ModInstaller(ModPackage modPackage); +} diff --git a/src/Core/IModFactory.cs b/src/Core/IModFactory.cs deleted file mode 100644 index f12838d..0000000 --- a/src/Core/IModFactory.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Core.Mods; - -namespace Core; - -public interface IModFactory -{ - IMod ManualInstallMod(string packageName, int packageFsHash, string extractedPath); - IMod GeneratedBootfiles(string generationBasePath); -} diff --git a/src/Core/IModInstaller.cs b/src/Core/IModInstaller.cs new file mode 100644 index 0000000..fd0df7f --- /dev/null +++ b/src/Core/IModInstaller.cs @@ -0,0 +1,9 @@ +using Core.Mods; +using Core.State; + +namespace Core; +public interface IModInstaller +{ + void InstallPackages(IReadOnlyCollection packages, string installDir, Action afterInstall, ModInstaller.IEventHandler eventHandler, CancellationToken cancellationToken); + void UninstallPackages(InternalInstallationState currentState, string installDir, Action afterUninstall, ModInstaller.IEventHandler eventHandler, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Core/IModManager.cs b/src/Core/IModManager.cs index a0df985..f29e24d 100644 --- a/src/Core/IModManager.cs +++ b/src/Core/IModManager.cs @@ -2,18 +2,15 @@ public interface IModManager { - // Temporary until proper events are provided - public delegate void LogHandler(string logLine); - public delegate void ProgressHandler(double? progress); - - public event LogHandler? Logs; - public event ProgressHandler? Progress; + public interface IEventHandler : ModInstaller.IEventHandler + { + } List FetchState(); string DisableMod(string packagePath); string EnableMod(string packagePath); ModState AddNewMod(string packagePath); void DeleteMod(string packagePath); - void InstallEnabledMods(CancellationToken cancellationToken = default); - void UninstallAllMods(CancellationToken cancellationToken = default); + void InstallEnabledMods(IEventHandler eventHandler, CancellationToken cancellationToken = default); + void UninstallAllMods(IEventHandler eventHandler, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/Core/ITempDir.cs b/src/Core/ITempDir.cs index 2cd430f..14b2f84 100644 --- a/src/Core/ITempDir.cs +++ b/src/Core/ITempDir.cs @@ -2,9 +2,6 @@ public interface ITempDir { - string BasePath - { - get; - } + string BasePath { get; } void Cleanup(); } \ No newline at end of file diff --git a/src/Core/Init.cs b/src/Core/Init.cs index 1a0e508..a70f9d6 100644 --- a/src/Core/Init.cs +++ b/src/Core/Init.cs @@ -16,8 +16,9 @@ public static IModManager CreateModManager(Config config) var tempDir = new SubdirectoryTempDir(modsDir); var statePersistence = new JsonFileStatePersistence(modsDir); var modRepository = new ModRepository(modsDir); - var modFactory = new ModFactory(config.ModInstall, game); + var installationFactory = new InstallationFactory(game, tempDir, config.ModInstall); var safeFileDelete = new WindowsRecyclingBin(); - return new ModManager(game, modRepository, modFactory, statePersistence, safeFileDelete, tempDir); + var modInstaller = new ModInstaller(installationFactory, tempDir, config.ModInstall); + return new ModManager(game, modRepository, modInstaller, statePersistence, safeFileDelete, tempDir); } } diff --git a/src/Core/InstallationFactory.cs b/src/Core/InstallationFactory.cs new file mode 100644 index 0000000..7d1d3c1 --- /dev/null +++ b/src/Core/InstallationFactory.cs @@ -0,0 +1,28 @@ +using Core.Mods; +using Core.Games; + +namespace Core; + +public class InstallationFactory : IInstallationFactory +{ + public interface IConfig : BaseInstaller.IConfig + { + } + + private readonly IConfig config; + private readonly IGame game; + private readonly ITempDir tempDir; + + public InstallationFactory(IGame game, ITempDir tempDir, IConfig config) + { + this.game = game; + this.tempDir = tempDir; + this.config = config; + } + + public IInstaller ModInstaller(ModPackage modPackage) => + new ModArchiveInstaller(modPackage.PackageName, modPackage.FsHash, tempDir, config, modPackage.FullPath); + + public IInstaller GeneratedBootfilesInstaller() => + new GeneratedBootfilesInstaller(tempDir, config, game); +} diff --git a/src/Core/ModFactory.cs b/src/Core/ModFactory.cs deleted file mode 100644 index f2958d1..0000000 --- a/src/Core/ModFactory.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Core.Mods; -using Core.Games; - -namespace Core; - -public class ModFactory : IModFactory -{ - public interface IConfig : ManualInstallMod.IConfig - { - } - - private readonly IConfig config; - private readonly IGame game; - - public ModFactory(IConfig config, IGame game) - { - this.config = config; - this.game = game; - } - - public IMod ManualInstallMod(string packageName, int packageFsHash, string extractedPath) => - new ManualInstallMod(packageName, packageFsHash, extractedPath, config); - - public IMod GeneratedBootfiles(string generationBasePath) => - new GeneratedBootfiles(game.InstallationDirectory, generationBasePath); -} diff --git a/src/Core/ModInstaller.cs b/src/Core/ModInstaller.cs new file mode 100644 index 0000000..b2be6ee --- /dev/null +++ b/src/Core/ModInstaller.cs @@ -0,0 +1,358 @@ +using System.Collections.Immutable; +using Core.Backup; +using Core.Mods; +using Core.State; +using Core.Utils; +using Microsoft.Extensions.FileSystemGlobbing; + +namespace Core; + +public class ModInstaller : IModInstaller +{ + public interface IConfig + { + IEnumerable ExcludedFromInstall { get; } + } + + public interface IEventHandler : IProgress + { + void InstallNoMods(); + void InstallStart(); + void InstallCurrent(string packageName); + void InstallEnd(); + + void PostProcessingNotRequired(); + void PostProcessingStart(); + void ExtractingBootfiles(string? packageName); + void ExtractingBootfilesErrorMultiple(IReadOnlyCollection bootfilesPackageNames); + void PostProcessingVehicles(); + void PostProcessingTracks(); + void PostProcessingDrivelines(); + void PostProcessingEnd(); + + void UninstallNoMods(); + void UninstallStart(); + void UninstallCurrent(string packageName); + void UninstallSkipModified(string filePath); + void UninstallEnd(); + } + + public interface IProgress + { + public void ProgressUpdate(IPercent? progress); + } + + private readonly IInstallationFactory installationFactory; + private readonly ITempDir tempDir; + private readonly Matcher filesToInstallMatcher; + private readonly IBackupStrategy backupStrategy; + + public ModInstaller(IInstallationFactory installationFactory, ITempDir tempDir, IConfig config) + { + this.installationFactory = installationFactory; + this.tempDir = tempDir; + filesToInstallMatcher = Matchers.ExcludingPatterns(config.ExcludedFromInstall); + backupStrategy = new SuffixBackupStrategy(); + } + + public void UninstallPackages( + InternalInstallationState currentState, + string installDir, + Action afterUninstall, + IEventHandler eventHandler, + CancellationToken cancellationToken) + { + if (currentState.Mods.Any()) + { + eventHandler.UninstallStart(); + var skipCreatedAfter = SkipCreatedAfter(eventHandler, currentState.Time); + var uninstallCallbacks = new ProcessingCallbacks + { + Accept = gamePath => + { + return skipCreatedAfter(gamePath); + }, + After = gamePath => + { + backupStrategy.RestoreBackup(gamePath.Full); + }, + NotAccepted = gamePath => + { + backupStrategy.DeleteBackup(gamePath.Full); + } + }; + foreach (var (packageName, modInstallationState) in currentState.Mods) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + eventHandler.UninstallCurrent(packageName); + var filesLeft = modInstallationState.Files.ToHashSet(StringComparer.OrdinalIgnoreCase); + try + { + UninstallFiles( + installDir, + filesLeft, + uninstallCallbacks + .AndAfter(_ => filesLeft.Remove(_.Relative)) + .AndNotAccepted(_ => filesLeft.Remove(_.Relative)) + ); + } + finally + { + var installationState = IInstallation.State.NotInstalled; + if (filesLeft.Count != 0) + { + // Once partially uninstalled, it will stay that way unless fully uninstalled + if (modInstallationState.Partial || filesLeft.Count != modInstallationState.Files.Count) + { + installationState = IInstallation.State.PartiallyInstalled; + } + else + { + installationState = IInstallation.State.Installed; + } + } + + afterUninstall(new ModInstallation( + packageName, + installationState, + filesLeft, + modInstallationState.FsHash + )); + } + } + eventHandler.UninstallEnd(); + } + else + { + eventHandler.UninstallNoMods(); + } + } + + private static void UninstallFiles(string dstPath, IEnumerable files, ProcessingCallbacks callbacks) + { + var fileList = files.ToList(); // It must be enumerated twice + foreach (var relativePath in fileList) + { + var gamePath = new RootedPath(dstPath, relativePath); + + if (!callbacks.Accept(gamePath)) + { + callbacks.NotAccepted(gamePath); + continue; + } + + callbacks.Before(gamePath); + + // Delete will fail if the parent directory does not exist + if (File.Exists(gamePath.Full)) + { + File.Delete(gamePath.Full); + } + + callbacks.After(gamePath); + } + DeleteEmptyDirectories(dstPath, fileList); + } + + private static void DeleteEmptyDirectories(string dstRootPath, IEnumerable filePaths) + { + var dirs = filePaths + .Select(file => Path.Combine(dstRootPath, file)) + .SelectMany(dstFilePath => AncestorsUpTo(dstRootPath, dstFilePath)) + .Distinct() + .OrderByDescending(name => name.Length); + foreach (var dir in dirs) + { + // Some mods have duplicate entries, so files might have been removed already + if (Directory.Exists(dir) && !Directory.EnumerateFileSystemEntries(dir).Any()) + { + Directory.Delete(dir); + } + } + } + + private static IEnumerable AncestorsUpTo(string root, string path) + { + var ancestors = new List(); + for (var dir = Directory.GetParent(path); dir is not null && dir.FullName != root; dir = dir.Parent) + { + ancestors.Add(dir.FullName); + } + return ancestors; + } + + public void InstallPackages( + IReadOnlyCollection packages, + string installDir, + Action afterInstall, + IEventHandler eventHandler, + CancellationToken cancellationToken) + { + var modPackages = packages.Where(_ => !BootfilesManager.IsBootFiles(_.PackageName)).Reverse(); + + var modConfigs = new List(); + var installedFiles = new HashSet(StringComparer.OrdinalIgnoreCase); + var installCallbacks = new ProcessingCallbacks + { + Accept = gamePath => + Whitelisted(gamePath) && + !backupStrategy.IsBackupFile(gamePath.Relative) && + !installedFiles.Contains(gamePath.Relative), + Before = gamePath => + { + backupStrategy.PerformBackup(gamePath.Full); + installedFiles.Add(gamePath.Relative); + }, + After = gamePath => EnsureNotCreatedAfter(DateTime.UtcNow) + }; + + // Increase by one in case bootfiles are needed and another one to show that something is happening + var progress = new PercentOfTotal(modPackages.Count() + 2); + if (modPackages.Any()) + { + eventHandler.InstallStart(); + eventHandler.ProgressUpdate(progress.IncrementDone()); + + foreach (var modPackage in modPackages) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + eventHandler.InstallCurrent(modPackage.PackageName); + using var mod = installationFactory.ModInstaller(modPackage); + try + { + var modConfig = mod.Install(installDir, installCallbacks); + modConfigs.Add(modConfig); + } + finally + { + afterInstall(mod); + } + eventHandler.ProgressUpdate(progress.IncrementDone()); + } + + if (modConfigs.Where(_ => _.NotEmpty()).Any()) + { + eventHandler.PostProcessingStart(); + using var bootfilesMod = CreateBootfilesMod(packages, eventHandler); + try + { + bootfilesMod.Install(installDir, installCallbacks); + bootfilesMod.PostProcessing(installDir, modConfigs, eventHandler); + } + finally + { + afterInstall(bootfilesMod); + } + eventHandler.PostProcessingEnd(); + } + else + { + eventHandler.PostProcessingNotRequired(); + } + eventHandler.ProgressUpdate(progress.IncrementDone()); + } + else + { + eventHandler.InstallNoMods(); + } + eventHandler.ProgressUpdate(progress.DoneAll()); + } + + private Predicate Whitelisted => + gamePath => filesToInstallMatcher.Match(gamePath.Relative).HasMatches; + + private static Predicate SkipCreatedAfter(IEventHandler eventHandler, DateTime? dateTimeUtc) + { + if (dateTimeUtc is null) + { + return _ => true; + } + + return gamePath => + { + var proceed = !File.Exists(gamePath.Full) || File.GetCreationTimeUtc(gamePath.Full) <= dateTimeUtc; + if (!proceed) + { + eventHandler.UninstallSkipModified(gamePath.Full); + } + return proceed; + }; + } + + private static Action EnsureNotCreatedAfter(DateTime dateTimeUtc) => gamePath => + { + if (File.Exists(gamePath.Full) && File.GetCreationTimeUtc(gamePath.Full) > dateTimeUtc) + { + File.SetCreationTimeUtc(gamePath.Full, dateTimeUtc); + } + }; + + private BootfilesMod CreateBootfilesMod(IReadOnlyCollection packages, IEventHandler eventHandler) + { + var bootfilesPackages = packages + .Where(_ => BootfilesManager.IsBootFiles(_.PackageName)); + switch (bootfilesPackages.Count()) + { + case 0: + eventHandler.ExtractingBootfiles(null); + return new BootfilesMod(installationFactory.GeneratedBootfilesInstaller()); + case 1: + var modPackage = bootfilesPackages.First(); + eventHandler.ExtractingBootfiles(modPackage.PackageName); + return new BootfilesMod(installationFactory.ModInstaller(modPackage)); + default: + var bootfilesPackageNames = bootfilesPackages.Select(_ => _.PackageName).ToImmutableList(); + eventHandler.ExtractingBootfilesErrorMultiple(bootfilesPackageNames); + throw new Exception("Too many bootfiles found"); + } + } + + private class BootfilesMod : IInstaller + { + private readonly IInstaller inner; + private bool postProcessingDone; + + public BootfilesMod(IInstaller inner) + { + this.inner = inner; + postProcessingDone = false; + } + + public string PackageName => inner.PackageName; + + public IInstallation.State Installed => + inner.Installed == IInstallation.State.Installed && !postProcessingDone + ? IInstallation.State.PartiallyInstalled + : inner.Installed; + + public IReadOnlyCollection InstalledFiles => inner.InstalledFiles; + + public int? PackageFsHash => inner.PackageFsHash; + + public ConfigEntries Install(string dstPath, ProcessingCallbacks callbacks) { + inner.Install(dstPath, callbacks); + return ConfigEntries.Empty; + } + + public void PostProcessing(string dstPath, IReadOnlyList modConfigs, IEventHandler eventHandler) + { + eventHandler.PostProcessingVehicles(); + PostProcessor.AppendCrdFileEntries(dstPath, modConfigs.SelectMany(_ => _.CrdFileEntries)); + eventHandler.PostProcessingTracks(); + PostProcessor.AppendTrdFileEntries(dstPath, modConfigs.SelectMany(_ => _.TrdFileEntries)); + eventHandler.PostProcessingDrivelines(); + PostProcessor.AppendDrivelineRecords(dstPath, modConfigs.SelectMany(_ => _.DrivelineRecords)); + postProcessingDone = true; + } + + public void Dispose() { + inner.Dispose(); + } + } +} \ No newline at end of file diff --git a/src/Core/ModManager.cs b/src/Core/ModManager.cs index ff35470..ce80f06 100644 --- a/src/Core/ModManager.cs +++ b/src/Core/ModManager.cs @@ -2,9 +2,7 @@ using Core.Mods; using Core.Utils; using Core.State; -using SevenZip; using static Core.IModManager; -using static Core.Mods.JsgmeFileInstaller; using Core.IO; namespace Core; @@ -12,30 +10,26 @@ namespace Core; internal class ModManager : IModManager { internal static readonly string FileRemovedByBootfiles = Path.Combine( - GeneratedBootfiles.PakfilesDirectory, - GeneratedBootfiles.PhysicsPersistentPakFileName + GeneratedBootfilesInstaller.PakfilesDirectory, + GeneratedBootfilesInstaller.PhysicsPersistentPakFileName ); - internal const string BootfilesPrefix = "__bootfiles"; - private readonly IGame game; private readonly IModRepository modRepository; - private readonly IModFactory modFactory; private readonly IStatePersistence statePersistence; private readonly ISafeFileDelete safeFileDelete; private readonly ITempDir tempDir; - public event LogHandler? Logs; - public event ProgressHandler? Progress; + private readonly IModInstaller modInstaller; - internal ModManager(IGame game, IModRepository modRepository, IModFactory modFactory, IStatePersistence statePersistence, ISafeFileDelete safeFileDelete, ITempDir tempDir) + internal ModManager(IGame game, IModRepository modRepository, IModInstaller modInstaller, IStatePersistence statePersistence, ISafeFileDelete safeFileDelete, ITempDir tempDir) { this.game = game; this.modRepository = modRepository; - this.modFactory = modFactory; this.statePersistence = statePersistence; this.safeFileDelete = safeFileDelete; this.tempDir = tempDir; + this.modInstaller = modInstaller; } private static void AddToEnvionmentPath(string additionalPath) @@ -56,7 +50,7 @@ public List FetchState() var disabledModPackages = modRepository.ListDisabledMods().ToDictionary(_ => _.PackageName); var availableModPackages = enabledModPackages.Merge(disabledModPackages); - var bootfilesFailed = installedMods.Where(kv => IsBootFiles(kv.Key) && (kv.Value?.Partial ?? false)).Any(); + var bootfilesFailed = installedMods.Where(kv => BootfilesManager.IsBootFiles(kv.Key) && (kv.Value?.Partial ?? false)).Any(); var isModInstalled = installedMods.SelectValues(modInstallationState => modInstallationState is null ? false : ((modInstallationState.Partial || bootfilesFailed) ? null : true) ); @@ -67,7 +61,7 @@ public List FetchState() }); var allPackageNames = installedMods.Keys - .Where(_ => !IsBootFiles(_)) + .Where(_ => !BootfilesManager.IsBootFiles(_)) .Concat(enabledModPackages.Keys) .Concat(disabledModPackages.Keys) .Distinct(); @@ -136,109 +130,66 @@ public string DisableMod(string packagePath) return modRepository.DisableMod(packagePath); } - public void InstallEnabledMods(CancellationToken cancellationToken = default) + public void InstallEnabledMods(IEventHandler eventHandler, CancellationToken cancellationToken = default) { CheckGameNotRunning(); // It shoulnd't be needed, but some systems seem to want to load oo2core // even when Mermaid and Kraken compression are not used in pak files! AddToEnvionmentPath(game.InstallationDirectory); - if (RestoreOriginalState(cancellationToken)) + // Clean what left by a previous failed installation + tempDir.Cleanup(); + if (RestoreOriginalState(eventHandler, cancellationToken)) { - // Clean what left by a previous failed installation - tempDir.Cleanup(); - - InstallAllModFiles(cancellationToken); - tempDir.Cleanup(); + InstallAllModFiles(eventHandler, cancellationToken); } + tempDir.Cleanup(); } - public void UninstallAllMods(CancellationToken cancellationToken = default) + public void UninstallAllMods(IEventHandler eventHandler, CancellationToken cancellationToken = default) { CheckGameNotRunning(); - RestoreOriginalState(cancellationToken); + RestoreOriginalState(eventHandler, cancellationToken); } - private bool RestoreOriginalState(CancellationToken cancellationToken) + private bool RestoreOriginalState(IEventHandler eventHandler, CancellationToken cancellationToken) { var previousInstallation = statePersistence.ReadState().Install; - if (previousInstallation.Mods.Any()) + var modsLeft = new Dictionary(previousInstallation.Mods); + try { - var modsLeft = new Dictionary(previousInstallation.Mods); - try - { - Logs?.Invoke($"Uninstalling mods:"); - foreach (var (modName, modInstallationState) in previousInstallation.Mods) + modInstaller.UninstallPackages( + previousInstallation, + game.InstallationDirectory, + modInstallation => { - if (cancellationToken.IsCancellationRequested) + if (modInstallation.Installed == IInstallation.State.NotInstalled) { - break; + modsLeft.Remove(modInstallation.PackageName); } - Logs?.Invoke($"- {modName}"); - var filesLeft = modInstallationState.Files.ToHashSet(); - try + else { - UninstallFiles(filesLeft, SkipCreatedAfter(previousInstallation.Time)); - } finally - { - if (filesLeft.Any()) - { - modsLeft[modName] = new InternalModInstallationState( - FsHash: null, - // Once partially uninstalled, it will stay that way unless fully uninstalled - Partial: modInstallationState.Partial || filesLeft.Count != modInstallationState.Files.Count, - Files: filesLeft - ); - } - else - { - modsLeft.Remove(modName); - } + modsLeft[modInstallation.PackageName] = new InternalModInstallationState( + FsHash: modInstallation.PackageFsHash, + Partial: modInstallation.Installed == IInstallation.State.PartiallyInstalled, + Files: modInstallation.InstalledFiles + ); } - } - } - finally - { - statePersistence.WriteState(new InternalState( - Install: new( - Time: modsLeft.Count > 0 ? previousInstallation.Time : null, - Mods: modsLeft - ) - )); - } - return !modsLeft.Any(); // Success if everything was uninstalled + }, + eventHandler, + cancellationToken); } - else - { - CheckNoBootfilesInstalled(); - Logs?.Invoke("No previously installed mods found. Skipping uninstall phase."); - return true; - } - } - - private void UninstallFiles(ISet files, BeforeFileCallback beforeFileCallback) => - JsgmeFileInstaller.UninstallFiles( - game.InstallationDirectory, - files, - beforeFileCallback, - p => files.Remove(p)); - - private BeforeFileCallback SkipCreatedAfter(DateTime? dateTimeUtc) - { - if (dateTimeUtc is null) + finally { - return _ => true; + statePersistence.WriteState(new InternalState( + Install: new( + Time: modsLeft.Any() ? previousInstallation.Time : null, + Mods: modsLeft + ) + )); } - - return path => - { - var include = File.GetCreationTimeUtc(path) <= dateTimeUtc; - if (!include) - { - Logs?.Invoke($" Skipping modified file {path}"); - } - return include; - }; + // Success if everything was uninstalled + return !modsLeft.Any(); } private void CheckGameNotRunning() @@ -249,90 +200,21 @@ private void CheckGameNotRunning() } } - private void CheckNoBootfilesInstalled() + private void InstallAllModFiles(IEventHandler eventHandler, CancellationToken cancellationToken) { - if (!File.Exists(Path.Combine(game.InstallationDirectory, FileRemovedByBootfiles))) - { - throw new Exception("Bootfiles installed by another tool (e.g. JSGME) have been detected. Please uninstall all mods."); - } - } - - private void InstallAllModFiles(CancellationToken cancellationToken) - { - var modPackages = modRepository.ListEnabledMods().Where(_ => !IsBootFiles(_.PackageName)).Reverse(); - var modConfigs = new List(); var installedFilesByMod = new Dictionary(); - var installedFiles = new HashSet(); - bool SkipAlreadyInstalled(string file) => installedFiles.Add(file.ToLowerInvariant()); try { - if (modPackages.Any()) - { - Logs?.Invoke("Installing mods:"); - // Increase by one in case bootfiles are needed and another one to show that something is happening - var progress = Percent.OfTotal(modPackages.Count() + 2); - Progress?.Invoke(progress.Increment()); - - foreach (var modPackage in modPackages) - { - if (cancellationToken.IsCancellationRequested) - { - break; - } - Logs?.Invoke($"- {modPackage.PackageName}"); - var mod = ExtractMod(modPackage); - try - { - var modConfig = mod.Install(game.InstallationDirectory, SkipAlreadyInstalled); - modConfigs.Add(modConfig); - } - finally - { - installedFilesByMod.Add(mod.PackageName, new( - FsHash: mod.PackageFsHash, - Partial: mod.Installed == IMod.InstalledState.PartiallyInstalled, - Files: mod.InstalledFiles - )); - } - Progress?.Invoke(progress.Increment()); - } - - if (modConfigs.Where(_ => _.NotEmpty()).Any()) - { - var bootfilesMod = BootfilesMod(); - var postProcessingDone = false; - try - { - bootfilesMod.Install(game.InstallationDirectory, SkipAlreadyInstalled); - Logs?.Invoke("Post-processing:"); - Logs?.Invoke("- Appending crd file entries"); - PostProcessor.AppendCrdFileEntries(game.InstallationDirectory, modConfigs.SelectMany(_ => _.CrdFileEntries)); - Logs?.Invoke("- Appending trd file entries"); - PostProcessor.AppendTrdFileEntries(game.InstallationDirectory, modConfigs.SelectMany(_ => _.TrdFileEntries)); - Logs?.Invoke("- Appending driveline records"); - PostProcessor.AppendDrivelineRecords(game.InstallationDirectory, modConfigs.SelectMany(_ => _.DrivelineRecords)); - postProcessingDone = true; - } - finally - { - installedFilesByMod.Add(bootfilesMod.PackageName, new( - FsHash: bootfilesMod.PackageFsHash, - Partial: bootfilesMod.Installed == IMod.InstalledState.PartiallyInstalled || !postProcessingDone, - Files: bootfilesMod.InstalledFiles - )); - } - } - else - { - Logs?.Invoke("Post-processing not required"); - } - Progress?.Invoke(progress.Increment()); - } - else - { - Logs?.Invoke($"No mod archives to install"); - Progress?.Invoke(1.0); - } + modInstaller.InstallPackages( + modRepository.ListEnabledMods(), + game.InstallationDirectory, + modInstallation => installedFilesByMod.Add(modInstallation.PackageName, new( + FsHash: modInstallation.PackageFsHash, + Partial: modInstallation.Installed == IInstallation.State.PartiallyInstalled, + Files: modInstallation.InstalledFiles + )), + eventHandler, + cancellationToken); } finally { @@ -344,37 +226,4 @@ private void InstallAllModFiles(CancellationToken cancellationToken) )); } } - - private static bool IsBootFiles(string packageName) => packageName.StartsWith(BootfilesPrefix); - - private IMod ExtractMod(ModPackage modPackage) - { - var extractionDir = Path.Combine(tempDir.BasePath, modPackage.PackageName); - using var extractor = new SevenZipExtractor(modPackage.FullPath); - extractor.ExtractArchive(extractionDir); - - return modFactory.ManualInstallMod(modPackage.PackageName, modPackage.FsHash, extractionDir); - } - - private IMod BootfilesMod() - { - var bootfilesPackages = modRepository.ListEnabledMods().Where(_ => IsBootFiles(_.PackageName)); - switch (bootfilesPackages.Count()) - { - case 0: - Logs?.Invoke("Extracting bootfiles from game"); - return modFactory.GeneratedBootfiles(tempDir.BasePath); - case 1: - var modPackage = bootfilesPackages.First(); - Logs?.Invoke($"Extracting bootfiles from {modPackage.PackageName}"); - return ExtractMod(modPackage); - default: - Logs?.Invoke("Multiple bootfiles found:"); - foreach (var bf in bootfilesPackages) - { - Logs?.Invoke($"- {bf.Name}"); - } - throw new Exception("Too many bootfiles found"); - } - } } \ No newline at end of file diff --git a/src/Core/Mods/BaseDirectoryInstaller.cs b/src/Core/Mods/BaseDirectoryInstaller.cs new file mode 100644 index 0000000..39c1e28 --- /dev/null +++ b/src/Core/Mods/BaseDirectoryInstaller.cs @@ -0,0 +1,32 @@ +namespace Core.Mods; + +internal abstract class BaseDirectoryInstaller : BaseInstaller +{ + private static readonly EnumerationOptions RecursiveEnumeration = new() + { + MatchType = MatchType.Win32, + IgnoreInaccessible = false, + AttributesToSkip = FileAttributes.Hidden | FileAttributes.System, + RecurseSubdirectories = true, + }; + + public BaseDirectoryInstaller(string packageName, int? packageFsHash, ITempDir tempDir, BaseInstaller.IConfig config) : + base(packageName, packageFsHash, tempDir, config) + { + } + + protected abstract DirectoryInfo Source { get; } + + protected override IEnumerable RelativeDirectoryPaths => + Source.EnumerateDirectories("*", RecursiveEnumeration) + .Select(_ => Path.GetRelativePath(Source.FullName, _.FullName)); + + protected override void InstalAllFiles(InstallBody body) + { + foreach (var fileInfo in Source.EnumerateFiles("*", RecursiveEnumeration)) + { + var relativePath = Path.GetRelativePath(Source.FullName, fileInfo.FullName); + body(relativePath, fileInfo); + } + } +} diff --git a/src/Core/Mods/BaseInstaller.cs b/src/Core/Mods/BaseInstaller.cs new file mode 100644 index 0000000..037f523 --- /dev/null +++ b/src/Core/Mods/BaseInstaller.cs @@ -0,0 +1,195 @@ +using Core.Utils; +using Microsoft.Extensions.FileSystemGlobbing; + +namespace Core.Mods; + +/// +/// +/// +/// Type used by the implementation during the install loop. +internal abstract class BaseInstaller : IInstaller +{ + protected readonly DirectoryInfo stagingDir; + + public string PackageName { get; } + public int? PackageFsHash { get; } + + public IInstallation.State Installed { get; private set; } + public IReadOnlyCollection InstalledFiles => installedFiles; + + private readonly IRootFinder rootFinder; + private readonly Matcher filesToConfigureMatcher; + private readonly List installedFiles = new(); + + internal BaseInstaller(string packageName, int? packageFsHash, ITempDir tempDir, BaseInstaller.IConfig config) + { + PackageName = packageName; + PackageFsHash = packageFsHash; + stagingDir = new DirectoryInfo(Path.Combine(tempDir.BasePath, packageName)); + rootFinder = new ContainedDirsRootFinder(config.DirsAtRoot); + filesToConfigureMatcher = Matchers.ExcludingPatterns(config.ExcludedFromConfig); + } + + public ConfigEntries Install(string dstPath, ProcessingCallbacks callbacks) + { + if (Installed != IInstallation.State.NotInstalled) + { + throw new InvalidOperationException(); + } + Installed = IInstallation.State.PartiallyInstalled; + + var rootPaths = rootFinder.FromDirectoryList(RelativeDirectoryPaths); + + InstalAllFiles((string pathInMod, TPassthrough context) => + { + var relativePathInMod = rootPaths.GetPathFromRoot(pathInMod); + // If not part of any game root + if (relativePathInMod is null) + { + // Config files only at the mod root + if (!pathInMod.Contains(Path.DirectorySeparatorChar)) + { + var dstPath = new RootedPath(stagingDir.FullName, pathInMod); + Directory.GetParent(dstPath.Full)?.Create(); + InstallFile(dstPath, context); + } + return; + } + + var (relativePath, removeFile) = NeedsRemoving(relativePathInMod); + + var gamePath = new RootedPath(dstPath, relativePath); + if (callbacks.Accept(gamePath)) + { + callbacks.Before(gamePath); + if (!removeFile) + { + Directory.GetParent(gamePath.Full)?.Create(); + InstallFile(gamePath, context); + } + installedFiles.Add(gamePath.Relative); + callbacks.After(gamePath); + } + else + { + callbacks.NotAccepted(gamePath); + } + }); + + Installed = IInstallation.State.Installed; + + return GenerateConfig(); + } + + /// + /// Mod directories, relative to the source root. + /// + protected abstract IEnumerable RelativeDirectoryPaths { get; } + + /// + /// Installation loop. + /// + /// Function to call for each file. + protected abstract void InstalAllFiles(InstallBody body); + + protected delegate void InstallBody(string relativePathInMod, TPassthrough context); + + protected abstract void InstallFile(RootedPath destinationPath, TPassthrough context); + + protected static (string, bool) NeedsRemoving(string filePath) + { + return filePath.EndsWith(BaseInstaller.RemoveFileSuffix) ? + (filePath.RemoveSuffix(BaseInstaller.RemoveFileSuffix).Trim(), true) : + (filePath, false); + } + + private ConfigEntries GenerateConfig() + { + var gameSupportedMod = FileEntriesToConfigure() + .Where(p => p.StartsWith(BaseInstaller.GameSupportedModDirectory)) + .Any(); + return gameSupportedMod + ? ConfigEntries.Empty + : new(CrdFileEntries(), TrdFileEntries(), FindDrivelineRecords()); + } + + private List CrdFileEntries() => + FileEntriesToConfigure() + .Where(p => p.EndsWith(".crd")) + .ToList(); + + private List TrdFileEntries() => + FileEntriesToConfigure() + .Where(p => p.EndsWith(".trd")) + .Select(fp => $"{Path.GetDirectoryName(fp)}{Path.DirectorySeparatorChar}@{Path.GetFileName(fp)}") + .ToList(); + + private IEnumerable FileEntriesToConfigure() => + installedFiles.Where(_ => filesToConfigureMatcher.Match(_).HasMatches); + + private List FindDrivelineRecords() + { + var recordBlocks = new List(); + if (stagingDir.Exists) + { + foreach (var fileAtModRoot in stagingDir.EnumerateFiles()) + { + var recordIndent = -1; + var recordLines = new List(); + foreach (var line in File.ReadAllLines(fileAtModRoot.FullName)) + { + // Read each line until we find one with RECORD + if (recordIndent < 0) + { + recordIndent = line.IndexOf("RECORD", StringComparison.InvariantCulture); + } + if (recordIndent < 0) + { + continue; + } + + // Once it finds a blank line, create a record block and start over + if (string.IsNullOrWhiteSpace(line)) + { + recordBlocks.Add(string.Join(Environment.NewLine, recordLines)); + recordIndent = -1; + recordLines.Clear(); + continue; + } + + // Otherwise add the line to the current record lines + var lineNoIndent = line.Substring(recordIndent).TrimEnd(); + recordLines.Add(lineNoIndent); + } + + // Create a record block also if the file finshed on a record line + if (recordIndent >= 0) + { + recordBlocks.Add(string.Join(Environment.NewLine, recordLines)); + } + } + } + + return recordBlocks; + } + + public abstract void Dispose(); +} + +public static class BaseInstaller +{ + public interface IConfig + { + IEnumerable DirsAtRoot + { + get; + } + IEnumerable ExcludedFromConfig + { + get; + } + } + + public const string RemoveFileSuffix = "-remove"; + public static readonly string GameSupportedModDirectory = Path.Combine("UserData", "Mods"); +} \ No newline at end of file diff --git a/src/Core/Mods/ContainedDirsRootFinder.cs b/src/Core/Mods/ContainedDirsRootFinder.cs new file mode 100644 index 0000000..285ade6 --- /dev/null +++ b/src/Core/Mods/ContainedDirsRootFinder.cs @@ -0,0 +1,34 @@ +using System.Collections.Immutable; + +namespace Core.Mods; + +internal class ContainedDirsRootFinder : IRootFinder +{ + private readonly ImmutableHashSet rootDirs; + + internal ContainedDirsRootFinder(IEnumerable rootDirs) + { + this.rootDirs = rootDirs.ToImmutableHashSet(StringComparer.InvariantCultureIgnoreCase); + } + + // Simplistic implementation. Not the best performance, but short and readable. + public IRootFinder.RootPaths FromDirectoryList(IEnumerable directories) => + directories + .SelectMany(file => + { + var dirSegments = file.Split(Path.DirectorySeparatorChar); + var segmentsUntilRoot = dirSegments.TakeWhile(_ => !rootDirs.Contains(_)); + if (dirSegments.Length == segmentsUntilRoot.Count()) + { + return Array.Empty(); + } + else + { + return new[] { Path.Join(segmentsUntilRoot.ToArray()) }; + } + }) + .ToImmutableSortedSet() + .Aggregate(new IRootFinder.RootPaths(), (roots, potentialRoot) => + roots.AddIfAncestorNotPresent(potentialRoot) + ); +} \ No newline at end of file diff --git a/src/Core/Mods/ExtractedMod.cs b/src/Core/Mods/ExtractedMod.cs deleted file mode 100644 index 8f95998..0000000 --- a/src/Core/Mods/ExtractedMod.cs +++ /dev/null @@ -1,69 +0,0 @@ -namespace Core.Mods; - -public abstract class ExtractedMod : IMod -{ - protected readonly string extractedPath; - protected readonly List installedFiles = new(); - - internal ExtractedMod(string packageName, int? packageFsHash, string extractedPath) - { - PackageName = packageName; - PackageFsHash = packageFsHash; - this.extractedPath = extractedPath; - } - - public string PackageName - { - get; - } - - public int? PackageFsHash - { - get; - } - - public IMod.InstalledState Installed - { - get; - private set; - } - - public IReadOnlyCollection InstalledFiles => installedFiles; - - public ConfigEntries Install(string dstPath, JsgmeFileInstaller.BeforeFileCallback beforeFileCallback) - { - if (Installed != IMod.InstalledState.NotInstalled) - { - throw new InvalidOperationException(); - } - Installed = IMod.InstalledState.PartiallyInstalled; - - var now = DateTime.UtcNow; - foreach (var rootPath in ExtractedRootDirs()) - { - JsgmeFileInstaller.InstallFiles(rootPath, dstPath, - relativePath => - FileShouldBeInstalled(relativePath) && - beforeFileCallback(relativePath), - relativePath => - { - installedFiles.Add(relativePath); - var fullPath = Path.Combine(dstPath, relativePath); - if (File.Exists(fullPath) && File.GetCreationTimeUtc(fullPath) > now) - { - File.SetCreationTimeUtc(fullPath, now); - } - } - ); - } - Installed = IMod.InstalledState.Installed; - - return GenerateConfig(); - } - - protected abstract IEnumerable ExtractedRootDirs(); - - protected abstract ConfigEntries GenerateConfig(); - - protected virtual bool FileShouldBeInstalled(string relativePath) => true; -} \ No newline at end of file diff --git a/src/Core/Mods/GeneratedBootfiles.cs b/src/Core/Mods/GeneratedBootfilesInstaller.cs similarity index 66% rename from src/Core/Mods/GeneratedBootfiles.cs rename to src/Core/Mods/GeneratedBootfilesInstaller.cs index 11106b9..67dd9ee 100644 --- a/src/Core/Mods/GeneratedBootfiles.cs +++ b/src/Core/Mods/GeneratedBootfilesInstaller.cs @@ -1,9 +1,10 @@ -using PCarsTools; +using Core.Games; using PCarsTools.Encryption; +using PCarsTools; namespace Core.Mods; -internal class GeneratedBootfiles : ExtractedMod +internal class GeneratedBootfilesInstaller : BaseDirectoryInstaller { internal const string VirtualPackageName = "__bootfiles_generated"; internal const string PakfilesDirectory = "Pakfiles"; @@ -15,18 +16,31 @@ internal class GeneratedBootfiles : ExtractedMod private readonly string BmtFilesWildcard = Path.Combine("vehicles", "_data", "effects", "backfire", "*.bmt"); - public GeneratedBootfiles(string gamePath, string generationBasePath) - : base(VirtualPackageName, null, Path.Combine(generationBasePath, VirtualPackageName)) + public GeneratedBootfilesInstaller(ITempDir tempDir, BaseInstaller.IConfig config, IGame game) : + base(VirtualPackageName, null, tempDir, config) { - pakPath = Path.Combine(gamePath, PakfilesDirectory); + pakPath = Path.Combine(game.InstallationDirectory, PakfilesDirectory); GenerateBootfiles(); } + protected override DirectoryInfo Source => stagingDir; + + protected override void InstallFile(RootedPath destinationPath, FileInfo fileInfo) + { + File.Move(fileInfo.FullName, destinationPath.Full); + } + + public override void Dispose() + { + } + + #region Bootfiles Generation + private void GenerateBootfiles() { ExtractPakFileFromGame(BootFlowPakFileName); ExtractPakFileFromGame(PhysicsPersistentPakFileName); - CreateEmptyFile(ExtractedPakPath($"{PhysicsPersistentPakFileName}{JsgmeFileInstaller.RemoveFileSuffix}")); + CreateEmptyFile(ExtractedPakPath($"{PhysicsPersistentPakFileName}{BaseInstaller.RemoveFileSuffix}")); File.Copy(Path.Combine(pakPath, BootSplashPakFileName), ExtractedPakPath(BootFlowPakFileName)); DeleteFromExtractedFiles(BmtFilesWildcard); } @@ -36,11 +50,11 @@ private void ExtractPakFileFromGame(string fileName) var filePath = Path.Combine(pakPath, fileName); BPakFileEncryption.SetKeyset(KeysetType.PC2AndAbove); using var pakFile = BPakFile.FromFile(filePath, withExtraInfo: true, outputWriter: TextWriter.Null); - pakFile.UnpackAll(extractedPath); + pakFile.UnpackAll(stagingDir.FullName); } private string ExtractedPakPath(string name) => - Path.Combine(extractedPath, PakfilesDirectory, name); + Path.Combine(stagingDir.FullName, PakfilesDirectory, name); private void CreateEmptyFile(string path) { @@ -57,13 +71,11 @@ private void CreateParentDirectory(string path) private void DeleteFromExtractedFiles(string wildcardRelative) { - foreach (var file in Directory.EnumerateFiles(extractedPath, wildcardRelative)) + foreach (var file in Directory.EnumerateFiles(stagingDir.FullName, wildcardRelative)) { File.Delete(file); } } - protected override IEnumerable ExtractedRootDirs() => new[] { extractedPath }; - - protected override ConfigEntries GenerateConfig() => ConfigEntries.Empty; + #endregion } diff --git a/src/Core/Mods/IInstallation.cs b/src/Core/Mods/IInstallation.cs new file mode 100644 index 0000000..0279650 --- /dev/null +++ b/src/Core/Mods/IInstallation.cs @@ -0,0 +1,16 @@ +namespace Core.Mods; + +public interface IInstallation +{ + string PackageName { get; } + int? PackageFsHash { get; } + + IReadOnlyCollection InstalledFiles { get; } + State Installed { get; } + enum State + { + NotInstalled = 0, + PartiallyInstalled = 1, + Installed = 2 + } +} \ No newline at end of file diff --git a/src/Core/Mods/IInstaller.cs b/src/Core/Mods/IInstaller.cs new file mode 100644 index 0000000..b66a744 --- /dev/null +++ b/src/Core/Mods/IInstaller.cs @@ -0,0 +1,6 @@ +namespace Core.Mods; + +public interface IInstaller : IInstallation, IDisposable +{ + ConfigEntries Install(string dstPath, ProcessingCallbacks callbacks); +} diff --git a/src/Core/Mods/IMod.cs b/src/Core/Mods/IMod.cs deleted file mode 100644 index 4cbb15a..0000000 --- a/src/Core/Mods/IMod.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Core.Mods; - -public interface IMod -{ - string PackageName { get; } - InstalledState Installed { get; } - IReadOnlyCollection InstalledFiles { get; } - int? PackageFsHash { get; } - - ConfigEntries Install(string dstPath, JsgmeFileInstaller.BeforeFileCallback beforeFileCallback); - - public enum InstalledState - { - NotInstalled = 0, - PartiallyInstalled = 1, - Installed = 2 - } -} diff --git a/src/Core/Mods/IModRepository.cs b/src/Core/Mods/IModRepository.cs index c1a994f..d726e45 100644 --- a/src/Core/Mods/IModRepository.cs +++ b/src/Core/Mods/IModRepository.cs @@ -14,7 +14,7 @@ public record ModPackage ( string Name, string PackageName, // TODO: rename to ID - string FullPath, // TODO: remove once all references are gone + string FullPath, bool Enabled, int FsHash ); \ No newline at end of file diff --git a/src/Core/Mods/IRootFinder.cs b/src/Core/Mods/IRootFinder.cs new file mode 100644 index 0000000..1b850df --- /dev/null +++ b/src/Core/Mods/IRootFinder.cs @@ -0,0 +1,42 @@ +namespace Core.Mods; + +public interface IRootFinder +{ + RootPaths FromDirectoryList(IEnumerable directories); + + /// + /// This could be made mutable using a builder pattern, but it wasn't + /// because of how simple it is and how it is used in the software. + /// + public record RootPaths() + { + internal List Roots = new(); + + public string? GetPathFromRoot(string path) + { + foreach (var root in Roots) + { + // Empty path is ancestor of any path + if (root.Length == 0) + { + return path; + } + // Adding directory separator prevents substring match in directory name + if ($"{path}{Path.DirectorySeparatorChar}".StartsWith($"{root}{Path.DirectorySeparatorChar}")) + { + return Path.GetRelativePath(root, path); + } + } + return null; + } + + public RootPaths AddIfAncestorNotPresent(string path) + { + if (GetPathFromRoot(path) is null) + { + Roots.Add(path); + } + return this; + } + } +} \ No newline at end of file diff --git a/src/Core/Mods/JsgmeFileInstaller.cs b/src/Core/Mods/JsgmeFileInstaller.cs deleted file mode 100644 index 41a8cf2..0000000 --- a/src/Core/Mods/JsgmeFileInstaller.cs +++ /dev/null @@ -1,170 +0,0 @@ -using Core.Utils; - -namespace Core.Mods; - -public static class JsgmeFileInstaller -{ - public delegate void AfterFileCallback(string relativePath); - public delegate bool BeforeFileCallback(string relativePath); - - private const string BackupFileSuffix = ".orig"; - public const string RemoveFileSuffix = "-remove"; - - private static readonly string[] ExcludeCopySuffix = - { - BackupFileSuffix - }; - - /// - /// Install mod directory. - /// - /// Directory containing extracted mod archive - /// Game directory - /// Function to decide if a file should be installed - /// Callback to allow partial file installation to be detected - public static void InstallFiles(string srcPath, string dstPath, BeforeFileCallback beforeFileCallback, AfterFileCallback afterFileCallback) => - RecursiveMoveWithBackup(srcPath, dstPath, - absoluteSrcFilePath => beforeFileCallback(Path.GetRelativePath(srcPath, absoluteSrcFilePath)), - absoluteSrcFilePath => afterFileCallback(Path.GetRelativePath(srcPath, absoluteSrcFilePath)) - ); - - private static void RecursiveMoveWithBackup(string srcPath, string dstPath, BeforeFileCallback beforeFileCallback, AfterFileCallback afterFileCallback) - { - if (!Directory.Exists(dstPath)) - { - Directory.CreateDirectory(dstPath); - } - - foreach (var maybeSrcSubPath in Directory.GetFileSystemEntries(srcPath)) - { - var (srcSubPath, remove) = NeedsRemoving(maybeSrcSubPath); - - var localName = Path.GetFileName(srcSubPath); - if (ExcludeCopySuffix.Any(suffix => localName.EndsWith(suffix))) - { - // TODO message: blacklisted - continue; - } - - var dstSubPath = Path.Combine(dstPath, localName); - if (Directory.Exists(srcSubPath)) // Is directory - { - RecursiveMoveWithBackup(srcSubPath, dstSubPath, beforeFileCallback, afterFileCallback); - continue; - } - - if (!beforeFileCallback(srcSubPath)) - continue; - - if (File.Exists(dstSubPath)) - { - BackupFile(dstSubPath); - } - - if (!remove) - { - File.Move(srcSubPath, dstSubPath); - } - - afterFileCallback(srcSubPath); - } - } - - private static (string, bool) NeedsRemoving(string filePath) - { - return filePath.EndsWith(RemoveFileSuffix) ? - (filePath.RemoveSuffix(RemoveFileSuffix).Trim(), true) : - (filePath, false); - } - - private static void BackupFile(string path) - { - var backupFile = BackupFileName(path); - if (File.Exists(backupFile)) - { - // TODO message: overwriting already installed file - File.Delete(path); - } - else - { - File.Move(path, backupFile); - } - } - - /// - /// Uninstall mod files. - /// - /// Game directory - /// Perviously installed mod files - /// Function to decide if a file backup should be restored - /// It is called for each uninstalled file - public static void UninstallFiles(string dstPath, IEnumerable files, BeforeFileCallback beforeFileCallback, AfterFileCallback afterFileCallback) - { - var fileList = files.ToList(); // It must be enumerated twice - foreach (var file in fileList) - { - var path = Path.Combine(dstPath, file); - // Some mods have duplicate entries, so files might have been removed already - if (File.Exists(path)) - { - if (!beforeFileCallback(path)) - { - DeleteBackup(path); - afterFileCallback(file); - continue; - } - File.Delete(path); - } - - RestoreFile(path); - afterFileCallback(file); - } - DeleteEmptyDirectories(dstPath, fileList); - } - - private static void DeleteEmptyDirectories(string dstRootPath, IEnumerable filePaths) { - var dirs = filePaths - .Select(file => Path.Combine(dstRootPath, file)) - .SelectMany(dstFilePath => AncestorsUpTo(dstRootPath, dstFilePath)) - .Distinct() - .OrderByDescending(name => name.Length); - foreach (var dir in dirs) - { - // Some mods have duplicate entries, so files might have been removed already - if (Directory.Exists(dir) && !Directory.EnumerateFileSystemEntries(dir).Any()) - { - Directory.Delete(dir); - } - } - } - - private static IEnumerable AncestorsUpTo(string root, string path) - { - var ancestors = new List(); - for (var dir = Directory.GetParent(path); dir is not null && dir.FullName != root; dir = dir.Parent) - { - ancestors.Add(dir.FullName); - } - return ancestors; - } - - private static void RestoreFile(string path) - { - var backupFilePath = BackupFileName(path); - if (File.Exists(backupFilePath)) - { - File.Move(backupFilePath, path); - } - } - - private static void DeleteBackup(string path) - { - var backupFilePath = BackupFileName(path); - if (File.Exists(backupFilePath)) - { - File.Delete(backupFilePath); - } - } - - private static string BackupFileName(string originalFileName) => $"{originalFileName}{BackupFileSuffix}"; -} \ No newline at end of file diff --git a/src/Core/Mods/ManualInstallMod.cs b/src/Core/Mods/ManualInstallMod.cs deleted file mode 100644 index 666e3bb..0000000 --- a/src/Core/Mods/ManualInstallMod.cs +++ /dev/null @@ -1,122 +0,0 @@ -using Microsoft.Extensions.FileSystemGlobbing; - -namespace Core.Mods; - -public class ManualInstallMod : ExtractedMod -{ - internal static readonly string GameSupportedModDirectory = Path.Combine("UserData", "Mods"); - - public interface IConfig - { - IEnumerable DirsAtRoot { get; } - IEnumerable ExcludedFromInstall { get; } - IEnumerable ExcludedFromConfig { get; } - } - - private readonly Matcher filesToInstallMatcher; - private readonly Matcher filesToConfigureMatcher; - private readonly List dirsAtRootLowerCase; - - internal ManualInstallMod(string packageName, int packageFsHash, string extractedPath, IConfig config) - : base(packageName, packageFsHash, extractedPath) - { - dirsAtRootLowerCase = config.DirsAtRoot.Select(dir => dir.ToLowerInvariant()).ToList(); - filesToInstallMatcher = MatcherExcluding(config.ExcludedFromInstall); - filesToConfigureMatcher = MatcherExcluding(config.ExcludedFromConfig); - } - - private static Matcher MatcherExcluding(IEnumerable exclusions) - { - var matcher = new Matcher(); - matcher.AddInclude(@"**\*"); - matcher.AddExcludePatterns(exclusions); - return matcher; - } - - protected override IEnumerable ExtractedRootDirs() - { - return FindRootContaining(extractedPath, dirsAtRootLowerCase); - } - - private static List FindRootContaining(string path, IEnumerable contained) - { - var roots = new List(); - foreach (var subdir in Directory.GetDirectories(path)) - { - var localName = Path.GetFileName(subdir).ToLowerInvariant(); - if (contained.Contains(localName)) - { - return new List { path }; - } - roots.AddRange(FindRootContaining(subdir, contained)); - } - - return roots; - } - - protected override ConfigEntries GenerateConfig() - { - var gameSupportedMod = FileEntriesToConfigure() - .Where(p => p.StartsWith(GameSupportedModDirectory)) - .Any(); - return gameSupportedMod - ? ConfigEntries.Empty - : new(CrdFileEntries(), TrdFileEntries(), FindDrivelineRecords()); - } - - private List CrdFileEntries() => - FileEntriesToConfigure() - .Where(p => p.EndsWith(".crd")) - .ToList(); - - private List TrdFileEntries() => - FileEntriesToConfigure() - .Where(p => p.EndsWith(".trd")) - .Select(fp => $"{Path.GetDirectoryName(fp)}{Path.DirectorySeparatorChar}@{Path.GetFileName(fp)}") - .ToList(); - - private IEnumerable FileEntriesToConfigure() => - installedFiles.Where(_ => filesToConfigureMatcher.Match(_).HasMatches); - - private List FindDrivelineRecords() - { - var recordBlocks = new List(); - foreach (var fileAtModRoot in Directory.EnumerateFiles(extractedPath)) - { - var recordIndent = -1; - var recordLines = new List(); - foreach (var line in File.ReadAllLines(fileAtModRoot)) - { - if (recordIndent < 0) - { - recordIndent = line.IndexOf("RECORD", StringComparison.InvariantCulture); - } - - if (recordIndent < 0) - { - continue; - } - - if (string.IsNullOrWhiteSpace(line)) - { - recordIndent = -1; - recordBlocks.Add(string.Join(Environment.NewLine, recordLines)); - recordLines.Clear(); - continue; - } - var lineNoIndent = line.Substring(recordIndent).TrimEnd(); - recordLines.Add(lineNoIndent); - } - - if (recordIndent >= 0) - { - recordBlocks.Add(string.Join(Environment.NewLine, recordLines)); - } - } - - return recordBlocks; - } - - protected override bool FileShouldBeInstalled(string relativePath) => - filesToInstallMatcher.Match(relativePath).HasMatches; -} \ No newline at end of file diff --git a/src/Core/Mods/ModArchiveInstaller.cs b/src/Core/Mods/ModArchiveInstaller.cs new file mode 100644 index 0000000..9fd9cb4 --- /dev/null +++ b/src/Core/Mods/ModArchiveInstaller.cs @@ -0,0 +1,76 @@ +using LibArchive.Net; + +namespace Core.Mods; + +internal class ModArchiveInstaller : BaseInstaller +{ + private const uint BlockSize = 1 << 23; + private const int CopyBufferSize = 1 << 27; + + private readonly string archivePath; + + public ModArchiveInstaller(string packageName, int? packageFsHash, ITempDir tempDir, BaseInstaller.IConfig config, string archivePath) : + base(packageName, packageFsHash, tempDir, config) + { + this.archivePath = archivePath; + } + + public override void Dispose() + { + } + + // LibArchive.Net is a mere wrapper around libarchive. It's better to avoid using + // LINQ expressions as they can lead to or + // being called out of order. + + protected override IEnumerable RelativeDirectoryPaths { + get + { + using var reader = new LibArchiveReader(archivePath, BlockSize); + var ret = new HashSet(); + foreach (var entry in reader.Entries()) + { + // Not all archive types store directories as separate entries + // We consider files and find their parent directory + if (!entry.IsRegularFile) + { + continue; + } + var normalizedPath = Path.GetDirectoryName(entry.Name); + if (normalizedPath is not null) { + ret.Add(normalizedPath); + } + } + return ret; + } + } + + protected override void InstalAllFiles(InstallBody body) + { + using var reader = new LibArchiveReader(archivePath, BlockSize); + foreach (var entry in reader.Entries()) + { + if (entry.IsRegularFile) { + var normalizedPath = NormalizePathSeparator(entry.Name); + body(normalizedPath, entry.Stream); + } + } + } + + /// + /// Make sure that paths never end with a directory separator and always + /// use the standard separator, and not the alternative. + /// + private static string NormalizePathSeparator(string path) => + Path.TrimEndingDirectorySeparator(path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar)); + + protected override void InstallFile(RootedPath? destinationPath, Stream stream) + { + if (destinationPath is not null) + { + Directory.GetParent(destinationPath.Full)?.Create(); + using var destinationStream = new FileStream(destinationPath.Full, FileMode.Create); + stream.CopyTo(destinationStream, CopyBufferSize); + } + } +} diff --git a/src/Core/Mods/ModInstallation.cs b/src/Core/Mods/ModInstallation.cs new file mode 100644 index 0000000..7d281c9 --- /dev/null +++ b/src/Core/Mods/ModInstallation.cs @@ -0,0 +1,9 @@ +namespace Core.Mods; + +internal record ModInstallation +( + string PackageName, + IInstallation.State Installed, + IReadOnlyCollection InstalledFiles, + int? PackageFsHash +) : IInstallation; \ No newline at end of file diff --git a/src/Core/Mods/ModRepository.cs b/src/Core/Mods/ModRepository.cs index a2121b8..860423b 100644 --- a/src/Core/Mods/ModRepository.cs +++ b/src/Core/Mods/ModRepository.cs @@ -1,6 +1,6 @@ namespace Core.Mods; -internal class ModRepository : IModRepository +public class ModRepository : IModRepository { private const string EnabledModsDirName = "Enabled"; private const string DisabledModsSubdir = "Disabled"; @@ -60,7 +60,7 @@ private IReadOnlyCollection ListMods(string rootPath) MatchType = MatchType.Win32, IgnoreInaccessible = false, AttributesToSkip = FileAttributes.Hidden | FileAttributes.System, - MaxRecursionDepth = 0, + RecurseSubdirectories = false, }; return directoryInfo.GetFiles("*", options) .Select(fileInfo => ModPackageFrom(rootPath, fileInfo)) diff --git a/src/Core/Mods/PostProcessor.cs b/src/Core/Mods/PostProcessor.cs index e5a0505..3b46477 100644 --- a/src/Core/Mods/PostProcessor.cs +++ b/src/Core/Mods/PostProcessor.cs @@ -4,9 +4,9 @@ namespace Core.Mods; internal static class PostProcessor { - internal readonly static string VehicleListRelativePath = Path.Combine("vehicles", "vehiclelist.lst"); - internal readonly static string TrackListRelativePath = Path.Combine("tracks", "_data", "tracklist.lst"); - internal readonly static string DrivelineRelativePath = Path.Combine("vehicles", "physics", "driveline", "driveline.rg"); + internal static readonly string VehicleListRelativePath = Path.Combine("vehicles", "vehiclelist.lst"); + internal static readonly string TrackListRelativePath = Path.Combine("tracks", "_data", "tracklist.lst"); + internal static readonly string DrivelineRelativePath = Path.Combine("vehicles", "physics", "driveline", "driveline.rg"); public static void AppendCrdFileEntries(string gamePath, IEnumerable crdFileEntries) { diff --git a/src/Core/Mods/ProcessingCallbacks.cs b/src/Core/Mods/ProcessingCallbacks.cs new file mode 100644 index 0000000..01f57d9 --- /dev/null +++ b/src/Core/Mods/ProcessingCallbacks.cs @@ -0,0 +1,89 @@ +namespace Core.Mods; + +public struct ProcessingCallbacks +{ + private static readonly Predicate EmptyPredicate = _ => true; + private static readonly Action EmptyAction = _ => { }; + + private Predicate? accept; + /// + /// Decide if an entry should be processed. + /// + public Predicate Accept + { + get => accept ?? EmptyPredicate; + set => accept = value; + } + + private Action? before; + /// + /// Called before processing an entry. + /// + public Action Before + { + get => before ?? EmptyAction; + set => before = value; + } + + private Action? after; + /// + /// Called after processing an entry. + /// + public Action After + { + get => after ?? EmptyAction; + set => after = value; + } + + private Action? notAccepted; + /// + /// Called if not processing an entry. + /// + public Action NotAccepted + { + get => notAccepted ?? EmptyAction; + set => notAccepted = value; + } + + public ProcessingCallbacks AndAccept(Predicate additional) => + new() + { + accept = Combine(accept, additional), + before = before, + after = after, + notAccepted = notAccepted + }; + + public ProcessingCallbacks AndBefore(Action additional) => + new() + { + accept = accept, + before = Combine(before, additional), + after = after, + notAccepted = notAccepted + }; + + public ProcessingCallbacks AndAfter(Action additional) => + new() + { + accept = accept, + before = before, + after = Combine(after, additional), + notAccepted = notAccepted + }; + + public ProcessingCallbacks AndNotAccepted(Action additional) => + new() + { + accept = accept, + before = before, + after = after, + notAccepted = Combine(notAccepted, additional) + }; + + private static Predicate? Combine(Predicate? p1, Predicate p2) => + p1 is null ? p2 : key => p1(key) && p2(key); + + private static Action? Combine(Action? a1, Action a2) => + a1 is null ? a2 : key => { a1(key); a2(key); }; +} \ No newline at end of file diff --git a/src/Core/Mods/RootedPath.cs b/src/Core/Mods/RootedPath.cs new file mode 100644 index 0000000..76dbc77 --- /dev/null +++ b/src/Core/Mods/RootedPath.cs @@ -0,0 +1,13 @@ +namespace Core.Mods; + +public class RootedPath +{ + public string Relative { get; } + public string Full { get; } + + public RootedPath(string rootPath, string relativePath) + { + Relative = relativePath; + Full = Path.Combine(rootPath, relativePath); + } +} diff --git a/src/Core/Utils/IPercent.cs b/src/Core/Utils/IPercent.cs new file mode 100644 index 0000000..7bb6c54 --- /dev/null +++ b/src/Core/Utils/IPercent.cs @@ -0,0 +1,9 @@ +namespace Core.Utils; + +public interface IPercent +{ + /// + /// Value guaranteed to be between 0.0 and 1.0 + /// + public double Percent { get; } +} \ No newline at end of file diff --git a/src/Core/Utils/Matchers.cs b/src/Core/Utils/Matchers.cs new file mode 100644 index 0000000..501e0cf --- /dev/null +++ b/src/Core/Utils/Matchers.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.FileSystemGlobbing; + +namespace Core.Utils; + +internal static class Matchers +{ + public static Matcher ExcludingPatterns(IEnumerable exclusions) + { + var matcher = new Matcher(); + matcher.AddInclude(@"**\*"); + matcher.AddExcludePatterns(exclusions); + return matcher; + } +} diff --git a/src/Core/Utils/Percent.cs b/src/Core/Utils/Percent.cs deleted file mode 100644 index 3bc7cce..0000000 --- a/src/Core/Utils/Percent.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace Core.Utils; - -public class Percent -{ - private readonly double total; - private double done; - - public static Percent OfTotal(int total) => new(total); - - private Percent(double total) - { - this.total = total; - } - - public double Increment() - { - done += 1.0; - return done / total; - } -} diff --git a/src/Core/Utils/PercentOfTotal.cs b/src/Core/Utils/PercentOfTotal.cs new file mode 100644 index 0000000..f5a28d2 --- /dev/null +++ b/src/Core/Utils/PercentOfTotal.cs @@ -0,0 +1,30 @@ +namespace Core.Utils; + +public class PercentOfTotal : IPercent +{ + private readonly double total; + private double done; + + public PercentOfTotal(int total) + { + this.total = total; + } + + public double Percent => done / total; + + public PercentOfTotal IncrementDone() + { + done += 1.0; + if (done > total) + { + done = total; + } + return this; + } + + public PercentOfTotal DoneAll() + { + done = total; + return this; + } +} \ No newline at end of file diff --git a/src/GUI/App.xaml.cs b/src/GUI/App.xaml.cs index 28a7ca6..8b490a6 100644 --- a/src/GUI/App.xaml.cs +++ b/src/GUI/App.xaml.cs @@ -1,6 +1,7 @@ using Core; using Core.SoftwareUpdates; using Microsoft.UI.Xaml; +using static Core.IModManager; namespace AMS2CM.GUI; @@ -54,23 +55,12 @@ public ThrowingModManager(Exception ex) this.ex = ex; } - public event IModManager.LogHandler? Logs { - add => throw ex; - remove => throw ex; - } - - public event IModManager.ProgressHandler? Progress - { - add => throw ex; - remove => throw ex; - } - public string DisableMod(string packagePath) => throw ex; public string EnableMod(string packagePath) => throw ex; public ModState AddNewMod(string packagePath) => throw ex; public void DeleteMod(string packagePath) => throw ex; public List FetchState() => throw ex; - public void InstallEnabledMods(CancellationToken cancellationToken) => throw ex; - public void UninstallAllMods(CancellationToken cancellationToken) => throw ex; + public void InstallEnabledMods(IEventHandler eventHandler, CancellationToken cancellationToken) => throw ex; + public void UninstallAllMods(IEventHandler eventHandler, CancellationToken cancellationToken) => throw ex; } } diff --git a/src/GUI/GUI.csproj b/src/GUI/GUI.csproj index 8f269e2..fcd0bd8 100644 --- a/src/GUI/GUI.csproj +++ b/src/GUI/GUI.csproj @@ -62,17 +62,8 @@ - - - - - - - - - - + diff --git a/src/GUI/MainWindow.xaml.cs b/src/GUI/MainWindow.xaml.cs index cb8184a..910a36f 100644 --- a/src/GUI/MainWindow.xaml.cs +++ b/src/GUI/MainWindow.xaml.cs @@ -1,5 +1,4 @@ using System.Collections.ObjectModel; -using Microsoft.VisualBasic.FileIO; using Core; using Microsoft.UI.Xaml; using Windows.ApplicationModel.DataTransfer; @@ -43,11 +42,8 @@ private async void ApplyButton_Click(Microsoft.UI.Xaml.Controls.SplitButton send { await SyncDialog.ShowAsync(Content.XamlRoot, (dialog, cancellationToken) => { - modManager.Logs += dialog.LogMessage; - modManager.Progress += dialog.SetProgress; - modManager.InstallEnabledMods(cancellationToken); - modManager.Progress -= dialog.SetProgress; - modManager.Logs -= dialog.LogMessage; + var eventLogger = new SyncDialogEventLogger(dialog); + modManager.InstallEnabledMods(eventLogger, cancellationToken); var status = cancellationToken.IsCancellationRequested ? "aborted" : "completed"; dialog.LogMessage($"Synchronization {status}."); }); @@ -58,10 +54,9 @@ private async void UninstallAllItem_Click(object sender, RoutedEventArgs e) { await SyncDialog.ShowAsync(Content.XamlRoot, (dialog, cancellationToken) => { - modManager.Logs += dialog.LogMessage; - modManager.UninstallAllMods(); + var eventLogger = new SyncDialogEventLogger(dialog); + modManager.UninstallAllMods(eventLogger); dialog.SetProgress(1.0); - modManager.Logs -= dialog.LogMessage; var status = cancellationToken.IsCancellationRequested ? "aborted" : "completed"; dialog.LogMessage($"Uninstall {status}."); }); @@ -199,4 +194,17 @@ await SyncDialog.ShowAsync(Content.XamlRoot, filePaths, (dialog, filePath) => SyncModListView(); } + + internal class SyncDialogEventLogger : BaseEventLogger + { + private readonly SyncDialog dialog; + + internal SyncDialogEventLogger(SyncDialog dialog) + { + this.dialog = dialog; + } + + public override void ProgressUpdate(IPercent? value) => dialog.SetProgress(value?.Percent); + protected override void LogMessage(string message) => dialog.LogMessage(message); + } } diff --git a/src/GUI/SyncDialog.xaml.cs b/src/GUI/SyncDialog.xaml.cs index 819dcf3..1d95168 100644 --- a/src/GUI/SyncDialog.xaml.cs +++ b/src/GUI/SyncDialog.xaml.cs @@ -68,7 +68,7 @@ public static async Task ShowAsync(XamlRoot xamlRoot, IEnumerable enumerab var items = enumerable.ToList(); await ShowAsync(xamlRoot, (sd, ct) => { - var progress = Percent.OfTotal(items.Count); + var progress = new PercentOfTotal(items.Count); foreach (var i in items) { if (ct.IsCancellationRequested) @@ -76,7 +76,7 @@ await ShowAsync(xamlRoot, (sd, ct) => break; } action(sd, i); - sd.SetProgress(progress.Increment()); + sd.SetProgress(progress.IncrementDone().Percent); } return true; }); diff --git a/tests/Core.Tests/Backup/MoveFileBackupStrategyTest.cs b/tests/Core.Tests/Backup/MoveFileBackupStrategyTest.cs new file mode 100644 index 0000000..12adc78 --- /dev/null +++ b/tests/Core.Tests/Backup/MoveFileBackupStrategyTest.cs @@ -0,0 +1,125 @@ +using System.IO.Abstractions.TestingHelpers; +using Core.Backup; + +namespace Core.Tests.Backup; + +public class MoveFileBackupStrategyTest +{ + private static readonly string OriginalFile = "original"; + private static readonly string OriginalContents = "something"; + private static readonly string BackupFile = GenerateBackupFilePath(OriginalFile); + + private static string GenerateBackupFilePath(string fullPath) => $"b{fullPath}"; + + [Fact] + public void BackupFile_MovesOriginalToBackup() + { + var fs = new MockFileSystem(new Dictionary + { + { OriginalFile, OriginalContents }, + }); + + var sbs = new MoveFileBackupStrategy(fs, GenerateBackupFilePath); + + sbs.PerformBackup(OriginalFile); + + Assert.False(fs.FileExists(OriginalFile)); + Assert.Equal(OriginalContents, fs.File.ReadAllText(BackupFile)); + } + + [Fact] + public void BackupFile_SkipsBackupIfFileNotPresent() + { + var fs = new MockFileSystem(); + + var sbs = new MoveFileBackupStrategy(fs, GenerateBackupFilePath); + + sbs.PerformBackup(OriginalFile); + + Assert.False(fs.FileExists(BackupFile)); + } + + [Fact] + public void BackupFile_KeepsExistingBackup() + { + var oldBackupContents = "old backup"; + var fs = new MockFileSystem(new Dictionary + { + { OriginalFile, OriginalContents }, + { BackupFile, oldBackupContents }, + }); + + var sbs = new MoveFileBackupStrategy(fs, GenerateBackupFilePath); + + sbs.PerformBackup(OriginalFile); + + Assert.False(fs.FileExists(OriginalFile)); + Assert.Equal(oldBackupContents, fs.File.ReadAllText(BackupFile)); + } + + [Fact] + public void RestoreBackup_MovesBackupToOriginal() + { + var fs = new MockFileSystem(new Dictionary + { + { BackupFile, OriginalContents}, + }); + + var sbs = new MoveFileBackupStrategy(fs, GenerateBackupFilePath); + + sbs.RestoreBackup(OriginalFile); + + Assert.Equal(OriginalContents, fs.File.ReadAllText(OriginalFile)); + Assert.False(fs.FileExists(BackupFile)); + } + + [Fact] + public void RestoreBackup_LeavesOriginalFileIfNoBackup() + { + var fs = new MockFileSystem(new Dictionary + { + { OriginalFile, "other contents" }, + }); + + var sbs = new MoveFileBackupStrategy(fs, GenerateBackupFilePath); + + sbs.RestoreBackup(OriginalFile); + + Assert.True(fs.FileExists(OriginalFile)); + } + + [Fact] + public void RestoreBackup_ErrorsIfOriginalFileExists() + { + var fs = new MockFileSystem(new Dictionary + { + { OriginalFile, "other contents" }, + { BackupFile, OriginalContents}, + }); + + var sbs = new MoveFileBackupStrategy(fs, GenerateBackupFilePath); + + Assert.Throws(() => sbs.RestoreBackup(OriginalFile)); + + Assert.NotEqual(OriginalContents, fs.File.ReadAllText(OriginalFile)); + Assert.True(fs.FileExists(BackupFile)); + } + + [Fact] + public void DeleteBackup_RemovesBackupIfItExists() + { + var fs = new MockFileSystem(new Dictionary + { + { BackupFile, OriginalContents}, + }); + + var sbs = new MoveFileBackupStrategy(fs, GenerateBackupFilePath); + + sbs.DeleteBackup(OriginalFile); + + Assert.False(fs.FileExists(OriginalFile)); + Assert.False(fs.FileExists(BackupFile)); + + sbs.DeleteBackup(OriginalFile); // Check that it does not error + } +} \ No newline at end of file diff --git a/tests/Core.Tests/Core.Tests.csproj b/tests/Core.Tests/Core.Tests.csproj index 33f299a..c7739a5 100644 --- a/tests/Core.Tests/Core.Tests.csproj +++ b/tests/Core.Tests/Core.Tests.csproj @@ -25,14 +25,6 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + - - - - - - - - - diff --git a/tests/Core.Tests/ModManagerTest.cs b/tests/Core.Tests/ModManagerTest.cs index 52feac3..33363c8 100644 --- a/tests/Core.Tests/ModManagerTest.cs +++ b/tests/Core.Tests/ModManagerTest.cs @@ -5,16 +5,18 @@ namespace Core.Tests; using Core.Mods; using Core.State; using Moq; -using SevenZip; using System; using System.Collections.Immutable; +using System.IO.Compression; public class ModManagerTest : IDisposable { #region Initialisation private const string DirAtRoot = "DirAtRoot"; - private readonly static TimeSpan TimeTolerance = TimeSpan.FromMilliseconds(100); + private const string FileExcludedFromInstall = "Excluded"; + + private static readonly TimeSpan TimeTolerance = TimeSpan.FromMilliseconds(100); private readonly DirectoryInfo testDir; private readonly DirectoryInfo gameDir; @@ -23,9 +25,10 @@ public class ModManagerTest : IDisposable private readonly Mock gameMock = new(); private readonly Mock modRepositoryMock = new(); private readonly Mock safeFileDeleteMock = new(); + private readonly Mock eventHandlerMock = new(); private readonly AssertState persistedState; - private readonly ModFactory modFactory; + private readonly InstallationFactory installationFactory; private readonly ModManager modManager; @@ -38,14 +41,20 @@ public ModManagerTest() var tempDir = new SubdirectoryTempDir(testDir.FullName); persistedState = new AssertState(); - modFactory = new ModFactory( - new ModInstallConfig { DirsAtRoot = [DirAtRoot] }, - gameMock.Object); + var modInstallConfig = new ModInstallConfig + { + DirsAtRoot = [DirAtRoot], + ExcludedFromInstall = [$"**\\{FileExcludedFromInstall}"] + }; + installationFactory = new InstallationFactory( + gameMock.Object, + tempDir, + modInstallConfig); modManager = new ModManager( gameMock.Object, modRepositoryMock.Object, - modFactory, + new ModInstaller(installationFactory, tempDir, modInstallConfig), persistedState, safeFileDeleteMock.Object, tempDir); @@ -66,24 +75,13 @@ public void Uninstall_FailsIfGameRunning() gameMock.Setup(_ => _.IsRunning).Returns(true); var exception = Assert.Throws(() => - modManager.UninstallAllMods() + modManager.UninstallAllMods(eventHandlerMock.Object) ); Assert.Contains("running", exception.Message); persistedState.AssertNotWritten(); } - [Fact] - public void Uninstall_FailsIfBootfilesInstalledByAnotherToolAndNothingToUninstall() - { - persistedState.InitState(InternalState.Empty()); - - var exception = Assert.Throws(() => modManager.UninstallAllMods()); - - Assert.Contains("another tool", exception.Message); - persistedState.AssertNotWritten(); - } - [Fact] public void Uninstall_DeletesCreatedFilesAndDirectories() { @@ -107,7 +105,7 @@ public void Uninstall_DeletesCreatedFilesAndDirectories() )); CreateGameFile(Path.Combine("Y", "ExistingFile")); - modManager.UninstallAllMods(); + modManager.UninstallAllMods(eventHandlerMock.Object); Assert.False(Directory.Exists(GamePath("X"))); Assert.False(File.Exists(GamePath(Path.Combine("Y", "ModAFile")))); @@ -137,7 +135,7 @@ public void Uninstall_SkipsFilesCreatedAfterInstallation() CreateGameFile("ModFile").CreationTime = installationDateTime; CreateGameFile("RecreatedFile"); - modManager.UninstallAllMods(); + modManager.UninstallAllMods(eventHandlerMock.Object); Assert.False(File.Exists(GamePath("ModFile"))); Assert.True(File.Exists(GamePath("RecreatedFile"))); @@ -147,9 +145,11 @@ public void Uninstall_SkipsFilesCreatedAfterInstallation() [Fact] public void Uninstall_StopsAfterAnyError() { + // It must be after files are created + var installationDateTime = DateTime.Now.AddDays(1); persistedState.InitState(new InternalState( Install: new( - Time: null, + Time: installationDateTime, Mods: new Dictionary { ["A"] = new( @@ -173,11 +173,11 @@ public void Uninstall_StopsAfterAnyError() using var _ = CreateGameFile("ModBFile2").OpenRead(); // Prevent deletion CreateGameFile("ModCFile"); - Assert.Throws(() => modManager.UninstallAllMods()); + Assert.Throws(() => modManager.UninstallAllMods(eventHandlerMock.Object)); persistedState.AssertEqual(new InternalState( Install: new InternalInstallationState( - Time: null, + Time: installationDateTime, Mods: new Dictionary { ["B"] = new( @@ -211,7 +211,7 @@ public void Uninstall_RestoresBackups() CreateGameFile("ModFile", "Mod"); CreateGameFile(BackupName("ModFile"), "Orig"); - modManager.UninstallAllMods(); + modManager.UninstallAllMods(eventHandlerMock.Object); Assert.Equal("Orig", File.ReadAllText(GamePath("ModFile"))); Assert.False(File.Exists(GamePath(BackupName("ModFile")))); @@ -223,23 +223,13 @@ public void Install_FailsIfGameRunning() gameMock.Setup(_ => _.IsRunning).Returns(true); var exception = Assert.Throws(() => - modManager.InstallEnabledMods() + modManager.InstallEnabledMods(eventHandlerMock.Object) ); Assert.Contains("running", exception.Message); persistedState.AssertNotWritten(); } - [Fact] - public void Install_FailsIfBootfilesInstalledByAnotherTool() - { - persistedState.InitState(InternalState.Empty()); - - var exception = Assert.Throws(() => modManager.InstallEnabledMods()); - Assert.Contains("another tool", exception.Message); - persistedState.AssertNotWritten(); - } - [Fact] public void Install_InstallsContentFromRootDirectories() { @@ -252,26 +242,45 @@ public void Install_InstallsContentFromRootDirectories() ]) ]); - modManager.InstallEnabledMods(); + modManager.InstallEnabledMods(eventHandlerMock.Object); Assert.True(File.Exists(GamePath(Path.Combine(DirAtRoot, "A")))); Assert.True(File.Exists(GamePath(Path.Combine(DirAtRoot, "B")))); Assert.True(File.Exists(GamePath("C"))); Assert.False(File.Exists(GamePath("D"))); Assert.False(File.Exists(GamePath(Path.Combine("Baz", "D")))); - persistedState.AssertEqual(new InternalState( - Install: new InternalInstallationState( - Time: DateTime.Now, - Mods: new Dictionary - { - ["Package100"] = new( - FsHash: 100, Partial: false, Files: [ - Path.Combine(DirAtRoot, "A"), - Path.Combine(DirAtRoot, "B"), - "C" - ]), - } - ))); + persistedState.AssertModsEqual(new Dictionary + { + ["Package100"] = new( + FsHash: 100, Partial: false, Files: [ + Path.Combine(DirAtRoot, "A"), + Path.Combine(DirAtRoot, "B"), + "C" + ]), + }); + } + + [Fact] + public void Install_SkipsBlacklistedFiles() + { + modRepositoryMock.Setup(_ => _.ListEnabledMods()).Returns([ + CreateModArchive(100, [ + Path.Combine("A", FileExcludedFromInstall), + Path.Combine(DirAtRoot, "B"), + ]) + ]); + + modManager.InstallEnabledMods(eventHandlerMock.Object); + + Assert.False(File.Exists(GamePath(Path.Combine("A", FileExcludedFromInstall)))); + Assert.True(File.Exists(GamePath(Path.Combine(DirAtRoot, "B")))); + persistedState.AssertModsEqual(new Dictionary + { + ["Package100"] = new( + FsHash: 100, Partial: false, Files: [ + Path.Combine(DirAtRoot, "B") + ]), + }); } [Fact] @@ -284,7 +293,7 @@ public void Install_DeletesFilesWithSuffix() ]); CreateGameFile(modFile, "Orig"); - modManager.InstallEnabledMods(); + modManager.InstallEnabledMods(eventHandlerMock.Object); Assert.False(File.Exists(GamePath(modFile))); Assert.Equal("Orig", File.ReadAllText(GamePath(BackupName(modFile)))); @@ -298,24 +307,40 @@ public void Install_GivesPriotiryToFilesLaterInTheModList() Path.Combine(DirAtRoot, "A") ]), CreateModArchive(200, [ - Path.Combine("Foo", DirAtRoot, "A") - ]) + Path.Combine("X", DirAtRoot, "a") + ]), ]); - modManager.InstallEnabledMods(); + modManager.InstallEnabledMods(eventHandlerMock.Object); Assert.Equal("200", File.ReadAllText(GamePath(Path.Combine(DirAtRoot, "A")))); - persistedState.AssertEqual(new InternalState( - Install: new InternalInstallationState( - Time: DateTime.Now, - Mods: new Dictionary - { - ["Package100"] = new(FsHash: 100, Partial: false, Files: []), - ["Package200"] = new(FsHash: 200, Partial: false, Files: [ - Path.Combine(DirAtRoot, "A") - ]), - } - ))); + persistedState.AssertModsEqual(new Dictionary + { + ["Package100"] = new(FsHash: 100, Partial: false, Files: []), + ["Package200"] = new(FsHash: 200, Partial: false, Files: [ + Path.Combine(DirAtRoot, "a") + ]), + }); + } + + [Fact] + public void Install_DuplicatesAreCaseInsensitive() + { + modRepositoryMock.Setup(_ => _.ListEnabledMods()).Returns([ + CreateModArchive(100, [ + Path.Combine("X", DirAtRoot, "A"), + Path.Combine("Y", DirAtRoot, "a") + ]) + ]); + + modManager.InstallEnabledMods(eventHandlerMock.Object); + + persistedState.AssertModsEqual(new Dictionary + { + ["Package100"] = new(FsHash: 100, Partial: false, Files: [ + Path.Combine(DirAtRoot, "A") + ]), + }); } [Fact] @@ -336,7 +361,7 @@ public void Install_StopsAfterAnyError() ]); using var _ = CreateGameFile(Path.Combine(DirAtRoot, "B2")).OpenRead(); // Prevent overwrite - Assert.Throws(() => modManager.InstallEnabledMods()); + Assert.Throws(() => modManager.InstallEnabledMods(eventHandlerMock.Object)); Assert.Equal("300", File.ReadAllText(GamePath(Path.Combine(DirAtRoot, "C")))); Assert.Equal("200", File.ReadAllText(GamePath(Path.Combine(DirAtRoot, "B1")))); @@ -371,7 +396,7 @@ public void Install_PreventsFileCreationTimeInTheFuture() ) ]); - modManager.InstallEnabledMods(); + modManager.InstallEnabledMods(eventHandlerMock.Object); AssertAboutNow(File.GetCreationTime(GamePath($@"{DirAtRoot}\A"))); } @@ -388,7 +413,7 @@ public void Install_PerformsBackups() CreateGameFile(modFile, "OrigA"); CreateGameFile(toBeDeleted, "OrigB"); - modManager.InstallEnabledMods(); + modManager.InstallEnabledMods(eventHandlerMock.Object); Assert.Equal("OrigA", File.ReadAllText(GamePath(BackupName(modFile)))); Assert.Equal("OrigB", File.ReadAllText(GamePath(BackupName(toBeDeleted)))); @@ -407,9 +432,9 @@ public void Install_OldVehiclesRequireBootfiles() CreateCustomBootfiles(900), ]); - modManager.InstallEnabledMods(); + modManager.InstallEnabledMods(eventHandlerMock.Object); - persistedState.AssertInstalled(["Package100", "__bootfiles900"]); + persistedState.AssertModsInstalled(["Package100", "__bootfiles900"]); Assert.Contains("Vehicle.crd", File.ReadAllText(GamePath(PostProcessor.VehicleListRelativePath))); Assert.Contains(drivelineRecord, File.ReadAllText(GamePath(PostProcessor.DrivelineRelativePath))); } @@ -420,14 +445,14 @@ public void Install_NewVehiclesDoNotRequireBootfiles() modRepositoryMock.Setup(_ => _.ListEnabledMods()).Returns([ CreateModArchive(100, [ Path.Combine(DirAtRoot, "Vehicle.crd"), - ManualInstallMod.GameSupportedModDirectory + BaseInstaller.GameSupportedModDirectory ]), CreateCustomBootfiles(900), ]); - modManager.InstallEnabledMods(); + modManager.InstallEnabledMods(eventHandlerMock.Object); - persistedState.AssertInstalled(["Package100"]); + persistedState.AssertModsInstalled(["Package100"]); } [Fact] @@ -438,9 +463,9 @@ public void Install_AllTracksRequireBootfiles() CreateCustomBootfiles(900), ]); - modManager.InstallEnabledMods(); + modManager.InstallEnabledMods(eventHandlerMock.Object); - persistedState.AssertInstalled(["Package100", "__bootfiles900"]); + persistedState.AssertModsInstalled(["Package100", "__bootfiles900"]); Assert.Contains("Track.trd", File.ReadAllText(GamePath(PostProcessor.TrackListRelativePath))); } @@ -453,7 +478,7 @@ public void Install_ExtractsBootfilesFromGameByDefault() ]); // Unfortunately, there is no easy way to create pak files! - Assert.Throws(() => modManager.InstallEnabledMods()); + Assert.Throws(() => modManager.InstallEnabledMods(eventHandlerMock.Object)); //CreateBootfileSources(); // @@ -471,10 +496,10 @@ public void Install_RejectsMultipleCustomBootfiles() CreateCustomBootfiles(901) ]); - var exception = Assert.Throws(() => modManager.InstallEnabledMods()); + var exception = Assert.Throws(() => modManager.InstallEnabledMods(eventHandlerMock.Object)); Assert.Contains("many bootfiles", exception.Message); - persistedState.AssertInstalled(["Package100"]); + persistedState.AssertModsInstalled(["Package100"]); } #region Utility methods @@ -486,7 +511,7 @@ private ModPackage CreateModArchive(int fsHash, IEnumerable relativePath CreateModPackage("Package", fsHash, relativePaths, callback); private ModPackage CreateCustomBootfiles(int fsHash) => - CreateModPackage(ModManager.BootfilesPrefix, fsHash, [ + CreateModPackage(BootfilesManager.BootfilesPrefix, fsHash, [ Path.Combine(DirAtRoot, "OrTheyWontBeInstalled"), PostProcessor.VehicleListRelativePath, PostProcessor.TrackListRelativePath, @@ -506,8 +531,9 @@ private ModPackage CreateModPackage(string packagePrefix, int fsHash, IEnumerabl CreateFile(Path.Combine(modContentsDir, relativePath), $"{fsHash}"); } callback(modContentsDir); - var archivePath = $@"{modsDir.FullName}\{modName}.7z"; - new SevenZipCompressor().CompressDirectory(modContentsDir, archivePath); + var archivePath = $@"{modsDir.FullName}\{modName}.zip"; + // TODO LibArchive.Net does not support compression yet + ZipFile.CreateFromDirectory(modContentsDir, archivePath); return new ModPackage(modName, $"{packagePrefix}{fsHash}", archivePath, true, fsHash); } @@ -531,7 +557,7 @@ private string BackupName(string relativePath) => // This can be removed once we hide it inside mod logic private string DeletionName(string relativePath) => - $"{relativePath}{JsgmeFileInstaller.RemoveFileSuffix}"; + $"{relativePath}{BaseInstaller.RemoveFileSuffix}"; private string GamePath(string relativePath) => Path.GetFullPath(relativePath, gameDir.FullName); @@ -547,7 +573,7 @@ private static void AssertEqualWithinToleration(DateTime? expected, DateTime? ac private class AssertState : IStatePersistence { // Avoids bootfiles checks on uninstall - private readonly static InternalState SkipBootfilesCheck = new InternalState( + private static readonly InternalState SkipBootfilesCheck = new InternalState( Install: new ( Time: null, Mods: new Dictionary @@ -570,8 +596,14 @@ internal void AssertEqual(InternalState expected) Assert.NotNull(savedState); // Not a great solution, but .NET doesn't natively provide support for mocking the clock AssertEqualWithinToleration(expected.Install.Time, savedState.Install.Time); - AssertInstalled(expected.Install.Mods.Keys); - foreach (var e in expected.Install.Mods) + AssertModsInstalled(expected.Install.Mods.Keys); + AssertModsEqual(expected.Install.Mods); + } + + internal void AssertModsEqual(IReadOnlyDictionary expected) + { + Assert.NotNull(savedState); + foreach (var e in expected) { var currentModState = savedState.Install.Mods[e.Key]; var expectedModState = e.Value; @@ -581,7 +613,7 @@ internal void AssertEqual(InternalState expected) }; } - internal void AssertInstalled(IEnumerable expected) + internal void AssertModsInstalled(IEnumerable expected) { Assert.Equal(expected.ToImmutableHashSet(), savedState?.Install.Mods.Keys.ToImmutableHashSet()); } diff --git a/tests/Core.Tests/Mods/ContainedDirsRootFinderTest.cs b/tests/Core.Tests/Mods/ContainedDirsRootFinderTest.cs new file mode 100644 index 0000000..4feb3c3 --- /dev/null +++ b/tests/Core.Tests/Mods/ContainedDirsRootFinderTest.cs @@ -0,0 +1,93 @@ +using Core.Mods; + +namespace Core.Tests.Mods; + +public class ContainedDirsRootFinderTest +{ + private static readonly string[] RootDirs = ["R1", "R2"]; + private readonly ContainedDirsRootFinder rootFinder = new(RootDirs); + + [Fact] + public void FromDirectoryList_FindsDirsAtRoot() + { + Assert.Equal( + [ + "", + ], + rootFinder.FromDirectoryList( + [ + Path.Combine("R1"), + ]).Roots); + } + + [Fact] + public void FromDirectoryList_FindsDirsWhenNamePrefixOfOtherDirs() + { + Assert.Equal( + [ + "D1", + "D11" + ], + rootFinder.FromDirectoryList( + [ + Path.Combine("D1", "R1"), + Path.Combine("D11", "R1") + ]).Roots); + } + + [Fact] + public void FromDirectoryList_IgnoresNestedRoots() + { + Assert.Equal( + [ + "D1" + ], + rootFinder.FromDirectoryList( + [ + Path.Combine("D1", "D2", "R1"), + Path.Combine("D1", "R1"), + Path.Combine("D1", "D3", "R1") + ]).Roots); + + // Empty path is a special case + Assert.Equal( + [ + "" + ], + rootFinder.FromDirectoryList( + [ + Path.Combine("D1", "R1"), + Path.Combine("R1"), + Path.Combine("D2", "R2") + ]).Roots); + } + + [Fact] + public void FromDirectoryList_IsCaseInsensitive() + { + Assert.Equal( + [ + "d1", + ], + rootFinder.FromDirectoryList( + [ + Path.Combine("d1", "r1"), + ]).Roots); + } + + [Fact] + public void FromDirectoryList_FindsDirsInSubDirs() + { + Assert.Equal( + [ + "D1", + Path.Combine("D2", "D3") + ], + rootFinder.FromDirectoryList( + [ + Path.Combine("D1", "R1"), + Path.Combine("D2", "D3", "R2"), + Path.Combine("D4") + ]).Roots); + } +} \ No newline at end of file diff --git a/tests/Core.Tests/Mods/ProcessingCallbacksTest.cs b/tests/Core.Tests/Mods/ProcessingCallbacksTest.cs new file mode 100644 index 0000000..533159d --- /dev/null +++ b/tests/Core.Tests/Mods/ProcessingCallbacksTest.cs @@ -0,0 +1,76 @@ +using Core.Mods; +using Moq; + +namespace Core.Tests.Mods; + +public class ProcessingCallbacksTest +{ + private static readonly int SomeValue = 42; + + [Fact] + public void Accept_AcceptsByDefault() + { + var callbacks = new ProcessingCallbacks(); + + Assert.NotNull(callbacks.Accept); + Assert.True(callbacks.Accept(SomeValue)); + } + + [Fact] + public void Accept_StopsChainOnFirstRejection() + { + var callbacks = new ProcessingCallbacks(); + var mp1 = new Mock>(); + var mp2 = new Mock>(); + var mp3 = new Mock>(); + + mp1.Setup(p => p.Invoke(SomeValue)).Returns(true); + mp2.Setup(p => p.Invoke(SomeValue)).Returns(false); + + callbacks + .AndAccept(mp1.Object) + .AndAccept(mp2.Object) + .AndAccept(mp3.Object) + .Accept(SomeValue); + + mp1.Verify(a => a.Invoke(SomeValue), Times.Once); + mp2.Verify(a => a.Invoke(SomeValue), Times.Once); + mp3.Verify(a => a.Invoke(SomeValue), Times.Never); + } + + [Fact] + public void Before_ExecutesAllActionsInChain() + { + var callbacks = new ProcessingCallbacks(); + var ma1 = new Mock>(); + var ma2 = new Mock>(); + + Assert.NotNull(callbacks.Before); + + callbacks + .AndBefore(ma1.Object) + .AndBefore(ma2.Object) + .Before(SomeValue); + + ma1.Verify(a => a.Invoke(SomeValue), Times.Once); + ma2.Verify(a => a.Invoke(SomeValue), Times.Once); + } + + [Fact] + public void After_ExecutesAllActionsInChain() + { + var callbacks = new ProcessingCallbacks(); + var ma1 = new Mock>(); + var ma2 = new Mock>(); + + Assert.NotNull(callbacks.After); + + callbacks + .AndAfter(ma1.Object) + .AndAfter(ma2.Object) + .After(SomeValue); + + ma1.Verify(a => a.Invoke(SomeValue), Times.Once); + ma2.Verify(a => a.Invoke(SomeValue), Times.Once); + } +} \ No newline at end of file