diff --git a/src/Core/IModInstaller.cs b/src/Core/IModInstaller.cs index c7eac9c..5b00841 100644 --- a/src/Core/IModInstaller.cs +++ b/src/Core/IModInstaller.cs @@ -5,5 +5,5 @@ namespace Core; public interface IModInstaller { - void Apply(IReadOnlyDictionary currentState, IReadOnlyCollection packages, string installDir, Action afterInstall, ModInstaller.IEventHandler eventHandler, CancellationToken cancellationToken); + void Apply(IReadOnlyDictionary currentState, IReadOnlyCollection packages, string installDir, Action afterInstall, ModInstaller.IEventHandler eventHandler, CancellationToken cancellationToken); } diff --git a/src/Core/ModInstaller.cs b/src/Core/ModInstaller.cs index ca354d8..a203440 100644 --- a/src/Core/ModInstaller.cs +++ b/src/Core/ModInstaller.cs @@ -61,19 +61,36 @@ public ModInstaller(IInstallationFactory installationFactory, IBackupStrategy ba } public void Apply( - IReadOnlyDictionary currentState, + IReadOnlyDictionary currentState, IReadOnlyCollection toInstall, string installDir, Action afterCallback, IEventHandler eventHandler, CancellationToken cancellationToken) { + var updaters = FooUpdaters(currentState, toInstall); + + UninstallPackages(currentState, installDir, afterCallback, eventHandler, cancellationToken); InstallPackages(toInstall, installDir, afterCallback, eventHandler, cancellationToken); } + private object FooUpdaters( + IReadOnlyDictionary currentState, + IReadOnlyCollection toInstall) + { + var left = new Dictionary(currentState); + var updatersToInstall = toInstall.Select(_ => { + if (left.TryGetValue(_.PackageName, out var installState)) + { + left.Remove(_.PackageName); + } + new ModUpdater(_, installState); + }); + } + private void UninstallPackages( - IReadOnlyDictionary currentState, + IReadOnlyDictionary currentState, string installDir, Action afterUninstall, IEventHandler eventHandler, diff --git a/src/Core/ModManager.cs b/src/Core/ModManager.cs index 9f8a9b6..2f22c95 100644 --- a/src/Core/ModManager.cs +++ b/src/Core/ModManager.cs @@ -1,10 +1,8 @@ -using System.Globalization; using Core.Games; using Core.IO; using Core.Mods; using Core.State; using Core.Utils; -using Microsoft.VisualBasic; using static Core.IModManager; namespace Core; @@ -53,7 +51,7 @@ public List FetchState() var availableModPackages = enabledModPackages.Merge(disabledModPackages); var bootfilesFailed = installedMods.Where(kv => BootfilesManager.IsBootFiles(kv.Key) && (kv.Value?.Partial ?? false)).Any(); - var isModInstalled = installedMods.SelectValues(modInstallationState => + var isModInstalled = installedMods.SelectValues(modInstallationState => modInstallationState is null ? false : ((modInstallationState.Partial || bootfilesFailed) ? null : true) ); var modsOutOfDate = installedMods.SelectValues((packageName, modInstallationState) => @@ -81,7 +79,7 @@ public List FetchState() }).ToList(); } - private static bool IsOutOfDate(ModPackage? modPackage, InternalModInstallationState? modInstallationState) + private static bool IsOutOfDate(ModPackage? modPackage, ModInstallationState? modInstallationState) { if (modPackage is null || modInstallationState is null) { @@ -140,14 +138,14 @@ public void InstallEnabledMods(IEventHandler eventHandler, CancellationToken can // Clean what left by a previous failed installation tempDir.Cleanup(); - InstallMods(modRepository.ListEnabledMods(), eventHandler, cancellationToken); + UpdateMods(modRepository.ListEnabledMods(), eventHandler, cancellationToken); tempDir.Cleanup(); } public void UninstallAllMods(IEventHandler eventHandler, CancellationToken cancellationToken = default) { CheckGameNotRunning(); - InstallMods(Array.Empty(), eventHandler, cancellationToken); + UpdateMods(Array.Empty(), eventHandler, cancellationToken); } private void CheckGameNotRunning() @@ -158,10 +156,10 @@ private void CheckGameNotRunning() } } - private void InstallMods(IReadOnlyCollection packages, IEventHandler eventHandler, CancellationToken cancellationToken) + private void UpdateMods(IReadOnlyCollection packages, IEventHandler eventHandler, CancellationToken cancellationToken) { var previousState = statePersistence.ReadState().Install.Mods; - var currentState = new Dictionary(previousState); + var currentState = new Dictionary(previousState); try { modInstaller.Apply( @@ -198,7 +196,7 @@ private void InstallMods(IReadOnlyCollection packages, IEventHandler } finally { - statePersistence.WriteState(new InternalState( + statePersistence.WriteState(new SavedState( Install: new( Time: currentState.Values.Max(_ => _.Time), Mods: currentState diff --git a/src/Core/Mods/ModUpdater.cs b/src/Core/Mods/ModUpdater.cs new file mode 100644 index 0000000..cd63b5e --- /dev/null +++ b/src/Core/Mods/ModUpdater.cs @@ -0,0 +1,34 @@ +using Core.State; + +namespace Core.Mods; + +public class ModUpdater +{ + private readonly IInstaller? installer; + public ModInstallationState State { get; private set; } + + /// + /// + /// + /// Installer, or null for uninstall. + /// Installation state, or null if not installed. + public ModUpdater(IInstaller? installer, ModInstallationState? currentState) + { + this.installer = installer; + State = currentState ?? ModInstallationState.Empty; + } + + public bool Installed => State.Files.Count > 0; + + //// Do we just need accept callaback for whitelisting, etc.? + //// Add CancellationToken to stop + //public void Update() + //{ + // // Could move the driveline config in the state + // // The other config should stay outside: even if we make it configurable per mod, + // // it will have an impact on what is installed rather than what is configured! + //} + + public void Install() => throw new NotImplementedException(); + public void Uninstall() => throw new NotImplementedException(); +} diff --git a/src/Core/State/IStatePersistence.cs b/src/Core/State/IStatePersistence.cs index ffea886..b87c7e2 100644 --- a/src/Core/State/IStatePersistence.cs +++ b/src/Core/State/IStatePersistence.cs @@ -2,6 +2,6 @@ public interface IStatePersistence { - public InternalState ReadState(); - public void WriteState(InternalState state); + public SavedState ReadState(); + public void WriteState(SavedState state); } diff --git a/src/Core/State/JsonFileStatePersistence.cs b/src/Core/State/JsonFileStatePersistence.cs index 3ddb50e..9bcc317 100644 --- a/src/Core/State/JsonFileStatePersistence.cs +++ b/src/Core/State/JsonFileStatePersistence.cs @@ -23,13 +23,13 @@ public JsonFileStatePersistence(string modsDir) oldStateFile = Path.Combine(modsDir, OldStateFileName); } - public InternalState ReadState() + public SavedState ReadState() { // Always favour new state if present if (File.Exists(stateFile)) { var contents = File.ReadAllText(stateFile); - var state = JsonConvert.DeserializeObject(contents); + var state = JsonConvert.DeserializeObject(contents); // Fill mod install time if not present (for migration) return state with { @@ -46,21 +46,21 @@ public InternalState ReadState() var contents = File.ReadAllText(oldStateFile); var oldState = JsonConvert.DeserializeObject>>(contents); var installTime = File.GetLastWriteTimeUtc(oldStateFile); - return new InternalState( + return new SavedState( Install: new( Time: installTime, Mods: oldState.AsEnumerable().ToDictionary( kv => kv.Key, - kv => new InternalModInstallationState(Time: installTime, FsHash: null, Partial: false, Files: kv.Value) + kv => new ModInstallationState(Time: installTime, FsHash: null, Partial: false, Files: kv.Value) ) ) ); } - return InternalState.Empty(); + return SavedState.Empty(); } - public void WriteState(InternalState state) + public void WriteState(SavedState state) { // Remove old state if upgrading from a previous version File.Delete(oldStateFile); diff --git a/src/Core/State/InternalState.cs b/src/Core/State/SavedState.cs similarity index 50% rename from src/Core/State/InternalState.cs rename to src/Core/State/SavedState.cs index 9b82701..f3fc549 100644 --- a/src/Core/State/InternalState.cs +++ b/src/Core/State/SavedState.cs @@ -2,28 +2,28 @@ namespace Core.State; -public record InternalState( - InternalInstallationState Install +public record SavedState( + InstallationState Install ) { - public static InternalState Empty() => new( - Install: InternalInstallationState.Empty() + public static SavedState Empty() => new( + Install: InstallationState.Empty() ); }; -public record InternalInstallationState( +public record InstallationState( // TODO: needed for backward compatibility DateTime? Time, - IReadOnlyDictionary Mods + IReadOnlyDictionary Mods ) { - public static InternalInstallationState Empty() => new( + public static InstallationState Empty() => new( Time: null, - Mods: ImmutableDictionary.Create() + Mods: ImmutableDictionary.Create() ); }; -public record InternalModInstallationState( +public record ModInstallationState( // TODO: nullable for backward compatibility DateTime? Time, // Unknown when partially installed or upgrading from a previous version @@ -32,4 +32,7 @@ public record InternalModInstallationState( // infer from null hash after the first install bool Partial, IReadOnlyCollection Files -); +) +{ + public static ModInstallationState Empty => new(null, null, false, Array.Empty()); +} diff --git a/tests/Core.Tests/ModInstallerIntegrationTest.cs b/tests/Core.Tests/ModInstallerIntegrationTest.cs index 68ffee0..3607afc 100644 --- a/tests/Core.Tests/ModInstallerIntegrationTest.cs +++ b/tests/Core.Tests/ModInstallerIntegrationTest.cs @@ -52,7 +52,7 @@ public ModInstallerIntegrationTest() public void Apply_NoMods() { modInstaller.Apply( - new Dictionary(), + new Dictionary(), [], "", RecordState, @@ -69,7 +69,7 @@ public void Apply_UninstallsMods() CreateTestFile("AF"); modInstaller.Apply( - new Dictionary{ + new Dictionary{ ["A"] = new( Time: null, FsHash: 42, @@ -106,7 +106,7 @@ public void Apply_UninstallStopsIfBackupFails() backupStrategyMock.Setup(_ => _.RestoreBackup(TestPath("Fail"))).Throws(); modInstaller.Invoking(_ => _.Apply( - new Dictionary + new Dictionary { ["A"] = new( Time: null, @@ -127,7 +127,7 @@ public void Apply_UninstallStopsIfBackupFails() public void Apply_InstallsMods() { modInstaller.Apply( - new Dictionary(), + new Dictionary(), [ PackageInstalling("A", 42, [ "AF" @@ -161,7 +161,7 @@ public void Apply_InstallStopsIfBackupFails() backupStrategyMock.Setup(_ => _.PerformBackup(TestPath("Fail"))).Throws(); modInstaller.Invoking(_ => _.Apply( - new Dictionary(), + new Dictionary(), [ PackageInstalling("A", 42, [ "AF1", "Fail", "AF2" @@ -180,7 +180,7 @@ public void Apply_UpdatesMods() { var endState = new Dictionary(); modInstaller.Apply( - new Dictionary + new Dictionary { ["A"] = new(Time: null, FsHash: 1, Partial: false, Files: [ "AF", diff --git a/tests/Core.Tests/ModManagerIntegrationTest.cs b/tests/Core.Tests/ModManagerIntegrationTest.cs index 348b7a7..159322b 100644 --- a/tests/Core.Tests/ModManagerIntegrationTest.cs +++ b/tests/Core.Tests/ModManagerIntegrationTest.cs @@ -79,11 +79,11 @@ public void Uninstall_FailsIfGameRunning() [Fact] public void Uninstall_DeletesCreatedFilesAndDirectories() { - persistedState.InitState(new InternalState + persistedState.InitState(new SavedState ( Install: new( Time: ValueNotUsed, - Mods: new Dictionary + Mods: new Dictionary { ["A"] = new( Time: null, FsHash: null, Partial: false, Files: [ @@ -111,11 +111,11 @@ public void Uninstall_DeletesCreatedFilesAndDirectories() public void Uninstall_SkipsFilesCreatedAfterInstallation() { var installationDateTime = DateTime.Now.Subtract(TimeSpan.FromDays(1)); - persistedState.InitState(new InternalState + persistedState.InitState(new SavedState ( Install: new( Time: ValueNotUsed, - Mods: new Dictionary + Mods: new Dictionary { [""] = new( Time: installationDateTime.ToUniversalTime(), FsHash: null, Partial: false, Files: [ @@ -141,10 +141,10 @@ public void Uninstall_StopsAfterAnyError() { // It must be after files are created var installationDateTime = DateTime.Now.AddMinutes(1); - persistedState.InitState(new InternalState( + persistedState.InitState(new SavedState( Install: new( Time: ValueNotUsed, - Mods: new Dictionary + Mods: new Dictionary { ["A"] = new( Time: installationDateTime.ToUniversalTime(), FsHash: null, Partial: false, Files: [ @@ -170,10 +170,10 @@ public void Uninstall_StopsAfterAnyError() modManager.Invoking(_ => _.UninstallAllMods(eventHandlerMock.Object)) .Should().Throw(); - persistedState.Should().Be(new InternalState( - Install: new InternalInstallationState( + persistedState.Should().Be(new SavedState( + Install: new InstallationState( Time: installationDateTime.ToUniversalTime(), - Mods: new Dictionary + Mods: new Dictionary { ["B"] = new( Time: installationDateTime.ToUniversalTime(), FsHash: null, Partial: true, Files: [ @@ -191,10 +191,10 @@ public void Uninstall_StopsAfterAnyError() [Fact] public void Uninstall_RestoresBackups() { - persistedState.InitState(new InternalState( + persistedState.InitState(new SavedState( Install: new( Time: ValueNotUsed, - Mods: new Dictionary + Mods: new Dictionary { [""] = new( Time: null, FsHash: null, Partial: false, Files: [ @@ -217,10 +217,10 @@ public void Uninstall_SkipsRestoreIfModFileOverwritten() { // It must be after files are created var installationDateTime = DateTime.Now.AddMinutes(1); - persistedState.InitState(new InternalState( + persistedState.InitState(new SavedState( Install: new( Time: ValueNotUsed, - Mods: new Dictionary + Mods: new Dictionary { [""] = new( Time: installationDateTime.ToUniversalTime(), FsHash: null, Partial: false, Files: [ @@ -269,7 +269,7 @@ public void Install_InstallsContentFromRootDirectories() File.Exists(GamePath("C")).Should().BeTrue(); File.Exists(GamePath("D")).Should().BeFalse(); File.Exists(GamePath(Path.Combine("Baz", "D"))).Should().BeFalse(); - persistedState.Should().HaveInstalled(new Dictionary + persistedState.Should().HaveInstalled(new Dictionary { ["Package100"] = new( Time: DateTime.UtcNow, FsHash: 100, Partial: false, Files: [ @@ -294,7 +294,7 @@ public void Install_SkipsBlacklistedFiles() File.Exists(GamePath(Path.Combine("A", FileExcludedFromInstall))).Should().BeFalse(); File.Exists(GamePath(Path.Combine(DirAtRoot, "B"))).Should().BeTrue(); - persistedState.Should().HaveInstalled(new Dictionary + persistedState.Should().HaveInstalled(new Dictionary { ["Package100"] = new( Time: DateTime.UtcNow, FsHash: 100, Partial: false, Files: [ @@ -334,7 +334,7 @@ public void Install_GivesPriotiryToFilesLaterInTheModList() modManager.InstallEnabledMods(eventHandlerMock.Object); File.ReadAllText(GamePath(Path.Combine(DirAtRoot, "A"))).Should().Be("200"); - persistedState.Should().HaveInstalled(new Dictionary + persistedState.Should().HaveInstalled(new Dictionary { ["Package100"] = new(Time: DateTime.UtcNow, FsHash: 100, Partial: false, Files: []), ["Package200"] = new(Time: DateTime.UtcNow, FsHash: 200, Partial: false, Files: [ @@ -355,7 +355,7 @@ public void Install_DuplicatesAreCaseInsensitive() modManager.InstallEnabledMods(eventHandlerMock.Object); - persistedState.Should().HaveInstalled(new Dictionary + persistedState.Should().HaveInstalled(new Dictionary { ["Package100"] = new(Time: DateTime.UtcNow, FsHash: 100, Partial: false, Files: [ Path.Combine(DirAtRoot, "A") @@ -388,10 +388,10 @@ public void Install_StopsAfterAnyError() File.ReadAllText(GamePath(Path.Combine(DirAtRoot, "B1"))).Should().Be("200"); File.Exists(GamePath(Path.Combine(DirAtRoot, "B3"))).Should().BeFalse(); File.Exists(GamePath(Path.Combine(DirAtRoot, "A"))).Should().BeFalse(); - persistedState.Should().Be(new InternalState( - Install: new InternalInstallationState( + persistedState.Should().Be(new SavedState( + Install: new InstallationState( Time: DateTime.UtcNow, - Mods: new Dictionary + Mods: new Dictionary { ["Package200"] = new( Time: DateTime.UtcNow, FsHash: 200, Partial: true, Files: [ @@ -576,44 +576,44 @@ private string GamePath(string relativePath) => private class InMemoryStatePersistence : IStatePersistence { // Avoids bootfiles checks on uninstall - private static readonly InternalState SkipBootfilesCheck = new( + private static readonly SavedState SkipBootfilesCheck = new( Install: new( Time: ValueNotUsed, - Mods: new Dictionary + Mods: new Dictionary { ["INIT"] = new(Time: null, FsHash: null, Partial: false, Files: []), } )); - private InternalState initState = SkipBootfilesCheck; - private InternalState? savedState; + private SavedState initState = SkipBootfilesCheck; + private SavedState? savedState; - public void InitState(InternalState state) => initState = state; + public void InitState(SavedState state) => initState = state; - public InternalState ReadState() => savedState ?? initState; + public SavedState ReadState() => savedState ?? initState; - public void WriteState(InternalState state) => savedState = state; + public void WriteState(SavedState state) => savedState = state; internal InMemoryStatePersistenceAssertions Should() => new(savedState); } private class InMemoryStatePersistenceAssertions { - private readonly InternalState? savedState; + private readonly SavedState? savedState; - internal InMemoryStatePersistenceAssertions(InternalState? savedState) + internal InMemoryStatePersistenceAssertions(SavedState? savedState) { this.savedState = savedState; } - internal void Be(InternalState expected) + internal void Be(SavedState expected) { var writtenState = WrittenState(); ValidateDateTime(expected.Install.Time, writtenState.Install.Time); HaveInstalled(expected.Install.Mods); } - internal void HaveInstalled(IReadOnlyDictionary expected) + internal void HaveInstalled(IReadOnlyDictionary expected) { var writtenState = WrittenState(); var actualMods = writtenState.Install.Mods; @@ -626,7 +626,7 @@ internal void HaveInstalled(IReadOnlyDictionary(mod.Key, mod.Value with { Time = actualTime }); + return new KeyValuePair(mod.Key, mod.Value with { Time = actualTime }); }); actualMods.Should().BeEquivalentTo(expectedMods); } @@ -637,7 +637,7 @@ internal void HaveInstalled(IEnumerable expected) writtenState.Install.Mods.Keys.Should().BeEquivalentTo(expected); } - private InternalState WrittenState() + private SavedState WrittenState() { savedState.Should().NotBeNull("State was not written"); return savedState!; @@ -651,7 +651,7 @@ private void ValidateDateTime(DateTime? expected, DateTime? actual) => internal void BeEmpty() { - savedState.Should().BeEquivalentTo(InternalState.Empty()); + savedState.Should().BeEquivalentTo(SavedState.Empty()); } internal void HaveNotBeenWritten()