From 0e0ccd0fb75eaaa55934efae7dc962dfba03278a Mon Sep 17 00:00:00 2001 From: Ionite Date: Sun, 20 Aug 2023 19:31:30 -0400 Subject: [PATCH 01/26] Add cancellation token for download service --- .../Services/DownloadService.cs | 78 +++++++++++++------ .../Services/IDownloadService.cs | 9 ++- 2 files changed, 61 insertions(+), 26 deletions(-) diff --git a/StabilityMatrix.Core/Services/DownloadService.cs b/StabilityMatrix.Core/Services/DownloadService.cs index 6bf13e8a..28cbf6e0 100644 --- a/StabilityMatrix.Core/Services/DownloadService.cs +++ b/StabilityMatrix.Core/Services/DownloadService.cs @@ -17,43 +17,66 @@ public class DownloadService : IDownloadService this.httpClientFactory = httpClientFactory; } - public async Task DownloadToFileAsync(string downloadUrl, string downloadPath, - IProgress? progress = null, string? httpClientName = null) + public async Task DownloadToFileAsync( + string downloadUrl, + string downloadPath, + IProgress? progress = null, + string? httpClientName = null, + CancellationToken cancellationToken = default + ) { using var client = string.IsNullOrWhiteSpace(httpClientName) ? httpClientFactory.CreateClient() : httpClientFactory.CreateClient(httpClientName); - + client.Timeout = TimeSpan.FromMinutes(10); - client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("StabilityMatrix", "2.0")); - await using var file = new FileStream(downloadPath, FileMode.Create, FileAccess.Write, FileShare.None); - + client.DefaultRequestHeaders.UserAgent.Add( + new ProductInfoHeaderValue("StabilityMatrix", "2.0") + ); + await using var file = new FileStream( + downloadPath, + FileMode.Create, + FileAccess.Write, + FileShare.None + ); + long contentLength = 0; - var response = await client.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead); + var response = await client + .GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); contentLength = response.Content.Headers.ContentLength ?? 0; - + var delays = Backoff.DecorrelatedJitterBackoffV2( - TimeSpan.FromMilliseconds(50), retryCount: 3); - + TimeSpan.FromMilliseconds(50), + retryCount: 3 + ); + foreach (var delay in delays) { - if (contentLength > 0) break; + if (contentLength > 0) + break; logger.LogDebug("Retrying get-headers for content-length"); - await Task.Delay(delay); - response = await client.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead); + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + response = await client + .GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); contentLength = response.Content.Headers.ContentLength ?? 0; } var isIndeterminate = contentLength == 0; - await using var stream = await response.Content.ReadAsStreamAsync(); + await using var stream = await response.Content + .ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); var totalBytesRead = 0L; var buffer = new byte[BufferSize]; while (true) { - var bytesRead = await stream.ReadAsync(buffer); - if (bytesRead == 0) break; - await file.WriteAsync(buffer.AsMemory(0, bytesRead)); + var bytesRead = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (bytesRead == 0) + break; + await file.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken) + .ConfigureAwait(false); totalBytesRead += bytesRead; @@ -63,13 +86,18 @@ public class DownloadService : IDownloadService } else { - progress?.Report(new ProgressReport(current: Convert.ToUInt64(totalBytesRead), - total: Convert.ToUInt64(contentLength), message: "Downloading...")); + progress?.Report( + new ProgressReport( + current: Convert.ToUInt64(totalBytesRead), + total: Convert.ToUInt64(contentLength), + message: "Downloading..." + ) + ); } } - await file.FlushAsync(); - + await file.FlushAsync(cancellationToken).ConfigureAwait(false); + progress?.Report(new ProgressReport(1f, message: "Download complete!")); } @@ -77,11 +105,13 @@ public class DownloadService : IDownloadService { using var client = httpClientFactory.CreateClient(); client.Timeout = TimeSpan.FromSeconds(10); - client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("StabilityMatrix", "2.0")); + client.DefaultRequestHeaders.UserAgent.Add( + new ProductInfoHeaderValue("StabilityMatrix", "2.0") + ); try { - var response = await client.GetAsync(url); - return await response.Content.ReadAsStreamAsync(); + var response = await client.GetAsync(url).ConfigureAwait(false); + return await response.Content.ReadAsStreamAsync().ConfigureAwait(false); } catch (Exception e) { diff --git a/StabilityMatrix.Core/Services/IDownloadService.cs b/StabilityMatrix.Core/Services/IDownloadService.cs index 21bddb98..2f6fa6e7 100644 --- a/StabilityMatrix.Core/Services/IDownloadService.cs +++ b/StabilityMatrix.Core/Services/IDownloadService.cs @@ -4,8 +4,13 @@ namespace StabilityMatrix.Core.Services; public interface IDownloadService { - Task DownloadToFileAsync(string downloadUrl, string downloadPath, - IProgress? progress = null, string? httpClientName = null); + Task DownloadToFileAsync( + string downloadUrl, + string downloadPath, + IProgress? progress = null, + string? httpClientName = null, + CancellationToken cancellationToken = default + ); Task GetImageStreamFromUrl(string url); } From 7ecb008a7a10e2611785645a8962bfc86f55a8ed Mon Sep 17 00:00:00 2001 From: Ionite Date: Mon, 21 Aug 2023 15:57:13 -0400 Subject: [PATCH 02/26] Add ToggleButtonStyles --- StabilityMatrix.Avalonia/App.axaml | 1 + .../Styles/ToggleButtonStyles.axaml | 347 ++++++++++++++++++ 2 files changed, 348 insertions(+) create mode 100644 StabilityMatrix.Avalonia/Styles/ToggleButtonStyles.axaml diff --git a/StabilityMatrix.Avalonia/App.axaml b/StabilityMatrix.Avalonia/App.axaml index 7d74ab58..3977db27 100644 --- a/StabilityMatrix.Avalonia/App.axaml +++ b/StabilityMatrix.Avalonia/App.axaml @@ -28,5 +28,6 @@ + diff --git a/StabilityMatrix.Avalonia/Styles/ToggleButtonStyles.axaml b/StabilityMatrix.Avalonia/Styles/ToggleButtonStyles.axaml new file mode 100644 index 00000000..2557dcea --- /dev/null +++ b/StabilityMatrix.Avalonia/Styles/ToggleButtonStyles.axaml @@ -0,0 +1,347 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 73f8f64c8fc0eba655d9601a99ab788108db562f Mon Sep 17 00:00:00 2001 From: Ionite Date: Mon, 21 Aug 2023 20:13:19 -0400 Subject: [PATCH 03/26] Add tracked download stuff --- StabilityMatrix.Avalonia/App.axaml.cs | 1 + .../DesignData/DesignData.cs | 12 +- .../MockDownloadProgressItemViewModel.cs | 65 +++++ .../DesignData/MockDownloadService.cs | 13 +- .../DesignData/MockTrackedDownloadService.cs | 18 ++ .../Base/PausableProgressItemViewModelBase.cs | 49 ++++ .../Base/ProgressItemViewModelBase.cs | 16 ++ .../CheckpointBrowserCardViewModel.cs | 11 +- .../DownloadProgressItemViewModel.cs | 73 ++++++ .../ViewModels/ProgressItemViewModel.cs | 12 +- .../ViewModels/ProgressManagerViewModel.cs | 28 +- .../Views/ProgressManagerPage.axaml | 206 +++++++++++---- .../Models/FileInterfaces/FilePath.cs | 14 +- .../Models/Progress/ProgressState.cs | 3 +- .../Models/TrackedDownload.cs | 245 ++++++++++++++++++ 15 files changed, 680 insertions(+), 86 deletions(-) create mode 100644 StabilityMatrix.Avalonia/DesignData/MockDownloadProgressItemViewModel.cs create mode 100644 StabilityMatrix.Avalonia/DesignData/MockTrackedDownloadService.cs create mode 100644 StabilityMatrix.Avalonia/ViewModels/Base/PausableProgressItemViewModelBase.cs create mode 100644 StabilityMatrix.Avalonia/ViewModels/Base/ProgressItemViewModelBase.cs create mode 100644 StabilityMatrix.Avalonia/ViewModels/DownloadProgressItemViewModel.cs create mode 100644 StabilityMatrix.Core/Models/TrackedDownload.cs diff --git a/StabilityMatrix.Avalonia/App.axaml.cs b/StabilityMatrix.Avalonia/App.axaml.cs index 382f7274..71272417 100644 --- a/StabilityMatrix.Avalonia/App.axaml.cs +++ b/StabilityMatrix.Avalonia/App.axaml.cs @@ -332,6 +332,7 @@ public sealed class App : Application services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Rich presence services.AddSingleton(); diff --git a/StabilityMatrix.Avalonia/DesignData/DesignData.cs b/StabilityMatrix.Avalonia/DesignData/DesignData.cs index b0fc04ba..3ccf7b3e 100644 --- a/StabilityMatrix.Avalonia/DesignData/DesignData.cs +++ b/StabilityMatrix.Avalonia/DesignData/DesignData.cs @@ -91,7 +91,8 @@ public static class DesignData .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton(); + .AddSingleton() + .AddSingleton(); // Placeholder services that nobody should need during design time services @@ -223,13 +224,12 @@ public static class DesignData }) }; - ProgressManagerViewModel.ProgressItems = new ObservableCollection + 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.UpdateText = diff --git a/StabilityMatrix.Avalonia/DesignData/MockDownloadProgressItemViewModel.cs b/StabilityMatrix.Avalonia/DesignData/MockDownloadProgressItemViewModel.cs new file mode 100644 index 00000000..5f94c91d --- /dev/null +++ b/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..."; + } + + /// + 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; + } + + /// + public override Task Pause() + { + // Cancel the task that updates progress + cts?.Cancel(); + cts = null; + dummyTask = null; + + State = ProgressState.Inactive; + + return Task.CompletedTask; + } + + /// + 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; + } +} diff --git a/StabilityMatrix.Avalonia/DesignData/MockDownloadService.cs b/StabilityMatrix.Avalonia/DesignData/MockDownloadService.cs index 419d972e..f3b35519 100644 --- a/StabilityMatrix.Avalonia/DesignData/MockDownloadService.cs +++ b/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? progress = null, string? httpClientName = null) + public Task DownloadToFileAsync(string downloadUrl, string downloadPath, IProgress? progress = null, + string? httpClientName = null, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + /// + public Task ResumeDownloadToFileAsync(string downloadUrl, string downloadPath, long existingFileSize, + IProgress? progress = null, string? httpClientName = null, + CancellationToken cancellationToken = default) { return Task.CompletedTask; } diff --git a/StabilityMatrix.Avalonia/DesignData/MockTrackedDownloadService.cs b/StabilityMatrix.Avalonia/DesignData/MockTrackedDownloadService.cs new file mode 100644 index 00000000..9900f919 --- /dev/null +++ b/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 +{ + /// + public event EventHandler? DownloadAdded; + + /// + public TrackedDownload NewDownload(Uri downloadUrl, FilePath downloadPath) + { + throw new NotImplementedException(); + } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/Base/PausableProgressItemViewModelBase.cs b/StabilityMatrix.Avalonia/ViewModels/Base/PausableProgressItemViewModelBase.cs new file mode 100644 index 00000000..2d0f2672 --- /dev/null +++ b/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; + + /// + /// Whether the progress is paused + /// + public bool IsPaused => State == ProgressState.Inactive; + + /// + /// Whether the progress has succeeded, failed or was cancelled + /// + 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(); + } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/Base/ProgressItemViewModelBase.cs b/StabilityMatrix.Avalonia/ViewModels/Base/ProgressItemViewModelBase.cs new file mode 100644 index 00000000..a304da5b --- /dev/null +++ b/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(); +} diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CheckpointBrowserCardViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CheckpointBrowserCardViewModel.cs index 3b7f0120..f0502fa5 100644 --- a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CheckpointBrowserCardViewModel.cs +++ b/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 dialogFactory; private readonly INotificationService notificationService; @@ -63,11 +63,13 @@ public partial class CheckpointBrowserCardViewModel : Base.ProgressViewModel public CheckpointBrowserCardViewModel( IDownloadService downloadService, + ITrackedDownloadService trackedDownloadService, ISettingsManager settingsManager, ServiceManager 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(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) diff --git a/StabilityMatrix.Avalonia/ViewModels/DownloadProgressItemViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/DownloadProgressItemViewModel.cs new file mode 100644 index 00000000..403d85e1 --- /dev/null +++ b/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; + + /// + 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"; + } + }; + } + + /// + public override Task Cancel() + { + download.Cancel(); + return Task.CompletedTask; + } + + /// + public override Task Pause() + { + download.Pause(); + return Task.CompletedTask; + } + + /// + public override Task Resume() + { + download.Resume(); + return Task.CompletedTask; + } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/ProgressItemViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/ProgressItemViewModel.cs index ceb7a2de..9a511042 100644 --- a/StabilityMatrix.Avalonia/ViewModels/ProgressItemViewModel.cs +++ b/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) diff --git a/StabilityMatrix.Avalonia/ViewModels/ProgressManagerViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/ProgressManagerViewModel.cs index 7109a042..9024965a 100644 --- a/StabilityMatrix.Avalonia/ViewModels/ProgressManagerViewModel.cs +++ b/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 progressItems; + public AvaloniaList 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(); + 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(itemsInProgress); + ProgressItems.RemoveAll(ProgressItems.Where(x => x.IsCompleted)); } private void OnProgressChanged(object? sender, ProgressItem e) diff --git a/StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml b/StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml index f45ab5d3..44955c68 100644 --- a/StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml +++ b/StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml @@ -1,46 +1,156 @@ - + - - + + + + + + + + + + + + + + + + - - - - - - + + + + + + + + + + + + + + + + @@ -51,23 +161,11 @@ - - - - - - - - - - + - - + + diff --git a/StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs b/StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs index d8a54fcd..4bc1b2e2 100644 --- a/StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs +++ b/StabilityMatrix.Core/Models/FileInterfaces/FilePath.cs @@ -115,12 +115,22 @@ public class FilePath : FileSystemPath, IPathObject return File.WriteAllBytesAsync(FullPath, bytes, ct); } + /// + /// Move the file to a directory. + /// + public FilePath MoveTo(FilePath destinationFile) + { + Info.MoveTo(destinationFile.FullPath, true); + // Return the new path + return destinationFile; + } + /// /// Move the file to a directory. /// public async Task 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 /// public async Task 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; } diff --git a/StabilityMatrix.Core/Models/Progress/ProgressState.cs b/StabilityMatrix.Core/Models/Progress/ProgressState.cs index 8a62751c..ec64960c 100644 --- a/StabilityMatrix.Core/Models/Progress/ProgressState.cs +++ b/StabilityMatrix.Core/Models/Progress/ProgressState.cs @@ -5,5 +5,6 @@ public enum ProgressState Inactive, Working, Success, - Failed + Failed, + Cancelled } diff --git a/StabilityMatrix.Core/Models/TrackedDownload.cs b/StabilityMatrix.Core/Models/TrackedDownload.cs new file mode 100644 index 00000000..ed62471b --- /dev/null +++ b/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? progressUpdateEventManager; + + public event EventHandler ProgressUpdate + { + add + { + progressUpdateEventManager ??= new WeakEventManager(); + progressUpdateEventManager.AddEventHandler(value); + } + remove => progressUpdateEventManager?.RemoveEventHandler(value); + } + + protected void OnProgressUpdate(ProgressReport e) + { + progressUpdateEventManager?.RaiseEvent(this, e, nameof(ProgressUpdate)); + } + + private WeakEventManager? progressStateChangedEventManager; + + public event EventHandler ProgressStateChanged + { + add + { + progressStateChangedEventManager ??= new WeakEventManager(); + 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(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(); + } + + /// + /// Invoked by the task's completion callback + /// + 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; + } +} From 009264f0e77f276eb4b2b114cde6af40d80b9b8d Mon Sep 17 00:00:00 2001 From: Ionite Date: Mon, 21 Aug 2023 20:13:33 -0400 Subject: [PATCH 04/26] Add TrackedDownloadService --- .../Services/ITrackedDownloadService.cs | 13 ++ .../Services/TrackedDownloadService.cs | 197 ++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 StabilityMatrix.Core/Services/ITrackedDownloadService.cs create mode 100644 StabilityMatrix.Core/Services/TrackedDownloadService.cs diff --git a/StabilityMatrix.Core/Services/ITrackedDownloadService.cs b/StabilityMatrix.Core/Services/ITrackedDownloadService.cs new file mode 100644 index 00000000..ca8fcab4 --- /dev/null +++ b/StabilityMatrix.Core/Services/ITrackedDownloadService.cs @@ -0,0 +1,13 @@ +using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.FileInterfaces; + +namespace StabilityMatrix.Core.Services; + +public interface ITrackedDownloadService +{ + event EventHandler? DownloadAdded; + + TrackedDownload NewDownload(Uri downloadUrl, FilePath downloadPath); + + TrackedDownload NewDownload(string downloadUrl, FilePath downloadPath) => NewDownload(new Uri(downloadUrl), downloadPath); +} diff --git a/StabilityMatrix.Core/Services/TrackedDownloadService.cs b/StabilityMatrix.Core/Services/TrackedDownloadService.cs new file mode 100644 index 00000000..a6f069ce --- /dev/null +++ b/StabilityMatrix.Core/Services/TrackedDownloadService.cs @@ -0,0 +1,197 @@ +using System.Collections.Concurrent; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using StabilityMatrix.Core.Database; +using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Models.Progress; + +namespace StabilityMatrix.Core.Services; + +public class TrackedDownloadService : ITrackedDownloadService, IDisposable +{ + private readonly ILogger logger; + private readonly IDownloadService downloadService; + private readonly ISettingsManager settingsManager; + + private readonly ConcurrentDictionary downloads = new(); + + /// + public event EventHandler? DownloadAdded; + + public TrackedDownloadService( + ILogger logger, + IDownloadService downloadService, + ISettingsManager settingsManager) + { + this.logger = logger; + this.downloadService = downloadService; + this.settingsManager = settingsManager; + + // Index for in-progress downloads when library dir loaded + settingsManager.RegisterOnLibraryDirSet(path => + { + var downloadsDir = new DirectoryPath(settingsManager.DownloadsDirectory); + // Ignore if not exist + if (!downloadsDir.Exists) return; + + LoadInProgressDownloads(downloadsDir); + }); + } + + private void OnDownloadAdded(TrackedDownload download) + { + DownloadAdded?.Invoke(this, download); + } + + /// + /// Creates a new tracked download with backed json file and adds it to the dictionary. + /// + /// + private void AddDownload(TrackedDownload download) + { + // Set download service + download.SetDownloadService(downloadService); + + // Create json file + var downloadsDir = new DirectoryPath(settingsManager.DownloadsDirectory); + var jsonFile = downloadsDir.JoinFile($"{download.Id}.json"); + var jsonFileStream = jsonFile.Info.Open(FileMode.CreateNew, FileAccess.ReadWrite, FileShare.Read); + + // Serialize to json + var json = JsonSerializer.Serialize(download); + jsonFileStream.Write(Encoding.UTF8.GetBytes(json)); + + // Add to dictionary + downloads.TryAdd(download.Id, (download, jsonFileStream)); + + // Connect to state changed event to update json file + download.ProgressStateChanged += TrackedDownload_OnProgressStateChanged; + + logger.LogDebug("Added download {Download}", download.FileName); + OnDownloadAdded(download); + } + + /// + /// Update the json file for the download. + /// + private void UpdateJsonForDownload(TrackedDownload download) + { + // Serialize to json + var json = JsonSerializer.Serialize(download); + var jsonBytes = Encoding.UTF8.GetBytes(json); + + // Write to file + var (_, fs) = downloads[download.Id]; + fs.Seek(0, SeekOrigin.Begin); + fs.Write(jsonBytes); + } + + /// + /// Handler when the download's state changes + /// + private void TrackedDownload_OnProgressStateChanged(object? sender, ProgressState e) + { + if (sender is not TrackedDownload download) + { + return; + } + + // Update json file + UpdateJsonForDownload(download); + + // If the download is completed, remove it from the dictionary and delete the json file + if (e is ProgressState.Success or ProgressState.Failed or ProgressState.Cancelled) + { + if (downloads.TryRemove(download.Id, out var downloadInfo)) + { + downloadInfo.Item2.Dispose(); + // Delete json file + new DirectoryPath(settingsManager.DownloadsDirectory).JoinFile($"{download.Id}.json").Delete(); + logger.LogDebug("Removed download {Download}", download.FileName); + } + } + } + + private void LoadInProgressDownloads(DirectoryPath downloadsDir) + { + logger.LogDebug("Indexing in-progress downloads at {DownloadsDir}...", downloadsDir); + + var jsonFiles = downloadsDir.Info.EnumerateFiles("*.json", SearchOption.TopDirectoryOnly); + + // Add to dictionary, the file name is the guid + foreach (var file in jsonFiles) + { + // Try to get a shared write handle + try + { + var fileStream = file.Open(FileMode.Open, FileAccess.ReadWrite, FileShare.Read); + + // Deserialize json and add to dictionary + var download = JsonSerializer.Deserialize(fileStream)!; + download.SetDownloadService(downloadService); + + downloads.TryAdd(download.Id, (download, fileStream)); + OnDownloadAdded(download); + + logger.LogDebug("Loaded in-progress download {Download}", download.FileName); + } + catch (Exception e) + { + logger.LogInformation(e, "Could not open file {File} for reading", file.Name); + } + } + } + + public TrackedDownload NewDownload(Uri downloadUrl, FilePath downloadPath) + { + var download = new TrackedDownload + { + Id = Guid.NewGuid(), + SourceUrl = downloadUrl, + DownloadDirectory = downloadPath.Directory!, + FileName = downloadPath.Name, + TempFileName = NewTempFileName(downloadPath.Directory!), + }; + download.SetDownloadService(downloadService); + + AddDownload(download); + + return download; + } + + /// + /// Generate a new temp file name that is unique in the given directory. + /// In format of "Unconfirmed {id}.smdownload" + /// + /// + /// + private static string NewTempFileName(DirectoryPath parentDir) + { + FilePath? tempFile = null; + + for (var i = 0; i < 10; i++) + { + if (tempFile is {Exists: false}) + { + return tempFile.Name; + } + var id = Random.Shared.Next(1000000, 9999999); + tempFile = parentDir.JoinFile($"Unconfirmed {id}.smdownload"); + } + + throw new Exception("Failed to generate a unique temp file name."); + } + + /// + public void Dispose() + { + foreach (var (_, fs) in downloads.Values) + { + fs.Dispose(); + } + + GC.SuppressFinalize(this); + } +} From 294e80b9357b34da14cedcb8b9f674230769400e Mon Sep 17 00:00:00 2001 From: Ionite Date: Mon, 21 Aug 2023 20:13:53 -0400 Subject: [PATCH 05/26] Add RegisterOnLibraryDirSet method for manager --- .../Services/ISettingsManager.cs | 8 +++++++ .../Services/SettingsManager.cs | 23 ++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/StabilityMatrix.Core/Services/ISettingsManager.cs b/StabilityMatrix.Core/Services/ISettingsManager.cs index 04abd1ad..f8858f66 100644 --- a/StabilityMatrix.Core/Services/ISettingsManager.cs +++ b/StabilityMatrix.Core/Services/ISettingsManager.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.Linq.Expressions; using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.FileInterfaces; using StabilityMatrix.Core.Models.Settings; namespace StabilityMatrix.Core.Services; @@ -12,10 +13,17 @@ public interface ISettingsManager bool IsLibraryDirSet { get; } string DatabasePath { get; } string ModelsDirectory { get; } + string DownloadsDirectory { get; } Settings Settings { get; } event EventHandler? LibraryDirChanged; event EventHandler? SettingsPropertyChanged; + /// + /// Register a handler that fires once when LibraryDir is first set. + /// Will fire instantly if it is already set. + /// + void RegisterOnLibraryDirSet(Action handler); + /// SettingsTransaction BeginTransaction(); diff --git a/StabilityMatrix.Core/Services/SettingsManager.cs b/StabilityMatrix.Core/Services/SettingsManager.cs index 4876057d..8bd487e2 100644 --- a/StabilityMatrix.Core/Services/SettingsManager.cs +++ b/StabilityMatrix.Core/Services/SettingsManager.cs @@ -49,12 +49,33 @@ public class SettingsManager : ISettingsManager public string DatabasePath => Path.Combine(LibraryDir, "StabilityMatrix.db"); private string SettingsPath => Path.Combine(LibraryDir, "settings.json"); public string ModelsDirectory => Path.Combine(LibraryDir, "Models"); - + public string DownloadsDirectory => Path.Combine(LibraryDir, ".downloads"); + public Settings Settings { get; private set; } = new(); public event EventHandler? LibraryDirChanged; public event EventHandler? SettingsPropertyChanged; + /// + public void RegisterOnLibraryDirSet(Action handler) + { + if (IsLibraryDirSet) + { + handler(LibraryDir); + return; + } + + LibraryDirChanged += Handler; + + return; + + void Handler(object? sender, string dir) + { + LibraryDirChanged -= Handler; + handler(dir); + } + } + /// public SettingsTransaction BeginTransaction() { From 7d1131ea1e9385e65a5d096bbc6ebfde84d91c67 Mon Sep 17 00:00:00 2001 From: Ionite Date: Mon, 21 Aug 2023 20:14:03 -0400 Subject: [PATCH 06/26] Add ResumeDownloadToFileAsync --- .../Services/DownloadService.cs | 99 +++++++++++++++++++ .../Services/IDownloadService.cs | 9 ++ 2 files changed, 108 insertions(+) diff --git a/StabilityMatrix.Core/Services/DownloadService.cs b/StabilityMatrix.Core/Services/DownloadService.cs index 28cbf6e0..0c446c23 100644 --- a/StabilityMatrix.Core/Services/DownloadService.cs +++ b/StabilityMatrix.Core/Services/DownloadService.cs @@ -101,6 +101,105 @@ public class DownloadService : IDownloadService progress?.Report(new ProgressReport(1f, message: "Download complete!")); } + /// + public async Task ResumeDownloadToFileAsync( + string downloadUrl, + string downloadPath, + long existingFileSize, + IProgress? progress = null, + string? httpClientName = null, + CancellationToken cancellationToken = default) + { + using var client = string.IsNullOrWhiteSpace(httpClientName) + ? httpClientFactory.CreateClient() + : httpClientFactory.CreateClient(httpClientName); + + client.Timeout = TimeSpan.FromMinutes(10); + client.DefaultRequestHeaders.UserAgent.Add( + new ProductInfoHeaderValue("StabilityMatrix", "2.0") + ); + + // Create file if it doesn't exist + if (!File.Exists(downloadPath)) + { + logger.LogInformation("Resume file doesn't exist, creating file {DownloadPath}", downloadPath); + File.Create(downloadPath).Close(); + } + + await using var file = new FileStream( + downloadPath, + FileMode.Append, + FileAccess.Write, + FileShare.None + ); + + long contentLength = 0; + + using var request = new HttpRequestMessage(); + request.Method = HttpMethod.Get; + request.RequestUri = new Uri(downloadUrl); + request.Headers.Range = new RangeHeaderValue(existingFileSize, null); + + HttpResponseMessage? response = null; + foreach (var delay in Backoff.DecorrelatedJitterBackoffV2( + TimeSpan.FromMilliseconds(50), + retryCount: 4 + )) + { + response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + contentLength = response.Content.Headers.ContentLength ?? 0; + + if (contentLength > 0) + break; + + logger.LogDebug("Retrying get-headers for content-length"); + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + } + + if (response == null) + { + throw new ApplicationException("Response is null"); + } + + var isIndeterminate = contentLength == 0; + + await using var stream = await response.Content + .ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + var totalBytesRead = 0L; + var buffer = new byte[BufferSize]; + while (true) + { + var bytesRead = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (bytesRead == 0) + break; + await file.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken) + .ConfigureAwait(false); + + totalBytesRead += bytesRead; + + if (isIndeterminate) + { + progress?.Report(new ProgressReport(-1, isIndeterminate: true)); + } + else + { + progress?.Report( + new ProgressReport( + current: Convert.ToUInt64(totalBytesRead), + total: Convert.ToUInt64(contentLength), + message: "Downloading..." + ) + ); + } + } + + await file.FlushAsync(cancellationToken).ConfigureAwait(false); + + progress?.Report(new ProgressReport(1f, message: "Download complete!")); + } + public async Task GetImageStreamFromUrl(string url) { using var client = httpClientFactory.CreateClient(); diff --git a/StabilityMatrix.Core/Services/IDownloadService.cs b/StabilityMatrix.Core/Services/IDownloadService.cs index 2f6fa6e7..41cad882 100644 --- a/StabilityMatrix.Core/Services/IDownloadService.cs +++ b/StabilityMatrix.Core/Services/IDownloadService.cs @@ -11,6 +11,15 @@ public interface IDownloadService string? httpClientName = null, CancellationToken cancellationToken = default ); + + Task ResumeDownloadToFileAsync( + string downloadUrl, + string downloadPath, + long existingFileSize, + IProgress? progress = null, + string? httpClientName = null, + CancellationToken cancellationToken = default + ); Task GetImageStreamFromUrl(string url); } From 4aa8dca4df1a65855ea40af21651f1fb9fc12093 Mon Sep 17 00:00:00 2001 From: Ionite Date: Mon, 21 Aug 2023 20:14:16 -0400 Subject: [PATCH 07/26] Add transparent-full button style --- .../Styles/ButtonStyles.axaml | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/StabilityMatrix.Avalonia/Styles/ButtonStyles.axaml b/StabilityMatrix.Avalonia/Styles/ButtonStyles.axaml index ab433d39..18de2349 100644 --- a/StabilityMatrix.Avalonia/Styles/ButtonStyles.axaml +++ b/StabilityMatrix.Avalonia/Styles/ButtonStyles.axaml @@ -11,6 +11,7 @@