diff --git a/Wabbajack.App.Wpf/App.xaml b/Wabbajack.App.Wpf/App.xaml index ce244afa..fb010729 100644 --- a/Wabbajack.App.Wpf/App.xaml +++ b/Wabbajack.App.Wpf/App.xaml @@ -3,17 +3,20 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="clr-namespace:Wabbajack" + xmlns:controls="clr-namespace:Wabbajack.Extensions" ShutdownMode="OnExplicitShutdown" Startup="OnStartup" Exit="OnExit"> - + + \ No newline at end of file diff --git a/Wabbajack.App.Wpf/App.xaml.cs b/Wabbajack.App.Wpf/App.xaml.cs index 970d2d02..ed7f3d9a 100644 --- a/Wabbajack.App.Wpf/App.xaml.cs +++ b/Wabbajack.App.Wpf/App.xaml.cs @@ -77,6 +77,7 @@ namespace Wabbajack services.AddAllSingleton(); services.AddAllSingleton(); services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/Wabbajack.App.Wpf/Extensions/TabContent.cs b/Wabbajack.App.Wpf/Extensions/TabContent.cs new file mode 100644 index 00000000..e338fcee --- /dev/null +++ b/Wabbajack.App.Wpf/Extensions/TabContent.cs @@ -0,0 +1,282 @@ +// TabContent.cs, version 1.2 +// The code in this file is Copyright (c) Ivan Krivyakov +// See http://www.ikriv.com/legal.php for more information +// +using System; +using System.ComponentModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Markup; + +namespace IKriv.Windows.Controls.Behaviors +{ + /// + /// Attached properties for persistent tab control + /// + /// By default WPF TabControl bound to an ItemsSource destroys visual state of invisible tabs. + /// Set ikriv:TabContent.IsCached="True" to preserve visual state of each tab. + /// + public static class TabContent + { + public static bool GetIsCached(DependencyObject obj) + { + return (bool)obj.GetValue(IsCachedProperty); + } + + public static void SetIsCached(DependencyObject obj, bool value) + { + obj.SetValue(IsCachedProperty, value); + } + + /// + /// Controls whether tab content is cached or not + /// + /// When TabContent.IsCached is true, visual state of each tab is preserved (cached), even when the tab is hidden + public static readonly DependencyProperty IsCachedProperty = + DependencyProperty.RegisterAttached("IsCached", typeof(bool), typeof(TabContent), new UIPropertyMetadata(false, OnIsCachedChanged)); + + + public static DataTemplate GetTemplate(DependencyObject obj) + { + return (DataTemplate)obj.GetValue(TemplateProperty); + } + + public static void SetTemplate(DependencyObject obj, DataTemplate value) + { + obj.SetValue(TemplateProperty, value); + } + + /// + /// Used instead of TabControl.ContentTemplate for cached tabs + /// + public static readonly DependencyProperty TemplateProperty = + DependencyProperty.RegisterAttached("Template", typeof(DataTemplate), typeof(TabContent), new UIPropertyMetadata(null)); + + + public static DataTemplateSelector GetTemplateSelector(DependencyObject obj) + { + return (DataTemplateSelector)obj.GetValue(TemplateSelectorProperty); + } + + public static void SetTemplateSelector(DependencyObject obj, DataTemplateSelector value) + { + obj.SetValue(TemplateSelectorProperty, value); + } + + /// + /// Used instead of TabControl.ContentTemplateSelector for cached tabs + /// + public static readonly DependencyProperty TemplateSelectorProperty = + DependencyProperty.RegisterAttached("TemplateSelector", typeof(DataTemplateSelector), typeof(TabContent), new UIPropertyMetadata(null)); + + [EditorBrowsable(EditorBrowsableState.Never)] + public static TabControl GetInternalTabControl(DependencyObject obj) + { + return (TabControl)obj.GetValue(InternalTabControlProperty); + } + + [EditorBrowsable(EditorBrowsableState.Never)] + public static void SetInternalTabControl(DependencyObject obj, TabControl value) + { + obj.SetValue(InternalTabControlProperty, value); + } + + // Using a DependencyProperty as the backing store for InternalTabControl. This enables animation, styling, binding, etc... + [EditorBrowsable(EditorBrowsableState.Never)] + public static readonly DependencyProperty InternalTabControlProperty = + DependencyProperty.RegisterAttached("InternalTabControl", typeof(TabControl), typeof(TabContent), new UIPropertyMetadata(null, OnInternalTabControlChanged)); + + + [EditorBrowsable(EditorBrowsableState.Never)] + public static ContentControl GetInternalCachedContent(DependencyObject obj) + { + return (ContentControl)obj.GetValue(InternalCachedContentProperty); + } + + [EditorBrowsable(EditorBrowsableState.Never)] + public static void SetInternalCachedContent(DependencyObject obj, ContentControl value) + { + obj.SetValue(InternalCachedContentProperty, value); + } + + // Using a DependencyProperty as the backing store for InternalCachedContent. This enables animation, styling, binding, etc... + [EditorBrowsable(EditorBrowsableState.Never)] + public static readonly DependencyProperty InternalCachedContentProperty = + DependencyProperty.RegisterAttached("InternalCachedContent", typeof(ContentControl), typeof(TabContent), new UIPropertyMetadata(null)); + + [EditorBrowsable(EditorBrowsableState.Never)] + public static object GetInternalContentManager(DependencyObject obj) + { + return (object)obj.GetValue(InternalContentManagerProperty); + } + + [EditorBrowsable(EditorBrowsableState.Never)] + public static void SetInternalContentManager(DependencyObject obj, object value) + { + obj.SetValue(InternalContentManagerProperty, value); + } + + // Using a DependencyProperty as the backing store for InternalContentManager. This enables animation, styling, binding, etc... + public static readonly DependencyProperty InternalContentManagerProperty = + DependencyProperty.RegisterAttached("InternalContentManager", typeof(object), typeof(TabContent), new UIPropertyMetadata(null)); + + private static void OnIsCachedChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args) + { + if (obj == null) return; + + var tabControl = obj as TabControl; + if (tabControl == null) + { + throw new InvalidOperationException("Cannot set TabContent.IsCached on object of type " + args.NewValue.GetType().Name + + ". Only objects of type TabControl can have TabContent.IsCached property."); + } + + bool newValue = (bool)args.NewValue; + + if (!newValue) + { + if (args.OldValue != null && ((bool)args.OldValue)) + { + throw new NotImplementedException("Cannot change TabContent.IsCached from True to False. Turning tab caching off is not implemented"); + } + + return; + } + + EnsureContentTemplateIsNull(tabControl); + tabControl.ContentTemplate = CreateContentTemplate(); + EnsureContentTemplateIsNotModified(tabControl); + } + + private static DataTemplate CreateContentTemplate() + { + const string xaml = + ""; + + var context = new ParserContext(); + + context.XamlTypeMapper = new XamlTypeMapper(new string[0]); + context.XamlTypeMapper.AddMappingProcessingInstruction("b", typeof(TabContent).Namespace, typeof(TabContent).Assembly.FullName); + + context.XmlnsDictionary.Add("", "http://schemas.microsoft.com/winfx/2006/xaml/presentation"); + context.XmlnsDictionary.Add("b", "b"); + + var template = (DataTemplate)XamlReader.Parse(xaml, context); + return template; + } + + private static void OnInternalTabControlChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args) + { + if (obj == null) return; + var container = obj as Decorator; + + if (container == null) + { + var message = "Cannot set TabContent.InternalTabControl on object of type " + obj.GetType().Name + + ". Only controls that derive from Decorator, such as Border can have a TabContent.InternalTabControl."; + throw new InvalidOperationException(message); + } + + if (args.NewValue == null) return; + if (!(args.NewValue is TabControl)) + { + throw new InvalidOperationException("Value of TabContent.InternalTabControl cannot be of type " + args.NewValue.GetType().Name +", it must be of type TabControl"); + } + + var tabControl = (TabControl)args.NewValue; + var contentManager = GetContentManager(tabControl, container); + contentManager.UpdateSelectedTab(); + } + + private static ContentManager GetContentManager(TabControl tabControl, Decorator container) + { + var contentManager = (ContentManager)GetInternalContentManager(tabControl); + if (contentManager != null) + { + /* + * Content manager already exists for the tab control. This means that tab content template is applied + * again, and new instance of the Border control (container) has been created. The old container + * referenced by the content manager is no longer visible and needs to be replaced + */ + contentManager.ReplaceContainer(container); + } + else + { + // create content manager for the first time + contentManager = new ContentManager(tabControl, container); + SetInternalContentManager(tabControl, contentManager); + } + + return contentManager; + } + + private static void EnsureContentTemplateIsNull(TabControl tabControl) + { + if (tabControl.ContentTemplate != null) + { + throw new InvalidOperationException("TabControl.ContentTemplate value is not null. If TabContent.IsCached is True, use TabContent.Template instead of ContentTemplate"); + } + } + + private static void EnsureContentTemplateIsNotModified(TabControl tabControl) + { + var descriptor = DependencyPropertyDescriptor.FromProperty(TabControl.ContentTemplateProperty, typeof(TabControl)); + descriptor.AddValueChanged(tabControl, (sender, args) => + { + throw new InvalidOperationException("Cannot assign to TabControl.ContentTemplate when TabContent.IsCached is True. Use TabContent.Template instead"); + }); + } + + public class ContentManager + { + TabControl _tabControl; + Decorator _border; + + public ContentManager(TabControl tabControl, Decorator border) + { + _tabControl = tabControl; + _border = border; + _tabControl.SelectionChanged += (sender, args) => { UpdateSelectedTab(); }; + } + + public void ReplaceContainer(Decorator newBorder) + { + if (Object.ReferenceEquals(_border, newBorder)) return; + + _border.Child = null; // detach any tab content that old border may hold + _border = newBorder; + } + + public void UpdateSelectedTab() + { + _border.Child = GetCurrentContent(); + } + + private ContentControl GetCurrentContent() + { + var item = _tabControl.SelectedItem; + if (item == null) return null; + + var tabItem = _tabControl.ItemContainerGenerator.ContainerFromItem(item); + if (tabItem == null) return null; + + var cachedContent = TabContent.GetInternalCachedContent(tabItem); + if (cachedContent == null) + { + cachedContent = new ContentControl + { + DataContext = item, + ContentTemplate = TabContent.GetTemplate(_tabControl), + ContentTemplateSelector = TabContent.GetTemplateSelector(_tabControl) + }; + + cachedContent.SetBinding(ContentControl.ContentProperty, new Binding()); + TabContent.SetInternalCachedContent(tabItem, cachedContent); + } + + return cachedContent; + } + } + } +} diff --git a/Wabbajack.App.Wpf/Interventions/InteventionHandler.cs b/Wabbajack.App.Wpf/Interventions/InteventionHandler.cs index d3748ac4..14a30177 100644 --- a/Wabbajack.App.Wpf/Interventions/InteventionHandler.cs +++ b/Wabbajack.App.Wpf/Interventions/InteventionHandler.cs @@ -14,8 +14,18 @@ public class InteventionHandler : IUserInterventionHandler } public void Raise(IUserIntervention intervention) { - // Recast these or they won't be properly handled by the message bus - if (intervention is ManualDownload md) - MessageBus.Current.SendMessage(md); + switch (intervention) + { + // Recast these or they won't be properly handled by the message bus + case ManualDownload md: + MessageBus.Current.SendMessage(md); + break; + case ManualBlobDownload bd: + MessageBus.Current.SendMessage(bd); + break; + default: + _logger.LogError("No handler for user intervention: {Type}", intervention); + break; + } } } \ No newline at end of file diff --git a/Wabbajack.App.Wpf/Models/ResourceMonitor.cs b/Wabbajack.App.Wpf/Models/ResourceMonitor.cs index fbd006c6..97f01c7e 100644 --- a/Wabbajack.App.Wpf/Models/ResourceMonitor.cs +++ b/Wabbajack.App.Wpf/Models/ResourceMonitor.cs @@ -45,7 +45,6 @@ public class ResourceMonitor : IDisposable .DisposeWith(_compositeDisposable); _tasks.Connect() - .Filter(t => t.IsWorking) .Bind(out _tasksFiltered) .Subscribe() .DisposeWith(_compositeDisposable); diff --git a/Wabbajack.App.Wpf/UserIntervention/ManualBlobDownloadHandler.cs b/Wabbajack.App.Wpf/UserIntervention/ManualBlobDownloadHandler.cs new file mode 100644 index 00000000..ca46f238 --- /dev/null +++ b/Wabbajack.App.Wpf/UserIntervention/ManualBlobDownloadHandler.cs @@ -0,0 +1,27 @@ +using System.Threading; +using System.Threading.Tasks; +using Wabbajack.DTOs.DownloadStates; +using Wabbajack.DTOs.Interventions; + +namespace Wabbajack.UserIntervention; + +public class ManualBlobDownloadHandler : BrowserTabViewModel +{ + public ManualBlobDownload Intervention { get; set; } + + protected override async Task Run(CancellationToken token) + { + await WaitForReady(); + var archive = Intervention.Archive; + var md = Intervention.Archive.State as Manual; + + HeaderText = $"Manual download ({md.Url.Host})"; + + Instructions = string.IsNullOrWhiteSpace(md.Prompt) ? $"Please download {archive.Name}" : md.Prompt; + var tsk = WaitForDownload(Intervention.Destination, token); + await NavigateTo(md.Url); + var hash = await tsk; + + Intervention.Finish(hash); + } +} \ No newline at end of file diff --git a/Wabbajack.App.Wpf/UserIntervention/ManualDownloadHandler.cs b/Wabbajack.App.Wpf/UserIntervention/ManualDownloadHandler.cs index 8e3697c0..ba20bdcb 100644 --- a/Wabbajack.App.Wpf/UserIntervention/ManualDownloadHandler.cs +++ b/Wabbajack.App.Wpf/UserIntervention/ManualDownloadHandler.cs @@ -11,11 +11,14 @@ namespace Wabbajack.UserIntervention; public class ManualDownloadHandler : BrowserTabViewModel { public ManualDownload Intervention { get; set; } + protected override async Task Run(CancellationToken token) { await WaitForReady(); var archive = Intervention.Archive; var md = Intervention.Archive.State as Manual; + + HeaderText = $"Manual download ({md.Url.Host})"; Instructions = string.IsNullOrWhiteSpace(md.Prompt) ? $"Please download {archive.Name}" : md.Prompt; await NavigateTo(md.Url); diff --git a/Wabbajack.App.Wpf/View Models/BrowserTabViewModel.cs b/Wabbajack.App.Wpf/View Models/BrowserTabViewModel.cs index dc770d14..810e6484 100644 --- a/Wabbajack.App.Wpf/View Models/BrowserTabViewModel.cs +++ b/Wabbajack.App.Wpf/View Models/BrowserTabViewModel.cs @@ -4,7 +4,6 @@ using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using System.Web; using HtmlAgilityPack; using Microsoft.Web.WebView2.Core; using Microsoft.Web.WebView2.Wpf; @@ -12,7 +11,9 @@ using ReactiveUI; using ReactiveUI.Fody.Helpers; using Wabbajack.DTOs.Interventions; using Wabbajack.DTOs.Logins; +using Wabbajack.Hashing.xxHash64; using Wabbajack.Messages; +using Wabbajack.Paths; using Wabbajack.Views; namespace Wabbajack; @@ -119,4 +120,31 @@ public abstract class BrowserTabViewModel : ViewModel ("Referer", referer.ToString()) }); } + + public async Task WaitForDownload(AbsolutePath path, CancellationToken token) + { + var source = new TaskCompletionSource(); + var referer = _browser.Source; + _browser.CoreWebView2.DownloadStarting += (sender, args) => + { + try + { + args.ResultFilePath = path.ToString(); + args.Handled = true; + args.DownloadOperation.StateChanged += (o, o1) => + { + var operation = (CoreWebView2DownloadOperation) o; + if (operation.State == CoreWebView2DownloadState.Completed) + source.TrySetResult(); + }; + } + catch (Exception ex) + { + source.SetCanceled(); + } + }; + + await source.Task; + return default; + } } \ No newline at end of file diff --git a/Wabbajack.App.Wpf/View Models/MainWindowVM.cs b/Wabbajack.App.Wpf/View Models/MainWindowVM.cs index 4b4b8f9c..9e9d5076 100644 --- a/Wabbajack.App.Wpf/View Models/MainWindowVM.cs +++ b/Wabbajack.App.Wpf/View Models/MainWindowVM.cs @@ -118,6 +118,11 @@ namespace Wabbajack .Subscribe(HandleManualDownload) .DisposeWith(CompositeDisposable); + + MessageBus.Current.Listen() + .Subscribe(HandleManualBlobDownload) + .DisposeWith(CompositeDisposable); + _resourceMonitor.Updates .Select(r => string.Join(", ", r.Where(r => r.Throughput > 0) .Select(s => $"{s.Name} - {s.Throughput.ToFileSizeString()}/sec"))) @@ -197,6 +202,13 @@ namespace Wabbajack handler.Intervention = manualDownload; MessageBus.Current.SendMessage(new OpenBrowserTab(handler)); } + + private void HandleManualBlobDownload(ManualBlobDownload manualDownload) + { + var handler = _serviceProvider.GetRequiredService(); + handler.Intervention = manualDownload; + MessageBus.Current.SendMessage(new OpenBrowserTab(handler)); + } private void HandleNavigateTo(NavigateToGlobal.ScreenType s) { diff --git a/Wabbajack.App.Wpf/Views/BrowserTabView.xaml.cs b/Wabbajack.App.Wpf/Views/BrowserTabView.xaml.cs index c81a27de..3e514008 100644 --- a/Wabbajack.App.Wpf/Views/BrowserTabView.xaml.cs +++ b/Wabbajack.App.Wpf/Views/BrowserTabView.xaml.cs @@ -44,8 +44,12 @@ public partial class BrowserTabView : IDisposable private void ClickClose(object sender, RoutedEventArgs e) { var tc = (TabControl) this.Parent; - if (tc.Items.Contains(this)) - tc.Items.Remove(this); + if (tc != null) + { + if (tc.Items.Contains(this)) + tc.Items.Remove(this); + } + this.Dispose(); } } diff --git a/Wabbajack.App.Wpf/Views/MainWindow.xaml b/Wabbajack.App.Wpf/Views/MainWindow.xaml index 1bd48b9d..fd9111e6 100644 --- a/Wabbajack.App.Wpf/Views/MainWindow.xaml +++ b/Wabbajack.App.Wpf/Views/MainWindow.xaml @@ -8,6 +8,7 @@ xmlns:mahapps="clr-namespace:MahApps.Metro.Controls;assembly=MahApps.Metro" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:viewModels="clr-namespace:Wabbajack.View_Models" + xmlns:behaviors="clr-namespace:IKriv.Windows.Controls.Behaviors" ShowTitleBar="False" Title="WABBAJACK" Width="1280" @@ -34,7 +35,13 @@ - + + + + + + + diff --git a/Wabbajack.App.Wpf/Views/MainWindow.xaml.cs b/Wabbajack.App.Wpf/Views/MainWindow.xaml.cs index caa1bf0c..52f5b42d 100644 --- a/Wabbajack.App.Wpf/Views/MainWindow.xaml.cs +++ b/Wabbajack.App.Wpf/Views/MainWindow.xaml.cs @@ -45,20 +45,20 @@ namespace Wabbajack }; MessageBus.Current.Listen() + .ObserveOnGuiThread() .Subscribe(u => { - Dispatcher.Invoke(() => - { - TaskbarItemInfo.Description = u.Description; - TaskbarItemInfo.ProgressValue = u.ProgressValue; - TaskbarItemInfo.ProgressState = u.State; - }); + TaskbarItemInfo.Description = u.Description; + TaskbarItemInfo.ProgressValue = u.ProgressValue; + TaskbarItemInfo.ProgressState = u.State; }); MessageBus.Current.Listen() + .ObserveOnGuiThread() .Subscribe(OnOpenBrowserTab); MessageBus.Current.Listen() + .ObserveOnGuiThread() .Subscribe(OnCloseBrowserTab); _logger.LogInformation("Wabbajack Build - {Sha}",ThisAssembly.Git.Sha); @@ -107,9 +107,10 @@ namespace Wabbajack { this.Topmost = false; }; - +/* ((MainWindowVM) DataContext).WhenAnyValue(vm => vm.OpenSettingsCommand) .BindTo(this, view => view.SettingsButton.Command); + */ } catch (Exception ex) { @@ -117,13 +118,13 @@ namespace Wabbajack Environment.Exit(-1); } - + /* vm.WhenAnyValue(vm => vm.ResourceStatus) .BindToStrict(this, view => view.ResourceUsage.Text); vm.WhenAnyValue(vm => vm.ResourceStatus) .Select(x => string.IsNullOrWhiteSpace(x) ? Visibility.Collapsed : Visibility.Visible) - .BindToStrict(this, view => view.ResourceUsage.Visibility); + .BindToStrict(this, view => view.ResourceUsage.Visibility);*/ } diff --git a/Wabbajack.App.Wpf/Views/MainWindowContent.xaml b/Wabbajack.App.Wpf/Views/MainWindowContent.xaml new file mode 100644 index 00000000..6f545965 --- /dev/null +++ b/Wabbajack.App.Wpf/Views/MainWindowContent.xaml @@ -0,0 +1,12 @@ + + + + + diff --git a/Wabbajack.App.Wpf/Views/MainWindowContent.xaml.cs b/Wabbajack.App.Wpf/Views/MainWindowContent.xaml.cs new file mode 100644 index 00000000..28fd9c06 --- /dev/null +++ b/Wabbajack.App.Wpf/Views/MainWindowContent.xaml.cs @@ -0,0 +1,11 @@ +using System.Windows.Controls; + +namespace Wabbajack.App.Wpf.Views; + +public partial class MainWindowContent : UserControl +{ + public MainWindowContent() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/Wabbajack.Common/HttpExtensions.cs b/Wabbajack.Common/HttpExtensions.cs index bcfaaa0c..a0900120 100644 --- a/Wabbajack.Common/HttpExtensions.cs +++ b/Wabbajack.Common/HttpExtensions.cs @@ -1,9 +1,11 @@ +using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Net.Http.Json; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Wabbajack.DTOs.Interventions; using Wabbajack.DTOs.Logins; using Wabbajack.Networking.Http; using Wabbajack.RateLimiter; @@ -17,6 +19,15 @@ public static class HttpExtensions msg.Headers.Add("Cookie", string.Join(";", cookies.Select(c => $"{c.Name}={c.Value}"))); return msg; } + + public static HttpRequestMessage AddHeaders(this HttpRequestMessage msg, IEnumerable<(string Key, string Value)> headers) + { + foreach (var header in headers) + { + msg.Headers.Add(header.Key, header.Value); + } + return msg; + } public static HttpRequestMessage AddChromeAgent(this HttpRequestMessage msg) { @@ -25,6 +36,15 @@ public static class HttpExtensions return msg; } + public static HttpRequestMessage ToHttpRequestMessage(this ManualDownload.BrowserDownloadState browserState) + { + var msg = new HttpRequestMessage(HttpMethod.Get, browserState.Uri); + msg.AddChromeAgent(); + msg.AddCookies(browserState.Cookies); + msg.AddHeaders(browserState.Headers); + return msg; + } + public static async Task GetFromJsonAsync(this HttpClient client, IResource limiter, HttpRequestMessage msg, JsonSerializerOptions? options, CancellationToken cancellationToken = default) diff --git a/Wabbajack.DTOs/Interventions/AUserIntervention.cs b/Wabbajack.DTOs/Interventions/AUserIntervention.cs index e9a5a2b0..9897f150 100644 --- a/Wabbajack.DTOs/Interventions/AUserIntervention.cs +++ b/Wabbajack.DTOs/Interventions/AUserIntervention.cs @@ -26,7 +26,7 @@ public class AUserIntervention : IUserIntervention public void Finish(T value) { - _tcs.SetResult(value); + _tcs.TrySetResult(value); _ct.Cancel(); } diff --git a/Wabbajack.DTOs/Interventions/ManualBlobDownload.cs b/Wabbajack.DTOs/Interventions/ManualBlobDownload.cs new file mode 100644 index 00000000..061b49af --- /dev/null +++ b/Wabbajack.DTOs/Interventions/ManualBlobDownload.cs @@ -0,0 +1,16 @@ +using Wabbajack.Hashing.xxHash64; +using Wabbajack.Paths; + +namespace Wabbajack.DTOs.Interventions; + +public class ManualBlobDownload : AUserIntervention +{ + public Archive Archive { get; } + public AbsolutePath Destination { get; } + + public ManualBlobDownload(Archive archive, AbsolutePath destination) + { + Archive = archive; + Destination = destination; + } +} \ No newline at end of file diff --git a/Wabbajack.DTOs/Interventions/ManualDownload.cs b/Wabbajack.DTOs/Interventions/ManualDownload.cs index d9d16a83..15a3be24 100644 --- a/Wabbajack.DTOs/Interventions/ManualDownload.cs +++ b/Wabbajack.DTOs/Interventions/ManualDownload.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading; using Wabbajack.DTOs.Logins; using Wabbajack.Hashing.xxHash64; using Wabbajack.Paths; @@ -9,12 +10,10 @@ namespace Wabbajack.DTOs.Interventions; public class ManualDownload : AUserIntervention { public Archive Archive { get; } - public AbsolutePath OutputPath { get; } - public ManualDownload(Archive archive, AbsolutePath outputPath) + public ManualDownload(Archive archive) { Archive = archive; - OutputPath = outputPath; } public record BrowserDownloadState(Uri Uri, Cookie[] Cookies, (string Key, string Value)[] Headers) diff --git a/Wabbajack.Downloaders.Manual/ManualDownloader.cs b/Wabbajack.Downloaders.Manual/ManualDownloader.cs index 172b48a6..14d99ecb 100644 --- a/Wabbajack.Downloaders.Manual/ManualDownloader.cs +++ b/Wabbajack.Downloaders.Manual/ManualDownloader.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using Wabbajack.Common; using Wabbajack.Downloaders.Interfaces; using Wabbajack.DTOs; using Wabbajack.DTOs.DownloadStates; @@ -28,28 +29,33 @@ public class ManualDownloader : ADownloader public override async Task Download(Archive archive, DTOs.DownloadStates.Manual state, AbsolutePath destination, IJob job, CancellationToken token) { _logger.LogInformation("Starting manual download of {Url}", state.Url); - var intervention = new ManualDownload(archive, destination); - _interventionHandler.Raise(intervention); - var browserState = await intervention.Task; - var msg = new HttpRequestMessage(HttpMethod.Get, browserState.Uri); - msg.Headers.Add("User-Agent", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36"); - msg.Headers.Add("Cookie", string.Join(";", browserState.Cookies.Select(c => $"{c.Name}={c.Value}"))); - - - foreach (var header in browserState.Headers) + if (state.Url.Host == "mega.nz") { - msg.Headers.Add(header.Key, header.Value); + var intervention = new ManualBlobDownload(archive, destination); + _interventionHandler.Raise(intervention); + await intervention.Task; + if (!destination.FileExists()) + throw new Exception("File does not exist after download"); + _logger.LogInformation("Hashing manually downloaded Mega file {File}", destination.FileName); + return await destination.Hash(token); } + else + { + var intervention = new ManualDownload(archive); + _interventionHandler.Raise(intervention); + var browserState = await intervention.Task; - using var response = await _client.SendAsync(msg, token); - if (!response.IsSuccessStatusCode) - throw new HttpRequestException(response.ReasonPhrase, null, statusCode:response.StatusCode); + var msg = browserState.ToHttpRequestMessage(); - await using var strm = await response.Content.ReadAsStreamAsync(token); - await using var os = destination.Open(FileMode.Create, FileAccess.Write, FileShare.None); - return await strm.HashingCopy(os, token, job); + using var response = await _client.SendAsync(msg, token); + if (!response.IsSuccessStatusCode) + throw new HttpRequestException(response.ReasonPhrase, null, statusCode: response.StatusCode); + + await using var strm = await response.Content.ReadAsStreamAsync(token); + await using var os = destination.Open(FileMode.Create, FileAccess.Write, FileShare.None); + return await strm.HashingCopy(os, token, job); + } } diff --git a/Wabbajack.Downloaders.Manual/Wabbajack.Downloaders.Manual.csproj b/Wabbajack.Downloaders.Manual/Wabbajack.Downloaders.Manual.csproj index 30490c37..17b55750 100644 --- a/Wabbajack.Downloaders.Manual/Wabbajack.Downloaders.Manual.csproj +++ b/Wabbajack.Downloaders.Manual/Wabbajack.Downloaders.Manual.csproj @@ -7,6 +7,7 @@ + diff --git a/Wabbajack.Downloaders.Nexus/NexusDownloader.cs b/Wabbajack.Downloaders.Nexus/NexusDownloader.cs index 985f3912..4899c8b1 100644 --- a/Wabbajack.Downloaders.Nexus/NexusDownloader.cs +++ b/Wabbajack.Downloaders.Nexus/NexusDownloader.cs @@ -1,20 +1,25 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using System.Web; using Microsoft.Extensions.Logging; +using Wabbajack.Common; using Wabbajack.Downloaders.Interfaces; using Wabbajack.DTOs; using Wabbajack.DTOs.DownloadStates; +using Wabbajack.DTOs.Interventions; using Wabbajack.DTOs.Validation; using Wabbajack.Hashing.xxHash64; using Wabbajack.Networking.Http; using Wabbajack.Networking.Http.Interfaces; using Wabbajack.Networking.NexusApi; using Wabbajack.Paths; +using Wabbajack.Paths.IO; using Wabbajack.RateLimiter; namespace Wabbajack.Downloaders; @@ -25,14 +30,20 @@ public class NexusDownloader : ADownloader, IUrlDownloader private readonly HttpClient _client; private readonly IHttpDownloader _downloader; private readonly ILogger _logger; + private readonly IUserInterventionHandler _userInterventionHandler; + private readonly IResource _interventionLimiter; + + private const bool IsManualDebugMode = true; public NexusDownloader(ILogger logger, HttpClient client, IHttpDownloader downloader, - NexusApi api) + NexusApi api, IUserInterventionHandler userInterventionHandler, IResource interventionLimiter) { _logger = logger; _client = client; _downloader = downloader; _api = api; + _userInterventionHandler = userInterventionHandler; + _interventionLimiter = interventionLimiter; } public override async Task Prepare() @@ -104,11 +115,59 @@ public class NexusDownloader : ADownloader, IUrlDownloader public override async Task Download(Archive archive, Nexus state, AbsolutePath destination, IJob job, CancellationToken token) { - var urls = await _api.DownloadLink(state.Game.MetaData().NexusName!, state.ModID, state.FileID, token); - _logger.LogInformation("Downloading Nexus File: {game}|{modid}|{fileid}", state.Game, state.ModID, - state.FileID); - var message = new HttpRequestMessage(HttpMethod.Get, urls.info.First().URI); - return await _downloader.Download(message, destination, job, token); + if (IsManualDebugMode || await _api.IsPremium(token)) + { + using var _ = await _interventionLimiter.Begin("Downloading file manually", 1, token); + return await DownloadManually(archive, state, destination, job, token); + } + else + { + try + { + var urls = await _api.DownloadLink(state.Game.MetaData().NexusName!, state.ModID, state.FileID, token); + _logger.LogInformation("Downloading Nexus File: {game}|{modid}|{fileid}", state.Game, state.ModID, + state.FileID); + var message = new HttpRequestMessage(HttpMethod.Get, urls.info.First().URI); + return await _downloader.Download(message, destination, job, token); + } + catch (HttpRequestException ex) + { + if (ex.StatusCode == HttpStatusCode.Forbidden) + { + return await DownloadManually(archive, state, destination, job, token); + } + + throw; + } + } + } + + private async Task DownloadManually(Archive archive, Nexus state, AbsolutePath destination, IJob job, CancellationToken token) + { + var md = new ManualDownload(new Archive + { + Name = archive.Name, + Hash = archive.Hash, + Meta = archive.Meta, + Size = archive.Size, + State = new Manual + { + Prompt = "Click Download - Buy Nexus Premium to automate this process", + Url = new Uri($"https://www.nexusmods.com/{state.Game.MetaData().NexusName}/mods/{state.ModID}?tab=files&file_id={state.FileID}") + } + }); + _userInterventionHandler.Raise(md); + var browserState = await md.Task; + + var msg = browserState.ToHttpRequestMessage(); + + using var response = await _client.SendAsync(msg, token); + if (!response.IsSuccessStatusCode) + throw new HttpRequestException(response.ReasonPhrase, null, statusCode:response.StatusCode); + + await using var strm = await response.Content.ReadAsStreamAsync(token); + await using var os = destination.Open(FileMode.Create, FileAccess.Write, FileShare.None); + return await strm.HashingCopy(os, token, job); } public override async Task Verify(Archive archive, Nexus state, IJob job, CancellationToken token) diff --git a/Wabbajack.Installer/AInstaller.cs b/Wabbajack.Installer/AInstaller.cs index 717becfe..406878b6 100644 --- a/Wabbajack.Installer/AInstaller.cs +++ b/Wabbajack.Installer/AInstaller.cs @@ -115,7 +115,7 @@ public abstract class AInstaller { Interlocked.Add(ref _currentStepProgress, stepProgress); - OnStatusUpdate?.Invoke(new StatusUpdate(_statusCategory, _statusText, + OnStatusUpdate?.Invoke(new StatusUpdate(_statusCategory, $"[{_currentStep}/{MaxSteps}] {_statusText} ({_currentStepProgress}/{MaxStepProgress})", Percent.FactoryPutInRange(_currentStep, MaxSteps), Percent.FactoryPutInRange(_currentStepProgress, MaxStepProgress))); } @@ -305,6 +305,9 @@ public abstract class AInstaller public async Task DownloadMissingArchives(List missing, CancellationToken token, bool download = true) { + _logger.LogInformation("Downloading {Count} archives", missing.Count.ToString()); + NextStep(Consts.StepDownloading, "Downloading files", missing.Count); + if (download) { var result = SendDownloadMetrics(missing); @@ -312,12 +315,10 @@ public abstract class AInstaller { var outputPath = _configuration.Downloads.Combine(a.Name); await DownloadArchive(a, true, token, outputPath); + UpdateProgress(1); } } - - _logger.LogInformation("Downloading {Count} archives", missing.Count.ToString()); - NextStep(Consts.StepDownloading, "Downloading files", missing.Count); - + await missing .OrderBy(a => a.Size) .Where(a => a.State is not Manual) diff --git a/Wabbajack.Networking.NexusApi/NexusApi.cs b/Wabbajack.Networking.NexusApi/NexusApi.cs index be2da24a..6daadfc7 100644 --- a/Wabbajack.Networking.NexusApi/NexusApi.cs +++ b/Wabbajack.Networking.NexusApi/NexusApi.cs @@ -30,6 +30,8 @@ public class NexusApi private readonly IResource _limiter; private readonly ILogger _logger; protected readonly ITokenProvider ApiKey; + private DateTime _lastValidated; + private (ValidateInfo info, ResponseMetadata header) _lastValidatedInfo; public NexusApi(ITokenProvider apiKey, ILogger logger, HttpClient client, IResource limiter, @@ -41,6 +43,8 @@ public class NexusApi _appInfo = appInfo; _jsonOptions = jsonOptions; _limiter = limiter; + _lastValidated = DateTime.MinValue; + _lastValidatedInfo = default; } public virtual async Task<(ValidateInfo info, ResponseMetadata header)> Validate( @@ -49,6 +53,19 @@ public class NexusApi var msg = await GenerateMessage(HttpMethod.Get, Endpoints.Validate); return await Send(msg, token); } + + public async Task<(ValidateInfo info, ResponseMetadata header)> ValidateCached( + CancellationToken token = default) + { + if (DateTime.Now - _lastValidated < TimeSpan.FromMinutes(10)) + { + return _lastValidatedInfo; + } + + var msg = await GenerateMessage(HttpMethod.Get, Endpoints.Validate); + _lastValidatedInfo = await Send(msg, token); + return _lastValidatedInfo; + } public virtual async Task<(ModInfo info, ResponseMetadata header)> ModInfo(string nexusGameName, long modId, CancellationToken token = default) @@ -310,4 +327,10 @@ public class NexusApi await Task.Delay(TimeSpan.FromSeconds(5)); } } + + public async Task IsPremium(CancellationToken token) + { + var validated = await ValidateCached(token); + return validated.info.IsPremium; + } } \ No newline at end of file diff --git a/Wabbajack.Services.OSIntegrated/ServiceExtensions.cs b/Wabbajack.Services.OSIntegrated/ServiceExtensions.cs index 2124e29d..836f6ca9 100644 --- a/Wabbajack.Services.OSIntegrated/ServiceExtensions.cs +++ b/Wabbajack.Services.OSIntegrated/ServiceExtensions.cs @@ -9,6 +9,7 @@ using Wabbajack.Compiler; using Wabbajack.Downloaders; using Wabbajack.Downloaders.GameFile; using Wabbajack.DTOs; +using Wabbajack.DTOs.Interventions; using Wabbajack.DTOs.Logins; using Wabbajack.Installer; using Wabbajack.Networking.BethesdaNet; @@ -99,6 +100,9 @@ public static class ServiceExtensions service.AddAllSingleton>(s => new Resource("Installer", GetSettings(s, "Installer"))); + + service.AddAllSingleton>(s => + new Resource("User Intervention", 3)); service.AddSingleton();