diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fa421ed..09ecfbb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,13 +19,13 @@ jobs: arch: [win10-x64, win10-arm64] steps: - - uses: actions/checkout@v3 - - uses: microsoft/setup-msbuild@v1.1 - - uses: actions/setup-dotnet@v2 + - uses: actions/checkout@v4 + - uses: microsoft/setup-msbuild@v2 + - uses: actions/setup-dotnet@v4 with: dotnet-version: | 6.0.x - 7.0.x + 8.0.x - name: Setup NuGet run: dotnet nuget add source --username github --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name github "https://nuget.pkg.github.com/dragonfruitnetwork/index.json" diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index db22077..03fa81e 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -9,21 +9,21 @@ jobs: quality: runs-on: windows-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-dotnet@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 with: - dotnet-version: | - "6.0.x" - "7.0.x" + dotnet-version: '8.0.x' - name: Restore Project/Tools run: | dotnet restore - dotnet tool install --global NVika dotnet tool install --global JetBrains.ReSharper.GlobalTools - name: InspectCode - run: jb inspectcode DragonFruit.Kaplan.sln --output=inspectcodereport.xml --verbosity=WARN --no-build + run: jb inspectcode DragonFruit.Kaplan.sln --output=inspectcode.sarif --severity=WARNING --properties:Configuration=Release - - name: Vika - run: nvika parsereport "${{github.workspace}}/inspectcodereport.xml" + - name: Upload SARIF file + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: inspectcode.sarif + category: InspectCode diff --git a/DragonFruit.Kaplan.sln.DotSettings b/DragonFruit.Kaplan.sln.DotSettings index e290b33..eba44ac 100644 --- a/DragonFruit.Kaplan.sln.DotSettings +++ b/DragonFruit.Kaplan.sln.DotSettings @@ -263,6 +263,7 @@ Licensed under Apache-2. Refer to the LICENSE file for more info WARNING WARNING CA + True True True True diff --git a/DragonFruit.Kaplan/App.axaml.cs b/DragonFruit.Kaplan/App.axaml.cs index 9ebc9d8..b93bd34 100644 --- a/DragonFruit.Kaplan/App.axaml.cs +++ b/DragonFruit.Kaplan/App.axaml.cs @@ -43,7 +43,7 @@ public override void Initialize() s.MaxBreadcrumbs = 200; s.MinimumEventLevel = LogLevel.Warning; - s.SetBeforeSend(e => BugReportingEnabled ? e : null); + s.SetBeforeSend(e => BugReportingEnabled && typeof(Program).Assembly.GetName().Version?.Major > 1 ? e : null); }); }); diff --git a/DragonFruit.Kaplan/DragonFruit.Kaplan.csproj b/DragonFruit.Kaplan/DragonFruit.Kaplan.csproj index 2715bfe..d2da0f4 100644 --- a/DragonFruit.Kaplan/DragonFruit.Kaplan.csproj +++ b/DragonFruit.Kaplan/DragonFruit.Kaplan.csproj @@ -5,7 +5,7 @@ Assets\icon.ico Debug;Release;DryRun app.manifest - net7.0-windows10.0.19041 + net8.0-windows10.0.19041 true $(Constants);DRY_RUN @@ -35,17 +35,16 @@ - - - - - - - - + + + + + + + - + diff --git a/DragonFruit.Kaplan/PackageRemover.cs b/DragonFruit.Kaplan/PackageRemover.cs new file mode 100644 index 0000000..094f3b4 --- /dev/null +++ b/DragonFruit.Kaplan/PackageRemover.cs @@ -0,0 +1,159 @@ +// Kaplan Copyright (c) DragonFruit Network +// Licensed under Apache-2. Refer to the LICENSE file for more info + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Windows.ApplicationModel; +using Windows.Management.Deployment; +using DragonFruit.Kaplan.ViewModels.Enums; + +namespace DragonFruit.Kaplan +{ + public class PackageRemover + { + private readonly RemovalOptions _mode; + private readonly PackageManager _manager; + private readonly IReadOnlyList _packages; + + private Package _currentPackage; + private DeploymentProgress _currentPackageRemovalProgress; + + private OperationState _state; + private Task _currentRemovalTask; + + public PackageRemover(PackageInstallationMode mode, PackageManager manager, IReadOnlyList packages) + { + _manager = manager; + _packages = packages; + + _mode = mode switch + { + PackageInstallationMode.Machine => RemovalOptions.RemoveForAllUsers, + PackageInstallationMode.User => RemovalOptions.None, + + _ => throw new ArgumentOutOfRangeException() + }; + } + + /// + /// The total number of packages to be removed + /// + public int TotalPackages => _packages.Count; + + /// + /// The index of the package currently being removed + /// + public int CurrentIndex { get; private set; } + + public OperationState State + { + get => _state; + private set + { + if (_state == value) return; + + _state = value; + StateChanged?.Invoke(this, value); + } + } + + public Package CurrentPackage + { + get => _currentPackage; + private set + { + _currentPackage = value; + CurrentPackageChanged?.Invoke(this, value); + } + } + + public DeploymentProgress CurrentPackageRemovalProgress + { + get => _currentPackageRemovalProgress; + private set + { + _currentPackageRemovalProgress = value; + CurrentPackageRemovalProgressChanged?.Invoke(this, value); + } + } + + public event EventHandler StateChanged; + public event EventHandler CurrentPackageChanged; + public event EventHandler CurrentPackageRemovalProgressChanged; + + /// + /// Iterates through the provided packages, removing them from the system. + /// If previously cancelled, will continue from the last package. + /// + public Task RemovePackagesAsync(CancellationToken cancellation = default) + { + // prevent multiple removal tasks from running at once + if (_currentRemovalTask?.Status is TaskStatus.WaitingForActivation or TaskStatus.WaitingToRun or TaskStatus.Running or TaskStatus.Created) + { + return _currentRemovalTask; + } + + return _currentRemovalTask = RemovePackagesAsyncImpl(cancellation); + } + + private async Task RemovePackagesAsyncImpl(CancellationToken cancellation) + { + var removed = 0; + State = OperationState.Pending; + + for (int i = CurrentIndex; i < _packages.Count; i++) + { + if (cancellation.IsCancellationRequested) + { + State = OperationState.Canceled; + break; + } + + CurrentIndex = i; + CurrentPackage = _packages[i]; + CurrentPackageRemovalProgress = default; + + try + { + State = OperationState.Running; +#if !DRY_RUN + var progress = new Progress(p => CurrentPackageRemovalProgress = p); + await _manager.RemovePackageAsync(_packages[i].Id.FullName, _mode).AsTask(cancellation, progress).ConfigureAwait(false); +#else + // dummy removal progress + for (uint j = 0; j < 50; j++) + { + await Task.Delay(50, cancellation); + CurrentPackageRemovalProgress = new DeploymentProgress(DeploymentProgressState.Processing, j * 2); + } +#endif + removed++; + } + catch (OperationCanceledException) + { + State = OperationState.Canceled; + return removed; + } + catch + { + State = OperationState.Errored; + return removed; + } + } + + State = OperationState.Completed; + return removed; + } + + public enum OperationState + { + Pending, + Running, + Errored, + Completed, + Canceled + } + } +} \ No newline at end of file diff --git a/DragonFruit.Kaplan/ReactiveAppWindow.cs b/DragonFruit.Kaplan/ReactiveAppWindow.cs new file mode 100644 index 0000000..c667cf4 --- /dev/null +++ b/DragonFruit.Kaplan/ReactiveAppWindow.cs @@ -0,0 +1,76 @@ +// Kaplan Copyright (c) DragonFruit Network +// Licensed under Apache-2. Refer to the LICENSE file for more info + +using Avalonia; +using Avalonia.Controls; +using Avalonia.ReactiveUI; +using FluentAvalonia.UI.Windowing; +using ReactiveUI; + +#nullable enable + +namespace DragonFruit.Kaplan +{ + /// + /// A ReactiveUI that implements the interface and will + /// activate your ViewModel automatically if the view model implements . When + /// the DataContext property changes, this class will update the ViewModel property with the new DataContext value, + /// and vice versa. + /// + /// ViewModel type. + /// + /// This is a version of the ReactiveUI class modified to support . + /// See https://github.com/AvaloniaUI/Avalonia/blob/master/src/Avalonia.ReactiveUI/ReactiveWindow.cs for the original implementation. + /// + public class ReactiveAppWindow : AppWindow, IViewFor where TViewModel : class + { + [System.Diagnostics.CodeAnalysis.SuppressMessage("AvaloniaProperty", "AVP1002", Justification = "Generic avalonia property is expected here.")] + public static readonly StyledProperty ViewModelProperty = AvaloniaProperty.Register, TViewModel?>(nameof(ViewModel)); + + /// + /// Initializes a new instance of the class. + /// + public ReactiveAppWindow() + { + // This WhenActivated block calls ViewModel's WhenActivated + // block if the ViewModel implements IActivatableViewModel. + this.WhenActivated(disposables => { }); + } + + /// + /// The ViewModel. + /// + public TViewModel? ViewModel + { + get => GetValue(ViewModelProperty); + set => SetValue(ViewModelProperty, value); + } + + object? IViewFor.ViewModel + { + get => ViewModel; + set => ViewModel = (TViewModel?)value; + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == DataContextProperty) + { + if (ReferenceEquals(change.OldValue, ViewModel) + && change.NewValue is null or TViewModel) + { + SetCurrentValue(ViewModelProperty, change.NewValue); + } + } + else if (change.Property == ViewModelProperty) + { + if (ReferenceEquals(change.OldValue, DataContext)) + { + SetCurrentValue(DataContextProperty, change.NewValue); + } + } + } + } +} \ No newline at end of file diff --git a/DragonFruit.Kaplan/ViewModels/MainWindowViewModel.cs b/DragonFruit.Kaplan/ViewModels/MainWindowViewModel.cs index 590d36c..c8991ce 100644 --- a/DragonFruit.Kaplan/ViewModels/MainWindowViewModel.cs +++ b/DragonFruit.Kaplan/ViewModels/MainWindowViewModel.cs @@ -5,15 +5,17 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using System.Reactive; using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; using System.Security.Principal; +using System.Threading; using System.Threading.Tasks; using System.Windows.Input; using Windows.ApplicationModel; using Windows.Management.Deployment; using Avalonia.Threading; using DragonFruit.Kaplan.ViewModels.Enums; -using DragonFruit.Kaplan.ViewModels.Messages; using DynamicData; using DynamicData.Binding; using Microsoft.Extensions.Logging; @@ -26,7 +28,6 @@ public class MainWindowViewModel : ReactiveObject, IDisposable private readonly ILogger _logger; private readonly WindowsIdentity _currentUser; private readonly PackageManager _packageManager; - private readonly IDisposable _packageRefreshListener; private readonly ObservableAsPropertyHelper> _displayedPackages; @@ -38,20 +39,18 @@ public MainWindowViewModel() { _packageManager = new PackageManager(); _currentUser = WindowsIdentity.GetCurrent(); - _logger = App.GetLogger(); AvailablePackageModes = _currentUser.User != null ? Enum.GetValues() - : new[] {PackageInstallationMode.Machine}; + : [PackageInstallationMode.Machine]; // create observables var packagesSelected = SelectedPackages.ToObservableChangeSet() .ToCollection() .ObserveOn(RxApp.MainThreadScheduler) - .Select(x => x.Any()); + .Select(x => x.Count != 0); - _packageRefreshListener = MessageBus.Current.Listen().ObserveOn(RxApp.TaskpoolScheduler).Subscribe(x => RefreshPackagesImpl()); _displayedPackages = this.WhenAnyValue(x => x.DiscoveredPackages, x => x.SearchQuery, x => x.SelectedPackages) .ObserveOn(RxApp.TaskpoolScheduler) .Select(q => @@ -66,7 +65,10 @@ public MainWindowViewModel() RefreshPackages = ReactiveCommand.CreateFromTask(RefreshPackagesImpl); RemovePackages = ReactiveCommand.Create(RemovePackagesImpl, packagesSelected); ClearSelection = ReactiveCommand.Create(() => SelectedPackages.Clear(), packagesSelected); - ShowAbout = ReactiveCommand.Create(() => MessageBus.Current.SendMessage(new ShowAboutWindowEventArgs())); + ShowAbout = ReactiveCommand.CreateFromTask(() => AboutPageInteraction.Handle(Unit.Default).ToTask()); + + AboutPageInteraction = new Interaction(); + BeginRemovalInteraction = new Interaction(); // auto refresh the package list if the user package filter switch is changed this.WhenValueChanged(x => x.PackageMode).ObserveOn(RxApp.TaskpoolScheduler).Subscribe(_ => RefreshPackages.Execute(null)); @@ -107,6 +109,9 @@ public string SearchQuery public ICommand RemovePackages { get; } public ICommand RefreshPackages { get; } + public Interaction AboutPageInteraction { get; } + public Interaction BeginRemovalInteraction { get; } + private async Task RefreshPackagesImpl() { IEnumerable packages; @@ -147,20 +152,26 @@ await Dispatcher.UIThread.InvokeAsync(() => }); } - private void RemovePackagesImpl() + private async Task RemovePackagesImpl() { - var packages = SelectedPackages.Select(x => x.Package).ToList(); - var args = new UninstallEventArgs(packages, PackageMode); + var remover = new PackageRemover(PackageMode, _packageManager, SelectedPackages.Select(x => x.Package).ToList()); + var cts = new CancellationTokenSource(); - _logger.LogInformation("Starting removal of {x} packages", packages.Count); + using (var model = new RemovalProgressViewModel(remover, cts)) + { + _logger.LogInformation("Starting removal of {x} packages", remover.TotalPackages); + _ = remover.RemovePackagesAsync(cts.Token); + + await BeginRemovalInteraction.Handle(model); + } - MessageBus.Current.SendMessage(args); + // reload packages after interaction ends + RefreshPackages.Execute(null); } public void Dispose() { _displayedPackages?.Dispose(); - _packageRefreshListener?.Dispose(); } } } \ No newline at end of file diff --git a/DragonFruit.Kaplan/ViewModels/Messages/PackageRefreshEventArgs.cs b/DragonFruit.Kaplan/ViewModels/Messages/PackageRefreshEventArgs.cs deleted file mode 100644 index 7b48bf0..0000000 --- a/DragonFruit.Kaplan/ViewModels/Messages/PackageRefreshEventArgs.cs +++ /dev/null @@ -1,9 +0,0 @@ -// Kaplan Copyright (c) DragonFruit Network -// Licensed under Apache-2. Refer to the LICENSE file for more info - -namespace DragonFruit.Kaplan.ViewModels.Messages -{ - public class PackageRefreshEventArgs - { - } -} \ No newline at end of file diff --git a/DragonFruit.Kaplan/ViewModels/Messages/ShowAboutWindowEventArgs.cs b/DragonFruit.Kaplan/ViewModels/Messages/ShowAboutWindowEventArgs.cs deleted file mode 100644 index 53d2d58..0000000 --- a/DragonFruit.Kaplan/ViewModels/Messages/ShowAboutWindowEventArgs.cs +++ /dev/null @@ -1,9 +0,0 @@ -// Kaplan Copyright (c) DragonFruit Network -// Licensed under Apache-2. Refer to the LICENSE file for more info - -namespace DragonFruit.Kaplan.ViewModels.Messages -{ - public class ShowAboutWindowEventArgs - { - } -} \ No newline at end of file diff --git a/DragonFruit.Kaplan/ViewModels/Messages/UninstallEventArgs.cs b/DragonFruit.Kaplan/ViewModels/Messages/UninstallEventArgs.cs deleted file mode 100644 index 3bbd91d..0000000 --- a/DragonFruit.Kaplan/ViewModels/Messages/UninstallEventArgs.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Kaplan Copyright (c) DragonFruit Network -// Licensed under Apache-2. Refer to the LICENSE file for more info - -using System.Collections.Generic; -using Windows.ApplicationModel; -using DragonFruit.Kaplan.ViewModels.Enums; - -namespace DragonFruit.Kaplan.ViewModels.Messages -{ - public class UninstallEventArgs - { - public UninstallEventArgs(IEnumerable packages, PackageInstallationMode mode) - { - Packages = packages; - Mode = mode; - } - - public IEnumerable Packages { get; } - public PackageInstallationMode Mode { get; } - } -} \ No newline at end of file diff --git a/DragonFruit.Kaplan/ViewModels/PackageRemovalTask.cs b/DragonFruit.Kaplan/ViewModels/PackageRemovalTask.cs deleted file mode 100644 index 32b72fa..0000000 --- a/DragonFruit.Kaplan/ViewModels/PackageRemovalTask.cs +++ /dev/null @@ -1,59 +0,0 @@ -// Kaplan Copyright (c) DragonFruit Network -// Licensed under Apache-2. Refer to the LICENSE file for more info - -using System; -using System.Reactive.Linq; -using System.Threading; -using System.Threading.Tasks; -using Windows.ApplicationModel; -using Windows.Management.Deployment; -using DragonFruit.Kaplan.ViewModels.Enums; -using ReactiveUI; - -namespace DragonFruit.Kaplan.ViewModels -{ - public class PackageRemovalTask : ReactiveObject - { - private readonly ObservableAsPropertyHelper _statusString; - private readonly PackageInstallationMode _mode; - private readonly PackageManager _manager; - - private DeploymentProgress? _progress; - - public PackageRemovalTask(PackageManager manager, Package package, PackageInstallationMode mode) - { - Package = new PackageViewModel(package); - - _mode = mode; - _manager = manager; - _statusString = this.WhenAnyValue(x => x.Progress) - .Select(x => x?.state switch - { - DeploymentProgressState.Queued => $"Removing {Package.Name}: Pending", - DeploymentProgressState.Processing when x.Value.percentage == 100 => $"Removing {Package.Name} Complete", - DeploymentProgressState.Processing when x.Value.percentage > 0 => $"Removing {Package.Name}: {x.Value.percentage}% Complete", - - _ => $"Removing {Package.Name}" - }) - .ToProperty(this, x => x.Status); - } - - private DeploymentProgress? Progress - { - get => _progress; - set => this.RaiseAndSetIfChanged(ref _progress, value); - } - - public PackageViewModel Package { get; } - - public string Status => _statusString.Value; - - public async Task RemoveAsync(CancellationToken cancellation = default) - { - var progressCallback = new Progress(p => Progress = p); - var options = _mode == PackageInstallationMode.Machine ? RemovalOptions.RemoveForAllUsers : RemovalOptions.None; - - await _manager.RemovePackageAsync(Package.Package.Id.FullName, options).AsTask(cancellation, progressCallback).ConfigureAwait(false); - } - } -} \ No newline at end of file diff --git a/DragonFruit.Kaplan/ViewModels/RemovalProgressViewModel.cs b/DragonFruit.Kaplan/ViewModels/RemovalProgressViewModel.cs index c884f0d..2ce6dc8 100644 --- a/DragonFruit.Kaplan/ViewModels/RemovalProgressViewModel.cs +++ b/DragonFruit.Kaplan/ViewModels/RemovalProgressViewModel.cs @@ -2,9 +2,8 @@ // Licensed under Apache-2. Refer to the LICENSE file for more info using System; -using System.Collections.Generic; using System.ComponentModel; -using System.Linq; +using System.Reactive; using System.Reactive.Linq; using System.Threading; using System.Threading.Tasks; @@ -12,76 +11,95 @@ using Windows.ApplicationModel; using Windows.Management.Deployment; using Avalonia.Media; -using DragonFruit.Kaplan.ViewModels.Enums; -using DragonFruit.Kaplan.ViewModels.Messages; -using DynamicData.Binding; -using Microsoft.Extensions.Logging; -using Nito.AsyncEx; using ReactiveUI; namespace DragonFruit.Kaplan.ViewModels { - public class RemovalProgressViewModel : ReactiveObject, IHandlesClosingEvent, IExecutesTaskPostLoad, ICanCloseWindow + public class RemovalProgressViewModel : ReactiveObject, IHandlesClosingEvent, IExecutesTaskPostLoad, ICanCloseWindow, IDisposable { - private readonly ILogger _logger = App.GetLogger(); - private readonly AsyncLock _lock = new(); - private readonly PackageInstallationMode _mode; - private readonly CancellationTokenSource _cancellation = new(); + private readonly PackageRemover _remover; + private readonly CancellationTokenSource _cancellation; + + private readonly ObservableAsPropertyHelper _progressValue; + private readonly ObservableAsPropertyHelper _progressText; private readonly ObservableAsPropertyHelper _progressColor; - private OperationState _status; - private int _currentPackageNumber; - private PackageRemovalTask _current; + private readonly ObservableAsPropertyHelper _currentPackage; + private readonly ObservableAsPropertyHelper _currentState; - public RemovalProgressViewModel(IEnumerable packages, PackageInstallationMode mode) + public RemovalProgressViewModel(PackageRemover remover, CancellationTokenSource cts = null) { - _mode = mode; - _status = OperationState.Pending; - _progressColor = this.WhenValueChanged(x => x.Status).Select(x => x switch - { - OperationState.Pending => Brushes.Gray, - OperationState.Running => Brushes.DodgerBlue, - OperationState.Errored => Brushes.Red, - OperationState.Completed => Brushes.Green, - OperationState.Canceled => Brushes.DarkGray, + _remover = remover; + _cancellation = cts ?? new CancellationTokenSource(); + + var currentPackage = Observable.FromEventPattern, Package>(h => remover.CurrentPackageChanged += h, h => remover.CurrentPackageChanged -= h) + .Select(static x => x.EventArgs); - _ => throw new ArgumentOutOfRangeException(nameof(x), x, null) - }).ToProperty(this, x => x.ProgressColor); + var currentPackageProgress = Observable.FromEventPattern, DeploymentProgress>(h => remover.CurrentPackageRemovalProgressChanged += h, h => remover.CurrentPackageRemovalProgressChanged -= h) + .StartWith(new EventPattern(null, remover.CurrentPackageRemovalProgress)) + .Select(static x => x.EventArgs); - var canCancelOperation = this.WhenAnyValue(x => x.CancellationRequested, x => x.Status) + _currentPackage = currentPackage + .Select(static x => new PackageViewModel(x)) .ObserveOn(RxApp.MainThreadScheduler) - .Select(x => !x.Item1 && x.Item2 == OperationState.Running); + .ToProperty(this, x => x.Current); - Packages = packages.ToList(); - RequestCancellation = ReactiveCommand.Create(CancelOperation, canCancelOperation); - } + var state = Observable.FromEventPattern, PackageRemover.OperationState>(h => remover.StateChanged += h, h => remover.StateChanged -= h) + .StartWith(new EventPattern(null, remover.State)) + .Select(static x => x.EventArgs); - public event Action CloseRequested; + _currentState = state.ObserveOn(RxApp.MainThreadScheduler).ToProperty(this, x => x.CurrentState); + _progressValue = currentPackage.CombineLatest(currentPackageProgress) + // don't add to index, we only want processed packages up until this point + .Select(x => + { + var singlePackagePercentage = 1f / remover.TotalPackages; + return remover.CurrentIndex * singlePackagePercentage + x.Second.percentage / 100f * singlePackagePercentage; + }) + .ObserveOn(RxApp.MainThreadScheduler) + .ToProperty(this, x => x.ProgressValue); - public PackageRemovalTask Current - { - get => _current; - private set => this.RaiseAndSetIfChanged(ref _current, value); - } + _progressColor = state.Select(static x => x switch + { + PackageRemover.OperationState.Pending => Brushes.Gray, + PackageRemover.OperationState.Running => Brushes.DodgerBlue, + PackageRemover.OperationState.Errored => Brushes.Red, + PackageRemover.OperationState.Completed => Brushes.Green, + PackageRemover.OperationState.Canceled => Brushes.DarkGray, + + _ => throw new ArgumentOutOfRangeException(nameof(x), x, null) + }) + .ObserveOn(RxApp.MainThreadScheduler) + .ToProperty(this, x => x.ProgressColor); - public int CurrentPackageNumber - { - get => _currentPackageNumber; - private set => this.RaiseAndSetIfChanged(ref _currentPackageNumber, value); - } + _progressText = currentPackageProgress.CombineLatest(currentPackage).Select(static x => x.First.state switch + { + DeploymentProgressState.Queued => $"Removing {x.Second.DisplayName}: Pending", + DeploymentProgressState.Processing when x.First.percentage == 100 => $"Removing {x.Second.DisplayName} Complete", + DeploymentProgressState.Processing when x.First.percentage > 0 => $"Removing {x.Second.DisplayName} ({x.First.percentage}% Complete)", - public OperationState Status - { - get => _status; - private set => this.RaiseAndSetIfChanged(ref _status, value); + _ => $"Removing {x.Second.DisplayName}" + }) + .ToProperty(this, x => x.ProgressText); + + var canCancelOperation = this.WhenAnyValue(x => x.CancellationRequested) + .CombineLatest(state) + .ObserveOn(RxApp.MainThreadScheduler) + .Select(static x => !x.Item1 && x.Item2 == PackageRemover.OperationState.Running); + + RequestCancellation = ReactiveCommand.Create(CancelOperation, canCancelOperation); } - public bool CancellationRequested => _cancellation.IsCancellationRequested; + public event Action CloseRequested; - public ISolidColorBrush ProgressColor => _progressColor.Value; + public PackageViewModel Current => _currentPackage.Value; + public PackageRemover.OperationState CurrentState => _currentState.Value; - public IReadOnlyList Packages { get; } + public float ProgressValue => _progressValue.Value; + public string ProgressText => _progressText.Value; + public ISolidColorBrush ProgressColor => _progressColor.Value; + public bool CancellationRequested => _cancellation.IsCancellationRequested; public ICommand RequestCancellation { get; } private void CancelOperation() @@ -92,70 +110,25 @@ private void CancelOperation() void IHandlesClosingEvent.OnClose(CancelEventArgs args) { - args.Cancel = Status == OperationState.Running; + args.Cancel = _remover.State == PackageRemover.OperationState.Running; } async Task IExecutesTaskPostLoad.Perform() { - _logger.LogInformation("Removal process started"); - _logger.LogDebug("Waiting for lock access"); + // waits for the process to end + await _remover.RemovePackagesAsync(_cancellation.Token); - using (await _lock.LockAsync(_cancellation.Token).ConfigureAwait(false)) + // auto close if completed and not cancelled + if (!_cancellation.IsCancellationRequested && _remover.State == PackageRemover.OperationState.Completed) { - Status = OperationState.Running; - - var manager = new PackageManager(); - - for (var i = 0; i < Packages.Count; i++) - { - if (CancellationRequested) - { - break; - } - - CurrentPackageNumber = i + 1; - Current = new PackageRemovalTask(manager, Packages[i], _mode); - - try - { - _logger.LogInformation("Starting removal of {packageId}", Current.Package.Id); - -#if DRY_RUN - await Task.Delay(1000, _cancellation.Token).ConfigureAwait(false); -#else - await Current.RemoveAsync(_cancellation.Token).ConfigureAwait(false); -#endif - } - catch (OperationCanceledException) - { - _logger.LogInformation("Package removal cancelled by user (stopped at {packageId})", Current.Package.Id); - } - catch (Exception ex) - { - Status = OperationState.Errored; - _logger.LogError(ex, "Package removal failed: {err}", ex.Message); - - break; - } - } + await Task.Delay(1000).ConfigureAwait(false); + CloseRequested?.Invoke(); } - - Status = CancellationRequested ? OperationState.Canceled : OperationState.Completed; - MessageBus.Current.SendMessage(new PackageRefreshEventArgs()); - - _logger.LogInformation("Package removal process ended: {state}", Status); - - await Task.Delay(1000).ConfigureAwait(false); - CloseRequested?.Invoke(); } - } - public enum OperationState - { - Pending, - Running, - Errored, - Completed, - Canceled + public void Dispose() + { + _cancellation?.Dispose(); + } } } \ No newline at end of file diff --git a/DragonFruit.Kaplan/Views/About.axaml b/DragonFruit.Kaplan/Views/About.axaml index 4eb48f7..6640c6f 100644 --- a/DragonFruit.Kaplan/Views/About.axaml +++ b/DragonFruit.Kaplan/Views/About.axaml @@ -21,7 +21,7 @@ - Copyright © 2023 DragonFruit Network + Copyright © 2024 DragonFruit Network // Licensed under Apache-2. Refer to the LICENSE file for more info -using System; -using System.Collections.Generic; +using System.Reactive; +using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; using DragonFruit.Kaplan.ViewModels; -using DragonFruit.Kaplan.ViewModels.Messages; using FluentAvalonia.UI.Windowing; using ReactiveUI; namespace DragonFruit.Kaplan.Views { - public partial class MainWindow : AppWindow + public partial class MainWindow : ReactiveAppWindow { - private readonly IEnumerable _messageListeners; - public MainWindow() { InitializeComponent(); @@ -25,22 +22,26 @@ public MainWindow() TitleBar.ExtendsContentIntoTitleBar = true; TitleBar.TitleBarHitTestType = TitleBarHitTestType.Complex; - _messageListeners = new[] - { - MessageBus.Current.Listen().Subscribe(OpenProgressDialog), - MessageBus.Current.Listen().Subscribe(_ => new About().ShowDialog(this)) - }; + this.WhenActivated(action => action(ViewModel!.AboutPageInteraction.RegisterHandler(OpenAboutPage))); + this.WhenActivated(action => action(ViewModel!.BeginRemovalInteraction.RegisterHandler(OpenProgressDialog))); + } + + private async Task OpenAboutPage(InteractionContext ctx) + { + await new About().ShowDialog(this).ConfigureAwait(false); + + ctx.SetOutput(Unit.Default); } - private async void OpenProgressDialog(UninstallEventArgs args) + private async Task OpenProgressDialog(InteractionContext ctx) { var window = new RemovalProgress { - DataContext = new RemovalProgressViewModel(args.Packages, args.Mode) + DataContext = ctx.Input }; - await window.ShowDialog(this).ConfigureAwait(false); - MessageBus.Current.SendMessage(new PackageRefreshEventArgs()); + await window.ShowDialog(this); + ctx.SetOutput(ctx.Input.CurrentState); } private void PackageListPropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e) @@ -56,15 +57,5 @@ private void PackageListPropertyChanged(object sender, AvaloniaPropertyChangedEv box.Scroll.Offset = Vector.Zero; } } - - protected override void OnClosed(EventArgs e) - { - base.OnClosed(e); - - foreach (var messageListener in _messageListeners) - { - messageListener.Dispose(); - } - } } } \ No newline at end of file diff --git a/DragonFruit.Kaplan/Views/RemovalProgress.axaml b/DragonFruit.Kaplan/Views/RemovalProgress.axaml index 8dd3c6a..052c894 100644 --- a/DragonFruit.Kaplan/Views/RemovalProgress.axaml +++ b/DragonFruit.Kaplan/Views/RemovalProgress.axaml @@ -6,7 +6,7 @@ mc:Ignorable="d" x:Class="DragonFruit.Kaplan.Views.RemovalProgress" x:DataType="vm:RemovalProgressViewModel" - Width="500" + Width="650" Height="100" CanResize="False" Title="Remove Packages" @@ -27,22 +27,21 @@ - + - + + Minimum="0" Maximum="1" Background="LightGray" + Value="{Binding ProgressValue}" + Foreground="{Binding ProgressColor}" /> + TextTrimming="WordEllipsis" Text="{Binding ProgressText}" />