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