diff --git a/Wabbajack.CacheServer/ListValidationService.cs b/Wabbajack.CacheServer/ListValidationService.cs index 7dd8690c..3261a5e6 100644 --- a/Wabbajack.CacheServer/ListValidationService.cs +++ b/Wabbajack.CacheServer/ListValidationService.cs @@ -111,13 +111,13 @@ namespace Wabbajack.CacheServer public static void Start() { - new Thread(() => + Task.Run(async () => { while (true) { try { - ValidateLists().Wait(); + await ValidateLists(); } catch (Exception ex) { @@ -125,9 +125,9 @@ namespace Wabbajack.CacheServer } // Sleep for two hours - Thread.Sleep(1000 * 60 * 60 * 2); + await Task.Delay(1000 * 60 * 60 * 2); } - }).Start(); + }).FireAndForget(); } public static async Task ValidateLists() { diff --git a/Wabbajack.Common/Util/AsyncLock.cs b/Wabbajack.Common/Util/AsyncLock.cs new file mode 100644 index 00000000..eb14e5cb --- /dev/null +++ b/Wabbajack.Common/Util/AsyncLock.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Disposables; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Wabbajack.Common +{ + public class AsyncLock + { + private SemaphoreSlim _lock = new SemaphoreSlim(1, 1); + + public async Task Wait() + { + await _lock.WaitAsync(); + return Disposable.Create(() => _lock.Release()); + } + } +} diff --git a/Wabbajack.Common/Utils.cs b/Wabbajack.Common/Utils.cs index fb02fede..c01fcfe9 100644 --- a/Wabbajack.Common/Utils.cs +++ b/Wabbajack.Common/Utils.cs @@ -1011,6 +1011,11 @@ namespace Wabbajack.Common p.WaitForExitAndWarn(TimeSpan.FromSeconds(30), $"Deletion process of {path}"); } + public static bool IsUnderneathDirectory(string path, string dirPath) + { + return path.StartsWith(dirPath, StringComparison.OrdinalIgnoreCase); + } + /// /// Writes a file to JSON but in an encrypted format in the user's app local directory. /// The data will be encrypted so that it can only be read by this machine and this user. diff --git a/Wabbajack.Common/Wabbajack.Common.csproj b/Wabbajack.Common/Wabbajack.Common.csproj index bbe54f78..a7a02d1e 100644 --- a/Wabbajack.Common/Wabbajack.Common.csproj +++ b/Wabbajack.Common/Wabbajack.Common.csproj @@ -130,6 +130,7 @@ + diff --git a/Wabbajack.Lib/Downloaders/HTTPDownloader.cs b/Wabbajack.Lib/Downloaders/HTTPDownloader.cs index a034da6f..c0471c66 100644 --- a/Wabbajack.Lib/Downloaders/HTTPDownloader.cs +++ b/Wabbajack.Lib/Downloaders/HTTPDownloader.cs @@ -75,8 +75,9 @@ namespace Wabbajack.Lib.Downloaders public async Task DoDownload(Archive a, string destination, bool download) { - if (download && !Directory.Exists(Directory.GetParent(destination).FullName)) - Directory.CreateDirectory(Directory.GetParent(destination).FullName); + var parent = Directory.GetParent(destination); + if (download && !Directory.Exists(parent.FullName)) + Directory.CreateDirectory(parent.FullName); using (var fs = download ? File.OpenWrite(destination) : null) { @@ -120,12 +121,6 @@ namespace Wabbajack.Lib.Downloaders var contentSize = headerVar != null ? long.Parse(headerVar) : 1; - FileInfo fileInfo = new FileInfo(destination); - if (!fileInfo.Directory.Exists) - { - Directory.CreateDirectory(fileInfo.Directory.FullName); - } - using (var webs = stream) { var buffer = new byte[bufferSize]; diff --git a/Wabbajack.Lib/Downloaders/ManualDownloader.cs b/Wabbajack.Lib/Downloaders/ManualDownloader.cs index b6f6550e..ad316936 100644 --- a/Wabbajack.Lib/Downloaders/ManualDownloader.cs +++ b/Wabbajack.Lib/Downloaders/ManualDownloader.cs @@ -17,6 +17,7 @@ namespace Wabbajack.Lib.Downloaders private FileSystemWatcher _watcher; private Subject _fileEvents = new Subject(); private KnownFolder _downloadfolder; + public readonly AsyncLock Lock = new AsyncLock(); class FileEvent { @@ -81,7 +82,7 @@ namespace Wabbajack.Lib.Downloaders { var downloader = (ManualDownloader)GetDownloader(); var absPath = Path.Combine(downloader._downloadfolder.Path, a.Name); - lock (downloader) + using (await downloader.Lock.Wait()) { try { @@ -95,8 +96,8 @@ namespace Wabbajack.Lib.Downloaders .Select(x => x.FirstOrDefault()) .FirstOrDefaultAsync(); Process.Start(Url); - - absPath = watcher.Wait()?.FullPath; + + absPath = (await watcher)?.FullPath; if (!File.Exists(absPath)) throw new InvalidDataException($"File not found after manual download operation"); File.Move(absPath, destination); diff --git a/Wabbajack.Lib/MO2Installer.cs b/Wabbajack.Lib/MO2Installer.cs index 6862545a..3d19ccd6 100644 --- a/Wabbajack.Lib/MO2Installer.cs +++ b/Wabbajack.Lib/MO2Installer.cs @@ -319,7 +319,7 @@ namespace Wabbajack.Lib File.WriteAllText(Path.Combine(OutputFolder, directive.To), data); } - public static IErrorResponse CheckValidInstallPath(string path) + public static IErrorResponse CheckValidInstallPath(string path, string downloadFolder) { var ret = Utils.IsDirectoryPathValid(path); if (!ret.Succeeded) return ret; @@ -339,8 +339,8 @@ namespace Wabbajack.Lib // Check folder is either empty, or a likely valid previous install if (!Directory.IsEmpty(path)) { - // Some probably naive check, but should be a good starting point to improve later - if (!Directory.EnumerateFiles(path).Any(file => + // If we have a MO2 install, assume good to go + if (Directory.EnumerateFiles(path).Any(file => { var fileName = Path.GetFileName(file); if (fileName.Equals("ModOrganizer.exe", StringComparison.OrdinalIgnoreCase)) return true; @@ -348,7 +348,19 @@ namespace Wabbajack.Lib return false; })) { - return ErrorResponse.Fail($"Cannot install into a non-empty folder that does not look like a previous WJ installation."); + return ErrorResponse.Success; + } + + // If we don't have a MO2 install, and there's any file that's not in the downloads folder, mark failure + if (Directory.EnumerateFiles(path).Any(file => + { + var fileName = Path.GetFileName(file); + if (string.IsNullOrWhiteSpace(downloadFolder)) return true; + return !Utils.IsUnderneathDirectory(file, downloadFolder); + })) + { + return ErrorResponse.Fail($"Cannot install into a non-empty folder that does not look like a previous WJ installation.\n" + + $"To override, delete all installed files from your target installation folder. Any files in your download folder are okay to keep."); } } diff --git a/Wabbajack.Lib/NexusApi/NexusApi.cs b/Wabbajack.Lib/NexusApi/NexusApi.cs index 512b1f31..98601469 100644 --- a/Wabbajack.Lib/NexusApi/NexusApi.cs +++ b/Wabbajack.Lib/NexusApi/NexusApi.cs @@ -62,12 +62,10 @@ namespace Wabbajack.Lib.NexusApi public async Task Username() => (await UserStatus).name; - private static SemaphoreSlim _getAPIKeyLock = new SemaphoreSlim(1, 1); + private static AsyncLock _getAPIKeyLock = new AsyncLock(); private static async Task GetApiKey() { - await _getAPIKeyLock.WaitAsync(); - - try + using (await _getAPIKeyLock.Wait()) { // Clean up old location if (File.Exists(API_KEY_CACHE_FILE)) @@ -91,10 +89,6 @@ namespace Wabbajack.Lib.NexusApi return await RequestAndCacheAPIKey(); } - finally - { - _getAPIKeyLock.Release(); - } } public static async Task RequestAndCacheAPIKey() diff --git a/Wabbajack.Test/ACompilerTest.cs b/Wabbajack.Test/ACompilerTest.cs index 875e29ec..77ce1409 100644 --- a/Wabbajack.Test/ACompilerTest.cs +++ b/Wabbajack.Test/ACompilerTest.cs @@ -45,11 +45,11 @@ namespace Wabbajack.Test protected async Task CompileAndInstall(string profile) { var compiler = await ConfigureAndRunCompiler(profile); - Install(compiler); + await Install(compiler); return compiler.ModList; } - protected void Install(MO2Compiler compiler) + protected async Task Install(MO2Compiler compiler) { var modlist = AInstaller.LoadFromFile(compiler.ModListOutputFile); var installer = new MO2Installer( @@ -59,7 +59,7 @@ namespace Wabbajack.Test downloadFolder: utils.DownloadsFolder); installer.WarnOnOverwrite = false; installer.GameFolder = utils.GameFolder; - installer.Begin().Wait(); + await installer.Begin(); } } } diff --git a/Wabbajack.Test/AVortexCompilerTest.cs b/Wabbajack.Test/AVortexCompilerTest.cs index c7433f87..1f9c7466 100644 --- a/Wabbajack.Test/AVortexCompilerTest.cs +++ b/Wabbajack.Test/AVortexCompilerTest.cs @@ -56,11 +56,11 @@ namespace Wabbajack.Test protected async Task CompileAndInstall() { var vortexCompiler = await ConfigureAndRunCompiler(); - Install(vortexCompiler); + await Install(vortexCompiler); return vortexCompiler.ModList; } - protected void Install(VortexCompiler vortexCompiler) + protected async Task Install(VortexCompiler vortexCompiler) { var modList = AInstaller.LoadFromFile(vortexCompiler.ModListOutputFile); var installer = new MO2Installer( @@ -71,7 +71,7 @@ namespace Wabbajack.Test { GameFolder = utils.GameFolder, }; - installer.Begin().Wait(); + await installer.Begin(); } } } diff --git a/Wabbajack.Test/AsyncLockTests.cs b/Wabbajack.Test/AsyncLockTests.cs new file mode 100644 index 00000000..dbcb912b --- /dev/null +++ b/Wabbajack.Test/AsyncLockTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Wabbajack.Common; + +namespace Wabbajack.Test +{ + [TestClass] + public class AsyncLockTests + { + [TestMethod] + public async Task Typical() + { + var asyncLock = new AsyncLock(); + bool firstRun = false; + var first = Task.Run(async () => + { + using (await asyncLock.Wait()) + { + await Task.Delay(500); + firstRun = true; + } + }); + var second = Task.Run(async () => + { + await Task.Delay(200); + using (await asyncLock.Wait()) + { + Assert.IsTrue(firstRun); + } + }); + await Task.WhenAll(first, second); + } + + [TestMethod] + public async Task Exception() + { + var asyncLock = new AsyncLock(); + bool firstRun = false; + bool secondRun = false; + // Throw exception inside a lock + await Assert.ThrowsExceptionAsync(() => + { + return Task.Run(async () => + { + using (await asyncLock.Wait()) + { + await Task.Delay(500); + firstRun = true; + throw new Exception(); + } + }); + }); + + await Task.WhenAll( + // Try to re-enter lock afterwards + Task.Run(async () => + { + await Task.Delay(200); + using (await asyncLock.Wait()) + { + Assert.IsTrue(firstRun); + secondRun = true; + } + }), + // Add a timeout to fail if we cannot + Task.Run(async () => + { + await Task.Delay(500); + if (!secondRun) + { + throw new ArgumentException(); + } + })); + } + } +} diff --git a/Wabbajack.Test/MO2Tests.cs b/Wabbajack.Test/MO2Tests.cs new file mode 100644 index 00000000..dca0f22e --- /dev/null +++ b/Wabbajack.Test/MO2Tests.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Wabbajack.Common; +using Wabbajack.Lib; + +namespace Wabbajack.Test +{ + [TestClass] + public class MO2Tests + { + #region CheckValidInstallPath + [TestMethod] + public void CheckValidInstallPath_Empty() + { + using (var tempDir = new TempFolder()) + { + Assert.IsTrue(MO2Installer.CheckValidInstallPath(tempDir.Dir.FullName, downloadFolder: null).Succeeded); + } + } + + [TestMethod] + public void CheckValidInstallPath_DoesNotExist() + { + using (var tempDir = new TempFolder()) + { + Assert.IsTrue(MO2Installer.CheckValidInstallPath(Path.Combine(tempDir.Dir.FullName, "Subfolder"), downloadFolder: null).Succeeded); + } + } + + [TestMethod] + public void CheckValidInstallPath_Invalid() + { + using (var tempDir = new TempFolder()) + { + Assert.IsFalse(MO2Installer.CheckValidInstallPath($"{tempDir.Dir.FullName}/*", downloadFolder: null).Succeeded); + } + } + + [TestMethod] + public void CheckValidInstallPath_HasModlist() + { + using (var tempDir = new TempFolder()) + { + File.Create(Path.Combine(tempDir.Dir.FullName, $"ModOrganizer.exe")); + File.Create(Path.Combine(tempDir.Dir.FullName, $"modlist{ExtensionManager.Extension}")); + Assert.IsFalse(MO2Installer.CheckValidInstallPath(tempDir.Dir.FullName, downloadFolder: null).Succeeded); + } + } + + [TestMethod] + public void CheckValidInstallPath_ProperOverwrite() + { + using (var tempDir = new TempFolder()) + { + File.Create(Path.Combine(tempDir.Dir.FullName, $"ModOrganizer.exe")); + Assert.IsTrue(MO2Installer.CheckValidInstallPath(tempDir.Dir.FullName, downloadFolder: null).Succeeded); + } + } + + [TestMethod] + public void CheckValidInstallPath_ImproperOverwrite() + { + using (var tempDir = new TempFolder()) + { + File.Create(Path.Combine(tempDir.Dir.FullName, $"someFile.txt")); + Assert.IsFalse(MO2Installer.CheckValidInstallPath(tempDir.Dir.FullName, downloadFolder: null).Succeeded); + } + } + + [TestMethod] + public void CheckValidInstallPath_OverwriteFilesInDownloads() + { + using (var tempDir = new TempFolder()) + { + var downloadsFolder = Path.Combine(tempDir.Dir.FullName, "downloads"); + Directory.CreateDirectory(downloadsFolder); + File.Create(Path.Combine(tempDir.Dir.FullName, $"downloads/someFile.txt")); + Assert.IsFalse(MO2Installer.CheckValidInstallPath(tempDir.Dir.FullName, downloadFolder: downloadsFolder).Succeeded); + } + } + #endregion + } +} diff --git a/Wabbajack.Test/Wabbajack.Test.csproj b/Wabbajack.Test/Wabbajack.Test.csproj index 6cddcaf3..137110d6 100644 --- a/Wabbajack.Test/Wabbajack.Test.csproj +++ b/Wabbajack.Test/Wabbajack.Test.csproj @@ -99,6 +99,7 @@ + @@ -107,6 +108,7 @@ + diff --git a/Wabbajack/Themes/Styles.xaml b/Wabbajack/Themes/Styles.xaml index 13314fef..e7cf7b4c 100644 --- a/Wabbajack/Themes/Styles.xaml +++ b/Wabbajack/Themes/Styles.xaml @@ -41,7 +41,8 @@ #BB86FC #00BB86FC #3700B3 - #1b0059 + #270080 + #1b0059 #03DAC6 #0e8f83 #095952 @@ -117,6 +118,7 @@ + diff --git a/Wabbajack/View Models/Installers/MO2InstallerVM.cs b/Wabbajack/View Models/Installers/MO2InstallerVM.cs index c0572a81..44ac1491 100644 --- a/Wabbajack/View Models/Installers/MO2InstallerVM.cs +++ b/Wabbajack/View Models/Installers/MO2InstallerVM.cs @@ -47,8 +47,6 @@ namespace Wabbajack PathType = FilePickerVM.PathTypeOptions.Folder, PromptTitle = "Select Installation Directory", }; - Location.AdditionalError = this.WhenAny(x => x.Location.TargetPath) - .Select(x => MO2Installer.CheckValidInstallPath(x)); DownloadLocation = new FilePickerVM() { ExistCheckOption = FilePickerVM.CheckOptions.Off, @@ -57,6 +55,13 @@ namespace Wabbajack }; DownloadLocation.AdditionalError = this.WhenAny(x => x.DownloadLocation.TargetPath) .Select(x => Utils.IsDirectoryPathValid(x)); + Location.AdditionalError = Observable.CombineLatest( + this.WhenAny(x => x.Location.TargetPath), + this.WhenAny(x => x.DownloadLocation.TargetPath), + resultSelector: (target, download) => + { + return MO2Installer.CheckValidInstallPath(target, download); + }); CanInstall = Observable.CombineLatest( this.WhenAny(x => x.Location.InError), @@ -83,7 +88,7 @@ namespace Wabbajack _CurrentSettings = installerVM.WhenAny(x => x.ModListLocation.TargetPath) .Select(path => path == null ? null : installerVM.MWVM.Settings.Installer.Mo2ModlistSettings.TryCreate(path)) .ToProperty(this, nameof(CurrentSettings)); - (this).WhenAny(x => x.CurrentSettings) + this.WhenAny(x => x.CurrentSettings) .Pairwise() .Subscribe(settingsPair => { diff --git a/Wabbajack/View Models/ModVM.cs b/Wabbajack/View Models/ModVM.cs index 41a7e416..095da277 100644 --- a/Wabbajack/View Models/ModVM.cs +++ b/Wabbajack/View Models/ModVM.cs @@ -49,7 +49,8 @@ namespace Wabbajack try { var ret = new MemoryStream(); - using (Stream stream = await new HttpClient().GetStreamAsync(url)) + using (var client = new HttpClient()) + using (Stream stream = await client.GetStreamAsync(url)) { stream.CopyTo(ret); } @@ -82,7 +83,7 @@ namespace Wabbajack } }) .Replay(1) - .RefCount(); + .RefCount(TimeSpan.FromMilliseconds(5000)); } } } diff --git a/Wabbajack/View Models/SlideShow.cs b/Wabbajack/View Models/SlideShow.cs index 053dfd87..1797e42b 100644 --- a/Wabbajack/View Models/SlideShow.cs +++ b/Wabbajack/View Models/SlideShow.cs @@ -5,8 +5,10 @@ using System; using System.Diagnostics; using System.Linq; using System.Reactive; +using System.Reactive.Disposables; using System.Reactive.Linq; using System.Windows.Media.Imaging; +using Wabbajack.Common; using Wabbajack.Lib; using Wabbajack.Lib.Downloaders; @@ -33,6 +35,8 @@ namespace Wabbajack public IReactiveCommand SlideShowNextItemCommand { get; } = ReactiveCommand.Create(() => { }); public IReactiveCommand VisitNexusSiteCommand { get; } + public const int PreloadAmount = 4; + public SlideShow(InstallerVM appState) { Installer = appState; @@ -98,7 +102,11 @@ namespace Wabbajack _targetMod = Observable.CombineLatest( modVMs.QueryWhenChanged(), selectedIndex, - resultSelector: (query, selected) => query.Items.ElementAtOrDefault(selected % (query.Count == 0 ? 1 : query.Count))) + resultSelector: (query, selected) => + { + var index = selected % (query.Count == 0 ? 1 : query.Count); + return query.Items.ElementAtOrDefault(index); + }) .StartWith(default(ModVM)) .ObserveOn(RxApp.MainThreadScheduler) .ToProperty(this, nameof(TargetMod)); @@ -116,14 +124,22 @@ namespace Wabbajack .Select(x => x?.StartsWith("https://") ?? false) .ObserveOnGuiThread()); - // ToDo - // Can maybe add "preload" systems to prep upcoming images - // This would entail subscribing to modVMs, narrowing it down to Top(X) or Page() somehow. - // The result would not be used anywhere, just simply expressing interest in those mods' - // images will implicitly cache them - // - // Page would be really clever to use, but it's not exactly right as its "window" won't follow the current index, - // so at the boundary of a page, the next image won't be cached. Need like a Page() /w an offset parameter, or something. + // Preload upcoming images + var list = Observable.CombineLatest( + modVMs.QueryWhenChanged(), + selectedIndex, + resultSelector: (query, selected) => + { + // Retrieve the mods that should be preloaded + var index = selected % (query.Count == 0 ? 1 : query.Count); + var amountToTake = Math.Min(query.Count - index, PreloadAmount); + return query.Items.Skip(index).Take(amountToTake).ToObservable(); + }) + .Select(i => i.ToObservableChangeSet()) + .Switch() + .Transform(mod => mod.ImageObservable.Subscribe()) + .DisposeMany() + .AsObservableList(); } } } diff --git a/Wabbajack/Views/Common/CpuView.xaml b/Wabbajack/Views/Common/CpuView.xaml index ac120c54..2934f379 100644 --- a/Wabbajack/Views/Common/CpuView.xaml +++ b/Wabbajack/Views/Common/CpuView.xaml @@ -12,32 +12,35 @@ + ItemsSource="{Binding StatusList}" + ScrollViewer.HorizontalScrollBarVisibility="Disabled"> - - - - + - - + + + + diff --git a/Wabbajack/Views/Common/DetailImageView.xaml b/Wabbajack/Views/Common/DetailImageView.xaml index 38280b2c..43bd46f1 100644 --- a/Wabbajack/Views/Common/DetailImageView.xaml +++ b/Wabbajack/Views/Common/DetailImageView.xaml @@ -1,4 +1,4 @@ - - + diff --git a/Wabbajack/UnderMaintenanceOverlay.xaml b/Wabbajack/Views/Common/UnderMaintenanceOverlay.xaml similarity index 58% rename from Wabbajack/UnderMaintenanceOverlay.xaml rename to Wabbajack/Views/Common/UnderMaintenanceOverlay.xaml index a8102af6..e7138b40 100644 --- a/Wabbajack/UnderMaintenanceOverlay.xaml +++ b/Wabbajack/Views/Common/UnderMaintenanceOverlay.xaml @@ -3,6 +3,7 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:icon="http://metro.mahapps.com/winfx/xaml/iconpacks" xmlns:local="clr-namespace:Wabbajack" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" d:DesignHeight="250" @@ -11,7 +12,7 @@ #fec701 - #99000000 + #AA000000 @@ -36,10 +37,36 @@ ShadowDepth="10" /> + + + + + + + + + VerticalAlignment="Top" + Visibility="{Binding ShowHelp, RelativeSource={RelativeSource AncestorType={x:Type UserControl}}, Converter={StaticResource bool2VisibilityConverter}, ConverterParameter=False}"> @@ -51,7 +78,6 @@ + public partial class UnderMaintenanceOverlay : UserControl { + public bool ShowHelp + { + get => (bool)GetValue(ShowHelpProperty); + set => SetValue(ShowHelpProperty, value); + } + public static readonly DependencyProperty ShowHelpProperty = DependencyProperty.Register(nameof(ShowHelp), typeof(bool), typeof(UnderMaintenanceOverlay), + new FrameworkPropertyMetadata(default(bool))); + public UnderMaintenanceOverlay() { InitializeComponent(); } + + private void Help_Click(object sender, RoutedEventArgs e) + { + ShowHelp = !ShowHelp; + } } } diff --git a/Wabbajack/Wabbajack.csproj b/Wabbajack/Wabbajack.csproj index c25cb35e..d661dc27 100644 --- a/Wabbajack/Wabbajack.csproj +++ b/Wabbajack/Wabbajack.csproj @@ -176,7 +176,7 @@ AttentionBorder.xaml - + UnderMaintenanceOverlay.xaml @@ -286,7 +286,7 @@ Designer MSBuild:Compile - + Designer MSBuild:Compile