mirror of
https://github.com/wabbajack-tools/wabbajack.git
synced 2024-08-30 18:42:17 +00:00
New Features from External Contributors [Merged to Internal Test Branch so Tests can run!] (#2325)
* added enderalse GOGID * Fix readme opening twice when loading last modlist * Edit Wabbajack CLI button text * Cancel running downloads when shutting down application * Add resume support for IHttpDownloader * Add resume support for manual downloads * Update CHANGELOG.md * Improve game selection to only show games with results combined with the amount of lists * Undo accidental removal of loading settings * Add more tooltips and improve existing ones * Update CHANGELOG.md * Main test external pull readme fix (#2335) * Fix SelectedGameType crashing Wabbajack when no settings are present yet, fix readme being clickable when not specified resulting in crash * Add readme fix to CHANGELOG, fix typo * Add readme button fix to changelog --------- Co-authored-by: UrbanCMC <UrbanCMC@web.de> Co-authored-by: Angad <angadmisra28@gmail.com> Co-authored-by: trawzified <55751269+tr4wzified@users.noreply.github.com> Co-authored-by: Timothy Baldridge <tbaldridge@gmail.com>
This commit is contained in:
parent
b66632f930
commit
a87f8dac7f
14
CHANGELOG.md
14
CHANGELOG.md
@ -1,8 +1,20 @@
|
||||
### Changelog
|
||||
|
||||
#### Version - TBD
|
||||
* Fixed Readme opening twice
|
||||
* Updated Text in the UI to better describe current app behavior
|
||||
* Added support for resumable downloads after closing the app during downloads (not available for MEGA downloads)
|
||||
* More and improved existing tooltips
|
||||
* Fixed being able to click the readme button when there is no readme
|
||||
* Game Selector Improvements:
|
||||
* Only games that have modlists are shown now
|
||||
* Amount of lists for a game is shown
|
||||
* Now able to filter for game in combination with filtering on only installed modlists
|
||||
* Game support:
|
||||
* Added Enderal GOG support (compatibility with existing lists unclear)
|
||||
|
||||
#### Version - 3.0.6.2 - 1/28/2023
|
||||
* Add fallback for DDS compression when installing older lists. This should keep older DDS files from not being compressed without any mipmaps at all.
|
||||
*
|
||||
|
||||
#### Version - 3.0.6.1 - 1/28/2023
|
||||
* Game support:
|
||||
|
@ -5,8 +5,7 @@
|
||||
xmlns:local="clr-namespace:Wabbajack"
|
||||
xmlns:controls="clr-namespace:Wabbajack.Extensions"
|
||||
ShutdownMode="OnExplicitShutdown"
|
||||
Startup="OnStartup"
|
||||
Exit="OnExit">
|
||||
Startup="OnStartup">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
|
@ -194,14 +194,5 @@ namespace Wabbajack
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private void OnExit(object sender, ExitEventArgs e)
|
||||
{
|
||||
using (_host)
|
||||
{
|
||||
_host.StopAsync();
|
||||
}
|
||||
base.OnExit(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Reactive.Disposables;
|
||||
using System.Reactive.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
using DynamicData;
|
||||
@ -28,6 +29,7 @@ namespace Wabbajack
|
||||
|
||||
private readonly SourceCache<ModListMetadataVM, string> _modLists = new(x => x.Metadata.NamespacedName);
|
||||
public ReadOnlyObservableCollection<ModListMetadataVM> _filteredModLists;
|
||||
|
||||
public ReadOnlyObservableCollection<ModListMetadataVM> ModLists => _filteredModLists;
|
||||
|
||||
private const string ALL_GAME_TYPE = "All";
|
||||
@ -44,19 +46,45 @@ namespace Wabbajack
|
||||
|
||||
[Reactive] public string GameType { get; set; }
|
||||
|
||||
public List<string> GameTypeEntries => GetGameTypeEntries();
|
||||
public class GameTypeEntry
|
||||
{
|
||||
public GameTypeEntry(string humanFriendlyName, int amount)
|
||||
{
|
||||
HumanFriendlyName = humanFriendlyName;
|
||||
Amount = amount;
|
||||
FormattedName = $"{HumanFriendlyName} ({Amount})";
|
||||
}
|
||||
public string HumanFriendlyName { get; set; }
|
||||
public int Amount { get; set; }
|
||||
public string FormattedName { get; set; }
|
||||
}
|
||||
|
||||
[Reactive] public List<GameTypeEntry> GameTypeEntries { get; set; }
|
||||
private bool _filteringOnGame;
|
||||
private GameTypeEntry _selectedGameTypeEntry = null;
|
||||
|
||||
public GameTypeEntry SelectedGameTypeEntry
|
||||
{
|
||||
get => _selectedGameTypeEntry;
|
||||
set
|
||||
{
|
||||
RaiseAndSetIfChanged(ref _selectedGameTypeEntry, value == null ? GameTypeEntries?.FirstOrDefault(gte => gte.HumanFriendlyName == ALL_GAME_TYPE) : value);
|
||||
GameType = _selectedGameTypeEntry?.HumanFriendlyName;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly Client _wjClient;
|
||||
private readonly ILogger<ModListGalleryVM> _logger;
|
||||
private readonly GameLocator _locator;
|
||||
private readonly ModListDownloadMaintainer _maintainer;
|
||||
private readonly SettingsManager _settingsManager;
|
||||
private readonly CancellationToken _cancellationToken;
|
||||
|
||||
private FiltersSettings settings { get; set; } = new();
|
||||
public ICommand ClearFiltersCommand { get; set; }
|
||||
|
||||
public ModListGalleryVM(ILogger<ModListGalleryVM> logger, Client wjClient,
|
||||
GameLocator locator, SettingsManager settingsManager, ModListDownloadMaintainer maintainer)
|
||||
public ModListGalleryVM(ILogger<ModListGalleryVM> logger, Client wjClient, GameLocator locator,
|
||||
SettingsManager settingsManager, ModListDownloadMaintainer maintainer, CancellationToken cancellationToken)
|
||||
: base(logger)
|
||||
{
|
||||
_wjClient = wjClient;
|
||||
@ -64,6 +92,7 @@ namespace Wabbajack
|
||||
_locator = locator;
|
||||
_maintainer = maintainer;
|
||||
_settingsManager = settingsManager;
|
||||
_cancellationToken = cancellationToken;
|
||||
|
||||
ClearFiltersCommand = ReactiveCommand.Create(
|
||||
() =>
|
||||
@ -72,7 +101,7 @@ namespace Wabbajack
|
||||
ShowNSFW = false;
|
||||
ShowUnofficialLists = false;
|
||||
Search = string.Empty;
|
||||
GameType = ALL_GAME_TYPE;
|
||||
SelectedGameTypeEntry = GameTypeEntries.FirstOrDefault();
|
||||
});
|
||||
|
||||
BackCommand = ReactiveCommand.Create(
|
||||
@ -127,6 +156,7 @@ namespace Wabbajack
|
||||
.Select(v => v.Value)
|
||||
.Select<string, Func<ModListMetadataVM, bool>>(selected =>
|
||||
{
|
||||
_filteringOnGame = true;
|
||||
if (selected is null or ALL_GAME_TYPE) return _ => true;
|
||||
return item => item.Metadata.Game.MetaData().HumanFriendlyGameName == selected;
|
||||
})
|
||||
@ -140,7 +170,18 @@ namespace Wabbajack
|
||||
.Filter(showNSFWFilter)
|
||||
.Filter(gameFilter)
|
||||
.Bind(out _filteredModLists)
|
||||
.Subscribe()
|
||||
.Subscribe((_) =>
|
||||
{
|
||||
if (!_filteringOnGame)
|
||||
{
|
||||
var previousGameType = GameType;
|
||||
SelectedGameTypeEntry = null;
|
||||
GameTypeEntries = new(GetGameTypeEntries());
|
||||
var nextEntry = GameTypeEntries.FirstOrDefault(gte => previousGameType == gte.HumanFriendlyName);
|
||||
SelectedGameTypeEntry = nextEntry != default ? nextEntry : GameTypeEntries.FirstOrDefault(gte => GameType == ALL_GAME_TYPE);
|
||||
}
|
||||
_filteringOnGame = false;
|
||||
})
|
||||
.DisposeWith(disposables);
|
||||
});
|
||||
}
|
||||
@ -177,7 +218,7 @@ namespace Wabbajack
|
||||
RxApp.MainThreadScheduler.Schedule(await _settingsManager.Load<FilterSettings>("modlist_gallery"),
|
||||
(_, s) =>
|
||||
{
|
||||
GameType = s.GameType;
|
||||
SelectedGameTypeEntry = GameTypeEntries?.FirstOrDefault(gte => gte.HumanFriendlyName.Equals(s.GameType));
|
||||
ShowNSFW = s.ShowNSFW;
|
||||
ShowUnofficialLists = s.ShowUnofficialLists;
|
||||
Search = s.Search;
|
||||
@ -196,7 +237,7 @@ namespace Wabbajack
|
||||
{
|
||||
e.Clear();
|
||||
e.AddOrUpdate(modLists.Select(m =>
|
||||
new ModListMetadataVM(_logger, this, m, _maintainer, _wjClient)));
|
||||
new ModListMetadataVM(_logger, this, m, _maintainer, _wjClient, _cancellationToken)));
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
@ -207,13 +248,14 @@ namespace Wabbajack
|
||||
ll.Succeed();
|
||||
}
|
||||
|
||||
private List<string> GetGameTypeEntries()
|
||||
private List<GameTypeEntry> GetGameTypeEntries()
|
||||
{
|
||||
List<string> gameEntries = new List<string> {ALL_GAME_TYPE};
|
||||
gameEntries.AddRange(GameRegistry.Games.Values.Select(gameType => gameType.HumanFriendlyGameName));
|
||||
gameEntries.Sort();
|
||||
return gameEntries;
|
||||
return ModLists.Select(fm => fm.Metadata)
|
||||
.GroupBy(m => m.Game)
|
||||
.Select(g => new GameTypeEntry(g.Key.MetaData().HumanFriendlyGameName, g.Count()))
|
||||
.OrderBy(gte => gte.HumanFriendlyName)
|
||||
.Prepend(new GameTypeEntry(ALL_GAME_TYPE, ModLists.Count))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
@ -4,6 +4,7 @@ using System.Linq;
|
||||
using System.Reactive;
|
||||
using System.Reactive.Linq;
|
||||
using System.Reactive.Subjects;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media.Imaging;
|
||||
@ -94,15 +95,17 @@ namespace Wabbajack
|
||||
private readonly ILogger _logger;
|
||||
private readonly ModListDownloadMaintainer _maintainer;
|
||||
private readonly Client _wjClient;
|
||||
private readonly CancellationToken _cancellationToken;
|
||||
|
||||
public ModListMetadataVM(ILogger logger, ModListGalleryVM parent, ModlistMetadata metadata,
|
||||
ModListDownloadMaintainer maintainer, Client wjClient)
|
||||
ModListDownloadMaintainer maintainer, Client wjClient, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger = logger;
|
||||
_parent = parent;
|
||||
_maintainer = maintainer;
|
||||
Metadata = metadata;
|
||||
_wjClient = wjClient;
|
||||
_cancellationToken = cancellationToken;
|
||||
Location = LauncherUpdater.CommonFolder.Value.Combine("downloaded_mod_lists", Metadata.NamespacedName).WithExtension(Ext.Wabbajack);
|
||||
ModListTagList = new List<ModListTag>();
|
||||
|
||||
@ -184,7 +187,7 @@ namespace Wabbajack
|
||||
Status = ModListStatus.Downloading;
|
||||
|
||||
using var ll = LoadingLock.WithLoading();
|
||||
var (progress, task) = _maintainer.DownloadModlist(Metadata);
|
||||
var (progress, task) = _maintainer.DownloadModlist(Metadata, _cancellationToken);
|
||||
var dispose = progress
|
||||
.BindToStrict(this, vm => vm.ProgressPercent);
|
||||
try
|
||||
|
@ -119,6 +119,7 @@ public class InstallerVM : BackNavigatingVM, IBackNavigatingVM, ICpuStatusVM
|
||||
private readonly HttpClient _client;
|
||||
private readonly DownloadDispatcher _downloadDispatcher;
|
||||
private readonly IEnumerable<INeedsLogin> _logins;
|
||||
private readonly CancellationToken _cancellationToken;
|
||||
public ReadOnlyObservableCollection<CPUDisplayVM> StatusList => _resourceMonitor.Tasks;
|
||||
|
||||
[Reactive]
|
||||
@ -146,7 +147,8 @@ public class InstallerVM : BackNavigatingVM, IBackNavigatingVM, ICpuStatusVM
|
||||
|
||||
public InstallerVM(ILogger<InstallerVM> logger, DTOSerializer dtos, SettingsManager settingsManager, IServiceProvider serviceProvider,
|
||||
SystemParametersConstructor parametersConstructor, IGameLocator gameLocator, LogStream loggerProvider, ResourceMonitor resourceMonitor,
|
||||
Wabbajack.Services.OSIntegrated.Configuration configuration, HttpClient client, DownloadDispatcher dispatcher, IEnumerable<INeedsLogin> logins) : base(logger)
|
||||
Wabbajack.Services.OSIntegrated.Configuration configuration, HttpClient client, DownloadDispatcher dispatcher, IEnumerable<INeedsLogin> logins,
|
||||
CancellationToken cancellationToken) : base(logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
@ -160,6 +162,7 @@ public class InstallerVM : BackNavigatingVM, IBackNavigatingVM, ICpuStatusVM
|
||||
_client = client;
|
||||
_downloadDispatcher = dispatcher;
|
||||
_logins = logins;
|
||||
_cancellationToken = cancellationToken;
|
||||
|
||||
Installer = new MO2InstallerVM(this);
|
||||
|
||||
@ -170,7 +173,7 @@ public class InstallerVM : BackNavigatingVM, IBackNavigatingVM, ICpuStatusVM
|
||||
OpenReadmeCommand = ReactiveCommand.Create(() =>
|
||||
{
|
||||
UIUtils.OpenWebsite(new Uri(ModList!.Readme));
|
||||
}, LoadingLock.IsNotLoadingObservable);
|
||||
}, this.WhenAnyValue(vm => vm.LoadingLock.IsNotLoading, vm => vm.ModList.Readme, (isNotLoading, readme) => isNotLoading && !string.IsNullOrWhiteSpace(readme)));
|
||||
|
||||
VisitModListWebsiteCommand = ReactiveCommand.Create(() =>
|
||||
{
|
||||
@ -312,7 +315,9 @@ public class InstallerVM : BackNavigatingVM, IBackNavigatingVM, ICpuStatusVM
|
||||
{
|
||||
var lst = await _settingsManager.Load<AbsolutePath>(LastLoadedModlist);
|
||||
if (lst.FileExists())
|
||||
await LoadModlist(lst, null);
|
||||
{
|
||||
ModListLocation.TargetPath = lst;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadModlist(AbsolutePath path, ModlistMetadata? metadata)
|
||||
@ -438,7 +443,7 @@ public class InstallerVM : BackNavigatingVM, IBackNavigatingVM, ICpuStatusVM
|
||||
update.StepsProgress.Value);
|
||||
};
|
||||
|
||||
if (!await installer.Begin(CancellationToken.None))
|
||||
if (!await installer.Begin(_cancellationToken))
|
||||
{
|
||||
TaskBarUpdate.Send($"Error during install of {ModList.Name}", TaskbarItemProgressState.Error);
|
||||
InstallState = InstallState.Failure;
|
||||
|
@ -243,6 +243,20 @@ namespace Wabbajack
|
||||
return false;
|
||||
}
|
||||
|
||||
public void CancelRunningTasks(TimeSpan timeout)
|
||||
{
|
||||
var endTime = DateTime.Now.Add(timeout);
|
||||
var cancellationTokenSource = _serviceProvider.GetRequiredService<CancellationTokenSource>();
|
||||
cancellationTokenSource.Cancel();
|
||||
|
||||
bool IsInstalling() => Installer.InstallState is InstallState.Installing;
|
||||
|
||||
while (DateTime.Now < endTime && IsInstalling())
|
||||
{
|
||||
Thread.Sleep(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
public void NavigateTo(ViewModel vm)
|
||||
{
|
||||
|
@ -224,7 +224,7 @@
|
||||
Margin="30,2"
|
||||
FontSize="20"
|
||||
Style="{StaticResource LargeButtonStyle}"
|
||||
ToolTip="Open the Discord for this Modlist">
|
||||
ToolTip="Open the Discord for the Modlist">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="30" />
|
||||
@ -290,7 +290,7 @@
|
||||
Margin="30,2"
|
||||
FontSize="20"
|
||||
Style="{StaticResource LargeButtonStyle}"
|
||||
ToolTip="Open an explicit listing of all actions this modlist will take">
|
||||
ToolTip="Open an explicit listing of all archives this modlist contains">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="30" />
|
||||
|
@ -25,23 +25,27 @@
|
||||
VerticalAlignment="Center"
|
||||
FontSize="14"
|
||||
Text="Modlist Installation Location"
|
||||
TextAlignment="Center" />
|
||||
TextAlignment="Center"
|
||||
ToolTip="The directory where the modlist will be installed" />
|
||||
<local:FilePicker Grid.Row="0" Grid.Column="2"
|
||||
Height="30"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="14"
|
||||
PickerVM="{Binding Location}" />
|
||||
PickerVM="{Binding Location}"
|
||||
ToolTip="The directory where the modlist will be installed" />
|
||||
<TextBlock Grid.Row="1" Grid.Column="0"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="14"
|
||||
Text="Resource Download Location"
|
||||
TextAlignment="Center" />
|
||||
TextAlignment="Center"
|
||||
ToolTip="The directory where modlist archives will be downloaded to" />
|
||||
<local:FilePicker Grid.Row="1" Grid.Column="2"
|
||||
Height="30"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="14"
|
||||
PickerVM="{Binding DownloadLocation}" />
|
||||
PickerVM="{Binding DownloadLocation}"
|
||||
ToolTip="The directory where modlist archives will be downloaded to" />
|
||||
<CheckBox Grid.Row="2" Grid.Column="2"
|
||||
HorizontalAlignment="Right"
|
||||
Content="Overwrite Installation"
|
||||
|
@ -40,7 +40,7 @@
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" FontSize="16" Margin="0, 0, 8, 0" Name="AppName"></TextBlock>
|
||||
<TextBlock Grid.Column="1" FontSize="16" Margin="5, 0" Name="ResourceUsage" HorizontalAlignment="Right" VerticalAlignment="Center"></TextBlock>
|
||||
<Button Grid.Column="2" Name="SettingsButton">
|
||||
<Button Grid.Column="2" Name="SettingsButton" ToolTip="Open Wabbajack settings">
|
||||
<icon:Material Kind="Cog"></icon:Material>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
@ -3,7 +3,7 @@ using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Reactive.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using DynamicData;
|
||||
@ -55,11 +55,8 @@ namespace Wabbajack
|
||||
|
||||
Closed += (s, e) =>
|
||||
{
|
||||
Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(5000);
|
||||
Environment.Exit(0);
|
||||
});
|
||||
_mwvm.CancelRunningTasks(TimeSpan.FromSeconds(10));
|
||||
Application.Current.Shutdown();
|
||||
};
|
||||
|
||||
MessageBus.Current.Listen<TaskBarUpdate>()
|
||||
|
@ -91,45 +91,52 @@
|
||||
<Label
|
||||
Margin="0,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Content="Game" />
|
||||
Content="Game"
|
||||
ToolTip="Select a game" />
|
||||
<ComboBox
|
||||
Width="150"
|
||||
Margin="0,0,10,0"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{StaticResource ForegroundBrush}"
|
||||
IsEnabled="{Binding OnlyInstalled, Converter={StaticResource InverseBooleanConverter}}"
|
||||
ItemsSource="{Binding Path=GameTypeEntries}"
|
||||
SelectedItem="{Binding GameType, Mode=TwoWay}"
|
||||
ItemsSource="{Binding GameTypeEntries, Mode=TwoWay}"
|
||||
SelectedItem="{Binding SelectedGameTypeEntry, Mode=TwoWay}"
|
||||
DisplayMemberPath="FormattedName"
|
||||
IsSynchronizedWithCurrentItem="True"
|
||||
ToolTip="Select a game" />
|
||||
<TextBlock
|
||||
Margin="0,0,5,0"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{StaticResource ForegroundBrush}"
|
||||
Text="Search" />
|
||||
Text="Search"
|
||||
ToolTip="Search for a game" />
|
||||
<TextBox
|
||||
x:Name="SearchBox"
|
||||
Width="95"
|
||||
VerticalContentAlignment="Center" />
|
||||
VerticalContentAlignment="Center"
|
||||
ToolTip="Only show Not Safe For Work (NSFW) modlists" />
|
||||
<CheckBox
|
||||
x:Name="ShowNSFW"
|
||||
Margin="10,0,10,0"
|
||||
VerticalAlignment="Center"
|
||||
Content="Show NSFW"
|
||||
Foreground="{StaticResource ForegroundBrush}" />
|
||||
Foreground="{StaticResource ForegroundBrush}"
|
||||
ToolTip="Only show Not Safe For Work (NSFW) modlists" />
|
||||
|
||||
<CheckBox
|
||||
x:Name="ShowUnofficialLists"
|
||||
Margin="10,0,10,0"
|
||||
VerticalAlignment="Center"
|
||||
Content="Show Unofficial Lists"
|
||||
Foreground="{StaticResource ForegroundBrush}" />
|
||||
Foreground="{StaticResource ForegroundBrush}"
|
||||
ToolTip="Show modlists from external repositories"/>
|
||||
|
||||
<CheckBox
|
||||
x:Name="OnlyInstalledCheckbox"
|
||||
Margin="10,0,10,0"
|
||||
VerticalAlignment="Center"
|
||||
Content="Only Installed"
|
||||
Foreground="{StaticResource ForegroundBrush}" />
|
||||
Foreground="{StaticResource ForegroundBrush}"
|
||||
ToolTip="Only show modlists for games you have installed"/>
|
||||
<Button
|
||||
x:Name="ClearFiltersButton"
|
||||
Margin="0,0,10,0"
|
||||
|
@ -367,7 +367,8 @@
|
||||
Height="40"
|
||||
Margin="5,0"
|
||||
VerticalAlignment="Center"
|
||||
Style="{StaticResource IconBareButtonStyle}">
|
||||
Style="{StaticResource IconBareButtonStyle}"
|
||||
ToolTip="View modlist website in browser">
|
||||
<iconPacks:Material
|
||||
Width="20"
|
||||
Height="20"
|
||||
@ -379,7 +380,8 @@
|
||||
Height="40"
|
||||
Margin="5,0"
|
||||
VerticalAlignment="Center"
|
||||
Style="{StaticResource IconBareButtonStyle}">
|
||||
Style="{StaticResource IconBareButtonStyle}"
|
||||
ToolTip="View modlist archives in browser">
|
||||
<iconPacks:Material
|
||||
Width="20"
|
||||
Height="20"
|
||||
@ -390,7 +392,8 @@
|
||||
Width="40"
|
||||
Height="40"
|
||||
Margin="5,0"
|
||||
VerticalAlignment="Center">
|
||||
VerticalAlignment="Center"
|
||||
ToolTip="Download modlist">
|
||||
<StackPanel x:Name="IconContainer">
|
||||
<iconPacks:Material
|
||||
x:Name="ErrorIcon"
|
||||
|
@ -82,7 +82,7 @@
|
||||
<Button Grid.Row="3"
|
||||
Name="OpenTerminal"
|
||||
Margin="0,5,0,0"
|
||||
Content="Open Terminal and Close WJ" />
|
||||
Content="Launch Wabbajack CLI" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
@ -21,6 +21,7 @@ public static class Ext
|
||||
public static Extension Md = new(".md");
|
||||
public static Extension MetaData = new(".metadata");
|
||||
public static Extension CompilerSettings = new(".compiler_settings");
|
||||
public static Extension DownloadPackage = new(".download_package");
|
||||
public static Extension Temp = new(".temp");
|
||||
public static Extension ModlistMetadataExtension = new(".modlist_metadata");
|
||||
public static Extension Txt = new(".txt");
|
||||
|
@ -182,6 +182,7 @@ public static class GameRegistry
|
||||
MO2Name = "Enderal Special Edition",
|
||||
MO2ArchiveName = "enderalse",
|
||||
SteamIDs = new[] {976620},
|
||||
GOGIDs = new [] {1708684988},
|
||||
RequiredFiles = new[]
|
||||
{
|
||||
"SkyrimSE.exe".ToRelativePath()
|
||||
|
@ -43,6 +43,11 @@ public class DownloadDispatcher
|
||||
|
||||
public async Task<Hash> Download(Archive a, AbsolutePath dest, CancellationToken token, bool? proxy = null)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
return new Hash();
|
||||
}
|
||||
|
||||
using var downloadScope = _logger.BeginScope("Downloading {Name}", a.Name);
|
||||
using var job = await _limiter.Begin("Downloading " + a.Name, a.Size, token);
|
||||
return await Download(a, dest, job, token, proxy);
|
||||
@ -74,33 +79,40 @@ public class DownloadDispatcher
|
||||
|
||||
public async Task<Hash> Download(Archive a, AbsolutePath dest, Job<DownloadDispatcher> job, CancellationToken token, bool? useProxy = null)
|
||||
{
|
||||
if (!dest.Parent.DirectoryExists())
|
||||
dest.Parent.CreateDirectory();
|
||||
|
||||
var downloader = Downloader(a);
|
||||
if ((useProxy ?? _useProxyCache) && downloader is IProxyable p)
|
||||
try
|
||||
{
|
||||
var uri = p.UnParse(a.State);
|
||||
var newUri = await _wjClient.MakeProxyUrl(a, uri);
|
||||
if (newUri != null)
|
||||
{
|
||||
a = new Archive
|
||||
{
|
||||
Name = a.Name,
|
||||
Size = a.Size,
|
||||
Hash = a.Hash,
|
||||
State = new DTOs.DownloadStates.Http()
|
||||
{
|
||||
Url = newUri
|
||||
}
|
||||
};
|
||||
downloader = Downloader(a);
|
||||
_logger.LogInformation("Downloading Proxy ({Hash}) {Uri}", (await uri.ToString().Hash()).ToHex(), uri);
|
||||
}
|
||||
}
|
||||
if (!dest.Parent.DirectoryExists())
|
||||
dest.Parent.CreateDirectory();
|
||||
|
||||
var hash = await downloader.Download(a, dest, job, token);
|
||||
return hash;
|
||||
var downloader = Downloader(a);
|
||||
if ((useProxy ?? _useProxyCache) && downloader is IProxyable p)
|
||||
{
|
||||
var uri = p.UnParse(a.State);
|
||||
var newUri = await _wjClient.MakeProxyUrl(a, uri);
|
||||
if (newUri != null)
|
||||
{
|
||||
a = new Archive
|
||||
{
|
||||
Name = a.Name,
|
||||
Size = a.Size,
|
||||
Hash = a.Hash,
|
||||
State = new DTOs.DownloadStates.Http()
|
||||
{
|
||||
Url = newUri
|
||||
}
|
||||
};
|
||||
downloader = Downloader(a);
|
||||
_logger.LogInformation("Downloading Proxy ({Hash}) {Uri}", (await uri.ToString().Hash()).ToHex(), uri);
|
||||
}
|
||||
}
|
||||
|
||||
var hash = await downloader.Download(a, dest, job, token);
|
||||
return hash;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return new Hash();
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IDownloadState?> ResolveArchive(IReadOnlyDictionary<string, string> ini)
|
||||
@ -285,24 +297,6 @@ public class DownloadDispatcher
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public async Task<Archive?> FindUpgrade(Archive archive, TemporaryFileManager fileManager, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var downloader = Downloader(archive);
|
||||
if (downloader is not IUpgradingDownloader ud) return null;
|
||||
|
||||
using var job = await _limiter.Begin($"Finding upgrade for {archive.Name} - {archive.State.PrimaryKeyString}", 0,
|
||||
token);
|
||||
return await ud.TryGetUpgrade(archive, job, fileManager, token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogCritical(ex, "While finding upgrade for {PrimaryKeyString}", archive.State.PrimaryKeyString);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IEnumerable<IDownloader>> AllDownloaders(IEnumerable<IDownloadState> downloadStates)
|
||||
{
|
||||
return Task.FromResult(downloadStates.Select(d => Downloader(new Archive {State = d})).Distinct());
|
||||
|
@ -21,7 +21,7 @@ using Wabbajack.RateLimiter;
|
||||
|
||||
namespace Wabbajack.Downloaders.Http;
|
||||
|
||||
public class HttpDownloader : ADownloader<DTOs.DownloadStates.Http>, IUrlDownloader, IUpgradingDownloader, IChunkedSeekableStreamDownloader
|
||||
public class HttpDownloader : ADownloader<DTOs.DownloadStates.Http>, IUrlDownloader, IChunkedSeekableStreamDownloader
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly IHttpDownloader _downloader;
|
||||
@ -34,23 +34,6 @@ public class HttpDownloader : ADownloader<DTOs.DownloadStates.Http>, IUrlDownloa
|
||||
_downloader = downloader;
|
||||
}
|
||||
|
||||
public async Task<Archive?> TryGetUpgrade(Archive archive, IJob job, TemporaryFileManager temporaryFileManager,
|
||||
CancellationToken token)
|
||||
{
|
||||
var state = (DTOs.DownloadStates.Http) archive.State;
|
||||
await using var file = temporaryFileManager.CreateFile();
|
||||
|
||||
var newHash = await Download(archive, file.Path, job, token);
|
||||
|
||||
return new Archive
|
||||
{
|
||||
Hash = newHash,
|
||||
Size = file.Path.Size(),
|
||||
State = archive.State,
|
||||
Name = archive.Name
|
||||
};
|
||||
}
|
||||
|
||||
public override IDownloadState? Resolve(IReadOnlyDictionary<string, string> iniData)
|
||||
{
|
||||
if (iniData.ContainsKey("directURL") && Uri.TryCreate(iniData["directURL"].CleanIniString(), UriKind.Absolute, out var uri))
|
||||
|
@ -26,7 +26,7 @@ using Wabbajack.RateLimiter;
|
||||
|
||||
namespace Wabbajack.Downloaders.IPS4OAuth2Downloader;
|
||||
|
||||
public class AIPS4OAuth2Downloader<TDownloader, TLogin, TState> : ADownloader<TState>, IUpgradingDownloader
|
||||
public class AIPS4OAuth2Downloader<TDownloader, TLogin, TState> : ADownloader<TState>
|
||||
where TLogin : OAuth2LoginState, new()
|
||||
where TState : IPS4OAuth2, new()
|
||||
{
|
||||
@ -58,41 +58,6 @@ public class AIPS4OAuth2Downloader<TDownloader, TLogin, TState> : ADownloader<TS
|
||||
|
||||
public override Priority Priority => Priority.Normal;
|
||||
|
||||
public async Task<Archive?> TryGetUpgrade(Archive archive, IJob job, TemporaryFileManager temporaryFileManager,
|
||||
CancellationToken token)
|
||||
{
|
||||
var state = (TState) archive.State;
|
||||
if (state.IsAttachment) return default;
|
||||
|
||||
var files = (await GetDownloads(state.IPS4Mod, token)).Files;
|
||||
var nl = new Levenshtein();
|
||||
|
||||
foreach (var newFile in files.Where(f => f.Url != null)
|
||||
.OrderBy(f => nl.Distance(archive.Name.ToLowerInvariant(), f.Name!.ToLowerInvariant())))
|
||||
{
|
||||
var newArchive = new Archive
|
||||
{
|
||||
State = new TState
|
||||
{
|
||||
IPS4Mod = state.IPS4Mod,
|
||||
IPS4File = newFile.Name!
|
||||
}
|
||||
};
|
||||
var tmp = temporaryFileManager.CreateFile();
|
||||
var newHash = await Download(newArchive, (TState) newArchive.State, tmp.Path, job, token);
|
||||
if (newHash != default)
|
||||
{
|
||||
newArchive.Size = tmp.Path.Size();
|
||||
newArchive.Hash = newHash;
|
||||
return newArchive;
|
||||
}
|
||||
|
||||
await tmp.DisposeAsync();
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
public async ValueTask<HttpRequestMessage> MakeMessage(HttpMethod method, Uri url, bool useOAuth2 = true)
|
||||
{
|
||||
var msg = new HttpRequestMessage(method, url);
|
||||
|
@ -1,13 +0,0 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Wabbajack.DTOs;
|
||||
using Wabbajack.Paths.IO;
|
||||
using Wabbajack.RateLimiter;
|
||||
|
||||
namespace Wabbajack.Downloaders.Interfaces;
|
||||
|
||||
public interface IUpgradingDownloader
|
||||
{
|
||||
public Task<Archive?> TryGetUpgrade(Archive archive, IJob job, TemporaryFileManager temporaryFileManager,
|
||||
CancellationToken token);
|
||||
}
|
@ -6,6 +6,7 @@ using Wabbajack.DTOs.DownloadStates;
|
||||
using Wabbajack.DTOs.Interventions;
|
||||
using Wabbajack.DTOs.Validation;
|
||||
using Wabbajack.Hashing.xxHash64;
|
||||
using Wabbajack.Networking.Http.Interfaces;
|
||||
using Wabbajack.Paths;
|
||||
using Wabbajack.Paths.IO;
|
||||
using Wabbajack.RateLimiter;
|
||||
@ -16,45 +17,25 @@ public class ManualDownloader : ADownloader<DTOs.DownloadStates.Manual>, IProxya
|
||||
{
|
||||
private readonly ILogger<ManualDownloader> _logger;
|
||||
private readonly IUserInterventionHandler _interventionHandler;
|
||||
private readonly HttpClient _client;
|
||||
private readonly IHttpDownloader _downloader;
|
||||
|
||||
public ManualDownloader(ILogger<ManualDownloader> logger, IUserInterventionHandler interventionHandler, HttpClient client)
|
||||
public ManualDownloader(ILogger<ManualDownloader> logger, IUserInterventionHandler interventionHandler, IHttpDownloader downloader)
|
||||
{
|
||||
_logger = logger;
|
||||
_interventionHandler = interventionHandler;
|
||||
_client = client;
|
||||
_downloader = downloader;
|
||||
}
|
||||
|
||||
public override async Task<Hash> Download(Archive archive, DTOs.DownloadStates.Manual state, AbsolutePath destination, IJob job, CancellationToken token)
|
||||
{
|
||||
_logger.LogInformation("Starting manual download of {Url}", state.Url);
|
||||
|
||||
if (state.Url.Host == "mega.nz")
|
||||
{
|
||||
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;
|
||||
var intervention = new ManualDownload(archive);
|
||||
_interventionHandler.Raise(intervention);
|
||||
var browserState = await intervention.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);
|
||||
}
|
||||
var msg = browserState.ToHttpRequestMessage();
|
||||
return await _downloader.Download(msg, destination, job, token);
|
||||
}
|
||||
|
||||
|
||||
|
@ -131,6 +131,11 @@ public class NexusDownloader : ADownloader<Nexus>, IUrlDownloader
|
||||
state.FileID);
|
||||
foreach (var link in urls.info)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
return new Hash();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var message = new HttpRequestMessage(HttpMethod.Get, link.URI);
|
||||
|
@ -228,7 +228,7 @@ public abstract class AInstaller<T>
|
||||
.ToDictionary(a => a.Key);
|
||||
|
||||
if (grouped.Count == 0) return;
|
||||
|
||||
if (token.IsCancellationRequested) return;
|
||||
|
||||
await _vfs.Extract(grouped.Keys.ToHashSet(), async (vf, sf) =>
|
||||
{
|
||||
@ -237,6 +237,7 @@ public abstract class AInstaller<T>
|
||||
token);
|
||||
foreach (var directive in directives)
|
||||
{
|
||||
if (token.IsCancellationRequested) return;
|
||||
var file = directive.Directive;
|
||||
UpdateProgress(file.Size);
|
||||
var destPath = file.To.RelativeTo(_configuration.Install);
|
||||
@ -363,9 +364,10 @@ public abstract class AInstaller<T>
|
||||
{
|
||||
_logger.LogInformation("Downloading {Archive}", archive.Name);
|
||||
var outputPath = _configuration.Downloads.Combine(archive.Name);
|
||||
var downloadPackagePath = outputPath.WithExtension(Ext.DownloadPackage);
|
||||
|
||||
if (download)
|
||||
if (outputPath.FileExists())
|
||||
if (outputPath.FileExists() && !downloadPackagePath.FileExists())
|
||||
{
|
||||
var origName = Path.GetFileNameWithoutExtension(archive.Name);
|
||||
var ext = Path.GetExtension(archive.Name);
|
||||
@ -396,6 +398,10 @@ public abstract class AInstaller<T>
|
||||
|
||||
var (result, hash) =
|
||||
await _downloadDispatcher.DownloadWithPossibleUpgrade(archive, destination.Value, token);
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hash != archive.Hash)
|
||||
{
|
||||
|
@ -109,12 +109,16 @@ public class StandardInstaller : AInstaller<StandardInstaller>
|
||||
_configuration.Downloads.CreateDirectory();
|
||||
|
||||
await OptimizeModlist(token);
|
||||
if (token.IsCancellationRequested) return false;
|
||||
|
||||
await HashArchives(token);
|
||||
if (token.IsCancellationRequested) return false;
|
||||
|
||||
await DownloadArchives(token);
|
||||
if (token.IsCancellationRequested) return false;
|
||||
|
||||
await HashArchives(token);
|
||||
if (token.IsCancellationRequested) return false;
|
||||
|
||||
var missing = ModList.Archives.Where(a => !HashedArchives.ContainsKey(a.Hash)).ToList();
|
||||
if (missing.Count > 0)
|
||||
@ -127,21 +131,27 @@ public class StandardInstaller : AInstaller<StandardInstaller>
|
||||
}
|
||||
|
||||
await ExtractModlist(token);
|
||||
if (token.IsCancellationRequested) return false;
|
||||
|
||||
await PrimeVFS();
|
||||
|
||||
await BuildFolderStructure();
|
||||
|
||||
await InstallArchives(token);
|
||||
if (token.IsCancellationRequested) return false;
|
||||
|
||||
await InstallIncludedFiles(token);
|
||||
if (token.IsCancellationRequested) return false;
|
||||
|
||||
await WriteMetaFiles(token);
|
||||
if (token.IsCancellationRequested) return false;
|
||||
|
||||
await BuildBSAs(token);
|
||||
if (token.IsCancellationRequested) return false;
|
||||
|
||||
// TODO: Port this
|
||||
await GenerateZEditMerges(token);
|
||||
if (token.IsCancellationRequested) return false;
|
||||
|
||||
await ForcePortable();
|
||||
await RemapMO2File();
|
||||
|
@ -1,4 +1,7 @@
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@ -26,4 +29,15 @@ public static class Extensions
|
||||
return (await JsonSerializer.DeserializeAsync<T>(await result.Content.ReadAsStreamAsync(token.Value)))!;
|
||||
}
|
||||
|
||||
public static WebHeaderCollection ToWebHeaderCollection(this HttpRequestHeaders headers)
|
||||
{
|
||||
var headerCollection = new WebHeaderCollection();
|
||||
|
||||
foreach (var header in headers.Where(header => !WebHeaderCollection.IsRestricted(header.Key)))
|
||||
{
|
||||
header.Value.ToList().ForEach(value => headerCollection.Add(header.Key, value));
|
||||
}
|
||||
|
||||
return headerCollection;
|
||||
}
|
||||
}
|
144
Wabbajack.Networking.Http/ResumableDownloader.cs
Normal file
144
Wabbajack.Networking.Http/ResumableDownloader.cs
Normal file
@ -0,0 +1,144 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Downloader;
|
||||
using Wabbajack.Hashing.xxHash64;
|
||||
using Wabbajack.Paths;
|
||||
using Wabbajack.Paths.IO;
|
||||
using Wabbajack.RateLimiter;
|
||||
|
||||
namespace Wabbajack.Networking.Http;
|
||||
|
||||
internal class ResumableDownloader
|
||||
{
|
||||
private readonly IJob _job;
|
||||
private readonly HttpRequestMessage _msg;
|
||||
private readonly AbsolutePath _outputPath;
|
||||
private readonly AbsolutePath _packagePath;
|
||||
private CancellationToken _token;
|
||||
private Exception? _error;
|
||||
|
||||
public ResumableDownloader(HttpRequestMessage msg, AbsolutePath outputPath, IJob job)
|
||||
{
|
||||
_job = job;
|
||||
_msg = msg;
|
||||
_outputPath = outputPath;
|
||||
_packagePath = outputPath.WithExtension(Extension.FromPath(".download_package"));
|
||||
}
|
||||
|
||||
public async Task<Hash> Download(CancellationToken token)
|
||||
{
|
||||
_token = token;
|
||||
|
||||
var downloader = new DownloadService(CreateConfiguration(_msg));
|
||||
downloader.DownloadStarted += OnDownloadStarted;
|
||||
downloader.DownloadProgressChanged += OnDownloadProgressChanged;
|
||||
downloader.DownloadFileCompleted += OnDownloadFileCompleted;
|
||||
|
||||
// Attempt to resume previous download
|
||||
var downloadPackage = LoadPackage();
|
||||
if (downloadPackage != null)
|
||||
{
|
||||
// Resume with different Uri in case old one is no longer valid
|
||||
downloadPackage.Address = _msg.RequestUri!.AbsoluteUri;
|
||||
|
||||
await downloader.DownloadFileTaskAsync(downloadPackage, token);
|
||||
}
|
||||
else
|
||||
{
|
||||
_outputPath.Delete();
|
||||
await downloader.DownloadFileTaskAsync(_msg.RequestUri!.AbsoluteUri, _outputPath.ToString(), token);
|
||||
}
|
||||
|
||||
// Save progress if download isn't completed yet
|
||||
if (downloader.Status is DownloadStatus.Stopped or DownloadStatus.Failed)
|
||||
{
|
||||
SavePackage(downloader.Package);
|
||||
if (_error != null && _error.GetType() != typeof(TaskCanceledException))
|
||||
{
|
||||
throw _error;
|
||||
}
|
||||
|
||||
return new Hash();
|
||||
}
|
||||
|
||||
if (downloader.Status == DownloadStatus.Completed)
|
||||
{
|
||||
DeletePackage();
|
||||
}
|
||||
|
||||
if (!_outputPath.FileExists())
|
||||
{
|
||||
return new Hash();
|
||||
}
|
||||
|
||||
return await _outputPath.Open(FileMode.Open).Hash(token);
|
||||
}
|
||||
|
||||
private DownloadConfiguration CreateConfiguration(HttpRequestMessage message)
|
||||
{
|
||||
var configuration = new DownloadConfiguration
|
||||
{
|
||||
RequestConfiguration = new RequestConfiguration
|
||||
{
|
||||
Headers = message.Headers.ToWebHeaderCollection(),
|
||||
ProtocolVersion = message.Version,
|
||||
UserAgent = message.Headers.UserAgent.ToString()
|
||||
}
|
||||
};
|
||||
|
||||
return configuration;
|
||||
}
|
||||
|
||||
private void OnDownloadFileCompleted(object? sender, AsyncCompletedEventArgs e)
|
||||
{
|
||||
_error = e.Error;
|
||||
}
|
||||
|
||||
private async void OnDownloadProgressChanged(object? sender, DownloadProgressChangedEventArgs e)
|
||||
{
|
||||
var processedSize = e.ProgressedByteSize;
|
||||
if (_job.Current == 0)
|
||||
{
|
||||
// Set current to total in case this download resumes from a previous one
|
||||
processedSize = e.ReceivedBytesSize;
|
||||
}
|
||||
|
||||
await _job.Report(processedSize, _token);
|
||||
}
|
||||
|
||||
private void OnDownloadStarted(object? sender, DownloadStartedEventArgs e)
|
||||
{
|
||||
_job.ResetProgress();
|
||||
_job.Size = e.TotalBytesToReceive;
|
||||
|
||||
// Get rid of package, since we can't use it to resume anymore
|
||||
DeletePackage();
|
||||
}
|
||||
|
||||
private void DeletePackage()
|
||||
{
|
||||
_packagePath.Delete();
|
||||
}
|
||||
|
||||
private DownloadPackage? LoadPackage()
|
||||
{
|
||||
if (!_packagePath.FileExists())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var packageJson = _packagePath.ReadAllText();
|
||||
return JsonSerializer.Deserialize<DownloadPackage>(packageJson);
|
||||
}
|
||||
|
||||
private void SavePackage(DownloadPackage package)
|
||||
{
|
||||
var packageJson = JsonSerializer.Serialize(package);
|
||||
_packagePath.WriteAllText(packageJson);
|
||||
}
|
||||
}
|
@ -29,24 +29,27 @@ public class SingleThreadedDownloader : IHttpDownloader
|
||||
public async Task<Hash> Download(HttpRequestMessage message, AbsolutePath outputPath, IJob job,
|
||||
CancellationToken token)
|
||||
{
|
||||
using var response = await _client.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, token);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new HttpException(response);
|
||||
var downloader = new ResumableDownloader(message, outputPath, job);
|
||||
return await downloader.Download(token);
|
||||
|
||||
if (job.Size == 0)
|
||||
job.Size = response.Content.Headers.ContentLength ?? 0;
|
||||
|
||||
/* Need to make this mulitthreaded to be much use
|
||||
if ((response.Content.Headers.ContentLength ?? 0) != 0 &&
|
||||
response.Headers.AcceptRanges.FirstOrDefault() == "bytes")
|
||||
{
|
||||
return await ResettingDownloader(response, message, outputPath, job, token);
|
||||
}
|
||||
*/
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(token);
|
||||
await using var outputStream = outputPath.Open(FileMode.Create, FileAccess.Write);
|
||||
return await stream.HashingCopy(outputStream, token, job);
|
||||
// using var response = await _client.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, token);
|
||||
// if (!response.IsSuccessStatusCode)
|
||||
// throw new HttpException(response);
|
||||
//
|
||||
// if (job.Size == 0)
|
||||
// job.Size = response.Content.Headers.ContentLength ?? 0;
|
||||
//
|
||||
// /* Need to make this mulitthreaded to be much use
|
||||
// if ((response.Content.Headers.ContentLength ?? 0) != 0 &&
|
||||
// response.Headers.AcceptRanges.FirstOrDefault() == "bytes")
|
||||
// {
|
||||
// return await ResettingDownloader(response, message, outputPath, job, token);
|
||||
// }
|
||||
// */
|
||||
//
|
||||
// await using var stream = await response.Content.ReadAsStreamAsync(token);
|
||||
// await using var outputStream = outputPath.Open(FileMode.Create, FileAccess.Write);
|
||||
// return await stream.HashingCopy(outputStream, token, job);
|
||||
}
|
||||
|
||||
private const int CHUNK_SIZE = 1024 * 1024 * 8;
|
||||
|
@ -8,6 +8,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Downloader" Version="3.0.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.2-mauipre.1.22102.15" />
|
||||
</ItemGroup>
|
||||
|
||||
|
@ -9,6 +9,7 @@ public interface IJob
|
||||
public long? Size { get; set; }
|
||||
public long Current { get; }
|
||||
public string Description { get; }
|
||||
public ValueTask Report(int processedSize, CancellationToken token);
|
||||
public ValueTask Report(long processedSize, CancellationToken token);
|
||||
public void ReportNoWait(int processedSize);
|
||||
public void ResetProgress();
|
||||
}
|
@ -23,9 +23,9 @@ public class Job<T> : IJob, IDisposable
|
||||
public long Current { get; internal set; }
|
||||
public long? Size { get; set; }
|
||||
|
||||
public async ValueTask Report(int processedSize, CancellationToken token)
|
||||
public async ValueTask Report(long processedSize, CancellationToken token)
|
||||
{
|
||||
await Resource.Report(this, processedSize, token);
|
||||
await Resource.Report(this, (int)Math.Min(processedSize, int.MaxValue), token);
|
||||
Current += processedSize;
|
||||
OnUpdate?.Invoke(this, (Percent.FactoryPutInRange(Current, Size ?? 1), Current));
|
||||
}
|
||||
@ -36,5 +36,11 @@ public class Job<T> : IJob, IDisposable
|
||||
OnUpdate?.Invoke(this, (Percent.FactoryPutInRange(Current, Size ?? 1), Current));
|
||||
}
|
||||
|
||||
public void ResetProgress()
|
||||
{
|
||||
Current = 0;
|
||||
OnUpdate?.Invoke(this, (Percent.FactoryPutInRange(Current, Size ?? 1), Current));
|
||||
}
|
||||
|
||||
public event EventHandler<(Percent Progress, long Processed)> OnUpdate;
|
||||
}
|
@ -19,7 +19,7 @@ public class Resource<T> : IResource<T>
|
||||
private long _totalUsed;
|
||||
public IEnumerable<IJob> Jobs => _tasks.Values;
|
||||
|
||||
public Resource(string? humanName = null, int? maxTasks = null, long maxThroughput = long.MaxValue)
|
||||
public Resource(string? humanName = null, int? maxTasks = null, long maxThroughput = long.MaxValue, CancellationToken? token = null)
|
||||
{
|
||||
Name = humanName ?? "<unknown>";
|
||||
MaxTasks = maxTasks ?? Environment.ProcessorCount;
|
||||
@ -28,10 +28,10 @@ public class Resource<T> : IResource<T>
|
||||
_channel = Channel.CreateBounded<PendingReport>(10);
|
||||
_tasks = new ConcurrentDictionary<ulong, Job<T>>();
|
||||
|
||||
var tsk = StartTask(CancellationToken.None);
|
||||
var tsk = StartTask(token ?? CancellationToken.None);
|
||||
}
|
||||
|
||||
public Resource(string humanName, Func<Task<(int MaxTasks, long MaxThroughput)>> settingGetter)
|
||||
public Resource(string humanName, Func<Task<(int MaxTasks, long MaxThroughput)>> settingGetter, CancellationToken? token = null)
|
||||
{
|
||||
Name = humanName;
|
||||
_tasks = new ConcurrentDictionary<ulong, Job<T>>();
|
||||
@ -44,8 +44,8 @@ public class Resource<T> : IResource<T>
|
||||
_semaphore = new SemaphoreSlim(MaxTasks);
|
||||
_channel = Channel.CreateBounded<PendingReport>(10);
|
||||
|
||||
await StartTask(CancellationToken.None);
|
||||
});
|
||||
await StartTask(token ?? CancellationToken.None);
|
||||
}, token ?? CancellationToken.None);
|
||||
}
|
||||
|
||||
public int MaxTasks { get; set; }
|
||||
|
@ -4,6 +4,7 @@ using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@ -45,6 +46,10 @@ public static class ServiceExtensions
|
||||
public static IServiceCollection AddOSIntegrated(this IServiceCollection service,
|
||||
Action<OSIntegratedOptions>? cfn = null)
|
||||
{
|
||||
// Register app-wide cancellation token source to allow clean termination
|
||||
service.AddSingleton(new CancellationTokenSource());
|
||||
service.AddTransient(typeof(CancellationToken), s => s.GetRequiredService<CancellationTokenSource>().Token);
|
||||
|
||||
var options = new OSIntegratedOptions();
|
||||
cfn?.Invoke(options);
|
||||
|
||||
@ -117,25 +122,25 @@ public static class ServiceExtensions
|
||||
// Resources
|
||||
|
||||
service.AddAllSingleton<IResource, IResource<DownloadDispatcher>>(s =>
|
||||
new Resource<DownloadDispatcher>("Downloads", GetSettings(s, "Downloads")));
|
||||
new Resource<DownloadDispatcher>("Downloads", GetSettings(s, "Downloads"), s.GetRequiredService<CancellationToken>()));
|
||||
|
||||
service.AddAllSingleton<IResource, IResource<HttpClient>>(s => new Resource<HttpClient>("Web Requests", GetSettings(s, "Web Requests")));
|
||||
service.AddAllSingleton<IResource, IResource<Context>>(s => new Resource<Context>("VFS", GetSettings(s, "VFS")));
|
||||
service.AddAllSingleton<IResource, IResource<HttpClient>>(s => new Resource<HttpClient>("Web Requests", GetSettings(s, "Web Requests"), s.GetRequiredService<CancellationToken>()));
|
||||
service.AddAllSingleton<IResource, IResource<Context>>(s => new Resource<Context>("VFS", GetSettings(s, "VFS"), s.GetRequiredService<CancellationToken>()));
|
||||
service.AddAllSingleton<IResource, IResource<FileHashCache>>(s =>
|
||||
new Resource<FileHashCache>("File Hashing", GetSettings(s, "File Hashing")));
|
||||
new Resource<FileHashCache>("File Hashing", GetSettings(s, "File Hashing"), s.GetRequiredService<CancellationToken>()));
|
||||
service.AddAllSingleton<IResource, IResource<Client>>(s =>
|
||||
new Resource<Client>("Wabbajack Client", GetSettings(s, "Wabbajack Client")));
|
||||
new Resource<Client>("Wabbajack Client", GetSettings(s, "Wabbajack Client"), s.GetRequiredService<CancellationToken>()));
|
||||
service.AddAllSingleton<IResource, IResource<FileExtractor.FileExtractor>>(s =>
|
||||
new Resource<FileExtractor.FileExtractor>("File Extractor", GetSettings(s, "File Extractor")));
|
||||
new Resource<FileExtractor.FileExtractor>("File Extractor", GetSettings(s, "File Extractor"), s.GetRequiredService<CancellationToken>()));
|
||||
|
||||
service.AddAllSingleton<IResource, IResource<ACompiler>>(s =>
|
||||
new Resource<ACompiler>("Compiler", GetSettings(s, "Compiler")));
|
||||
new Resource<ACompiler>("Compiler", GetSettings(s, "Compiler"), s.GetRequiredService<CancellationToken>()));
|
||||
|
||||
service.AddAllSingleton<IResource, IResource<IInstaller>>(s =>
|
||||
new Resource<IInstaller>("Installer", GetSettings(s, "Installer")));
|
||||
new Resource<IInstaller>("Installer", GetSettings(s, "Installer"), s.GetRequiredService<CancellationToken>()));
|
||||
|
||||
service.AddAllSingleton<IResource, IResource<IUserInterventionHandler>>(s =>
|
||||
new Resource<IUserInterventionHandler>("User Intervention", 1));
|
||||
new Resource<IUserInterventionHandler>("User Intervention", 1, token: s.GetRequiredService<CancellationToken>()));
|
||||
|
||||
service.AddSingleton<LoggingRateLimiterReporter>();
|
||||
|
||||
|
@ -54,12 +54,10 @@ public class ModListDownloadMaintainer
|
||||
return await _hashCache.FileHashCachedAsync(path, token.Value) == metadata.DownloadMetadata!.Hash;
|
||||
}
|
||||
|
||||
public (IObservable<Percent> Progress, Task Task) DownloadModlist(ModlistMetadata metadata, CancellationToken? token = null)
|
||||
public (IObservable<Percent> Progress, Task Task) DownloadModlist(ModlistMetadata metadata, CancellationToken token)
|
||||
{
|
||||
var path = ModListPath(metadata);
|
||||
|
||||
token ??= CancellationToken.None;
|
||||
|
||||
var progress = new Subject<Percent>();
|
||||
progress.OnNext(Percent.Zero);
|
||||
|
||||
@ -69,7 +67,7 @@ public class ModListDownloadMaintainer
|
||||
{
|
||||
Interlocked.Increment(ref _downloadingCount);
|
||||
using var job = await _rateLimiter.Begin($"Downloading {metadata.Title}", metadata.DownloadMetadata!.Size,
|
||||
token.Value);
|
||||
token);
|
||||
|
||||
job.OnUpdate += (_, pr) => { progress.OnNext(pr.Progress); };
|
||||
|
||||
@ -78,17 +76,21 @@ public class ModListDownloadMaintainer
|
||||
State = _dispatcher.Parse(new Uri(metadata.Links.Download))!,
|
||||
Size = metadata.DownloadMetadata.Size,
|
||||
Hash = metadata.DownloadMetadata.Hash
|
||||
}, path, job, token.Value);
|
||||
}, path, job, token);
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _hashCache.FileHashWriteCache(path, hash);
|
||||
await path.WithExtension(Ext.MetaData).WriteAllTextAsync(JsonSerializer.Serialize(metadata));
|
||||
await path.WithExtension(Ext.MetaData).WriteAllTextAsync(JsonSerializer.Serialize(metadata), token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
progress.OnCompleted();
|
||||
Interlocked.Decrement(ref _downloadingCount);
|
||||
}
|
||||
});
|
||||
}, token);
|
||||
|
||||
return (progress, tsk);
|
||||
}
|
||||
|
@ -117,6 +117,8 @@ public class Context
|
||||
|
||||
async Task HandleFile(VirtualFile file, IExtractedFile sfn)
|
||||
{
|
||||
if (token.IsCancellationRequested) return;
|
||||
|
||||
if (filesByParent.ContainsKey(file))
|
||||
sfn.CanMove = false;
|
||||
|
||||
@ -138,6 +140,7 @@ public class Context
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (token.IsCancellationRequested) return;
|
||||
await using var stream = await sfn.GetStream();
|
||||
var hash = await stream.HashingCopy(Stream.Null, token);
|
||||
if (hash != file.Hash)
|
||||
|
Loading…
Reference in New Issue
Block a user