From a1fd8d50997bc7e4dd6f99ad5c3db8ba7030d412 Mon Sep 17 00:00:00 2001 From: Robert Date: Mon, 14 Aug 2023 20:10:05 +0200 Subject: [PATCH] Shared UI - Added HttpClientUtilities offering progressable stream content Submission wizard - Added upload progress Submission wizard - Close on finish --- .../Utilities/ProgressableStreamContent.cs | 119 ++++++++++++++++++ .../Utilities/StreamProgress.cs | 57 +++++++++ .../Workshop/Home/WorkshopHomeViewModel.cs | 2 +- .../Steps/UploadStepView.axaml | 6 + .../Steps/UploadStepViewModel.cs | 50 +++++++- .../SubmissionWizard/SubmissionWizardState.cs | 10 ++ .../Artemis.WebClient.Workshop.csproj | 1 + .../UploadHandlers/IEntryUploadHandler.cs | 5 +- .../LayoutEntryUploadHandler.cs | 4 +- .../ProfileEntryUploadHandler.cs | 11 +- 10 files changed, 249 insertions(+), 16 deletions(-) create mode 100644 src/Artemis.UI.Shared/Utilities/ProgressableStreamContent.cs create mode 100644 src/Artemis.UI.Shared/Utilities/StreamProgress.cs diff --git a/src/Artemis.UI.Shared/Utilities/ProgressableStreamContent.cs b/src/Artemis.UI.Shared/Utilities/ProgressableStreamContent.cs new file mode 100644 index 000000000..bac5bb9d1 --- /dev/null +++ b/src/Artemis.UI.Shared/Utilities/ProgressableStreamContent.cs @@ -0,0 +1,119 @@ +// Heavily based on: +// SkyClip +// - ProgressableStreamContent.cs +// -------------------------------------------------------------------- +// Author: Jeff Hansen +// Copyright (C) Jeff Hansen 2015. All rights reserved. + +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Artemis.UI.Shared.Utilities; + +/// +/// Provides HTTP content based on a stream with support for IProgress. +/// +public class ProgressableStreamContent : StreamContent +{ + private const int DEFAULT_BUFFER_SIZE = 4096; + + private readonly int _bufferSize; + private readonly IProgress _progress; + private readonly Stream _streamToWrite; + private bool _contentConsumed; + + /// + /// Initializes a new instance of the class. + /// + /// The stream to write. + /// The downloader. + public ProgressableStreamContent(Stream streamToWrite, IProgress progress) : this(streamToWrite, DEFAULT_BUFFER_SIZE, progress) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The stream to write. + /// The buffer size. + /// The downloader. + public ProgressableStreamContent(Stream streamToWrite, int bufferSize, IProgress progress) : base(streamToWrite, bufferSize) + { + if (bufferSize <= 0) + throw new ArgumentOutOfRangeException(nameof(bufferSize)); + + _streamToWrite = streamToWrite; + _bufferSize = bufferSize; + _progress = progress; + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + _streamToWrite.Dispose(); + + base.Dispose(disposing); + } + + /// + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) + { + await SerializeToStreamAsync(stream, context, CancellationToken.None); + } + + /// + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) + { + PrepareContent(); + + byte[] buffer = new byte[_bufferSize]; + long size = _streamToWrite.Length; + int uploaded = 0; + + await using (_streamToWrite) + { + while (!cancellationToken.IsCancellationRequested) + { + int length = await _streamToWrite.ReadAsync(buffer, cancellationToken); + if (length <= 0) + break; + + uploaded += length; + _progress.Report(new StreamProgress(uploaded, size)); + await stream.WriteAsync(buffer, 0, length, cancellationToken); + } + } + } + + /// + protected override bool TryComputeLength(out long length) + { + length = _streamToWrite.Length; + return true; + } + + /// + /// Prepares the content. + /// + /// The stream has already been read. + private void PrepareContent() + { + if (_contentConsumed) + { + // If the content needs to be written to a target stream a 2nd time, then the stream must support + // seeking (e.g. a FileStream), otherwise the stream can't be copied a second time to a target + // stream (e.g. a NetworkStream). + if (_streamToWrite.CanSeek) + _streamToWrite.Position = 0; + else + throw new InvalidOperationException("The stream has already been read."); + } + + _contentConsumed = true; + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Utilities/StreamProgress.cs b/src/Artemis.UI.Shared/Utilities/StreamProgress.cs new file mode 100644 index 000000000..eb0b7a713 --- /dev/null +++ b/src/Artemis.UI.Shared/Utilities/StreamProgress.cs @@ -0,0 +1,57 @@ +// Heavily based on: +// SkyClip +// - UploadProgress.cs +// -------------------------------------------------------------------- +// Author: Jeff Hansen +// Copyright (C) Jeff Hansen 2015. All rights reserved. + +namespace Artemis.UI.Shared.Utilities; + +/// +/// The upload progress. +/// +public class StreamProgress +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// The bytes transfered. + /// + /// + /// The total bytes. + /// + public StreamProgress(long bytesTransfered, long? totalBytes) + { + BytesTransfered = bytesTransfered; + TotalBytes = totalBytes; + if (totalBytes.HasValue) + ProgressPercentage = (int) ((float) bytesTransfered / totalBytes.Value * 100); + } + + /// + /// Returns a that represents this instance. + /// + /// + /// A that represents this instance. + /// + public override string ToString() + { + return string.Format("{0}% ({1} / {2})", ProgressPercentage, BytesTransfered, TotalBytes); + } + + /// + /// Gets the bytes transfered. + /// + public long BytesTransfered { get; } + + /// + /// Gets the progress percentage. + /// + public int ProgressPercentage { get; } + + /// + /// Gets the total bytes. + /// + public long? TotalBytes { get; } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeViewModel.cs b/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeViewModel.cs index 67391c0bd..dfaf0a8f2 100644 --- a/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeViewModel.cs @@ -26,7 +26,7 @@ public class WorkshopHomeViewModel : ActivatableViewModelBase, IWorkshopViewMode private async Task ExecuteAddSubmission(CancellationToken arg) { - await _windowService.ShowDialogAsync(); + await _windowService.ShowDialogAsync(); } public EntryType? EntryType => null; diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepView.axaml index bd215cce5..89a7ec328 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepView.axaml +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepView.axaml @@ -21,6 +21,12 @@ Wooo, the final step, that was pretty easy, right!? + + diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs index 8cec0dba1..60d2c92c6 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs @@ -10,6 +10,9 @@ using ReactiveUI; using StrawberryShake; using System.Reactive.Disposables; using Artemis.Core; +using Artemis.UI.Shared.Routing; +using System; +using Artemis.UI.Shared.Utilities; namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; @@ -18,19 +21,33 @@ public class UploadStepViewModel : SubmissionViewModel private readonly IWorkshopClient _workshopClient; private readonly EntryUploadHandlerFactory _entryUploadHandlerFactory; private readonly IWindowService _windowService; + private readonly IRouter _router; + private readonly Progress _progress = new(); + private readonly ObservableAsPropertyHelper _progressPercentage; + private readonly ObservableAsPropertyHelper _progressIndeterminate; + private bool _finished; + private Guid? _entryId; /// - public UploadStepViewModel(IWorkshopClient workshopClient, EntryUploadHandlerFactory entryUploadHandlerFactory, IWindowService windowService) + public UploadStepViewModel(IWorkshopClient workshopClient, EntryUploadHandlerFactory entryUploadHandlerFactory, IWindowService windowService, IRouter router) { _workshopClient = workshopClient; _entryUploadHandlerFactory = entryUploadHandlerFactory; _windowService = windowService; + _router = router; ShowGoBack = false; ContinueText = "Finish"; - Continue = ReactiveCommand.Create(ExecuteContinue, this.WhenAnyValue(vm => vm.Finished)); - + Continue = ReactiveCommand.CreateFromTask(ExecuteContinue, this.WhenAnyValue(vm => vm.Finished)); + + _progressPercentage = Observable.FromEventPattern(x => _progress.ProgressChanged += x, x => _progress.ProgressChanged -= x) + .Select(e => e.EventArgs.ProgressPercentage) + .ToProperty(this, vm => vm.ProgressPercentage); + _progressIndeterminate = Observable.FromEventPattern(x => _progress.ProgressChanged += x, x => _progress.ProgressChanged -= x) + .Select(e => e.EventArgs.ProgressPercentage == 0) + .ToProperty(this, vm => vm.ProgressIndeterminate); + this.WhenActivated(d => Observable.FromAsync(ExecuteUpload).Subscribe().DisposeWith(d)); } @@ -40,6 +57,9 @@ public class UploadStepViewModel : SubmissionViewModel /// public override ReactiveCommand GoBack { get; } = null!; + public int ProgressPercentage => _progressPercentage.Value; + public bool ProgressIndeterminate => _progressIndeterminate.Value; + public bool Finished { get => _finished; @@ -72,7 +92,7 @@ public class UploadStepViewModel : SubmissionViewModel try { IEntryUploadHandler uploadHandler = _entryUploadHandlerFactory.CreateHandler(State.EntryType); - EntryUploadResult uploadResult = await uploadHandler.CreateReleaseAsync(entryId.Value, State.EntrySource!, cancellationToken); + EntryUploadResult uploadResult = await uploadHandler.CreateReleaseAsync(entryId.Value, State.EntrySource!, _progress, cancellationToken); if (!uploadResult.IsSuccess) { string? message = uploadResult.Message; @@ -85,6 +105,7 @@ public class UploadStepViewModel : SubmissionViewModel return; } + _entryId = entryId; Finished = true; } catch (Exception e) @@ -94,8 +115,25 @@ public class UploadStepViewModel : SubmissionViewModel } } - private void ExecuteContinue() + private async Task ExecuteContinue() { - throw new NotImplementedException(); + if (_entryId == null) + return; + + State.Finish(); + switch (State.EntryType) + { + case EntryType.Layout: + await _router.Navigate($"workshop/layouts/{_entryId.Value}"); + break; + case EntryType.Plugin: + await _router.Navigate($"workshop/plugins/{_entryId.Value}"); + break; + case EntryType.Profile: + await _router.Navigate($"workshop/profiles/{_entryId.Value}"); + break; + default: + throw new ArgumentOutOfRangeException(); + } } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs index 996496e03..3484c8c36 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardState.cs @@ -44,4 +44,14 @@ public class SubmissionWizardState _windowService.ShowExceptionDialog("Wizard screen failed to activate", e); } } + + public void Finish() + { + _wizardViewModel.Close(true); + } + + public void Cancel() + { + _wizardViewModel.Close(false); + } } \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj b/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj index e09fefe42..d1c6d5490 100644 --- a/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj +++ b/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj @@ -25,6 +25,7 @@ + diff --git a/src/Artemis.WebClient.Workshop/UploadHandlers/IEntryUploadHandler.cs b/src/Artemis.WebClient.Workshop/UploadHandlers/IEntryUploadHandler.cs index a80787acb..21cdc2d5a 100644 --- a/src/Artemis.WebClient.Workshop/UploadHandlers/IEntryUploadHandler.cs +++ b/src/Artemis.WebClient.Workshop/UploadHandlers/IEntryUploadHandler.cs @@ -1,7 +1,8 @@ - +using Artemis.UI.Shared.Utilities; + namespace Artemis.WebClient.Workshop.UploadHandlers; public interface IEntryUploadHandler { - Task CreateReleaseAsync(Guid entryId, object file, CancellationToken cancellationToken); + Task CreateReleaseAsync(Guid entryId, object file, Progress progress, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/LayoutEntryUploadHandler.cs b/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/LayoutEntryUploadHandler.cs index 1b3318940..57f2f664c 100644 --- a/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/LayoutEntryUploadHandler.cs +++ b/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/LayoutEntryUploadHandler.cs @@ -1,11 +1,11 @@ -using RGB.NET.Layout; +using Artemis.UI.Shared.Utilities; namespace Artemis.WebClient.Workshop.UploadHandlers.Implementations; public class LayoutEntryUploadHandler : IEntryUploadHandler { /// - public async Task CreateReleaseAsync(Guid entryId, object file, CancellationToken cancellationToken) + public async Task CreateReleaseAsync(Guid entryId, object file, Progress progress, CancellationToken cancellationToken) { throw new NotImplementedException(); } diff --git a/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/ProfileEntryUploadHandler.cs b/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/ProfileEntryUploadHandler.cs index 4582e4f9b..7f09ce387 100644 --- a/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/ProfileEntryUploadHandler.cs +++ b/src/Artemis.WebClient.Workshop/UploadHandlers/Implementations/ProfileEntryUploadHandler.cs @@ -3,6 +3,7 @@ using System.Net.Http.Headers; using System.Text; using Artemis.Core; using Artemis.Core.Services; +using Artemis.UI.Shared.Utilities; using Artemis.Web.Workshop.Entities; using Newtonsoft.Json; @@ -20,7 +21,7 @@ public class ProfileEntryUploadHandler : IEntryUploadHandler } /// - public async Task CreateReleaseAsync(Guid entryId, object file, CancellationToken cancellationToken) + public async Task CreateReleaseAsync(Guid entryId, object file, Progress progress, CancellationToken cancellationToken) { if (file is not ProfileConfiguration profileConfiguration) throw new InvalidOperationException("Can only create releases for profile configurations"); @@ -42,19 +43,19 @@ public class ProfileEntryUploadHandler : IEntryUploadHandler // Submit the archive HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.WORKSHOP_CLIENT_NAME); - + // Construct the request archiveStream.Seek(0, SeekOrigin.Begin); MultipartFormDataContent content = new(); - StreamContent streamContent = new(archiveStream); + ProgressableStreamContent streamContent = new(archiveStream, progress); streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/zip"); content.Add(streamContent, "file", "file.zip"); - + // Submit HttpResponseMessage response = await client.PostAsync("releases/upload/" + entryId, content, cancellationToken); if (!response.IsSuccessStatusCode) return EntryUploadResult.FromFailure($"{response.StatusCode} - {await response.Content.ReadAsStringAsync(cancellationToken)}"); - + Release? release = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync(cancellationToken)); return release != null ? EntryUploadResult.FromSuccess(release) : EntryUploadResult.FromFailure("Failed to deserialize response"); }