Browse Source

Add tracked download stuff

pull/109/head
Ionite 1 year ago
parent
commit
73f8f64c8f
No known key found for this signature in database
  1. 1
      StabilityMatrix.Avalonia/App.axaml.cs
  2. 12
      StabilityMatrix.Avalonia/DesignData/DesignData.cs
  3. 65
      StabilityMatrix.Avalonia/DesignData/MockDownloadProgressItemViewModel.cs
  4. 13
      StabilityMatrix.Avalonia/DesignData/MockDownloadService.cs
  5. 18
      StabilityMatrix.Avalonia/DesignData/MockTrackedDownloadService.cs
  6. 49
      StabilityMatrix.Avalonia/ViewModels/Base/PausableProgressItemViewModelBase.cs
  7. 16
      StabilityMatrix.Avalonia/ViewModels/Base/ProgressItemViewModelBase.cs
  8. 11
      StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CheckpointBrowserCardViewModel.cs
  9. 73
      StabilityMatrix.Avalonia/ViewModels/DownloadProgressItemViewModel.cs
  10. 12
      StabilityMatrix.Avalonia/ViewModels/ProgressItemViewModel.cs
  11. 28
      StabilityMatrix.Avalonia/ViewModels/ProgressManagerViewModel.cs
  12. 206
      StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml
  13. 14
      StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs
  14. 3
      StabilityMatrix.Core/Models/Progress/ProgressState.cs
  15. 245
      StabilityMatrix.Core/Models/TrackedDownload.cs

1
StabilityMatrix.Avalonia/App.axaml.cs

@ -332,6 +332,7 @@ public sealed class App : Application
services.AddSingleton<IPyRunner, PyRunner>();
services.AddSingleton<IUpdateHelper, UpdateHelper>();
services.AddSingleton<INavigationService, NavigationService>();
services.AddSingleton<ITrackedDownloadService, TrackedDownloadService>();
// Rich presence
services.AddSingleton<IDiscordRichPresenceService, DiscordRichPresenceService>();

12
StabilityMatrix.Avalonia/DesignData/DesignData.cs

@ -91,7 +91,8 @@ public static class DesignData
.AddSingleton<ISharedFolders, MockSharedFolders>()
.AddSingleton<IDownloadService, MockDownloadService>()
.AddSingleton<IHttpClientFactory, MockHttpClientFactory>()
.AddSingleton<IDiscordRichPresenceService, MockDiscordRichPresenceService>();
.AddSingleton<IDiscordRichPresenceService, MockDiscordRichPresenceService>()
.AddSingleton<ITrackedDownloadService, MockTrackedDownloadService>();
// Placeholder services that nobody should need during design time
services
@ -223,13 +224,12 @@ public static class DesignData
})
};
ProgressManagerViewModel.ProgressItems = new ObservableCollection<ProgressItemViewModel>
ProgressManagerViewModel.ProgressItems.AddRange(new ProgressItemViewModelBase[]
{
new(new ProgressItem(Guid.NewGuid(), "Test File.exe",
new ProgressItemViewModel(new ProgressItem(Guid.NewGuid(), "Test File.exe",
new ProgressReport(0.5f, "Downloading..."))),
new(new ProgressItem(Guid.NewGuid(), "Test File 2.uwu",
new ProgressReport(0.25f, "Extracting...")))
};
new MockDownloadProgressItemViewModel("Test File 2.exe"),
});
UpdateViewModel = Services.GetRequiredService<UpdateViewModel>();
UpdateViewModel.UpdateText =

65
StabilityMatrix.Avalonia/DesignData/MockDownloadProgressItemViewModel.cs

@ -0,0 +1,65 @@
using System.Threading;
using System.Threading.Tasks;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Core.Models.Progress;
namespace StabilityMatrix.Avalonia.DesignData;
public class MockDownloadProgressItemViewModel : PausableProgressItemViewModelBase
{
private Task? dummyTask;
private CancellationTokenSource? cts;
public MockDownloadProgressItemViewModel(string fileName)
{
Name = fileName;
Progress.Value = 5;
Progress.IsIndeterminate = false;
Progress.Text = "Downloading...";
}
/// <inheritdoc />
public override Task Cancel()
{
// Cancel the task that updates progress
cts?.Cancel();
cts = null;
dummyTask = null;
State = ProgressState.Cancelled;
Progress.Text = "Cancelled";
return Task.CompletedTask;
}
/// <inheritdoc />
public override Task Pause()
{
// Cancel the task that updates progress
cts?.Cancel();
cts = null;
dummyTask = null;
State = ProgressState.Inactive;
return Task.CompletedTask;
}
/// <inheritdoc />
public override Task Resume()
{
// Start a task that updates progress every 100ms
cts = new CancellationTokenSource();
dummyTask = Task.Run(async () =>
{
while (State != ProgressState.Success)
{
await Task.Delay(100, cts.Token);
Progress.Value += 1;
}
}, cts.Token);
State = ProgressState.Working;
return Task.CompletedTask;
}
}

13
StabilityMatrix.Avalonia/DesignData/MockDownloadService.cs

@ -1,5 +1,6 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Services;
@ -8,8 +9,16 @@ namespace StabilityMatrix.Avalonia.DesignData;
public class MockDownloadService : IDownloadService
{
public Task DownloadToFileAsync(string downloadUrl, string downloadPath,
IProgress<ProgressReport>? progress = null, string? httpClientName = null)
public Task DownloadToFileAsync(string downloadUrl, string downloadPath, IProgress<ProgressReport>? progress = null,
string? httpClientName = null, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
/// <inheritdoc />
public Task ResumeDownloadToFileAsync(string downloadUrl, string downloadPath, long existingFileSize,
IProgress<ProgressReport>? progress = null, string? httpClientName = null,
CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}

18
StabilityMatrix.Avalonia/DesignData/MockTrackedDownloadService.cs

@ -0,0 +1,18 @@
using System;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.DesignData;
public class MockTrackedDownloadService : ITrackedDownloadService
{
/// <inheritdoc />
public event EventHandler<TrackedDownload>? DownloadAdded;
/// <inheritdoc />
public TrackedDownload NewDownload(Uri downloadUrl, FilePath downloadPath)
{
throw new NotImplementedException();
}
}

49
StabilityMatrix.Avalonia/ViewModels/Base/PausableProgressItemViewModelBase.cs

@ -0,0 +1,49 @@
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using StabilityMatrix.Core.Models.Progress;
namespace StabilityMatrix.Avalonia.ViewModels.Base;
[SuppressMessage("ReSharper", "VirtualMemberNeverOverridden.Global")]
public abstract partial class PausableProgressItemViewModelBase : ProgressItemViewModelBase
{
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsPaused), nameof(IsCompleted), nameof(CanPauseResume), nameof(CanCancel))]
private ProgressState state = ProgressState.Inactive;
/// <summary>
/// Whether the progress is paused
/// </summary>
public bool IsPaused => State == ProgressState.Inactive;
/// <summary>
/// Whether the progress has succeeded, failed or was cancelled
/// </summary>
public override bool IsCompleted => State is ProgressState.Success or ProgressState.Failed or ProgressState.Cancelled;
public virtual bool SupportsPauseResume => true;
public virtual bool SupportsCancel => true;
public bool CanPauseResume => SupportsPauseResume && !IsCompleted;
public bool CanCancel => SupportsCancel && !IsCompleted;
private AsyncRelayCommand? pauseCommand;
public IAsyncRelayCommand PauseCommand => pauseCommand ??= new AsyncRelayCommand(Pause);
public virtual Task Pause() => Task.CompletedTask;
private AsyncRelayCommand? resumeCommand;
public IAsyncRelayCommand ResumeCommand => resumeCommand ??= new AsyncRelayCommand(Resume);
public virtual Task Resume() => Task.CompletedTask;
private AsyncRelayCommand? cancelCommand;
public IAsyncRelayCommand CancelCommand => cancelCommand ??= new AsyncRelayCommand(Cancel);
public virtual Task Cancel() => Task.CompletedTask;
[RelayCommand]
private Task TogglePauseResume()
{
return IsPaused ? Resume() : Pause();
}
}

16
StabilityMatrix.Avalonia/ViewModels/Base/ProgressItemViewModelBase.cs

@ -0,0 +1,16 @@
using System;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
namespace StabilityMatrix.Avalonia.ViewModels.Base;
public abstract partial class ProgressItemViewModelBase : ViewModelBase
{
[ObservableProperty] private Guid id;
[ObservableProperty] private string? name;
[ObservableProperty] private bool failed;
public virtual bool IsCompleted => Progress.Value >= 100 || Failed;
public ProgressViewModel Progress { get; } = new();
}

11
StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CheckpointBrowserCardViewModel.cs

@ -32,10 +32,10 @@ using Notification = Avalonia.Controls.Notifications.Notification;
namespace StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser;
public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private readonly IDownloadService downloadService;
private readonly ITrackedDownloadService trackedDownloadService;
private readonly ISettingsManager settingsManager;
private readonly ServiceManager<ViewModelBase> dialogFactory;
private readonly INotificationService notificationService;
@ -63,11 +63,13 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel
public CheckpointBrowserCardViewModel(
IDownloadService downloadService,
ITrackedDownloadService trackedDownloadService,
ISettingsManager settingsManager,
ServiceManager<ViewModelBase> dialogFactory,
INotificationService notificationService)
{
this.downloadService = downloadService;
this.trackedDownloadService = trackedDownloadService;
this.settingsManager = settingsManager;
this.dialogFactory = dialogFactory;
this.notificationService = notificationService;
@ -240,7 +242,10 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel
filesForCleanup.Add(downloadPath);
// Do the download
var progressId = Guid.NewGuid();
var download = trackedDownloadService.NewDownload(modelFile.DownloadUrl, downloadPath);
download.Start();
/*var progressId = Guid.NewGuid();
var downloadTask = downloadService.DownloadToFileAsync(modelFile.DownloadUrl,
downloadPath,
new Progress<ProgressReport>(report =>
@ -258,7 +263,7 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel
}));
var downloadResult =
await notificationService.TryAsync(downloadTask, "Could not download file");
await notificationService.TryAsync(downloadTask, "Could not download file");*/
// Failed download handling
if (downloadResult.Exception is not null)

73
StabilityMatrix.Avalonia/ViewModels/DownloadProgressItemViewModel.cs

@ -0,0 +1,73 @@
using System.Threading.Tasks;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Progress;
namespace StabilityMatrix.Avalonia.ViewModels;
public class DownloadProgressItemViewModel : PausableProgressItemViewModelBase
{
private readonly TrackedDownload download;
/// <inheritdoc />
public override bool SupportsPauseResume => true;
public DownloadProgressItemViewModel(TrackedDownload download)
{
this.download = download;
download.ProgressUpdate += (s, e) =>
{
Progress.Value = e.Percentage;
Progress.IsIndeterminate = e.IsIndeterminate;
Progress.Text = e.Title;
};
download.ProgressStateChanged += (s, e) =>
{
State = e;
if (e == ProgressState.Inactive)
{
Progress.Text = "Paused";
}
else if (e == ProgressState.Working)
{
Progress.Text = "Downloading...";
}
else if (e == ProgressState.Success)
{
Progress.Text = "Completed";
}
else if (e == ProgressState.Cancelled)
{
Progress.Text = "Cancelled";
}
else if (e == ProgressState.Failed)
{
Progress.Text = "Failed";
}
};
}
/// <inheritdoc />
public override Task Cancel()
{
download.Cancel();
return Task.CompletedTask;
}
/// <inheritdoc />
public override Task Pause()
{
download.Pause();
return Task.CompletedTask;
}
/// <inheritdoc />
public override Task Resume()
{
download.Resume();
return Task.CompletedTask;
}
}

12
StabilityMatrix.Avalonia/ViewModels/ProgressItemViewModel.cs

@ -6,21 +6,19 @@ using StabilityMatrix.Core.Models.Progress;
namespace StabilityMatrix.Avalonia.ViewModels;
public partial class ProgressItemViewModel : ViewModelBase
public partial class ProgressItemViewModel : ProgressItemViewModelBase
{
[ObservableProperty] private Guid id;
[ObservableProperty] private string name;
[ObservableProperty] private ProgressReport progress;
[ObservableProperty] private bool failed;
[ObservableProperty] private string? progressText;
public ProgressItemViewModel(ProgressItem progressItem)
{
Id = progressItem.ProgressId;
Name = progressItem.Name;
Progress = progressItem.Progress;
Progress.Value = progressItem.Progress.Percentage;
Failed = progressItem.Failed;
ProgressText = GetProgressText(Progress);
Progress.Text = GetProgressText(progressItem.Progress);
EventManager.Instance.ProgressChanged += OnProgressChanged;
}
@ -30,9 +28,9 @@ public partial class ProgressItemViewModel : ViewModelBase
if (e.ProgressId != Id)
return;
Progress = e.Progress;
Progress.Value = e.Progress.Percentage;
Failed = e.Failed;
ProgressText = GetProgressText(Progress);
Progress.Text = GetProgressText(e.Progress);
}
private string GetProgressText(ProgressReport report)

28
StabilityMatrix.Avalonia/ViewModels/ProgressManagerViewModel.cs

@ -1,13 +1,18 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using Avalonia.Collections;
using CommunityToolkit.Mvvm.ComponentModel;
using FluentAvalonia.UI.Controls;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Services;
using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
@ -19,14 +24,20 @@ public partial class ProgressManagerViewModel : PageViewModelBase
public override string Title => "Download Manager";
public override IconSource IconSource => new SymbolIconSource {Symbol = Symbol.ArrowCircleDown, IsFilled = true};
[ObservableProperty]
private ObservableCollection<ProgressItemViewModel> progressItems;
public AvaloniaList<ProgressItemViewModelBase> ProgressItems { get; } = new();
public ProgressManagerViewModel(ITrackedDownloadService trackedDownloadService)
{
// Attach to the event
trackedDownloadService.DownloadAdded += TrackedDownloadService_OnDownloadAdded;
}
public ProgressManagerViewModel()
private void TrackedDownloadService_OnDownloadAdded(object? sender, TrackedDownload e)
{
ProgressItems = new ObservableCollection<ProgressItemViewModel>();
var vm = new DownloadProgressItemViewModel(e);
ProgressItems.Add(vm);
}
public void StartEventListener()
{
EventManager.Instance.ProgressChanged += OnProgressChanged;
@ -34,12 +45,7 @@ public partial class ProgressManagerViewModel : PageViewModelBase
public void ClearDownloads()
{
if (!ProgressItems.Any(p => Math.Abs(p.Progress.Percentage - 100) < 0.01f || p.Failed))
return;
var itemsInProgress = ProgressItems
.Where(p => p.Progress.Percentage < 100 && !p.Failed).ToList();
ProgressItems = new ObservableCollection<ProgressItemViewModel>(itemsInProgress);
ProgressItems.RemoveAll(ProgressItems.Where(x => x.IsCompleted));
}
private void OnProgressChanged(object? sender, ProgressItem e)

206
StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml

@ -1,46 +1,156 @@
<controls:UserControlBase xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
d:DataContext="{x:Static mocks:DesignData.ProgressManagerViewModel}"
x:DataType="vm:ProgressManagerViewModel"
MaxHeight="250"
mc:Ignorable="d" d:DesignWidth="300" d:DesignHeight="250"
x:Class="StabilityMatrix.Avalonia.Views.ProgressManagerPage">
<controls:UserControlBase
x:Class="StabilityMatrix.Avalonia.Views.ProgressManagerPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:fluentAvalonia="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia"
xmlns:icons="clr-namespace:Projektanker.Icons.Avalonia;assembly=Projektanker.Icons.Avalonia"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:vmBase="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Base"
MaxHeight="250"
d:DataContext="{x:Static mocks:DesignData.ProgressManagerViewModel}"
d:DesignHeight="250"
d:DesignWidth="300"
x:DataType="vm:ProgressManagerViewModel"
mc:Ignorable="d">
<ScrollViewer>
<Grid RowDefinitions="Auto, *">
<TextBlock Grid.Row="0" Text="Downloads"
VerticalAlignment="Center"
TextDecorations="Underline"
FontSize="16"
TextAlignment="Left" />
<Button Grid.Row="0"
Content="Clear Finished"
Padding="4"
Classes="transparent"
Margin="0,0,8,0"
IsVisible="{Binding !!ProgressItems.Count}"
Command="{Binding ClearDownloads}"
HorizontalAlignment="Right" />
<TextBlock
Grid.Row="0"
VerticalAlignment="Center"
FontSize="16"
Text="Downloads"
TextAlignment="Left"
TextDecorations="Underline" />
<Button
Grid.Row="0"
Margin="0,0,8,0"
Padding="4"
HorizontalAlignment="Right"
Classes="transparent"
Command="{Binding ClearDownloads}"
Content="Clear Finished"
IsVisible="{Binding !!ProgressItems.Count}" />
<ItemsControl
Grid.Row="1"
Margin="0,4,0,0"
ItemsSource="{Binding ProgressItems, Mode=OneWay}">
<ItemsControl.DataTemplates>
<DataTemplate DataType="{x:Type vmBase:PausableProgressItemViewModelBase}">
<Border
Margin="4"
Padding="8"
Background="#22000000"
BorderBrush="#33000000"
BorderThickness="2"
CornerRadius="8">
<Grid ColumnDefinitions="*,Auto" RowDefinitions="*,Auto">
<StackPanel Grid.Row="0" Grid.Column="0">
<TextBlock Margin="0,0" Text="{Binding Name, Mode=OneWay}" />
<!-- non-indeterminate progress -->
<TextBlock
Margin="0,0"
IsVisible="{Binding !Progress.IsIndeterminate}">
<Run Text="{Binding Progress.Text, Mode=OneWay}" />
<Run Text="{Binding Progress.Value, Mode=OneWay}" /><Run Text="%" />
</TextBlock>
<!-- indeterminate progress -->
<TextBlock
Margin="0,4"
IsVisible="{Binding Progress.IsIndeterminate}"
Text="{Binding Progress.Text, Mode=OneWay}" />
</StackPanel>
<!-- Buttons -->
<UniformGrid
Grid.Row="0"
Grid.Column="1"
Columns="2">
<ToggleButton
Name="PauseResumeToggleButton"
Classes="transparent-full"
Command="{Binding TogglePauseResumeCommand}"
IsChecked="{Binding IsPaused}"
IsVisible="{Binding CanPauseResume}">
<Panel>
<ui:SymbolIcon IsVisible="{Binding ElementName=PauseResumeToggleButton, Path=!IsChecked}" Symbol="Pause" />
<ui:SymbolIcon IsVisible="{Binding ElementName=PauseResumeToggleButton, Path=IsChecked}" Symbol="Play" />
</Panel>
</ToggleButton>
<Button
Command="{Binding CancelCommand}"
IsVisible="{Binding CanCancel}"
Classes="transparent-full">
<ui:SymbolIcon Symbol="Cancel" />
</Button>
</UniformGrid>
<ProgressBar
Grid.Row="1"
Grid.Column="0"
Grid.ColumnSpan="2"
Margin="0,8,0,4"
IsIndeterminate="{Binding Progress.IsIndeterminate}"
Value="{Binding Progress.Value, Mode=OneWay}">
<ProgressBar.Transitions>
<Transitions>
<DoubleTransition Property="Value" Duration="00:00:00.150">
<DoubleTransition.Easing>
<SineEaseInOut />
</DoubleTransition.Easing>
</DoubleTransition>
</Transitions>
</ProgressBar.Transitions>
</ProgressBar>
</Grid>
</Border>
</DataTemplate>
<ItemsRepeater Margin="0,4,0,0" Grid.Row="1" ItemsSource="{Binding ProgressItems, Mode=OneWay}">
<ItemsRepeater.ItemTemplate>
<DataTemplate DataType="{x:Type vm:ProgressItemViewModel}">
<Border BorderBrush="#33000000"
Background="#22000000"
BorderThickness="2"
CornerRadius="8"
Padding="8"
Margin="4">
<StackPanel>
<TextBlock Text="{Binding Name, Mode=OneWay}"
Margin="0, 4" />
<ProgressBar Value="{Binding Progress.Percentage, Mode=OneWay}"
IsIndeterminate="{Binding Progress.IsIndeterminate}"
Margin="0, 4">
<Border
Margin="4"
Padding="8"
Background="#22000000"
BorderBrush="#33000000"
BorderThickness="2"
CornerRadius="8">
<Grid ColumnDefinitions="*,Auto" RowDefinitions="*,Auto">
<StackPanel Grid.Row="0" Grid.Column="0">
<TextBlock Margin="0,0" Text="{Binding Name, Mode=OneWay}" />
<!-- non-indeterminate progress -->
<TextBlock
Margin="0,0"
IsVisible="{Binding !Progress.IsIndeterminate}">
<Run Text="{Binding Progress.Text, Mode=OneWay}" />
<Run Text="{Binding Progress.Value, Mode=OneWay}" /><Run Text="%" />
</TextBlock>
<!-- indeterminate progress -->
<TextBlock
Margin="0,4"
IsVisible="{Binding Progress.IsIndeterminate}"
Text="{Binding Progress.Text, Mode=OneWay}" />
</StackPanel>
<ProgressBar
Grid.Row="1"
Grid.Column="0"
Grid.ColumnSpan="2"
Margin="0,8,0,4"
IsIndeterminate="{Binding Progress.IsIndeterminate}"
Value="{Binding Progress.Value, Mode=OneWay}">
<ProgressBar.Transitions>
<Transitions>
<DoubleTransition Property="Value" Duration="00:00:00.150">
@ -51,23 +161,11 @@
</Transitions>
</ProgressBar.Transitions>
</ProgressBar>
<!-- non-indeterminate progress -->
<TextBlock Margin="0, 4"
IsVisible="{Binding !Progress.IsIndeterminate}">
<Run Text="{Binding ProgressText, Mode=OneWay}" />
<Run Text="{Binding Progress.Percentage, Mode=OneWay}" /><Run Text="%" />
</TextBlock>
<!-- indeterminate progress -->
<TextBlock Margin="0, 4"
IsVisible="{Binding Progress.IsIndeterminate}"
Text="{Binding ProgressText, Mode=OneWay}" />
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
</ItemsControl.DataTemplates>
</ItemsControl>
</Grid>
</ScrollViewer>
</controls:UserControlBase>

14
StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs

@ -115,12 +115,22 @@ public class FilePath : FileSystemPath, IPathObject
return File.WriteAllBytesAsync(FullPath, bytes, ct);
}
/// <summary>
/// Move the file to a directory.
/// </summary>
public FilePath MoveTo(FilePath destinationFile)
{
Info.MoveTo(destinationFile.FullPath, true);
// Return the new path
return destinationFile;
}
/// <summary>
/// Move the file to a directory.
/// </summary>
public async Task<FilePath> MoveToAsync(DirectoryPath directory)
{
await Task.Run(() => Info.MoveTo(directory.FullPath));
await Task.Run(() => Info.MoveTo(directory.FullPath)).ConfigureAwait(false);
// Return the new path
return directory.JoinFile(this);
}
@ -130,7 +140,7 @@ public class FilePath : FileSystemPath, IPathObject
/// </summary>
public async Task<FilePath> MoveToAsync(FilePath destinationFile)
{
await Task.Run(() => Info.MoveTo(destinationFile.FullPath));
await Task.Run(() => Info.MoveTo(destinationFile.FullPath)).ConfigureAwait(false);
// Return the new path
return destinationFile;
}

3
StabilityMatrix.Core/Models/Progress/ProgressState.cs

@ -5,5 +5,6 @@ public enum ProgressState
Inactive,
Working,
Success,
Failed
Failed,
Cancelled
}

245
StabilityMatrix.Core/Models/TrackedDownload.cs

@ -0,0 +1,245 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using AsyncAwaitBestPractices;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Core.Models;
public class TrackedDownloadProgressEventArgs : EventArgs
{
public ProgressReport Progress { get; init; }
public ProgressState State { get; init; }
}
public class TrackedDownload
{
[JsonIgnore]
private IDownloadService? downloadService;
[JsonIgnore]
private Task? downloadTask;
[JsonIgnore]
private CancellationTokenSource? downloadCancellationTokenSource;
[JsonIgnore]
private CancellationTokenSource? downloadPauseTokenSource;
private CancellationTokenSource AggregateCancellationTokenSource =>
CancellationTokenSource.CreateLinkedTokenSource(
downloadCancellationTokenSource?.Token ?? CancellationToken.None,
downloadPauseTokenSource?.Token ?? CancellationToken.None);
public required Guid Id { get; init; }
public required Uri SourceUrl { get; init; }
public Uri? RedirectedUrl { get; init; }
public required DirectoryPath DownloadDirectory { get; init; }
public required string FileName { get; init; }
public required string TempFileName { get; init; }
public string? ExpectedHashSha256 { get; init; }
public bool ValidateHash { get; init; }
public ProgressState ProgressState { get; private set; } = ProgressState.Inactive;
public Exception? Exception { get; private set; }
#region Events
private WeakEventManager<ProgressReport>? progressUpdateEventManager;
public event EventHandler<ProgressReport> ProgressUpdate
{
add
{
progressUpdateEventManager ??= new WeakEventManager<ProgressReport>();
progressUpdateEventManager.AddEventHandler(value);
}
remove => progressUpdateEventManager?.RemoveEventHandler(value);
}
protected void OnProgressUpdate(ProgressReport e)
{
progressUpdateEventManager?.RaiseEvent(this, e, nameof(ProgressUpdate));
}
private WeakEventManager<ProgressState>? progressStateChangedEventManager;
public event EventHandler<ProgressState> ProgressStateChanged
{
add
{
progressStateChangedEventManager ??= new WeakEventManager<ProgressState>();
progressStateChangedEventManager.AddEventHandler(value);
}
remove => progressStateChangedEventManager?.RemoveEventHandler(value);
}
protected void OnProgressStateChanged(ProgressState e)
{
progressStateChangedEventManager?.RaiseEvent(this, e, nameof(ProgressStateChanged));
}
#endregion
[MemberNotNull(nameof(downloadService))]
private void EnsureDownloadService()
{
if (downloadService == null)
{
throw new InvalidOperationException("Download service is not set.");
}
}
private async Task StartDownloadTask(long resumeFromByte, CancellationToken cancellationToken)
{
var progress = new Progress<ProgressReport>(OnProgressUpdate);
await downloadService!.ResumeDownloadToFileAsync(
SourceUrl.ToString(),
DownloadDirectory.JoinFile(TempFileName),
resumeFromByte,
progress,
cancellationToken: cancellationToken).ConfigureAwait(false);
// If hash validation is enabled, validate the hash
if (ValidateHash)
{
var hash = await FileHash.GetSha256Async(DownloadDirectory.JoinFile(TempFileName), progress).ConfigureAwait(false);
if (hash != ExpectedHashSha256)
{
throw new Exception($"Hash validation for {FileName} failed, expected {ExpectedHashSha256} but got {hash}");
}
}
}
public void Start()
{
if (ProgressState != ProgressState.Inactive)
{
throw new InvalidOperationException($"Download state must be inactive to start, not {ProgressState}");
}
EnsureDownloadService();
downloadCancellationTokenSource = new CancellationTokenSource();
downloadPauseTokenSource = new CancellationTokenSource();
downloadTask = StartDownloadTask(0, AggregateCancellationTokenSource.Token)
.ContinueWith(OnDownloadTaskCompleted);
}
public void Resume()
{
if (ProgressState != ProgressState.Inactive) return;
EnsureDownloadService();
downloadCancellationTokenSource = new CancellationTokenSource();
downloadPauseTokenSource = new CancellationTokenSource();
downloadTask = StartDownloadTask(0, AggregateCancellationTokenSource.Token)
.ContinueWith(OnDownloadTaskCompleted);
}
public void Pause()
{
if (ProgressState != ProgressState.Working) return;
downloadPauseTokenSource?.Cancel();
}
public void Cancel()
{
if (ProgressState is not (ProgressState.Working or ProgressState.Inactive)) return;
downloadCancellationTokenSource?.Cancel();
}
/// <summary>
/// Invoked by the task's completion callback
/// </summary>
private void OnDownloadTaskCompleted(Task task)
{
// For cancelled, check if it was actually cancelled or paused
if (task.IsCanceled)
{
// If the task was cancelled, set the state to cancelled
if (downloadCancellationTokenSource?.IsCancellationRequested == true)
{
ProgressState = ProgressState.Cancelled;
}
// If the task was not cancelled, set the state to paused
else if (downloadPauseTokenSource?.IsCancellationRequested == true)
{
ProgressState = ProgressState.Inactive;
}
else
{
throw new InvalidOperationException("Download task was cancelled but neither cancellation token was cancelled.");
}
}
// For faulted
else if (task.IsFaulted)
{
// Set the exception
Exception = task.Exception;
// Delete the temp file
try
{
DownloadDirectory.JoinFile(TempFileName).Delete();
}
catch (IOException)
{
}
ProgressState = ProgressState.Failed;
}
// Otherwise success
else
{
ProgressState = ProgressState.Success;
}
// For failed or cancelled, delete the temp file
if (ProgressState is ProgressState.Failed or ProgressState.Cancelled)
{
// Delete the temp file
try
{
DownloadDirectory.JoinFile(TempFileName).Delete();
}
catch (IOException)
{
}
}
else if (ProgressState == ProgressState.Success)
{
// Move the temp file to the final file
DownloadDirectory.JoinFile(TempFileName).MoveTo(DownloadDirectory.JoinFile(FileName));
}
// For pause, just do nothing
OnProgressStateChanged(ProgressState);
// Dispose of the task and cancellation token
downloadTask?.Dispose();
downloadTask = null;
downloadCancellationTokenSource?.Dispose();
downloadCancellationTokenSource = null;
}
public void SetDownloadService(IDownloadService service)
{
downloadService = service;
}
}
Loading…
Cancel
Save