diff --git a/Wabbajack.App/App.axaml.cs b/Wabbajack.App/App.axaml.cs index 05bab6da..5c8de9fa 100644 --- a/Wabbajack.App/App.axaml.cs +++ b/Wabbajack.App/App.axaml.cs @@ -1,8 +1,10 @@ using System; +using System.Threading; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; +using Avalonia.Threading; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -24,6 +26,7 @@ public class App : Application public override void Initialize() { + Dispatcher.UIThread.Post(() => Thread.CurrentThread.Name = "UIThread"); AvaloniaXamlLoader.Load(this); } diff --git a/Wabbajack.App/Assets/Wabbajack.axaml b/Wabbajack.App/Assets/Wabbajack.axaml index 3d9c8975..46111d3e 100644 --- a/Wabbajack.App/Assets/Wabbajack.axaml +++ b/Wabbajack.App/Assets/Wabbajack.axaml @@ -37,7 +37,31 @@ + + + + + + + + + \ No newline at end of file diff --git a/Wabbajack.App/Controls/FileSelectionBox.axaml.cs b/Wabbajack.App/Controls/FileSelectionBox.axaml.cs index 92bd55e4..08939f90 100644 --- a/Wabbajack.App/Controls/FileSelectionBox.axaml.cs +++ b/Wabbajack.App/Controls/FileSelectionBox.axaml.cs @@ -30,7 +30,7 @@ public partial class FileSelectionBox : ReactiveUserControl { - this.Bind(ViewModel, vm => vm.Path, view => view.SelectedPath) + this.OneWayBind(ViewModel, vm => vm.Path, view => view.SelectedPath) .DisposeWith(disposables); this.WhenAnyValue(view => view.SelectFolder) .BindTo(ViewModel, vm => vm.SelectFolder) @@ -41,7 +41,7 @@ public partial class FileSelectionBox : ReactiveUserControl new Extension(s)).ToArray()) .BindTo(ViewModel, vm => vm.Extensions) .DisposeWith(disposables); - this.Bind(ViewModel, vm => vm.Path, + this.OneWayBind(ViewModel, vm => vm.Path, view => view.TextBox.Text) .DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.BrowseCommand, @@ -67,4 +67,9 @@ public partial class FileSelectionBox : ReactiveUserControl GetValue(SelectFolderProperty); set => SetValue(SelectFolderProperty, value); } + + public void Load(AbsolutePath path) + { + ViewModel.Path = path; + } } \ No newline at end of file diff --git a/Wabbajack.App/Controls/LogView.axaml b/Wabbajack.App/Controls/LogView.axaml index bb33f98d..a59ad25a 100644 --- a/Wabbajack.App/Controls/LogView.axaml +++ b/Wabbajack.App/Controls/LogView.axaml @@ -6,23 +6,25 @@ xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Wabbajack.App.Controls.LogView"> - - Current Log Contents - - - - - - - - - - - - - - + + Current Log Contents + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Wabbajack.App/Extensions/IObservableExtensions.cs b/Wabbajack.App/Extensions/IObservableExtensions.cs index 210a9d9d..c28f9bf6 100644 --- a/Wabbajack.App/Extensions/IObservableExtensions.cs +++ b/Wabbajack.App/Extensions/IObservableExtensions.cs @@ -1,24 +1,43 @@ using System; +using System.Linq.Expressions; using System.Reactive.Disposables; +using System.Reactive.Linq; using System.Reactive.Subjects; using System.Threading.Tasks; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using ReactiveUI; namespace Wabbajack.App.Extensions; public static class IObservableExtensions { - public static IObservable SelectAsync(this IObservable input, - CompositeDisposable disposable, - Func> func) + public static IDisposable SimpleOneWayBind( + this TView view, + TViewModel? viewModel, + Expression> vmProperty, + Expression> viewProperty) + where TView : class { - Subject returnObs = new(); + var d = viewModel.WhenAny(vmProperty, change => change.Value) + .ObserveOn(RxApp.MainThreadScheduler) + .BindTo(view, viewProperty); - input.Subscribe(x => Task.Run(async () => - { - var result = await func(x); - returnObs.OnNext(result); - })).DisposeWith(disposable); + return Disposable.Create(() => d.Dispose()); + } - return returnObs; + public static IDisposable SimpleOneWayBind( + this TView view, + TViewModel? viewModel, + Expression> vmProperty, + Expression> viewProperty, + Func selector) + where TView : class + { + var d = viewModel.WhenAnyValue(vmProperty) + .Select(change => selector(change)) + .ObserveOn(RxApp.MainThreadScheduler) + .BindTo(view, viewProperty); + + return Disposable.Create(() => d.Dispose()); } } \ No newline at end of file diff --git a/Wabbajack.App/Extensions/ReactiveUIExtensions.cs b/Wabbajack.App/Extensions/ReactiveUIExtensions.cs index 50f47067..22b190a3 100644 --- a/Wabbajack.App/Extensions/ReactiveUIExtensions.cs +++ b/Wabbajack.App/Extensions/ReactiveUIExtensions.cs @@ -1,5 +1,14 @@ +using System; +using System.Reactive.Linq; +using Avalonia.Threading; +using ReactiveUI; + namespace Wabbajack.App.Extensions; public static class ReactiveUIExtensions { + public static IObservable OnUIThread(this IObservable src) + { + return src.ObserveOn(AvaloniaScheduler.Instance); + } } \ No newline at end of file diff --git a/Wabbajack.App/Interfaces/IScreenView.cs b/Wabbajack.App/Interfaces/IScreenView.cs index 5ee01c93..d2ce26e5 100644 --- a/Wabbajack.App/Interfaces/IScreenView.cs +++ b/Wabbajack.App/Interfaces/IScreenView.cs @@ -5,4 +5,5 @@ namespace Wabbajack.App.Interfaces; public interface IScreenView { public Type ViewModelType { get; } + public string HumanName { get; } } \ No newline at end of file diff --git a/Wabbajack.App/Models/SettingsManager.cs b/Wabbajack.App/Models/SettingsManager.cs index 15335685..7ca49047 100644 --- a/Wabbajack.App/Models/SettingsManager.cs +++ b/Wabbajack.App/Models/SettingsManager.cs @@ -48,10 +48,12 @@ public class SettingsManager try { if (path.FileExists()) + { await using (var s = path.Open(FileMode.Open)) { return (await JsonSerializer.DeserializeAsync(s, _dtos.Options))!; } + } } catch (Exception ex) { diff --git a/Wabbajack.App/Screens/BrowseView.axaml.cs b/Wabbajack.App/Screens/BrowseView.axaml.cs index 64b8d898..aa766c48 100644 --- a/Wabbajack.App/Screens/BrowseView.axaml.cs +++ b/Wabbajack.App/Screens/BrowseView.axaml.cs @@ -6,7 +6,7 @@ namespace Wabbajack.App.Screens; public partial class BrowseView : ScreenBase { - public BrowseView() + public BrowseView() : base("Web Browser") { InitializeComponent(); this.WhenActivated(disposables => diff --git a/Wabbajack.App/Screens/CompilationView.axaml.cs b/Wabbajack.App/Screens/CompilationView.axaml.cs index 18b4e8fe..0f09a05e 100644 --- a/Wabbajack.App/Screens/CompilationView.axaml.cs +++ b/Wabbajack.App/Screens/CompilationView.axaml.cs @@ -6,7 +6,7 @@ namespace Wabbajack.App.Screens; public partial class CompilationView : ScreenBase { - public CompilationView() + public CompilationView() : base("Compiling") { InitializeComponent(); diff --git a/Wabbajack.App/Screens/CompilerConfigurationView.axaml.cs b/Wabbajack.App/Screens/CompilerConfigurationView.axaml.cs index 4bc24004..ecb150f8 100644 --- a/Wabbajack.App/Screens/CompilerConfigurationView.axaml.cs +++ b/Wabbajack.App/Screens/CompilerConfigurationView.axaml.cs @@ -15,7 +15,7 @@ namespace Wabbajack.App.Screens; public partial class CompilerConfigurationView : ScreenBase { - public CompilerConfigurationView() + public CompilerConfigurationView() : base("Compiler Configuration") { InitializeComponent(); AddAlwaysEnabled.Command = ReactiveCommand.Create(() => AddAlwaysEnabled_Command().FireAndForget()); diff --git a/Wabbajack.App/Screens/ErrorPageView.axaml.cs b/Wabbajack.App/Screens/ErrorPageView.axaml.cs index 36441c1d..234fb8d6 100644 --- a/Wabbajack.App/Screens/ErrorPageView.axaml.cs +++ b/Wabbajack.App/Screens/ErrorPageView.axaml.cs @@ -6,7 +6,7 @@ namespace Wabbajack.App.Screens; public partial class ErrorPageView : ScreenBase { - public ErrorPageView() + public ErrorPageView() : base("Error") { InitializeComponent(); this.WhenActivated(disposables => diff --git a/Wabbajack.App/Screens/LauncherView.axaml.cs b/Wabbajack.App/Screens/LauncherView.axaml.cs index bf0ac326..3c8ccfa9 100644 --- a/Wabbajack.App/Screens/LauncherView.axaml.cs +++ b/Wabbajack.App/Screens/LauncherView.axaml.cs @@ -6,7 +6,7 @@ namespace Wabbajack.App.Screens; public partial class LauncherView : ScreenBase { - public LauncherView() + public LauncherView() : base("Launch Modlist") { InitializeComponent(); this.WhenActivated(disposables => diff --git a/Wabbajack.App/Screens/LauncherViewModel.cs b/Wabbajack.App/Screens/LauncherViewModel.cs index 10183ca1..f62793a5 100644 --- a/Wabbajack.App/Screens/LauncherViewModel.cs +++ b/Wabbajack.App/Screens/LauncherViewModel.cs @@ -35,20 +35,20 @@ public class LauncherViewModel : ViewModelBase _logger = logger; MessageBus.Current.Listen() - .Subscribe(v => Receive(v)) + .Subscribe(Receive) .DisposeWith(VMDisposables); this.WhenActivated(disposables => { this.WhenAnyValue(v => v.InstallFolder) - .SelectAsync(disposables, async folder => await manager.GetByInstallFolder(folder)) - .ObserveOn(RxApp.MainThreadScheduler) + .SelectMany(async folder => await manager.GetByInstallFolder(folder)) + .OnUIThread() .Where(v => v != null) .BindTo(this, vm => vm.Setting) .DisposeWith(disposables); this.WhenAnyValue(v => v.Setting) - .Where(v => v != default) + .Where(v => v != default && v!.Image != default && v!.Image.FileExists()) .Select(v => new Bitmap(v!.Image.ToString())) .BindTo(this, vm => vm.Image) .DisposeWith(disposables); diff --git a/Wabbajack.App/Screens/LogScreenView.axaml.cs b/Wabbajack.App/Screens/LogScreenView.axaml.cs index 85a85735..dd3cb47e 100644 --- a/Wabbajack.App/Screens/LogScreenView.axaml.cs +++ b/Wabbajack.App/Screens/LogScreenView.axaml.cs @@ -4,7 +4,7 @@ namespace Wabbajack.App.Screens; public partial class LogScreenView : ScreenBase { - public LogScreenView() + public LogScreenView() : base("Application Log") { InitializeComponent(); } diff --git a/Wabbajack.App/Screens/PlaySelectView.axaml.cs b/Wabbajack.App/Screens/PlaySelectView.axaml.cs index 28aa6e7f..3a6ffa53 100644 --- a/Wabbajack.App/Screens/PlaySelectView.axaml.cs +++ b/Wabbajack.App/Screens/PlaySelectView.axaml.cs @@ -6,7 +6,7 @@ namespace Wabbajack.App.Screens; public partial class PlaySelectView : ScreenBase { - public PlaySelectView() + public PlaySelectView() : base("Modlist Selection") { InitializeComponent(); this.WhenActivated(disposables => diff --git a/Wabbajack.App/Screens/SettingsView.axaml.cs b/Wabbajack.App/Screens/SettingsView.axaml.cs index 0d7f7c1f..fb7fc1cb 100644 --- a/Wabbajack.App/Screens/SettingsView.axaml.cs +++ b/Wabbajack.App/Screens/SettingsView.axaml.cs @@ -6,7 +6,7 @@ namespace Wabbajack.App.Screens; public partial class SettingsView : ScreenBase { - public SettingsView() + public SettingsView() : base("Settings") { InitializeComponent(); this.WhenActivated(disposables => diff --git a/Wabbajack.App/Screens/StandardInstallationView.axaml b/Wabbajack.App/Screens/StandardInstallationView.axaml index d7e35f37..b60d6bf4 100644 --- a/Wabbajack.App/Screens/StandardInstallationView.axaml +++ b/Wabbajack.App/Screens/StandardInstallationView.axaml @@ -7,8 +7,8 @@ x:Class="Wabbajack.App.Views.StandardInstallationView"> [20/30] Installing Files - - + + diff --git a/Wabbajack.App/Screens/StandardInstallationView.axaml.cs b/Wabbajack.App/Screens/StandardInstallationView.axaml.cs index 60b12837..1e0d59a1 100644 --- a/Wabbajack.App/Screens/StandardInstallationView.axaml.cs +++ b/Wabbajack.App/Screens/StandardInstallationView.axaml.cs @@ -1,18 +1,22 @@ +using System; using System.Reactive.Disposables; +using System.Reactive.Linq; +using Avalonia.Threading; using ReactiveUI; +using Wabbajack.App.Extensions; using Wabbajack.App.ViewModels; namespace Wabbajack.App.Views; public partial class StandardInstallationView : ScreenBase { - public StandardInstallationView() + public StandardInstallationView() : base("Installing") { InitializeComponent(); this.WhenActivated(disposables => { - this.Bind(ViewModel, vm => vm.Slide.Image, view => view.SlideImage.Source) + this.OneWayBind(ViewModel, vm => vm.Slide.Image, view => view.SlideImage.Source) .DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.NextCommand, view => view.NextSlide) @@ -33,8 +37,9 @@ public partial class StandardInstallationView : ScreenBase vm.StepsProgress, view => view.StepsProgress.Value, p => p.Value * 1000) .DisposeWith(disposables); - this.OneWayBind(ViewModel, vm => vm.StepProgress, view => view.StepProgress.Value, p => p.Value * 10000) + this.OneWayBind(ViewModel, vm => vm.StepProgress, p => p.StepProgress.Value, p => p.Value * 10000) .DisposeWith(disposables); + }); } } \ No newline at end of file diff --git a/Wabbajack.App/Screens/StandardInstallationViewModel.cs b/Wabbajack.App/Screens/StandardInstallationViewModel.cs index 57ae7134..8d489822 100644 --- a/Wabbajack.App/Screens/StandardInstallationViewModel.cs +++ b/Wabbajack.App/Screens/StandardInstallationViewModel.cs @@ -20,6 +20,7 @@ using Wabbajack.App.Utilities; using Wabbajack.App.ViewModels.SubViewModels; using Wabbajack.Common; using Wabbajack.Downloaders.GameFile; +using Wabbajack.Downloaders.Interfaces; using Wabbajack.DTOs; using Wabbajack.DTOs.DownloadStates; using Wabbajack.DTOs.JsonConverters; @@ -44,6 +45,7 @@ public class StandardInstallationViewModel : ViewModelBase private IServiceScope _scope; private SlideViewModel[] _slides = Array.Empty(); private Timer _slideTimer; + private Timer _updateTimer; public StandardInstallationViewModel(ILogger logger, IServiceProvider provider, GameLocator locator, DTOSerializer dtos, @@ -63,6 +65,9 @@ public class StandardInstallationViewModel : ViewModelBase this.WhenActivated(disposables => { + _updateTimer = new Timer(UpdateStatus, null, TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(100)); + _updateTimer.DisposeWith(disposables); + _slideTimer = new Timer(_ => { if (IsPlaying) NextSlide(1); @@ -102,12 +107,24 @@ public class StandardInstallationViewModel : ViewModelBase [Reactive] public string StatusText { get; set; } = ""; [Reactive] public Percent StepsProgress { get; set; } = Percent.Zero; [Reactive] public Percent StepProgress { get; set; } = Percent.Zero; + + // Not Reactive, so we don't end up spamming the UI threads with events + public StatusUpdate _latestStatus = new("", Percent.Zero, Percent.Zero); public void Receive(StartInstallation msg) { Install(msg).FireAndForget(); } + private void UpdateStatus(object? state) + { + Dispatcher.UIThread.Post(() => + { + StepsProgress = _latestStatus.StepsProgress; + StepProgress = _latestStatus.StepProgress; + StatusText = _latestStatus.StatusText; + }, DispatcherPriority.Render); + } private void NextSlide(int direction) { @@ -175,21 +192,12 @@ public class StandardInstallationViewModel : ViewModelBase _installer = _provider.GetService()!; - _installer.OnStatusUpdate = async update => - { - Trace.TraceInformation("Update...."); - await Dispatcher.UIThread.InvokeAsync(() => - { - StatusText = update.StatusText; - StepsProgress = update.StepsProgress; - StepProgress = update.StepProgress; - }, DispatcherPriority.Background); - }; + _installer.OnStatusUpdate = update => _latestStatus = update; _logger.LogInformation("Installer created, starting the installation process"); try { - var result = await _installer.Begin(CancellationToken.None); + var result = await Task.Run(async () => await _installer.Begin(CancellationToken.None)); if (!result) throw new Exception("Installation failed"); if (result) await SaveConfigAndContinue(_config); @@ -200,6 +208,7 @@ public class StandardInstallationViewModel : ViewModelBase } } + private async Task SaveConfigAndContinue(InstallerConfiguration config) { var path = config.Install.Combine("modlist-image.png"); diff --git a/Wabbajack.App/ViewModels/InstallConfigurationViewModel.cs b/Wabbajack.App/ViewModels/InstallConfigurationViewModel.cs index e1bb76b3..92907309 100644 --- a/Wabbajack.App/ViewModels/InstallConfigurationViewModel.cs +++ b/Wabbajack.App/ViewModels/InstallConfigurationViewModel.cs @@ -27,11 +27,12 @@ public class InstallConfigurationViewModel : ViewModelBase, IActivatableViewMode { private readonly DTOSerializer _dtos; private readonly InstallationStateManager _stateManager; + private readonly SettingsManager _settingsManager; - - public InstallConfigurationViewModel(DTOSerializer dtos, InstallationStateManager stateManager) + public InstallConfigurationViewModel(DTOSerializer dtos, InstallationStateManager stateManager, SettingsManager settingsManager) { _stateManager = stateManager; + _settingsManager = settingsManager; _dtos = dtos; Activator = new ViewModelActivator(); @@ -51,22 +52,22 @@ public class InstallConfigurationViewModel : ViewModelBase, IActivatableViewMode this.WhenAnyValue(t => t.ModListPath) .Where(t => t != default) - .SelectAsync(disposables, async x => await LoadModList(x)) - .Select(x => x) + .SelectMany(async x => await LoadModList(x)) + .OnUIThread() .ObserveOn(AvaloniaScheduler.Instance) .BindTo(this, t => t.ModList) .DisposeWith(disposables); this.WhenAnyValue(t => t.ModListPath) .Where(t => t != default) - .SelectAsync(disposables, async x => await LoadModListImage(x)) - .ObserveOn(AvaloniaScheduler.Instance) + .SelectMany(async x => await LoadModListImage(x)) + .OnUIThread() .BindTo(this, t => t.ModListImage) .DisposeWith(disposables); var settings = this.WhenAnyValue(t => t.ModListPath) - .SelectAsync(disposables, async v => await _stateManager.Get(v)) - .ObserveOn(RxApp.MainThreadScheduler) + .SelectMany(async v => await _stateManager.Get(v)) + .OnUIThread() .Where(s => s != null); settings.Select(s => s!.Install) @@ -76,9 +77,24 @@ public class InstallConfigurationViewModel : ViewModelBase, IActivatableViewMode settings.Select(s => s!.Downloads) .BindTo(this, vm => vm.Download) .DisposeWith(disposables); + + + LoadSettings().FireAndForget(); + }); } + private async Task LoadSettings() + { + var path = await _settingsManager.Load("last-install-path"); + if (path != default && path.FileExists()) + { + Dispatcher.UIThread.Post(() => { + ModListPath = path; + }); + } + } + [Reactive] public AbsolutePath ModListPath { get; set; } [Reactive] public AbsolutePath Install { get; set; } @@ -107,13 +123,15 @@ public class InstallConfigurationViewModel : ViewModelBase, IActivatableViewMode if (metadataPath.FileExists()) metadata = _dtos.Deserialize(await metadataPath.ReadAllTextAsync()); - _stateManager.SetLastState(new InstallationConfigurationSetting + await _stateManager.SetLastState(new InstallationConfigurationSetting { ModList = ModListPath, Downloads = Download, Install = Install, Metadata = metadata - }).FireAndForget(); + }); + + await _settingsManager.Save("last-install-path", ModListPath); MessageBus.Current.SendMessage(new NavigateTo(typeof(StandardInstallationViewModel))); MessageBus.Current.SendMessage(new StartInstallation(ModListPath, Install, Download, metadata)); diff --git a/Wabbajack.App/ViewModels/MainWindowViewModel.cs b/Wabbajack.App/ViewModels/MainWindowViewModel.cs index ef36998b..420e5141 100644 --- a/Wabbajack.App/ViewModels/MainWindowViewModel.cs +++ b/Wabbajack.App/ViewModels/MainWindowViewModel.cs @@ -59,8 +59,8 @@ public class MainWindowViewModel : ReactiveValidationObject, IActivatableViewMod this.WhenActivated(disposables => { BackButton = ReactiveCommand.Create(() => { Receive(new NavigateBack()); }, - this.ObservableForProperty(vm => vm.BreadCrumbs) - .Select(bc => bc.Value.Count() > 1)) + this.WhenAnyValue(vm => vm.BreadCrumbs) + .Select(bc => bc.Count() > 1)) .DisposeWith(disposables); SettingsButton = ReactiveCommand.Create(() => { Receive(new NavigateTo(typeof(SettingsViewModel))); }) @@ -68,6 +68,13 @@ public class MainWindowViewModel : ReactiveValidationObject, IActivatableViewMod LogViewButton = ReactiveCommand.Create(() => { Receive(new NavigateTo(typeof(LogScreenViewModel))); }) .DisposeWith(disposables); + + this.WhenAnyValue(vm => vm.CurrentScreen) + .Where(view => view != default) + .Select(view => ((IScreenView) view).HumanName) + .Select(txt => txt == "" ? "Wabbajack" : $"Wabbajack - {txt}") + .BindTo(this, vm => vm.TitleText) + .DisposeWith(disposables); }); CurrentScreen = (Control) _screens.First(s => s.ViewModelType == typeof(ModeSelectionViewModel)); @@ -85,6 +92,8 @@ public class MainWindowViewModel : ReactiveValidationObject, IActivatableViewMod [Reactive] public ReactiveCommand LogViewButton { get; set; } [Reactive] public string ResourceStatus { get; set; } + + [Reactive] public string TitleText { get; set; } public ViewModelActivator Activator { get; } diff --git a/Wabbajack.App/Views/GuidedWebView.axaml.cs b/Wabbajack.App/Views/GuidedWebView.axaml.cs index b9e00b00..7c6ac82f 100644 --- a/Wabbajack.App/Views/GuidedWebView.axaml.cs +++ b/Wabbajack.App/Views/GuidedWebView.axaml.cs @@ -8,7 +8,7 @@ namespace Wabbajack.App.Views; public partial class GuidedWebView : ScreenBase { - public GuidedWebView() : base(false) + public GuidedWebView() : base("Web View", false) { InitializeComponent(); diff --git a/Wabbajack.App/Views/InstallConfigurationView.axaml.cs b/Wabbajack.App/Views/InstallConfigurationView.axaml.cs index e7de5283..5292ca79 100644 --- a/Wabbajack.App/Views/InstallConfigurationView.axaml.cs +++ b/Wabbajack.App/Views/InstallConfigurationView.axaml.cs @@ -9,39 +9,44 @@ using Wabbajack.App.ViewModels; namespace Wabbajack.App.Views; -public partial class InstallConfigurationView : ReactiveUserControl, IScreenView +public partial class InstallConfigurationView : ScreenBase, IScreenView { - public InstallConfigurationView() + public InstallConfigurationView() : base("Install Configuration") { InitializeComponent(); DataContext = App.Services.GetService()!; this.WhenActivated(disposables => { - this.Bind(ViewModel, x => x.ModListPath, - view => view.ModListFile.SelectedPath) + ViewModel.WhenAnyValue(vm => vm.ModListPath) + .Subscribe(path => ModListFile.Load(path)) .DisposeWith(disposables); - this.Bind(ViewModel, x => x.Download, - view => view.DownloadPath.SelectedPath) + + ViewModel.WhenAnyValue(vm => vm.ModListPath) + .Subscribe(path => ModListFile.Load(path)) .DisposeWith(disposables); - this.Bind(ViewModel, x => x.Install, - view => view.InstallPath.SelectedPath) + + ViewModel.WhenAnyValue(vm => vm.Download) + .Subscribe(path => DownloadPath.Load(path)) + .DisposeWith(disposables); + + ViewModel.WhenAnyValue(vm => vm.Install) + .Subscribe(path => InstallPath.Load(path)) .DisposeWith(disposables); - ViewModel.WhenAnyValue(x => x.BeginCommand) - .Where(x => x != default) - .BindTo(BeginInstall, x => x.Button.Command) + this.WhenAnyValue(view => view.ModListFile.SelectedPath) + .BindTo(ViewModel, vm => vm.ModListPath) .DisposeWith(disposables); - ViewModel.WhenAnyValue(x => x.ModList) - .Where(x => x != default) - .Select(x => x!.Name) - .BindTo(ModListName, x => x.Text) + this.WhenAnyValue(view => view.DownloadPath.SelectedPath) + .BindTo(ViewModel, vm => vm.Download) .DisposeWith(disposables); - ViewModel.WhenAnyValue(x => x.ModListImage) - .Where(x => x != default) - .BindTo(ModListImage, x => x.Source) + this.WhenAnyValue(view => view.InstallPath.SelectedPath) + .BindTo(ViewModel, vm => vm.Install) + .DisposeWith(disposables); + + this.BindCommand(ViewModel, vm => vm.BeginCommand, view => view.BeginInstall.Button) .DisposeWith(disposables); }); } diff --git a/Wabbajack.App/Views/MainWindow.axaml b/Wabbajack.App/Views/MainWindow.axaml index 760bbb33..18c23a71 100644 --- a/Wabbajack.App/Views/MainWindow.axaml +++ b/Wabbajack.App/Views/MainWindow.axaml @@ -32,25 +32,26 @@ - + + - + - - - - diff --git a/Wabbajack.App/Views/MainWindow.axaml.cs b/Wabbajack.App/Views/MainWindow.axaml.cs index fe5e25e6..91001076 100644 --- a/Wabbajack.App/Views/MainWindow.axaml.cs +++ b/Wabbajack.App/Views/MainWindow.axaml.cs @@ -16,27 +16,30 @@ public partial class MainWindow : ReactiveWindow InitializeComponent(); DataContext = App.Services.GetService()!; - this.WhenActivated(dispose => + this.WhenActivated(disposables => { CloseButton.Command = ReactiveCommand.Create(() => Environment.Exit(0)) - .DisposeWith(dispose); + .DisposeWith(disposables); MinimizeButton.Command = ReactiveCommand.Create(() => WindowState = WindowState.Minimized) - .DisposeWith(dispose); + .DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.BackButton, view => view.BackButton) - .DisposeWith(dispose); + .DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.SettingsButton, view => view.SettingsButton) - .DisposeWith(dispose); + .DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.LogViewButton, view => view.LogButton) - .DisposeWith(dispose); + .DisposeWith(disposables); - this.Bind(ViewModel, vm => vm.CurrentScreen, view => view.Contents.Content) - .DisposeWith(dispose); + this.OneWayBind(ViewModel, vm => vm.CurrentScreen, view => view.Contents.Content) + .DisposeWith(disposables); - this.Bind(ViewModel, vm => vm.ResourceStatus, view => view.ResourceStatus.Text) - .DisposeWith(dispose); + this.OneWayBind(ViewModel, vm => vm.ResourceStatus, view => view.ResourceStatus.Text) + .DisposeWith(disposables); + + this.OneWayBind(ViewModel, vm => vm.TitleText, view => view.TitleText.Text) + .DisposeWith(disposables); }); diff --git a/Wabbajack.App/Views/ModeSelectionView.axaml.cs b/Wabbajack.App/Views/ModeSelectionView.axaml.cs index eec2e51c..0df4eadb 100644 --- a/Wabbajack.App/Views/ModeSelectionView.axaml.cs +++ b/Wabbajack.App/Views/ModeSelectionView.axaml.cs @@ -9,7 +9,7 @@ namespace Wabbajack.App.Views; public partial class ModeSelectionView : ScreenBase { - public ModeSelectionView(IServiceProvider provider) + public ModeSelectionView(IServiceProvider provider) : base("") { InitializeComponent(); this.WhenActivated(disposables => diff --git a/Wabbajack.App/Views/ScreenBase.cs b/Wabbajack.App/Views/ScreenBase.cs index c2157524..d745fe8b 100644 --- a/Wabbajack.App/Views/ScreenBase.cs +++ b/Wabbajack.App/Views/ScreenBase.cs @@ -1,4 +1,5 @@ using System; +using ReactiveUI.Fody.Helpers; using Wabbajack.App.Interfaces; using Wabbajack.App.ViewModels; @@ -7,9 +8,13 @@ namespace Wabbajack.App.Views; public abstract class ScreenBase : ViewBase, IScreenView where T : ViewModelBase { - protected ScreenBase(bool createViewModel = true) : base(createViewModel) + protected ScreenBase(string humanName, bool createViewModel = true) : base(createViewModel) { + HumanName = humanName; } public Type ViewModelType => typeof(T); + + [Reactive] + public string HumanName { get; set; } } \ No newline at end of file diff --git a/Wabbajack.FileExtractor/FileExtractor.cs b/Wabbajack.FileExtractor/FileExtractor.cs index 09e1ba7e..9a7347c8 100644 --- a/Wabbajack.FileExtractor/FileExtractor.cs +++ b/Wabbajack.FileExtractor/FileExtractor.cs @@ -67,7 +67,8 @@ public class FileExtractor Predicate shouldExtract, Func> mapfn, CancellationToken token, - HashSet? onlyFiles = null) + HashSet? onlyFiles = null, + Action? progressFunction = null) { if (sFn is NativeFileStreamFactory) _logger.LogInformation("Extracting {file}", sFn.Name); await using var archive = await sFn.GetStream(); @@ -92,7 +93,7 @@ public class FileExtractor { await using var tempFolder = _manager.CreateFolder(); results = await GatheringExtractWith7Zip(sFn, shouldExtract, - mapfn, onlyFiles, token); + mapfn, onlyFiles, token, progressFunction); } break; @@ -179,7 +180,8 @@ public class FileExtractor Predicate shouldExtract, Func> mapfn, IReadOnlyCollection? onlyFiles, - CancellationToken token) + CancellationToken token, + Action? progressFunction = null) { TemporaryPath? tmpFile = null; await using var dest = _manager.CreateFolder(); @@ -262,6 +264,9 @@ public class FileExtractor var newPosition = percentInt == 0 ? 0 : totalSize / percentInt; var throughput = newPosition - oldPosition; job.ReportNoWait((int) throughput); + + progressFunction?.Invoke(Percent.FactoryPutInRange(lastPercent, 100)); + lastPercent = percentInt; }, token); @@ -306,7 +311,7 @@ public class FileExtractor } public async Task ExtractAll(AbsolutePath src, AbsolutePath dest, CancellationToken token, - Predicate? filterFn = null) + Predicate? filterFn = null, Action? updateProgress = null) { filterFn ??= _ => true; await GatheringExtract(new NativeFileStreamFactory(src), filterFn, async (path, factory) => @@ -316,6 +321,6 @@ public class FileExtractor await using var stream = await factory.GetStream(); await abs.WriteAllAsync(stream, token); return 0; - }, token); + }, token, progressFunction: updateProgress); } } \ No newline at end of file diff --git a/Wabbajack.Installer/AInstaller.cs b/Wabbajack.Installer/AInstaller.cs index 5a691f25..b6583172 100644 --- a/Wabbajack.Installer/AInstaller.cs +++ b/Wabbajack.Installer/AInstaller.cs @@ -51,11 +51,11 @@ public abstract class AInstaller private long _currentStepProgress; - private long _maxStepProgress; + protected long MaxStepProgress { get; set; } private string _statusText; private readonly Stopwatch _updateStopWatch = new(); - public Func? OnStatusUpdate; + public Action? OnStatusUpdate; public AInstaller(ILogger logger, InstallerConfiguration config, IGameLocator gameLocator, @@ -87,41 +87,42 @@ public abstract class AInstaller public ModList ModList => _configuration.ModList; - public async Task NextStep(string statusText, long maxStepProgress) + public void NextStep(string statusText, long maxStepProgress) { _updateStopWatch.Restart(); - _maxStepProgress = maxStepProgress; + MaxStepProgress = maxStepProgress; _currentStep += 1; _statusText = statusText; _logger.LogInformation("Next Step: {Step}", statusText); - if (OnStatusUpdate != null) - await OnStatusUpdate!(new StatusUpdate($"[{_currentStep}/{MaxSteps}] " + statusText, - Percent.FactoryPutInRange(_currentStep, MaxSteps), Percent.Zero)); + OnStatusUpdate?.Invoke(new StatusUpdate($"[{_currentStep}/{MaxSteps}] " + statusText, + Percent.FactoryPutInRange(_currentStep, MaxSteps), Percent.Zero)); } - public async ValueTask UpdateProgress(long stepProgress) + public void UpdateProgress(long stepProgress) { Interlocked.Add(ref _currentStepProgress, stepProgress); - if (_updateStopWatch.ElapsedMilliseconds < _limitMS) return; - lock (_updateStopWatch) - { - if (_updateStopWatch.ElapsedMilliseconds < _limitMS) return; - _updateStopWatch.Restart(); - } - - if (OnStatusUpdate != null) - await OnStatusUpdate!(new StatusUpdate(_statusText, Percent.FactoryPutInRange(_currentStep, MaxSteps), - Percent.FactoryPutInRange(_currentStepProgress, _maxStepProgress))); + OnStatusUpdate?.Invoke(new StatusUpdate($"[{_currentStep}/{MaxSteps}] " + _statusText, Percent.FactoryPutInRange(_currentStep, MaxSteps), + Percent.FactoryPutInRange(_currentStepProgress, MaxStepProgress))); } public abstract Task Begin(CancellationToken token); - public async Task ExtractModlist(CancellationToken token) + protected async Task ExtractModlist(CancellationToken token) { ExtractedModlistFolder = _manager.CreateFolder(); - await _extractor.ExtractAll(_configuration.ModlistArchive, ExtractedModlistFolder, token); + await using var stream = _configuration.ModlistArchive.Open(FileMode.Open, FileAccess.Read, FileShare.Read); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + NextStep("Extracting Modlist", archive.Entries.Count); + foreach (var entry in archive.Entries) + { + var path = entry.FullName.ToRelativePath().RelativeTo(ExtractedModlistFolder); + path.Parent.CreateDirectory(); + await using var of = path.Open(FileMode.Create, FileAccess.Write, FileShare.None); + await entry.Open().CopyToAsync(of, token); + UpdateProgress(1); + } } public async Task LoadBytesFromPath(RelativePath path) @@ -165,13 +166,15 @@ public abstract class AInstaller /// protected async Task PrimeVFS() { + NextStep("Priming VFS", 0); _vfs.AddKnown(_configuration.ModList.Directives.OfType().Select(d => d.ArchiveHashPath), HashedArchives); await _vfs.BackfillMissing(); } - public void BuildFolderStructure() - { + public async Task BuildFolderStructure() + { + NextStep("Building Folder Structure", 0); _logger.LogInformation("Building Folder Structure"); ModList.Directives .Where(d => d.To.Depth > 1) @@ -182,7 +185,7 @@ public abstract class AInstaller public async Task InstallArchives(CancellationToken token) { - await NextStep("Installing files", ModList.Directives.Sum(d => d.Size)); + NextStep("Installing files", ModList.Directives.Sum(d => d.Size)); var grouped = ModList.Directives .OfType() .Select(a => new {VF = _vfs.Index.FileForArchiveHashPath(a.ArchiveHashPath), Directive = a}) @@ -196,7 +199,7 @@ public abstract class AInstaller foreach (var directive in grouped[vf]) { var file = directive.Directive; - await UpdateProgress(file.Size); + UpdateProgress(file.Size); switch (file) { @@ -284,7 +287,7 @@ public abstract class AInstaller } _logger.LogInformation("Downloading {count} archives", missing.Count); - await NextStep("Downloading files", missing.Count); + NextStep("Downloading files", missing.Count); await missing .OrderBy(a => a.Size) @@ -305,7 +308,7 @@ public abstract class AInstaller } await DownloadArchive(archive, download, token, outputPath); - await UpdateProgress(1); + UpdateProgress(1); }); } @@ -345,23 +348,30 @@ public abstract class AInstaller public async Task HashArchives(CancellationToken token) { + NextStep("Hashing Archives", 0); _logger.LogInformation("Looking for files to hash"); var allFiles = _configuration.Downloads.EnumerateFiles() .Concat(_gameLocator.GameLocation(_configuration.Game).EnumerateFiles()) .ToList(); - + var hashDict = allFiles.GroupBy(f => f.Size()).ToDictionary(g => g.Key); var toHash = ModList.Archives.Where(a => hashDict.ContainsKey(a.Size)) .SelectMany(a => hashDict[a.Size]).ToList(); + MaxStepProgress = toHash.Count; + _logger.LogInformation("Found {count} total files, {hashedCount} matching filesize", allFiles.Count, toHash.Count); var hashResults = await toHash - .PMapAll(async e => (await _fileHashCache.FileHashCachedAsync(e, token), e)) + .PMapAll(async e => + { + UpdateProgress(1); + return (await _fileHashCache.FileHashCachedAsync(e, token), e); + }) .ToList(); HashedArchives = hashResults @@ -389,11 +399,11 @@ public abstract class AInstaller var savePath = (RelativePath) "saves"; var existingFiles = _configuration.Install.EnumerateFiles().ToList(); - await NextStep("Optimizing Modlist: Looking for files to delete", existingFiles.Count); + NextStep("Optimizing Modlist: Looking for files to delete", existingFiles.Count); await existingFiles .PDoAll(async f => { - await UpdateProgress(1); + UpdateProgress(1); var relativeTo = f.RelativeTo(_configuration.Install); if (indexed.ContainsKey(relativeTo) || f.InFolder(_configuration.Downloads)) return; @@ -408,12 +418,12 @@ public abstract class AInstaller }); _logger.LogInformation("Cleaning empty folders"); - await NextStep("Optimizing Modlist: Cleaning empty folders", indexed.Keys.Count); - var expectedFolders = (await indexed.Keys + NextStep("Optimizing Modlist: Cleaning empty folders", indexed.Keys.Count); + var expectedFolders = (indexed.Keys .Select(f => f.RelativeTo(_configuration.Install)) // We ignore the last part of the path, so we need a dummy file name .Append(_configuration.Downloads.Combine("_")) - .OnEach(async _ => await UpdateProgress(1)) + .OnEach(_ => UpdateProgress(1)) .Where(f => f.InFolder(_configuration.Install)) .SelectMany(path => { @@ -443,10 +453,9 @@ public abstract class AInstaller var existingfiles = _configuration.Install.EnumerateFiles().ToHashSet(); - await NextStep("Optimizing Modlist: Removing redundant directives", indexed.Count); + NextStep("Optimizing Modlist: Removing redundant directives", indexed.Count); await indexed.Values.PMapAll(async d => { - await UpdateProgress(1); // Bit backwards, but we want to return null for // all files we *want* installed. We return the files // to remove from the install list. @@ -457,11 +466,13 @@ public abstract class AInstaller }) .Do(d => { + UpdateProgress(1); if (d != null) indexed.Remove(d.To); }); _logger.LogInformation("Optimized {optimized} directives to {indexed} required", ModList.Directives.Length, indexed.Count); + NextStep("Finalizing modlist optimization", 0); var requiredArchives = indexed.Values.OfType() .GroupBy(d => d.ArchiveHashPath.Hash) .Select(d => d.Key) diff --git a/Wabbajack.Installer/StandardInstaller.cs b/Wabbajack.Installer/StandardInstaller.cs index ff091b18..cb2d0802 100644 --- a/Wabbajack.Installer/StandardInstaller.cs +++ b/Wabbajack.Installer/StandardInstaller.cs @@ -38,13 +38,14 @@ public class StandardInstaller : AInstaller base(logger, config, gameLocator, extractor, jsonSerializer, vfs, fileHashCache, downloadDispatcher, parallelOptions, wjClient) { - MaxSteps = 7; + MaxSteps = 14; } public override async Task Begin(CancellationToken token) { if (token.IsCancellationRequested) return false; await _wjClient.SendMetric(MetricNames.BeginInstall, ModList.Name); + NextStep("Configuring Installer", 0); _logger.LogInformation("Configuring Processor"); if (_configuration.GameFolder == default) @@ -84,8 +85,7 @@ public class StandardInstaller : AInstaller _configuration.Downloads.CreateDirectory(); await OptimizeModlist(token); - - + await HashArchives(token); await DownloadArchives(token); @@ -106,7 +106,7 @@ public class StandardInstaller : AInstaller await PrimeVFS(); - BuildFolderStructure(); + await BuildFolderStructure(); await InstallArchives(token); @@ -129,7 +129,7 @@ public class StandardInstaller : AInstaller await ExtractedModlistFolder!.DisposeAsync(); await _wjClient.SendMetric(MetricNames.FinishInstall, ModList.Name); - await NextStep("Finished", 1); + NextStep("Finished", 1); _logger.LogInformation("Finished Installation"); return true; } @@ -259,12 +259,12 @@ public class StandardInstaller : AInstaller private async Task InstallIncludedFiles(CancellationToken token) { _logger.LogInformation("Writing inline files"); - await NextStep("Installing Included Files", ModList.Directives.OfType().Count()); + NextStep("Installing Included Files", ModList.Directives.OfType().Count()); await ModList.Directives .OfType() .PDoAll(async directive => { - await UpdateProgress(1); + UpdateProgress(1); var outPath = _configuration.Install.Combine(directive.To); outPath.Delete(); diff --git a/Wabbajack.RateLimiter/Percent.cs b/Wabbajack.RateLimiter/Percent.cs index aa6702ee..ecb2d55f 100644 --- a/Wabbajack.RateLimiter/Percent.cs +++ b/Wabbajack.RateLimiter/Percent.cs @@ -30,7 +30,7 @@ public readonly struct Percent : IComparable, IEquatable public static bool InRange(double d) { - return d >= 0 || d <= 1; + return d is >= 0 or <= 1; } public static Percent operator +(Percent c1, Percent c2) diff --git a/Wabbajack.VFS/Context.cs b/Wabbajack.VFS/Context.cs index cf9b1fd2..fa99d0b6 100644 --- a/Wabbajack.VFS/Context.cs +++ b/Wabbajack.VFS/Context.cs @@ -172,7 +172,7 @@ public class Context _knownArchives.TryAdd(key, value); } - public async Task BackfillMissing() + public async ValueTask BackfillMissing() { var newFiles = _knownArchives.ToDictionary(kv => kv.Key, kv => new VirtualFile