mirror of
https://github.com/Artemis-RGB/Artemis
synced 2025-12-13 05:48:35 +00:00
Shared UI - Added HttpClientUtilities offering progressable stream content
Submission wizard - Added upload progress Submission wizard - Close on finish
This commit is contained in:
parent
ad4da3032d
commit
a1fd8d5099
119
src/Artemis.UI.Shared/Utilities/ProgressableStreamContent.cs
Normal file
119
src/Artemis.UI.Shared/Utilities/ProgressableStreamContent.cs
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
// Heavily based on:
|
||||||
|
// SkyClip
|
||||||
|
// - ProgressableStreamContent.cs
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
// Author: Jeff Hansen <jeff@jeffijoe.com>
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides HTTP content based on a stream with support for IProgress.
|
||||||
|
/// </summary>
|
||||||
|
public class ProgressableStreamContent : StreamContent
|
||||||
|
{
|
||||||
|
private const int DEFAULT_BUFFER_SIZE = 4096;
|
||||||
|
|
||||||
|
private readonly int _bufferSize;
|
||||||
|
private readonly IProgress<StreamProgress> _progress;
|
||||||
|
private readonly Stream _streamToWrite;
|
||||||
|
private bool _contentConsumed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ProgressableStreamContent" /> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="streamToWrite">The stream to write.</param>
|
||||||
|
/// <param name="progress">The downloader.</param>
|
||||||
|
public ProgressableStreamContent(Stream streamToWrite, IProgress<StreamProgress> progress) : this(streamToWrite, DEFAULT_BUFFER_SIZE, progress)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ProgressableStreamContent" /> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="streamToWrite">The stream to write.</param>
|
||||||
|
/// <param name="bufferSize">The buffer size.</param>
|
||||||
|
/// <param name="progress">The downloader.</param>
|
||||||
|
public ProgressableStreamContent(Stream streamToWrite, int bufferSize, IProgress<StreamProgress> progress) : base(streamToWrite, bufferSize)
|
||||||
|
{
|
||||||
|
if (bufferSize <= 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(bufferSize));
|
||||||
|
|
||||||
|
_streamToWrite = streamToWrite;
|
||||||
|
_bufferSize = bufferSize;
|
||||||
|
_progress = progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing)
|
||||||
|
_streamToWrite.Dispose();
|
||||||
|
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context)
|
||||||
|
{
|
||||||
|
await SerializeToStreamAsync(stream, context, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override bool TryComputeLength(out long length)
|
||||||
|
{
|
||||||
|
length = _streamToWrite.Length;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prepares the content.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="System.InvalidOperationException">The stream has already been read.</exception>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/Artemis.UI.Shared/Utilities/StreamProgress.cs
Normal file
57
src/Artemis.UI.Shared/Utilities/StreamProgress.cs
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
// Heavily based on:
|
||||||
|
// SkyClip
|
||||||
|
// - UploadProgress.cs
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
// Author: Jeff Hansen <jeff@jeffijoe.com>
|
||||||
|
// Copyright (C) Jeff Hansen 2015. All rights reserved.
|
||||||
|
|
||||||
|
namespace Artemis.UI.Shared.Utilities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The upload progress.
|
||||||
|
/// </summary>
|
||||||
|
public class StreamProgress
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="StreamProgress" /> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="bytesTransfered">
|
||||||
|
/// The bytes transfered.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="totalBytes">
|
||||||
|
/// The total bytes.
|
||||||
|
/// </param>
|
||||||
|
public StreamProgress(long bytesTransfered, long? totalBytes)
|
||||||
|
{
|
||||||
|
BytesTransfered = bytesTransfered;
|
||||||
|
TotalBytes = totalBytes;
|
||||||
|
if (totalBytes.HasValue)
|
||||||
|
ProgressPercentage = (int) ((float) bytesTransfered / totalBytes.Value * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a <see cref="System.String" /> that represents this instance.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>
|
||||||
|
/// A <see cref="System.String" /> that represents this instance.
|
||||||
|
/// </returns>
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return string.Format("{0}% ({1} / {2})", ProgressPercentage, BytesTransfered, TotalBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the bytes transfered.
|
||||||
|
/// </summary>
|
||||||
|
public long BytesTransfered { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the progress percentage.
|
||||||
|
/// </summary>
|
||||||
|
public int ProgressPercentage { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the total bytes.
|
||||||
|
/// </summary>
|
||||||
|
public long? TotalBytes { get; }
|
||||||
|
}
|
||||||
@ -26,7 +26,7 @@ public class WorkshopHomeViewModel : ActivatableViewModelBase, IWorkshopViewMode
|
|||||||
|
|
||||||
private async Task ExecuteAddSubmission(CancellationToken arg)
|
private async Task ExecuteAddSubmission(CancellationToken arg)
|
||||||
{
|
{
|
||||||
await _windowService.ShowDialogAsync<SubmissionWizardViewModel>();
|
await _windowService.ShowDialogAsync<SubmissionWizardViewModel, bool>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public EntryType? EntryType => null;
|
public EntryType? EntryType => null;
|
||||||
|
|||||||
@ -21,6 +21,12 @@
|
|||||||
<TextBlock TextAlignment="Center" TextWrapping="Wrap">
|
<TextBlock TextAlignment="Center" TextWrapping="Wrap">
|
||||||
Wooo, the final step, that was pretty easy, right!?
|
Wooo, the final step, that was pretty easy, right!?
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
|
|
||||||
|
<ProgressBar Margin="0 15 0 0"
|
||||||
|
Width="380"
|
||||||
|
IsVisible="{CompiledBinding !Finished}"
|
||||||
|
IsIndeterminate="{CompiledBinding ProgressIndeterminate}"
|
||||||
|
Value="{CompiledBinding ProgressPercentage}"></ProgressBar>
|
||||||
|
|
||||||
<StackPanel IsVisible="{CompiledBinding Finished}" Margin="0 100 0 0">
|
<StackPanel IsVisible="{CompiledBinding Finished}" Margin="0 100 0 0">
|
||||||
<Lottie Path="/Assets/Animations/success.json" RepeatCount="1" Width="300" Height="300"></Lottie>
|
<Lottie Path="/Assets/Animations/success.json" RepeatCount="1" Width="300" Height="300"></Lottie>
|
||||||
|
|||||||
@ -10,6 +10,9 @@ using ReactiveUI;
|
|||||||
using StrawberryShake;
|
using StrawberryShake;
|
||||||
using System.Reactive.Disposables;
|
using System.Reactive.Disposables;
|
||||||
using Artemis.Core;
|
using Artemis.Core;
|
||||||
|
using Artemis.UI.Shared.Routing;
|
||||||
|
using System;
|
||||||
|
using Artemis.UI.Shared.Utilities;
|
||||||
|
|
||||||
namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps;
|
namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps;
|
||||||
|
|
||||||
@ -18,19 +21,33 @@ public class UploadStepViewModel : SubmissionViewModel
|
|||||||
private readonly IWorkshopClient _workshopClient;
|
private readonly IWorkshopClient _workshopClient;
|
||||||
private readonly EntryUploadHandlerFactory _entryUploadHandlerFactory;
|
private readonly EntryUploadHandlerFactory _entryUploadHandlerFactory;
|
||||||
private readonly IWindowService _windowService;
|
private readonly IWindowService _windowService;
|
||||||
|
private readonly IRouter _router;
|
||||||
|
private readonly Progress<StreamProgress> _progress = new();
|
||||||
|
private readonly ObservableAsPropertyHelper<int> _progressPercentage;
|
||||||
|
private readonly ObservableAsPropertyHelper<bool> _progressIndeterminate;
|
||||||
|
|
||||||
private bool _finished;
|
private bool _finished;
|
||||||
|
private Guid? _entryId;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public UploadStepViewModel(IWorkshopClient workshopClient, EntryUploadHandlerFactory entryUploadHandlerFactory, IWindowService windowService)
|
public UploadStepViewModel(IWorkshopClient workshopClient, EntryUploadHandlerFactory entryUploadHandlerFactory, IWindowService windowService, IRouter router)
|
||||||
{
|
{
|
||||||
_workshopClient = workshopClient;
|
_workshopClient = workshopClient;
|
||||||
_entryUploadHandlerFactory = entryUploadHandlerFactory;
|
_entryUploadHandlerFactory = entryUploadHandlerFactory;
|
||||||
_windowService = windowService;
|
_windowService = windowService;
|
||||||
|
_router = router;
|
||||||
|
|
||||||
ShowGoBack = false;
|
ShowGoBack = false;
|
||||||
ContinueText = "Finish";
|
ContinueText = "Finish";
|
||||||
Continue = ReactiveCommand.Create(ExecuteContinue, this.WhenAnyValue(vm => vm.Finished));
|
Continue = ReactiveCommand.CreateFromTask(ExecuteContinue, this.WhenAnyValue(vm => vm.Finished));
|
||||||
|
|
||||||
|
_progressPercentage = Observable.FromEventPattern<StreamProgress>(x => _progress.ProgressChanged += x, x => _progress.ProgressChanged -= x)
|
||||||
|
.Select(e => e.EventArgs.ProgressPercentage)
|
||||||
|
.ToProperty(this, vm => vm.ProgressPercentage);
|
||||||
|
_progressIndeterminate = Observable.FromEventPattern<StreamProgress>(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));
|
this.WhenActivated(d => Observable.FromAsync(ExecuteUpload).Subscribe().DisposeWith(d));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,6 +57,9 @@ public class UploadStepViewModel : SubmissionViewModel
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override ReactiveCommand<Unit, Unit> GoBack { get; } = null!;
|
public override ReactiveCommand<Unit, Unit> GoBack { get; } = null!;
|
||||||
|
|
||||||
|
public int ProgressPercentage => _progressPercentage.Value;
|
||||||
|
public bool ProgressIndeterminate => _progressIndeterminate.Value;
|
||||||
|
|
||||||
public bool Finished
|
public bool Finished
|
||||||
{
|
{
|
||||||
get => _finished;
|
get => _finished;
|
||||||
@ -72,7 +92,7 @@ public class UploadStepViewModel : SubmissionViewModel
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
IEntryUploadHandler uploadHandler = _entryUploadHandlerFactory.CreateHandler(State.EntryType);
|
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)
|
if (!uploadResult.IsSuccess)
|
||||||
{
|
{
|
||||||
string? message = uploadResult.Message;
|
string? message = uploadResult.Message;
|
||||||
@ -85,6 +105,7 @@ public class UploadStepViewModel : SubmissionViewModel
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_entryId = entryId;
|
||||||
Finished = true;
|
Finished = true;
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -44,4 +44,14 @@ public class SubmissionWizardState
|
|||||||
_windowService.ShowExceptionDialog("Wizard screen failed to activate", e);
|
_windowService.ShowExceptionDialog("Wizard screen failed to activate", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Finish()
|
||||||
|
{
|
||||||
|
_wizardViewModel.Close(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Cancel()
|
||||||
|
{
|
||||||
|
_wizardViewModel.Close(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -25,6 +25,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Artemis.Core\Artemis.Core.csproj" />
|
<ProjectReference Include="..\Artemis.Core\Artemis.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\Artemis.UI.Shared\Artemis.UI.Shared.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
|
using Artemis.UI.Shared.Utilities;
|
||||||
|
|
||||||
namespace Artemis.WebClient.Workshop.UploadHandlers;
|
namespace Artemis.WebClient.Workshop.UploadHandlers;
|
||||||
|
|
||||||
public interface IEntryUploadHandler
|
public interface IEntryUploadHandler
|
||||||
{
|
{
|
||||||
Task<EntryUploadResult> CreateReleaseAsync(Guid entryId, object file, CancellationToken cancellationToken);
|
Task<EntryUploadResult> CreateReleaseAsync(Guid entryId, object file, Progress<StreamProgress> progress, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
@ -1,11 +1,11 @@
|
|||||||
using RGB.NET.Layout;
|
using Artemis.UI.Shared.Utilities;
|
||||||
|
|
||||||
namespace Artemis.WebClient.Workshop.UploadHandlers.Implementations;
|
namespace Artemis.WebClient.Workshop.UploadHandlers.Implementations;
|
||||||
|
|
||||||
public class LayoutEntryUploadHandler : IEntryUploadHandler
|
public class LayoutEntryUploadHandler : IEntryUploadHandler
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<EntryUploadResult> CreateReleaseAsync(Guid entryId, object file, CancellationToken cancellationToken)
|
public async Task<EntryUploadResult> CreateReleaseAsync(Guid entryId, object file, Progress<StreamProgress> progress, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
throw new NotImplementedException();
|
throw new NotImplementedException();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ using System.Net.Http.Headers;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using Artemis.Core;
|
using Artemis.Core;
|
||||||
using Artemis.Core.Services;
|
using Artemis.Core.Services;
|
||||||
|
using Artemis.UI.Shared.Utilities;
|
||||||
using Artemis.Web.Workshop.Entities;
|
using Artemis.Web.Workshop.Entities;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
@ -20,7 +21,7 @@ public class ProfileEntryUploadHandler : IEntryUploadHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<EntryUploadResult> CreateReleaseAsync(Guid entryId, object file, CancellationToken cancellationToken)
|
public async Task<EntryUploadResult> CreateReleaseAsync(Guid entryId, object file, Progress<StreamProgress> progress, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (file is not ProfileConfiguration profileConfiguration)
|
if (file is not ProfileConfiguration profileConfiguration)
|
||||||
throw new InvalidOperationException("Can only create releases for profile configurations");
|
throw new InvalidOperationException("Can only create releases for profile configurations");
|
||||||
@ -42,19 +43,19 @@ public class ProfileEntryUploadHandler : IEntryUploadHandler
|
|||||||
|
|
||||||
// Submit the archive
|
// Submit the archive
|
||||||
HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.WORKSHOP_CLIENT_NAME);
|
HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.WORKSHOP_CLIENT_NAME);
|
||||||
|
|
||||||
// Construct the request
|
// Construct the request
|
||||||
archiveStream.Seek(0, SeekOrigin.Begin);
|
archiveStream.Seek(0, SeekOrigin.Begin);
|
||||||
MultipartFormDataContent content = new();
|
MultipartFormDataContent content = new();
|
||||||
StreamContent streamContent = new(archiveStream);
|
ProgressableStreamContent streamContent = new(archiveStream, progress);
|
||||||
streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/zip");
|
streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/zip");
|
||||||
content.Add(streamContent, "file", "file.zip");
|
content.Add(streamContent, "file", "file.zip");
|
||||||
|
|
||||||
// Submit
|
// Submit
|
||||||
HttpResponseMessage response = await client.PostAsync("releases/upload/" + entryId, content, cancellationToken);
|
HttpResponseMessage response = await client.PostAsync("releases/upload/" + entryId, content, cancellationToken);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
return EntryUploadResult.FromFailure($"{response.StatusCode} - {await response.Content.ReadAsStringAsync(cancellationToken)}");
|
return EntryUploadResult.FromFailure($"{response.StatusCode} - {await response.Content.ReadAsStringAsync(cancellationToken)}");
|
||||||
|
|
||||||
Release? release = JsonConvert.DeserializeObject<Release>(await response.Content.ReadAsStringAsync(cancellationToken));
|
Release? release = JsonConvert.DeserializeObject<Release>(await response.Content.ReadAsStringAsync(cancellationToken));
|
||||||
return release != null ? EntryUploadResult.FromSuccess(release) : EntryUploadResult.FromFailure("Failed to deserialize response");
|
return release != null ? EntryUploadResult.FromSuccess(release) : EntryUploadResult.FromFailure("Failed to deserialize response");
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user