using System; using ReactiveUI; using System.Linq; using System.Reactive.Disposables; using System.Reactive.Linq; using System.Windows.Media.Imaging; using Wabbajack.Lib; using ReactiveUI.Fody.Helpers; using System.Windows.Media; using DynamicData; using DynamicData.Binding; using System.Reactive; using Microsoft.Extensions.Logging; using Microsoft.WindowsAPICodePack.Dialogs; using Microsoft.WindowsAPICodePack.Shell; using Wabbajack.Installer; using Wabbajack.Lib.Extensions; using Wabbajack.Lib.Interventions; using Wabbajack.Paths; using Wabbajack.RateLimiter; using Wabbajack.View_Models; using Consts = Wabbajack.Lib.Consts; namespace Wabbajack; enum ModManager { Standard } public class InstallerVM : BackNavigatingVM, IBackNavigatingVM, ICpuStatusVM { public SlideShow Slideshow { get; } public MainWindowVM MWVM { get; } private readonly ObservableAsPropertyHelper _modList; public ModListVM ModList => _modList.Value; public FilePickerVM ModListLocation { get; } private readonly ObservableAsPropertyHelper _installer; public ISubInstallerVM Installer => _installer.Value; private readonly ObservableAsPropertyHelper _installing; public bool Installing => _installing.Value; [Reactive] public bool StartedInstallation { get; set; } [Reactive] public ErrorResponse? Completed { get; set; } private readonly ObservableAsPropertyHelper _image; public ImageSource Image => _image.Value; private readonly ObservableAsPropertyHelper _titleText; public string TitleText => _titleText.Value; private readonly ObservableAsPropertyHelper _authorText; public string AuthorText => _authorText.Value; private readonly ObservableAsPropertyHelper _description; public string Description => _description.Value; private readonly ObservableAsPropertyHelper _progressTitle; public string ProgressTitle => _progressTitle.Value; private readonly ObservableAsPropertyHelper _modListName; public string ModListName => _modListName.Value; private readonly ObservableAsPropertyHelper _percentCompleted; public Percent PercentCompleted => _percentCompleted.Value; public ObservableCollectionExtended StatusList { get; } = new ObservableCollectionExtended(); public ObservableCollectionExtended Log => MWVM.Log; private readonly ObservableAsPropertyHelper _TargetManager; public ModManager? TargetManager => _TargetManager.Value; private readonly ObservableAsPropertyHelper _ActiveGlobalUserIntervention; public IUserIntervention ActiveGlobalUserIntervention => _ActiveGlobalUserIntervention.Value; private readonly ObservableAsPropertyHelper<(int CurrentCPUs, int DesiredCPUs)> _CurrentCpuCount; public (int CurrentCPUs, int DesiredCPUs) CurrentCpuCount => _CurrentCpuCount.Value; private readonly ObservableAsPropertyHelper _LoadingModlist; private readonly ILogger _logger; public bool LoadingModlist => _LoadingModlist.Value; // Command properties public ReactiveCommand ShowManifestCommand { get; } public ReactiveCommand OpenReadmeCommand { get; } public ReactiveCommand VisitModListWebsiteCommand { get; } public ReactiveCommand CloseWhenCompleteCommand { get; } public ReactiveCommand OpenLogsCommand { get; } public ReactiveCommand GoToInstallCommand { get; } public ReactiveCommand BeginCommand { get; } public InstallerVM(ILogger logger, MainWindowVM mainWindowVM) : base(logger, mainWindowVM) { _logger = logger; var downloadsPath = KnownFolders.Downloads.Path; /* TODO var skyDrivePath = KnownFolders.SkyDrive.Path; if (downloadsPath != null && AbsolutePath.EntryPoint.IsChildOf(new AbsolutePath(downloadsPath))) { logger.LogError(Error(new CriticalFailureIntervention( "Wabbajack is running inside your Downloads folder. This folder is often highly monitored by antivirus software and these can often " + "conflict with the operations Wabbajack needs to perform. Please move Wabbajack outside of your Downloads folder and then restart the app.", "Cannot run inside Downloads", true)); } if (skyDrivePath != null && AbsolutePath.EntryPoint.IsChildOf(new AbsolutePath(skyDrivePath))) { Utils.Error(new CriticalFailureIntervention( $"Wabbajack is running inside a OneDrive folder \"{skyDrivePath}\". This folder is known to cause issues with Wabbajack. " + "Please move Wabbajack outside of your OneDrive folder and then restart the app.", "Cannot run inside OneDrive", true)); }*/ MWVM = mainWindowVM; ModListLocation = new FilePickerVM { ExistCheckOption = FilePickerVM.CheckOptions.On, PathType = FilePickerVM.PathTypeOptions.File, PromptTitle = "Select a ModList to install" }; ModListLocation.Filters.Add(new CommonFileDialogFilter("Wabbajack Modlist", "*.wabbajack")); // Swap to proper sub VM based on selected type _installer = this.WhenAny(x => x.TargetManager) // Delay so the initial VM swap comes in immediately, subVM comes right after .DelayInitial(TimeSpan.FromMilliseconds(50), RxApp.MainThreadScheduler) .Select(type => { switch (type) { case ModManager.Standard: return new MO2InstallerVM(this); default: return null; } }) // Unload old VM .Pairwise() .Do(pair => { pair.Previous?.Unload(); }) .Select(p => p.Current) .ToGuiProperty(this, nameof(Installer)); // Load settings MWVM.Settings.SaveSignal .Subscribe(_ => { MWVM.Settings.Installer.LastInstalledListLocation = ModListLocation.TargetPath; }) .DisposeWith(CompositeDisposable); _IsActive = this.ConstructIsActive(MWVM) .ToGuiProperty(this, nameof(IsActive)); // Active path represents the path to currently have loaded // If we're not actively showing, then "unload" the active path var activePath = Observable.CombineLatest( this.WhenAny(x => x.ModListLocation.TargetPath), this.WhenAny(x => x.IsActive), resultSelector: (path, active) => (path, active)) .Select(x => { if (!x.active) return default; return x.path; }) // Throttle slightly so changes happen more atomically .Throttle(TimeSpan.FromMilliseconds(50), RxApp.MainThreadScheduler) .Replay(1) .RefCount(); _modList = activePath .ObserveOn(RxApp.TaskpoolScheduler) // Convert from active path to modlist VM .Select(modListPath => { if (modListPath == default) return default; if (!modListPath.FileExists()) return default; return new ModListVM(modListPath); }) .DisposeOld() .ObserveOnGuiThread() .StartWith(default(ModListVM)) .ToGuiProperty(this, nameof(ModList)); // Force GC collect when modlist changes, just to make sure we clean up any loose large items immediately this.WhenAny(x => x.ModList) .Delay(TimeSpan.FromMilliseconds(50), RxApp.MainThreadScheduler) .Subscribe(x => { GC.Collect(); }); _LoadingModlist = Observable.Merge( // When active path changes, mark as loading activePath .Select(_ => true), // When the resulting modlist comes in, mark it as done this.WhenAny(x => x.ModList) .Select(_ => false)) .ToGuiProperty(this, nameof(LoadingModlist)); _installing = this.WhenAny(x => x.Installer.ActiveInstallation) .Select(i => i != null) .ToGuiProperty(this, nameof(Installing)); _TargetManager = this.WhenAny(x => x.ModList) .Select(modList => ModManager.Standard) .ToGuiProperty(this, nameof(TargetManager)); // Add additional error check on ModList ModListLocation.AdditionalError = this.WhenAny(x => x.ModList) .Select(modList => { if (modList == null) return ErrorResponse.Fail("Modlist path resulted in a null object."); if (modList.Error != null) return ErrorResponse.Fail("Modlist is corrupt", modList.Error); if (modList.WabbajackVersion != null && modList.WabbajackVersion > Consts.CurrentMinimumWabbajackVersion) return ErrorResponse.Fail("The Modlist you are trying to install was made using a newer Version of Wabbajack. Please update Wabbajack before installing!"); return ErrorResponse.Success; }); BackCommand = ReactiveCommand.Create( execute: () => { StartedInstallation = false; Completed = null; mainWindowVM.NavigateTo(mainWindowVM.ModeSelectionVM); }, canExecute: Observable.CombineLatest( this.WhenAny(x => x.Installing) .Select(x => !x), this.ConstructCanNavigateBack(), resultSelector: (i, b) => i && b) .ObserveOnGuiThread()); _percentCompleted = this.WhenAny(x => x.Installer.ActiveInstallation) .StartWith(default(IInstaller)) .CombineLatest( this.WhenAny(x => x.Completed), (installer, completed) => { if (installer == null) { return Observable.Return(completed != null ? Percent.One : Percent.Zero); } return installer.PercentCompleted.StartWith(Percent.Zero); }) .Switch() .Debounce(TimeSpan.FromMilliseconds(25), RxApp.MainThreadScheduler) .ToGuiProperty(this, nameof(PercentCompleted)); Slideshow = new SlideShow(this); // Set display items to ModList if configuring or complete, // or to the current slideshow data if installing _image = Observable.CombineLatest( this.WhenAny(x => x.ModList.Error), this.WhenAny(x => x.ModList) .Select(x => x?.ImageObservable ?? Observable.Return(default(BitmapImage))) .Switch() .StartWith(default(BitmapImage)), this.WhenAny(x => x.Slideshow.Image) .StartWith(default(BitmapImage)), this.WhenAny(x => x.Installing), this.WhenAny(x => x.LoadingModlist), resultSelector: (err, modList, slideshow, installing, loading) => { if (err != null) { return ResourceLinks.WabbajackErrLogo.Value; } if (loading) return default; return installing ? slideshow : modList; }) .Select(x => x) .ToGuiProperty(this, nameof(Image)); _titleText = Observable.CombineLatest( this.WhenAny(x => x.ModList) .Select(modList => modList?.Name ?? string.Empty), this.WhenAny(x => x.Slideshow.TargetMod.State.Name) .StartWith(default(string)), this.WhenAny(x => x.Installing), resultSelector: (modList, mod, installing) => installing ? mod : modList) .ToGuiProperty(this, nameof(TitleText)); _authorText = Observable.CombineLatest( this.WhenAny(x => x.ModList) .Select(modList => modList?.Author ?? string.Empty), this.WhenAny(x => x.Slideshow.TargetMod.State.Author) .StartWith(default(string)), this.WhenAny(x => x.Installing), resultSelector: (modList, mod, installing) => installing ? mod : modList) .ToGuiProperty(this, nameof(AuthorText)); _description = Observable.CombineLatest( this.WhenAny(x => x.ModList) .Select(modList => modList?.Description ?? string.Empty), this.WhenAny(x => x.Slideshow.TargetMod.State.Description) .StartWith(default(string)), this.WhenAny(x => x.Installing), resultSelector: (modList, mod, installing) => installing ? mod : modList) .ToGuiProperty(this, nameof(Description)); _modListName = Observable.CombineLatest( this.WhenAny(x => x.ModList.Error) .Select(x => x != null), this.WhenAny(x => x.ModList) .Select(x => x?.Name), resultSelector: (err, name) => { if (err) return "Corrupted Modlist"; return name; }) .Merge(this.WhenAny(x => x.Installer.ActiveInstallation) .Where(c => c != null) .SelectMany(c => c.TextStatus)) .ToGuiProperty(this, nameof(ModListName)); ShowManifestCommand = ReactiveCommand.Create(() => { UIUtils.OpenWebsite(new Uri("https://www.wabbajack.org/#/modlists/manifest")); }, this.WhenAny(x => x.ModList) .Select(x => x?.SourceModList != null) .ObserveOnGuiThread()); OpenReadmeCommand = ReactiveCommand.Create( execute: () => this.ModList?.OpenReadme(), canExecute: this.WhenAny(x => x.ModList) .Select(modList => !string.IsNullOrEmpty(modList?.Readme)) .ObserveOnGuiThread()); OpenLogsCommand = ReactiveCommand.Create( execute: () => UIUtils.OpenFolder(Consts.LogsFolder)); VisitModListWebsiteCommand = ReactiveCommand.Create( execute: () => { UIUtils.OpenWebsite(ModList.Website); return Unit.Default; }, canExecute: this.WhenAny(x => x.ModList.Website) .Select(x => x != null) .ObserveOnGuiThread()); _progressTitle = this.WhenAnyValue( x => x.Installing, x => x.StartedInstallation, x => x.Completed, selector: (installing, started, completed) => { if (installing) { return "Installing"; } else if (started) { if (completed == null) return "Installing"; return completed.Value.Succeeded ? "Installed" : "Failed"; } else { return "Awaiting Input"; } }) .ToGuiProperty(this, nameof(ProgressTitle)); UIUtils.BindCpuStatus( this.WhenAny(x => x.Installer.ActiveInstallation) .SelectMany(c => c?.QueueStatus ?? Observable.Empty()), StatusList) .DisposeWith(CompositeDisposable); BeginCommand = ReactiveCommand.CreateFromTask( canExecute: this.WhenAny(x => x.Installer.CanInstall) .Select(err => err.Succeeded), execute: async () => { try { UIUtils.Log($"Starting to install {ModList.Name}"); IsBackEnabledSubject.OnNext(false); var success = await this.Installer.Install(); Completed = ErrorResponse.Create(success); try { this.ModList?.OpenReadme(); } catch (Exception ex) { UIUtils.Error(ex); } } catch (Exception ex) { Utils.Error(ex, $"Encountered error, can't continue"); while (ex.InnerException != null) ex = ex.InnerException; Completed = ErrorResponse.Fail(ex); } finally { IsBackEnabledSubject.OnNext(true); } }); // When sub installer begins an install, mark state variable BeginCommand.StartingExecution() .Subscribe(_ => { StartedInstallation = true; }) .DisposeWith(CompositeDisposable); // Listen for user interventions, and compile a dynamic list of all unhandled ones var activeInterventions = this.WhenAny(x => x.Installer.ActiveInstallation) .WithLatestFrom( this.WhenAny(x => x.Installer), (activeInstall, installer) => { if (activeInstall == null) return Observable.Empty>(); return activeInstall.LogMessages .WhereCastable() .ToObservableChangeSet() .AutoRefresh(i => i.Handled) .Filter(i => !i.Handled) .Transform(x => installer.InterventionConverter(x)); }) .Switch() .AsObservableList(); // Find the top intervention /w no CPU ID to be marked as "global" _ActiveGlobalUserIntervention = activeInterventions.Connect() .Filter(x => x.CpuID == WorkQueue.UnassignedCpuId) .QueryWhenChanged(query => query.FirstOrDefault()) .ToGuiProperty(this, nameof(ActiveGlobalUserIntervention)); CloseWhenCompleteCommand = ReactiveCommand.CreateFromTask( canExecute: this.WhenAny(x => x.Completed) .Select(x => x != null), execute: async () => { await MWVM.ShutdownApplication(); }); GoToInstallCommand = ReactiveCommand.Create( canExecute: Observable.CombineLatest( this.WhenAny(x => x.Completed) .Select(x => x != null), this.WhenAny(x => x.Installer.SupportsAfterInstallNavigation), resultSelector: (complete, supports) => complete && supports), execute: () => { Installer.AfterInstallNavigation(); }); _CurrentCpuCount = this.WhenAny(x => x.Installer.ActiveInstallation.Queue.CurrentCpuCount) .Switch() .ToGuiProperty(this, nameof(CurrentCpuCount)); } }