From 35d5ef174322a071807f1c2cd7907a7d136e77d6 Mon Sep 17 00:00:00 2001 From: Robert Date: Sat, 25 May 2024 16:45:59 +0200 Subject: [PATCH 1/5] Startup wizard - Show device providers from all plugins --- .../StartupWizard/StartupWizardViewModel.cs | 20 +- .../WizardPluginFeatureView.axaml | 79 +++++++ .../WizardPluginFeatureView.axaml.cs | 22 ++ .../WizardPluginFeatureViewModel.cs | 208 ++++++++++++++++++ 4 files changed, 321 insertions(+), 8 deletions(-) create mode 100644 src/Artemis.UI/Screens/StartupWizard/WizardPluginFeatureView.axaml create mode 100644 src/Artemis.UI/Screens/StartupWizard/WizardPluginFeatureView.axaml.cs create mode 100644 src/Artemis.UI/Screens/StartupWizard/WizardPluginFeatureViewModel.cs diff --git a/src/Artemis.UI/Screens/StartupWizard/StartupWizardViewModel.cs b/src/Artemis.UI/Screens/StartupWizard/StartupWizardViewModel.cs index 588338b35..7366bbfaf 100644 --- a/src/Artemis.UI/Screens/StartupWizard/StartupWizardViewModel.cs +++ b/src/Artemis.UI/Screens/StartupWizard/StartupWizardViewModel.cs @@ -26,6 +26,8 @@ public partial class StartupWizardViewModel : DialogViewModelBase private readonly ISettingsService _settingsService; private readonly IWindowService _windowService; private readonly IDeviceService _deviceService; + private readonly Func _getPluginFeatureViewModel; + [Notify] private int _currentStep; [Notify] private bool _showContinue; [Notify] private bool _showFinish; @@ -36,12 +38,13 @@ public StartupWizardViewModel(IContainer container, IPluginManagementService pluginManagementService, IWindowService windowService, IDeviceService deviceService, - ISettingsVmFactory settingsVmFactory, - LayoutFinderViewModel layoutFinderViewModel) + LayoutFinderViewModel layoutFinderViewModel, + Func getPluginFeatureViewModel) { _settingsService = settingsService; _windowService = windowService; _deviceService = deviceService; + _getPluginFeatureViewModel = getPluginFeatureViewModel; _autoRunProvider = container.Resolve(IfUnresolved.ReturnDefault); _protocolProvider = container.Resolve(IfUnresolved.ReturnDefault); @@ -51,11 +54,12 @@ public StartupWizardViewModel(IContainer container, SelectLayout = ReactiveCommand.Create(ExecuteSelectLayout); Version = $"Version {Constants.CurrentVersion}"; - // Take all compatible plugins that have an always-enabled device provider - DeviceProviders = new ObservableCollection(pluginManagementService.GetAllPlugins() - .Where(p => p.Info.IsCompatible && p.Features.Any(f => f.AlwaysEnabled && f.FeatureType.IsAssignableTo(typeof(DeviceProvider)))) - .OrderBy(p => p.Info.Name) - .Select(p => settingsVmFactory.PluginViewModel(p, ReactiveCommand.Create(() => new Unit())))); + // Take all compatible device providers and create a view model for them + DeviceProviders = new ObservableCollection(pluginManagementService.GetAllPlugins() + .Where(p => p.Info.IsCompatible) + .SelectMany(p => p.Features.Where(f => f.FeatureType.IsAssignableTo(typeof(DeviceProvider)))) + .OrderBy(f => f.Name) + .Select(f => _getPluginFeatureViewModel(f))); LayoutFinderViewModel = layoutFinderViewModel; CurrentStep = 1; @@ -84,7 +88,7 @@ public StartupWizardViewModel(IContainer container, public ReactiveCommand SelectLayout { get; } public string Version { get; } - public ObservableCollection DeviceProviders { get; } + public ObservableCollection DeviceProviders { get; } public LayoutFinderViewModel LayoutFinderViewModel { get; } public bool IsAutoRunSupported => _autoRunProvider != null; diff --git a/src/Artemis.UI/Screens/StartupWizard/WizardPluginFeatureView.axaml b/src/Artemis.UI/Screens/StartupWizard/WizardPluginFeatureView.axaml new file mode 100644 index 000000000..c2dde7ecb --- /dev/null +++ b/src/Artemis.UI/Screens/StartupWizard/WizardPluginFeatureView.axaml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Enable feature + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/StartupWizard/WizardPluginFeatureView.axaml.cs b/src/Artemis.UI/Screens/StartupWizard/WizardPluginFeatureView.axaml.cs new file mode 100644 index 000000000..5ff8c58e8 --- /dev/null +++ b/src/Artemis.UI/Screens/StartupWizard/WizardPluginFeatureView.axaml.cs @@ -0,0 +1,22 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; +using Avalonia.Threading; + +namespace Artemis.UI.Screens.StartupWizard; + +public partial class WizardPluginFeatureView : ReactiveUserControl +{ + public WizardPluginFeatureView() + { + InitializeComponent(); + EnabledToggle.Click += EnabledToggleOnClick; + } + + private void EnabledToggleOnClick(object? sender, RoutedEventArgs e) + { + Dispatcher.UIThread.Post(() => ViewModel?.UpdateEnabled(!ViewModel.IsEnabled)); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/StartupWizard/WizardPluginFeatureViewModel.cs b/src/Artemis.UI/Screens/StartupWizard/WizardPluginFeatureViewModel.cs new file mode 100644 index 000000000..7245809ff --- /dev/null +++ b/src/Artemis.UI/Screens/StartupWizard/WizardPluginFeatureViewModel.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reactive; +using System.Reactive.Disposables; +using System.Threading.Tasks; +using Artemis.Core; +using Artemis.Core.Services; +using Artemis.UI.Exceptions; +using Artemis.UI.Screens.Plugins; +using Artemis.UI.Shared; +using Artemis.UI.Shared.Services; +using Artemis.UI.Shared.Services.Builders; +using Avalonia.Controls; +using Avalonia.Threading; +using Material.Icons; +using PropertyChanged.SourceGenerator; +using ReactiveUI; + +namespace Artemis.UI.Screens.StartupWizard; + +public partial class WizardPluginFeatureViewModel : ActivatableViewModelBase +{ + private readonly ICoreService _coreService; + private readonly IPluginManagementService _pluginManagementService; + private readonly IWindowService _windowService; + private Window? _settingsWindow; + [Notify] private bool _canInstallPrerequisites; + [Notify] private bool _canRemovePrerequisites; + [Notify] private bool _enabling; + + public WizardPluginFeatureViewModel(PluginFeatureInfo pluginFeature, ICoreService coreService, IWindowService windowService, IPluginManagementService pluginManagementService) + { + PluginFeature = pluginFeature; + Plugin = pluginFeature.Plugin; + + _coreService = coreService; + _windowService = windowService; + _pluginManagementService = pluginManagementService; + + Platforms = new ObservableCollection(); + if (Plugin.Info.Platforms != null) + { + if (Plugin.Info.Platforms.Value.HasFlag(PluginPlatform.Windows)) + Platforms.Add(new PluginPlatformViewModel("Windows", MaterialIconKind.MicrosoftWindows)); + if (Plugin.Info.Platforms.Value.HasFlag(PluginPlatform.Linux)) + Platforms.Add(new PluginPlatformViewModel("Linux", MaterialIconKind.Linux)); + if (Plugin.Info.Platforms.Value.HasFlag(PluginPlatform.OSX)) + Platforms.Add(new PluginPlatformViewModel("OSX", MaterialIconKind.Apple)); + } + + OpenSettings = ReactiveCommand.Create(ExecuteOpenSettings, this.WhenAnyValue(vm => vm.IsEnabled, e => e && Plugin.ConfigurationDialog != null)); + + this.WhenActivated(d => + { + pluginManagementService.PluginFeatureEnabled += PluginManagementServiceOnPluginFeatureChanged; + pluginManagementService.PluginFeatureDisabled += PluginManagementServiceOnPluginFeatureChanged; + + Disposable.Create(() => + { + pluginManagementService.PluginFeatureEnabled -= PluginManagementServiceOnPluginFeatureChanged; + pluginManagementService.PluginFeatureDisabled -= PluginManagementServiceOnPluginFeatureChanged; + _settingsWindow?.Close(); + }).DisposeWith(d); + }); + } + + public ReactiveCommand OpenSettings { get; } + + public ObservableCollection Platforms { get; } + + public Plugin Plugin { get; } + public PluginFeatureInfo PluginFeature { get; } + public bool IsEnabled => PluginFeature.Instance != null && PluginFeature.Instance.IsEnabled; + + public async Task UpdateEnabled(bool enable) + { + if (Enabling) + return; + + if (!enable) + { + try + { + if (PluginFeature.AlwaysEnabled) + await Task.Run(() => _pluginManagementService.DisablePlugin(Plugin, true)); + else if (PluginFeature.Instance != null) + await Task.Run(() => _pluginManagementService.DisablePluginFeature(PluginFeature.Instance, true)); + } + catch (Exception e) + { + await ShowUpdateEnableFailure(enable, e); + } + finally + { + this.RaisePropertyChanged(nameof(IsEnabled)); + } + + return; + } + + try + { + Enabling = true; + if (Plugin.Info.RequiresAdmin && !_coreService.IsElevated) + { + bool confirmed = await _windowService.ShowConfirmContentDialog("Enable feature", "This feature requires admin rights, are you sure you want to enable it?"); + if (!confirmed) + return; + } + + // Check if all prerequisites are met async + List subjects = new() {Plugin.Info}; + subjects.AddRange(Plugin.Features.Where(f => f.AlwaysEnabled || f.EnabledInStorage)); + + if (subjects.Any(s => !s.ArePrerequisitesMet())) + { + await PluginPrerequisitesInstallDialogViewModel.Show(_windowService, subjects); + if (!subjects.All(s => s.ArePrerequisitesMet())) + return; + } + + await Task.Run(() => + { + if (!Plugin.IsEnabled) + _pluginManagementService.EnablePlugin(Plugin, true, true); + if (PluginFeature.Instance != null && !PluginFeature.Instance.IsEnabled) + _pluginManagementService.EnablePluginFeature(PluginFeature.Instance, true); + }); + } + catch (Exception e) + { + await ShowUpdateEnableFailure(enable, e); + } + finally + { + Enabling = false; + this.RaisePropertyChanged(nameof(IsEnabled)); + } + } + + private void ExecuteOpenSettings() + { + if (Plugin.ConfigurationDialog == null) + return; + + if (_settingsWindow != null) + { + _settingsWindow.WindowState = WindowState.Normal; + _settingsWindow.Activate(); + return; + } + + try + { + if (Plugin.Resolve(Plugin.ConfigurationDialog.Type) is not PluginConfigurationViewModel viewModel) + throw new ArtemisUIException($"The type of a plugin configuration dialog must inherit {nameof(PluginConfigurationViewModel)}"); + + _settingsWindow = _windowService.ShowWindow(new PluginSettingsWindowViewModel(viewModel)); + _settingsWindow.Closed += (_, _) => _settingsWindow = null; + } + catch (Exception e) + { + _windowService.ShowExceptionDialog("An exception occured while trying to show the plugin's settings window", e); + throw; + } + } + + private async Task ShowUpdateEnableFailure(bool enable, Exception e) + { + string action = enable ? "enable" : "disable"; + ContentDialogBuilder builder = _windowService.CreateContentDialog() + .WithTitle($"Failed to {action} plugin {Plugin.Info.Name}") + .WithContent(e.Message) + .HavingPrimaryButton(b => b.WithText("View logs").WithAction(ShowLogsFolder)); + // If available, add a secondary button pointing to the support page + if (Plugin.Info.HelpPage != null) + builder = builder.HavingSecondaryButton(b => b.WithText("Open support page").WithAction(() => Utilities.OpenUrl(Plugin.Info.HelpPage.ToString()))); + + await builder.ShowAsync(); + } + + private void ShowLogsFolder() + { + try + { + Utilities.OpenFolder(Constants.LogsFolder); + } + catch (Exception e) + { + _windowService.ShowExceptionDialog("Welp, we couldn\'t open the logs folder for you", e); + } + } + + private void PluginManagementServiceOnPluginFeatureChanged(object? sender, PluginFeatureEventArgs e) + { + if (e.PluginFeature.Info != PluginFeature) + return; + + Dispatcher.UIThread.Post(() => + { + this.RaisePropertyChanged(nameof(IsEnabled)); + if (!IsEnabled) + _settingsWindow?.Close(); + }); + } +} \ No newline at end of file From 966ca4733557b01c35904ef1954605d4cc66271f Mon Sep 17 00:00:00 2001 From: Robert Date: Sat, 22 Jun 2024 22:47:02 +0200 Subject: [PATCH 2/5] Meta - Updated packages --- src/Directory.Packages.props | 59 +++++++++++++++++++----------------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 3eb203ff3..766748e45 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -5,58 +5,61 @@ - + - - - + + + - + - + - + - + - + - + - - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + - + - - - + + + - - - - - + + + + + - - - - - - - + + + + + + + \ No newline at end of file From 86f78940b156a129114ab271a40fe8c4999b90d0 Mon Sep 17 00:00:00 2001 From: Robert Date: Tue, 25 Jun 2024 21:29:52 +0200 Subject: [PATCH 3/5] Workshop - Manage entries after installing them Workshop - Auto-enable plugins after installing them Workshop - Show the latest release above the details page --- .../Routing/Router/IRouter.cs | 2 +- .../Routing/Router/NavigationArguments.cs | 8 +- .../Routing/Router/Router.cs | 6 +- .../Routing/Router/RouterNavigationOptions.cs | 5 + .../Screens/Plugins/PluginViewModel.cs | 19 +- .../EntryReleases/EntryReleaseInfoView.axaml | 111 +++++++++++ .../EntryReleaseInfoView.axaml.cs | 11 ++ .../EntryReleaseInfoViewModel.cs | 173 ++++++++++++++++++ .../EntryReleases/EntryReleaseView.axaml | 100 +--------- .../EntryReleases/EntryReleaseViewModel.cs | 83 +-------- .../Layout/LayoutDescriptionView.axaml | 4 + .../Layout/LayoutDescriptionViewModel.cs | 9 + .../Workshop/Layout/LayoutDetailsViewModel.cs | 5 +- .../Plugins/PluginDescriptionView.axaml | 4 + .../Plugins/PluginDescriptionViewModel.cs | 7 +- .../Plugins/PluginDetailsViewModel.cs | 1 + .../Workshop/Plugins/PluginManageViewModel.cs | 6 + .../Profile/ProfileDescriptionView.axaml | 4 + .../Profile/ProfileDescriptionViewModel.cs | 7 +- .../Profile/ProfileDetailsViewModel.cs | 1 + .../Queries/Fragments.graphql | 11 ++ .../Queries/GetEntryById.graphql | 8 +- .../Queries/GetReleaseById.graphql | 6 +- 23 files changed, 401 insertions(+), 190 deletions(-) create mode 100644 src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseInfoView.axaml create mode 100644 src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseInfoView.axaml.cs create mode 100644 src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseInfoViewModel.cs diff --git a/src/Artemis.UI.Shared/Routing/Router/IRouter.cs b/src/Artemis.UI.Shared/Routing/Router/IRouter.cs index 2901fdbaf..58d915da4 100644 --- a/src/Artemis.UI.Shared/Routing/Router/IRouter.cs +++ b/src/Artemis.UI.Shared/Routing/Router/IRouter.cs @@ -50,7 +50,7 @@ public interface IRouter /// Asynchronously navigates upwards to the parent route. /// /// - Task GoUp(); + Task GoUp(RouterNavigationOptions? options = null); /// /// Clears the navigation history. diff --git a/src/Artemis.UI.Shared/Routing/Router/NavigationArguments.cs b/src/Artemis.UI.Shared/Routing/Router/NavigationArguments.cs index 5c26142e9..3b732d987 100644 --- a/src/Artemis.UI.Shared/Routing/Router/NavigationArguments.cs +++ b/src/Artemis.UI.Shared/Routing/Router/NavigationArguments.cs @@ -8,9 +8,10 @@ namespace Artemis.UI.Shared.Routing; /// public class NavigationArguments { - internal NavigationArguments(IRouter router, string path, object[] routeParameters) + internal NavigationArguments(IRouter router, RouterNavigationOptions options, string path, object[] routeParameters) { Router = router; + Options = options; Path = path; RouteParameters = routeParameters; SegmentParameters = Array.Empty(); @@ -21,6 +22,11 @@ internal NavigationArguments(IRouter router, string path, object[] routeParamete /// public IRouter Router { get; } + /// + /// Gets the options that are being used for this navigation. + /// + public RouterNavigationOptions Options { get; } + /// /// Gets the path of the route that is being navigated to. /// diff --git a/src/Artemis.UI.Shared/Routing/Router/Router.cs b/src/Artemis.UI.Shared/Routing/Router/Router.cs index 269d51fce..2850b88a3 100644 --- a/src/Artemis.UI.Shared/Routing/Router/Router.cs +++ b/src/Artemis.UI.Shared/Routing/Router/Router.cs @@ -114,7 +114,7 @@ private async Task InternalNavigate(string path, RouterNavigationOptions options return; } - NavigationArguments args = new(this, resolution.Path, resolution.GetAllParameters()); + NavigationArguments args = new(this, options, resolution.Path, resolution.GetAllParameters()); if (!await RequestClose(_root, args)) return; @@ -169,7 +169,7 @@ public async Task GoForward() } /// - public async Task GoUp() + public async Task GoUp(RouterNavigationOptions? options = null) { string? currentPath = _currentRouteSubject.Value; @@ -180,7 +180,7 @@ public async Task GoUp() RouteResolution resolution = Resolve(parentPath); if (resolution.Success) { - await Navigate(parentPath, new RouterNavigationOptions {AddToHistory = false}); + await Navigate(parentPath, options); return true; } diff --git a/src/Artemis.UI.Shared/Routing/Router/RouterNavigationOptions.cs b/src/Artemis.UI.Shared/Routing/Router/RouterNavigationOptions.cs index 273f85c2c..e0a8bbd18 100644 --- a/src/Artemis.UI.Shared/Routing/Router/RouterNavigationOptions.cs +++ b/src/Artemis.UI.Shared/Routing/Router/RouterNavigationOptions.cs @@ -35,6 +35,11 @@ public class RouterNavigationOptions /// public bool EnableLogging { get; set; } = true; + /// + /// Gets or sets any additional arguments to pass to the screen. + /// + public object? AdditionalArguments { get; set; } + /// /// Determines whether the given two paths are considered equal using these navigation options. /// diff --git a/src/Artemis.UI/Screens/Plugins/PluginViewModel.cs b/src/Artemis.UI/Screens/Plugins/PluginViewModel.cs index 1a0333aca..4cecb945e 100644 --- a/src/Artemis.UI/Screens/Plugins/PluginViewModel.cs +++ b/src/Artemis.UI/Screens/Plugins/PluginViewModel.cs @@ -249,7 +249,7 @@ private async Task ExecuteRemove() return; // If the plugin or any of its features has uninstall actions, offer to run these - await ExecuteRemovePrerequisites(true); + await ExecuteRemovePrerequisites(true); try { @@ -264,7 +264,7 @@ private async Task ExecuteRemove() InstalledEntry? entry = _workshopService.GetInstalledEntries().FirstOrDefault(e => e.TryGetMetadata("PluginId", out Guid pluginId) && pluginId == Plugin.Guid); if (entry != null) _workshopService.RemoveInstalledEntry(entry); - + _notificationService.CreateNotification().WithTitle("Removed plugin.").Show(); } @@ -303,4 +303,19 @@ private void OnPluginToggled(object? sender, EventArgs e) _settingsWindow?.Close(); }); } + + public async Task AutoEnable() + { + if (IsEnabled) + return; + + await UpdateEnabled(true); + + // If enabling failed, don't offer to show the settings + if (!IsEnabled || Plugin.ConfigurationDialog == null) + return; + + if (await _windowService.ShowConfirmContentDialog("Open plugin settings", "This plugin has settings, would you like to view them?", "Yes", "No")) + ExecuteOpenSettings(); + } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseInfoView.axaml b/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseInfoView.axaml new file mode 100644 index 000000000..6487fc19c --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseInfoView.axaml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + Release info + Latest release + + + + + + + + + + + + + + + + + + + + + + + Version + + + + + + Release date + + + + + + File size + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseInfoView.axaml.cs b/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseInfoView.axaml.cs new file mode 100644 index 000000000..f216c9ea7 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseInfoView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.EntryReleases; + +public partial class EntryReleaseInfoView : ReactiveUserControl +{ + public EntryReleaseInfoView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseInfoViewModel.cs b/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseInfoViewModel.cs new file mode 100644 index 000000000..fa1968f6e --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseInfoViewModel.cs @@ -0,0 +1,173 @@ +using System; +using System.ComponentModel; +using System.Linq; +using System.Reactive.Linq; +using System.Threading; +using System.Threading.Tasks; +using Artemis.Core; +using Artemis.Core.Services; +using Artemis.UI.DryIoc.Factories; +using Artemis.UI.Screens.Plugins; +using Artemis.UI.Shared; +using Artemis.UI.Shared.Routing; +using Artemis.UI.Shared.Services; +using Artemis.UI.Shared.Services.Builders; +using Artemis.UI.Shared.Utilities; +using Artemis.WebClient.Workshop; +using Artemis.WebClient.Workshop.Handlers.InstallationHandlers; +using Artemis.WebClient.Workshop.Models; +using Artemis.WebClient.Workshop.Services; +using Humanizer; +using PropertyChanged.SourceGenerator; +using ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.EntryReleases; + +public partial class EntryReleaseInfoViewModel : ActivatableViewModelBase +{ + private readonly IRouter _router; + private readonly INotificationService _notificationService; + private readonly IWindowService _windowService; + private readonly IWorkshopService _workshopService; + private readonly IPluginManagementService _pluginManagementService; + private readonly EntryInstallationHandlerFactory _factory; + private readonly ISettingsVmFactory _settingsVmFactory; + private readonly Progress _progress = new(); + private readonly ObservableAsPropertyHelper _isCurrentVersion; + + [Notify] private IReleaseDetails? _release; + [Notify] private float _installProgress; + [Notify] private bool _installationInProgress; + [Notify] private bool _inDetailsScreen; + + private CancellationTokenSource? _cts; + + public EntryReleaseInfoViewModel(IRouter router, + INotificationService notificationService, + IWindowService windowService, + IWorkshopService workshopService, + IPluginManagementService pluginManagementService, + EntryInstallationHandlerFactory factory, + ISettingsVmFactory settingsVmFactory) + { + _router = router; + _notificationService = notificationService; + _windowService = windowService; + _workshopService = workshopService; + _pluginManagementService = pluginManagementService; + _factory = factory; + _settingsVmFactory = settingsVmFactory; + _progress.ProgressChanged += (_, f) => InstallProgress = f.ProgressPercentage; + + _isCurrentVersion = this.WhenAnyValue(vm => vm.Release, vm => vm.InstallationInProgress, (release, _) => release) + .Select(r => r != null && _workshopService.GetInstalledEntry(r.Entry.Id)?.ReleaseId == r.Id) + .ToProperty(this, vm => vm.IsCurrentVersion); + + InDetailsScreen = true; + } + + public bool IsCurrentVersion => _isCurrentVersion.Value; + + public async Task Close() + { + await _router.GoUp(); + } + + public async Task Install() + { + if (Release == null) + return; + + // If the entry has missing dependencies, show a dialog + foreach (IGetEntryById_Entry_LatestRelease_Dependencies dependency in Release.Dependencies) + { + if (_workshopService.GetInstalledEntry(dependency.Id) == null) + { + if (await _windowService.ShowConfirmContentDialog("Missing dependencies", + $"One or more dependencies are missing, this {Release.Entry.EntryType.Humanize(LetterCasing.LowerCase)} won't work without them", "View dependencies")) + await _router.GoUp(); + return; + } + } + + _cts = new CancellationTokenSource(); + InstallProgress = 0; + InstallationInProgress = true; + try + { + IEntryInstallationHandler handler = _factory.CreateHandler(Release.Entry.EntryType); + EntryInstallResult result = await handler.InstallAsync(Release.Entry, Release, _progress, _cts.Token); + if (result.IsSuccess) + { + _notificationService.CreateNotification().WithTitle("Installation succeeded").WithSeverity(NotificationSeverity.Success).Show(); + InstallationInProgress = false; + await Manage(); + } + else if (!_cts.IsCancellationRequested) + _notificationService.CreateNotification().WithTitle("Installation failed").WithMessage(result.Message).WithSeverity(NotificationSeverity.Error).Show(); + } + catch (Exception e) + { + _windowService.ShowExceptionDialog("Failed to install workshop entry", e); + } + finally + { + InstallationInProgress = false; + } + } + + public async Task Manage() + { + if (Release?.Entry.EntryType != EntryType.Profile) + await _router.Navigate("../../manage", new RouterNavigationOptions {AdditionalArguments = true}); + } + + public async Task Reinstall() + { + if (await _windowService.ShowConfirmContentDialog("Reinstall entry", "Are you sure you want to reinstall this entry?")) + await Install(); + } + + public async Task Uninstall() + { + InstalledEntry? installedEntry = _workshopService.GetInstalledEntry(Release!.Entry.Id); + if (installedEntry == null) + return; + + InstallationInProgress = true; + try + { + bool confirmed = await _windowService.ShowConfirmContentDialog("Do you want to uninstall this entry?", "Both the entry and its contents will be removed."); + if (!confirmed) + return; + + // Ideally the installation handler does this but it doesn't have access to the required view models + if (installedEntry.EntryType == EntryType.Plugin) + await UninstallPluginPrerequisites(installedEntry); + + IEntryInstallationHandler handler = _factory.CreateHandler(installedEntry.EntryType); + await handler.UninstallAsync(installedEntry, CancellationToken.None); + } + finally + { + InstallationInProgress = false; + } + } + + public void Cancel() + { + _cts?.Cancel(); + } + + private async Task UninstallPluginPrerequisites(InstalledEntry installedEntry) + { + if (!installedEntry.TryGetMetadata("PluginId", out Guid pluginId)) + return; + Plugin? plugin = _pluginManagementService.GetAllPlugins().FirstOrDefault(p => p.Guid == pluginId); + if (plugin == null) + return; + + PluginViewModel pluginViewModel = _settingsVmFactory.PluginViewModel(plugin, ReactiveCommand.Create(() => { })); + await pluginViewModel.ExecuteRemovePrerequisites(true); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseView.axaml b/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseView.axaml index 62a991f55..ea659de07 100644 --- a/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseView.axaml +++ b/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseView.axaml @@ -4,110 +4,12 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia.Tight" xmlns:entryReleases="clr-namespace:Artemis.UI.Screens.Workshop.EntryReleases" - xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" - xmlns:converters="clr-namespace:Artemis.UI.Converters" - xmlns:sharedConverters="clr-namespace:Artemis.UI.Shared.Converters;assembly=Artemis.UI.Shared" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Artemis.UI.Screens.Workshop.EntryReleases.EntryReleaseView" x:DataType="entryReleases:EntryReleaseViewModel"> - - - - - - - - - - - - - - - - - Release info - - - - - - - - - - - - - - - - - - - - Version - - - - - - Release date - - - - - - File size - - - - - + diff --git a/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseViewModel.cs b/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseViewModel.cs index 32b1de42a..47083dffd 100644 --- a/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseViewModel.cs @@ -1,14 +1,9 @@ -using System; using System.Threading; using System.Threading.Tasks; using Artemis.UI.Screens.Workshop.Parameters; using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; -using Artemis.UI.Shared.Services.Builders; -using Artemis.UI.Shared.Utilities; using Artemis.WebClient.Workshop; -using Artemis.WebClient.Workshop.Handlers.InstallationHandlers; -using Artemis.WebClient.Workshop.Services; using PropertyChanged.SourceGenerator; using StrawberryShake; @@ -17,89 +12,27 @@ namespace Artemis.UI.Screens.Workshop.EntryReleases; public partial class EntryReleaseViewModel : RoutableScreen { private readonly IWorkshopClient _client; - private readonly IRouter _router; private readonly INotificationService _notificationService; - private readonly IWindowService _windowService; - private readonly IWorkshopService _workshopService; - private readonly EntryInstallationHandlerFactory _factory; - private readonly Progress _progress = new(); [Notify] private IGetReleaseById_Release? _release; - [Notify] private float _installProgress; - [Notify] private bool _installationInProgress; - [Notify] private bool _isCurrentVersion; - private CancellationTokenSource? _cts; - - public EntryReleaseViewModel(IWorkshopClient client, IRouter router, INotificationService notificationService, IWindowService windowService, IWorkshopService workshopService, - EntryInstallationHandlerFactory factory) + public EntryReleaseViewModel(IWorkshopClient client, INotificationService notificationService, EntryReleaseInfoViewModel entryReleaseInfoViewModel) { + EntryReleaseInfoViewModel = entryReleaseInfoViewModel; + _client = client; - _router = router; _notificationService = notificationService; - _windowService = windowService; - _workshopService = workshopService; - _factory = factory; - _progress.ProgressChanged += (_, f) => InstallProgress = f.ProgressPercentage; - } - - public async Task Close() - { - await _router.GoUp(); - } - - public async Task Install() - { - if (Release == null) - return; - - _cts = new CancellationTokenSource(); - InstallProgress = 0; - InstallationInProgress = true; - try - { - IEntryInstallationHandler handler = _factory.CreateHandler(Release.Entry.EntryType); - EntryInstallResult result = await handler.InstallAsync(Release.Entry, Release, _progress, _cts.Token); - if (result.IsSuccess) - { - _notificationService.CreateNotification().WithTitle("Installation succeeded").WithSeverity(NotificationSeverity.Success).Show(); - IsCurrentVersion = true; - InstallationInProgress = false; - await Manage(); - } - else if (!_cts.IsCancellationRequested) - _notificationService.CreateNotification().WithTitle("Installation failed").WithMessage(result.Message).WithSeverity(NotificationSeverity.Error).Show(); - } - catch (Exception e) - { - InstallationInProgress = false; - _windowService.ShowExceptionDialog("Failed to install workshop entry", e); - } - } - - public async Task Manage() - { - if (Release?.Entry.EntryType != EntryType.Profile) - await _router.Navigate("../../manage"); - } - - public async Task Reinstall() - { - if (await _windowService.ShowConfirmContentDialog("Reinstall entry", "Are you sure you want to reinstall this entry?")) - await Install(); - } - - public void Cancel() - { - _cts?.Cancel(); } + + public EntryReleaseInfoViewModel EntryReleaseInfoViewModel { get; } /// public override async Task OnNavigating(ReleaseDetailParameters parameters, NavigationArguments args, CancellationToken cancellationToken) { IOperationResult result = await _client.GetReleaseById.ExecuteAsync(parameters.ReleaseId, cancellationToken); + Release = result.Data?.Release; - IsCurrentVersion = Release != null && _workshopService.GetInstalledEntry(Release.Entry.Id)?.ReleaseId == Release.Id; + EntryReleaseInfoViewModel.Release = Release; } #region Overrides of RoutableScreen @@ -107,7 +40,7 @@ public override async Task OnNavigating(ReleaseDetailParameters parameters, Navi /// public override Task OnClosing(NavigationArguments args) { - if (!InstallationInProgress) + if (!EntryReleaseInfoViewModel.InstallationInProgress) return Task.CompletedTask; args.Cancel(); diff --git a/src/Artemis.UI/Screens/Workshop/Layout/LayoutDescriptionView.axaml b/src/Artemis.UI/Screens/Workshop/Layout/LayoutDescriptionView.axaml index e4cf60508..a38ea2771 100644 --- a/src/Artemis.UI/Screens/Workshop/Layout/LayoutDescriptionView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Layout/LayoutDescriptionView.axaml @@ -8,6 +8,10 @@ x:Class="Artemis.UI.Screens.Workshop.Layout.LayoutDescriptionView" x:DataType="layout:LayoutDescriptionViewModel"> + + + + diff --git a/src/Artemis.UI/Screens/Workshop/Layout/LayoutDescriptionViewModel.cs b/src/Artemis.UI/Screens/Workshop/Layout/LayoutDescriptionViewModel.cs index 3fb8f678e..e5f00884b 100644 --- a/src/Artemis.UI/Screens/Workshop/Layout/LayoutDescriptionViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Layout/LayoutDescriptionViewModel.cs @@ -1,3 +1,4 @@ +using Artemis.UI.Screens.Workshop.EntryReleases; using Artemis.UI.Shared.Routing; using Artemis.WebClient.Workshop; using PropertyChanged.SourceGenerator; @@ -7,4 +8,12 @@ namespace Artemis.UI.Screens.Workshop.Layout; public partial class LayoutDescriptionViewModel : RoutableScreen { [Notify] private IEntryDetails? _entry; + + public LayoutDescriptionViewModel(EntryReleaseInfoViewModel releaseInfoViewModel) + { + ReleaseInfoViewModel = releaseInfoViewModel; + ReleaseInfoViewModel.InDetailsScreen = false; + } + + public EntryReleaseInfoViewModel ReleaseInfoViewModel { get; } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Layout/LayoutDetailsViewModel.cs b/src/Artemis.UI/Screens/Workshop/Layout/LayoutDetailsViewModel.cs index 7379c1217..20f4cd286 100644 --- a/src/Artemis.UI/Screens/Workshop/Layout/LayoutDetailsViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Layout/LayoutDetailsViewModel.cs @@ -1,4 +1,5 @@ using System; +using System.Reactive.Linq; using System.Threading; using System.Threading.Tasks; using Artemis.UI.Screens.Workshop.Entries.Details; @@ -7,6 +8,7 @@ using Artemis.UI.Shared.Routing; using Artemis.WebClient.Workshop; using PropertyChanged.SourceGenerator; +using ReactiveUI; using StrawberryShake; namespace Artemis.UI.Screens.Workshop.Layout; @@ -31,7 +33,7 @@ public LayoutDetailsViewModel(IWorkshopClient client, _layoutDescriptionViewModel = layoutDescriptionViewModel; _getEntryReleasesViewModel = getEntryReleasesViewModel; _getEntryImagesViewModel = getEntryImagesViewModel; - + RecycleScreen = false; EntryInfoViewModel = entryInfoViewModel; } @@ -60,5 +62,6 @@ private async Task GetEntry(long entryId, CancellationToken cancellationToken) EntryReleasesViewModel = Entry != null ? _getEntryReleasesViewModel(Entry) : null; EntryImagesViewModel = Entry != null ? _getEntryImagesViewModel(Entry) : null; _layoutDescriptionViewModel.Entry = Entry; + _layoutDescriptionViewModel.ReleaseInfoViewModel.Release = result.Data?.Entry?.LatestRelease; } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Plugins/PluginDescriptionView.axaml b/src/Artemis.UI/Screens/Workshop/Plugins/PluginDescriptionView.axaml index 24c91abf8..2584bfd69 100644 --- a/src/Artemis.UI/Screens/Workshop/Plugins/PluginDescriptionView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Plugins/PluginDescriptionView.axaml @@ -8,6 +8,10 @@ x:Class="Artemis.UI.Screens.Workshop.Plugins.PluginDescriptionView" x:DataType="plugins:PluginDescriptionViewModel"> + + + + diff --git a/src/Artemis.UI/Screens/Workshop/Plugins/PluginDescriptionViewModel.cs b/src/Artemis.UI/Screens/Workshop/Plugins/PluginDescriptionViewModel.cs index 0cae2bbcd..99a474583 100644 --- a/src/Artemis.UI/Screens/Workshop/Plugins/PluginDescriptionViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Plugins/PluginDescriptionViewModel.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using Artemis.UI.Screens.Workshop.Entries.List; +using Artemis.UI.Screens.Workshop.EntryReleases; using Artemis.UI.Shared.Routing; using Artemis.WebClient.Workshop; using PropertyChanged.SourceGenerator; @@ -18,12 +19,16 @@ public partial class PluginDescriptionViewModel : RoutableScreen private readonly IWorkshopClient _client; private readonly Func _getEntryListViewModel; - public PluginDescriptionViewModel(IWorkshopClient client, Func getEntryListViewModel) + public PluginDescriptionViewModel(IWorkshopClient client, EntryReleaseInfoViewModel releaseInfoViewModel, Func getEntryListViewModel) { _client = client; _getEntryListViewModel = getEntryListViewModel; + ReleaseInfoViewModel = releaseInfoViewModel; + ReleaseInfoViewModel.InDetailsScreen = false; } + public EntryReleaseInfoViewModel ReleaseInfoViewModel { get; } + public async Task SetEntry(IEntryDetails? entry, CancellationToken cancellationToken) { Entry = entry; diff --git a/src/Artemis.UI/Screens/Workshop/Plugins/PluginDetailsViewModel.cs b/src/Artemis.UI/Screens/Workshop/Plugins/PluginDetailsViewModel.cs index 802698aab..24d731110 100644 --- a/src/Artemis.UI/Screens/Workshop/Plugins/PluginDetailsViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Plugins/PluginDetailsViewModel.cs @@ -64,5 +64,6 @@ private async Task GetEntry(long entryId, CancellationToken cancellationToken) EntryImagesViewModel = Entry != null ? _getEntryImagesViewModel(Entry) : null; await _pluginDescriptionViewModel.SetEntry(Entry, cancellationToken); + _pluginDescriptionViewModel.ReleaseInfoViewModel.Release = result.Data?.Entry?.LatestRelease; } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Plugins/PluginManageViewModel.cs b/src/Artemis.UI/Screens/Workshop/Plugins/PluginManageViewModel.cs index 9bd558217..7804c6b30 100644 --- a/src/Artemis.UI/Screens/Workshop/Plugins/PluginManageViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Plugins/PluginManageViewModel.cs @@ -73,5 +73,11 @@ public override async Task OnNavigating(WorkshopDetailParameters parameters, Nav PluginViewModel = _settingsVmFactory.PluginViewModel(plugin, ReactiveCommand.Create(() => { })); PluginFeatures = new ObservableCollection(plugin.Features.Select(f => _settingsVmFactory.PluginFeatureViewModel(f, false))); + + // If additional arguments were provided and it is a boolean, auto-enable the plugin + if (args.Options.AdditionalArguments is true) + { + await PluginViewModel.AutoEnable(); + } } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDescriptionView.axaml b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDescriptionView.axaml index 5b29dc430..a82df57ec 100644 --- a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDescriptionView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDescriptionView.axaml @@ -8,6 +8,10 @@ x:Class="Artemis.UI.Screens.Workshop.Profile.ProfileDescriptionView" x:DataType="profile:ProfileDescriptionViewModel"> + + + + diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDescriptionViewModel.cs b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDescriptionViewModel.cs index fd16c6cac..aec502c40 100644 --- a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDescriptionViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDescriptionViewModel.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using Artemis.UI.Screens.Workshop.Entries.List; +using Artemis.UI.Screens.Workshop.EntryReleases; using Artemis.UI.Shared.Routing; using Artemis.WebClient.Workshop; using PropertyChanged.SourceGenerator; @@ -18,12 +19,16 @@ public partial class ProfileDescriptionViewModel : RoutableScreen [Notify] private IEntryDetails? _entry; [Notify] private List? _dependencies; - public ProfileDescriptionViewModel(IWorkshopClient client, Func getEntryListViewModel) + public ProfileDescriptionViewModel(IWorkshopClient client, EntryReleaseInfoViewModel releaseInfoViewModel, Func getEntryListViewModel) { _client = client; _getEntryListViewModel = getEntryListViewModel; + ReleaseInfoViewModel = releaseInfoViewModel; + ReleaseInfoViewModel.InDetailsScreen = false; } + public EntryReleaseInfoViewModel ReleaseInfoViewModel { get; } + public async Task SetEntry(IEntryDetails? entry, CancellationToken cancellationToken) { Entry = entry; diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs index e9b8480a2..1d4987fb7 100644 --- a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs @@ -65,5 +65,6 @@ private async Task GetEntry(long entryId, CancellationToken cancellationToken) EntryImagesViewModel = Entry != null ? _getEntryImagesViewModel(Entry) : null; await _profileDescriptionViewModel.SetEntry(Entry, cancellationToken); + _profileDescriptionViewModel.ReleaseInfoViewModel.Release = result.Data?.Entry?.LatestRelease; } } \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Queries/Fragments.graphql b/src/Artemis.WebClient.Workshop/Queries/Fragments.graphql index ea41aa36a..e25faeb5d 100644 --- a/src/Artemis.WebClient.Workshop/Queries/Fragments.graphql +++ b/src/Artemis.WebClient.Workshop/Queries/Fragments.graphql @@ -70,6 +70,17 @@ fragment release on Release { createdAt } +fragment releaseDetails on Release { + ...release + changelog + entry { + ...entrySummary + } + dependencies { + ...entrySummary + } +} + fragment pluginInfo on PluginInfo { requiresAdmin supportsWindows diff --git a/src/Artemis.WebClient.Workshop/Queries/GetEntryById.graphql b/src/Artemis.WebClient.Workshop/Queries/GetEntryById.graphql index 400352910..d33b92089 100644 --- a/src/Artemis.WebClient.Workshop/Queries/GetEntryById.graphql +++ b/src/Artemis.WebClient.Workshop/Queries/GetEntryById.graphql @@ -1,6 +1,9 @@ query GetEntryById($id: Long!) { entry(id: $id) { ...entryDetails + latestRelease { + ...releaseDetails + } } } @@ -9,7 +12,10 @@ query GetPluginEntryById($id: Long!) { ...entryDetails pluginInfo { ...pluginInfo - } + } + latestRelease { + ...releaseDetails + } } } diff --git a/src/Artemis.WebClient.Workshop/Queries/GetReleaseById.graphql b/src/Artemis.WebClient.Workshop/Queries/GetReleaseById.graphql index 84ee9604a..b0da9f40e 100644 --- a/src/Artemis.WebClient.Workshop/Queries/GetReleaseById.graphql +++ b/src/Artemis.WebClient.Workshop/Queries/GetReleaseById.graphql @@ -1,9 +1,5 @@ query GetReleaseById($id: Long!) { release(id: $id) { - ...release - changelog - entry { - ...entrySummary - } + ...releaseDetails } } \ No newline at end of file From 648b7765eff1841c22336702555e4df018f0f3cf Mon Sep 17 00:00:00 2001 From: Robert Date: Sun, 30 Jun 2024 09:42:41 +0200 Subject: [PATCH 4/5] Core - Added API for retrieving current suspended device providers Core - Added events for plugin removal, entry installlation/uninstallation Workshop - Remove the related workshop entry when manually removing a plugin or profile Workshop - Prevent installing profiles with missing plugins and show a dialog with which plugins are missing --- src/Artemis.Core/Services/DeviceService.cs | 2 + .../Services/Interfaces/IDeviceService.cs | 9 +- .../Interfaces/IPluginManagementService.cs | 5 + .../Services/PluginManagementService.cs | 8 ++ .../Storage/Interfaces/IProfileService.cs | 20 ++- .../Services/Storage/ProfileService.cs | 26 +++- .../Screens/Plugins/PluginViewModel.cs | 13 +- .../Entries/Details/EntryInfoView.axaml | 31 +++-- .../Dialogs/DependenciesDialogView.axaml | 44 +++++++ .../Dialogs/DependenciesDialogView.axaml.cs | 14 +++ .../Dialogs/DependenciesDialogViewModel.cs | 36 ++++++ .../EntryReleaseInfoViewModel.cs | 57 +++++---- .../EntryReleaseItemViewModel.cs | 19 +-- .../LayoutFinderDeviceViewModel.cs | 13 +- .../Library/Tabs/InstalledTabItemViewModel.cs | 9 +- .../Library/Tabs/InstalledTabViewModel.cs | 21 +++- .../ProfileEntryInstallationHandler.cs | 14 +-- .../Providers/WorkshopLayoutProvider.cs | 5 +- .../Services/Interfaces/IWorkshopService.cs | 115 +++++++++++++++++- .../Services/WorkshopService.cs | 85 ++++++++++++- 20 files changed, 440 insertions(+), 106 deletions(-) create mode 100644 src/Artemis.UI/Screens/Workshop/EntryReleases/Dialogs/DependenciesDialogView.axaml create mode 100644 src/Artemis.UI/Screens/Workshop/EntryReleases/Dialogs/DependenciesDialogView.axaml.cs create mode 100644 src/Artemis.UI/Screens/Workshop/EntryReleases/Dialogs/DependenciesDialogViewModel.cs diff --git a/src/Artemis.Core/Services/DeviceService.cs b/src/Artemis.Core/Services/DeviceService.cs index 5d2da4db4..54dfd34a2 100644 --- a/src/Artemis.Core/Services/DeviceService.cs +++ b/src/Artemis.Core/Services/DeviceService.cs @@ -38,12 +38,14 @@ public DeviceService(ILogger logger, _renderService = renderService; _getLayoutProviders = getLayoutProviders; + SuspendedDeviceProviders = new ReadOnlyCollection(_suspendedDeviceProviders); EnabledDevices = new ReadOnlyCollection(_enabledDevices); Devices = new ReadOnlyCollection(_devices); RenderScale.RenderScaleMultiplierChanged += RenderScaleOnRenderScaleMultiplierChanged; } + public IReadOnlyCollection SuspendedDeviceProviders { get; } public IReadOnlyCollection EnabledDevices { get; } public IReadOnlyCollection Devices { get; } diff --git a/src/Artemis.Core/Services/Interfaces/IDeviceService.cs b/src/Artemis.Core/Services/Interfaces/IDeviceService.cs index 5c5c60ad7..6594535fd 100644 --- a/src/Artemis.Core/Services/Interfaces/IDeviceService.cs +++ b/src/Artemis.Core/Services/Interfaces/IDeviceService.cs @@ -10,6 +10,11 @@ namespace Artemis.Core.Services; /// public interface IDeviceService : IArtemisService { + /// + /// Gets a read-only collection containing all enabled but suspended device providers + /// + IReadOnlyCollection SuspendedDeviceProviders { get; } + /// /// Gets a read-only collection containing all enabled devices /// @@ -42,7 +47,7 @@ public interface IDeviceService : IArtemisService /// Applies auto-arranging logic to the surface /// void AutoArrangeDevices(); - + /// /// Apples the best available to the provided /// @@ -111,7 +116,7 @@ public interface IDeviceService : IArtemisService /// Occurs when a device provider was removed. /// event EventHandler DeviceProviderRemoved; - + /// /// Occurs when the surface has had modifications to its LED collection /// diff --git a/src/Artemis.Core/Services/Interfaces/IPluginManagementService.cs b/src/Artemis.Core/Services/Interfaces/IPluginManagementService.cs index fbd97344d..806526773 100644 --- a/src/Artemis.Core/Services/Interfaces/IPluginManagementService.cs +++ b/src/Artemis.Core/Services/Interfaces/IPluginManagementService.cs @@ -186,6 +186,11 @@ public interface IPluginManagementService : IArtemisService, IDisposable /// event EventHandler PluginDisabled; + /// + /// Occurs when a plugin is removed + /// + event EventHandler PluginRemoved; + /// /// Occurs when a plugin feature is being enabled /// diff --git a/src/Artemis.Core/Services/PluginManagementService.cs b/src/Artemis.Core/Services/PluginManagementService.cs index 11db7dc55..346d82a88 100644 --- a/src/Artemis.Core/Services/PluginManagementService.cs +++ b/src/Artemis.Core/Services/PluginManagementService.cs @@ -686,6 +686,8 @@ public void RemovePlugin(Plugin plugin, bool removeSettings) if (removeSettings) RemovePluginSettings(plugin); + + OnPluginRemoved(new PluginEventArgs(plugin)); } public void RemovePluginSettings(Plugin plugin) @@ -850,6 +852,7 @@ private void SavePlugin(Plugin plugin) public event EventHandler? PluginEnabling; public event EventHandler? PluginEnabled; public event EventHandler? PluginDisabled; + public event EventHandler? PluginRemoved; public event EventHandler? PluginFeatureEnabling; public event EventHandler? PluginFeatureEnabled; @@ -890,6 +893,11 @@ protected virtual void OnPluginDisabled(PluginEventArgs e) { PluginDisabled?.Invoke(this, e); } + + protected virtual void OnPluginRemoved(PluginEventArgs e) + { + PluginRemoved?.Invoke(this, e); + } protected virtual void OnPluginFeatureEnabling(PluginFeatureEventArgs e) { diff --git a/src/Artemis.Core/Services/Storage/Interfaces/IProfileService.cs b/src/Artemis.Core/Services/Storage/Interfaces/IProfileService.cs index 2a55664a9..d6fa0eca7 100644 --- a/src/Artemis.Core/Services/Storage/Interfaces/IProfileService.cs +++ b/src/Artemis.Core/Services/Storage/Interfaces/IProfileService.cs @@ -30,7 +30,7 @@ public interface IProfileService : IArtemisService /// Gets or sets a value indicating whether the currently focused profile should receive updates. /// bool UpdateFocusProfile { get; set; } - + /// /// Gets or sets whether profiles are rendered each frame by calling their Render method /// @@ -54,7 +54,7 @@ public interface IProfileService : IArtemisService /// /// The profile configuration of the profile to activate. void DeactivateProfile(ProfileConfiguration profileConfiguration); - + /// /// Saves the provided and it's s but not the /// s themselves. @@ -117,8 +117,9 @@ public interface IProfileService : IArtemisService /// Text to add after the name of the profile (separated by a dash). /// The profile before which to import the profile into the category. /// The resulting profile configuration. - Task ImportProfile(Stream archiveStream, ProfileCategory category, bool makeUnique, bool markAsFreshImport, string? nameAffix = "imported", ProfileConfiguration? target = null); - + Task ImportProfile(Stream archiveStream, ProfileCategory category, bool makeUnique, bool markAsFreshImport, string? nameAffix = "imported", + ProfileConfiguration? target = null); + /// /// Imports the provided ZIP archive stream into the provided profile configuration /// @@ -163,5 +164,14 @@ public interface IProfileService : IArtemisService /// Occurs whenever a profile category is removed. /// public event EventHandler? ProfileCategoryRemoved; - + + /// + /// Occurs whenever a profile is added. + /// + public event EventHandler? ProfileRemoved; + + /// + /// Occurs whenever a profile is removed. + /// + public event EventHandler? ProfileAdded; } \ No newline at end of file diff --git a/src/Artemis.Core/Services/Storage/ProfileService.cs b/src/Artemis.Core/Services/Storage/ProfileService.cs index 913c38e0e..f3c4dca4e 100644 --- a/src/Artemis.Core/Services/Storage/ProfileService.cs +++ b/src/Artemis.Core/Services/Storage/ProfileService.cs @@ -26,7 +26,6 @@ internal class ProfileService : IProfileService private readonly IPluginManagementService _pluginManagementService; private readonly IDeviceService _deviceService; private readonly List _pendingKeyboardEvents = new(); - private readonly List _profileMigrators; private readonly List _renderExceptions = new(); private readonly List _updateExceptions = new(); @@ -38,15 +37,13 @@ public ProfileService(ILogger logger, IProfileRepository profileRepository, IPluginManagementService pluginManagementService, IInputService inputService, - IDeviceService deviceService, - List profileMigrators) + IDeviceService deviceService) { _logger = logger; _profileCategoryRepository = profileCategoryRepository; _profileRepository = profileRepository; _pluginManagementService = pluginManagementService; _deviceService = deviceService; - _profileMigrators = profileMigrators; ProfileCategories = new ReadOnlyCollection(_profileCategoryRepository.GetAll().Select(c => new ProfileCategory(c)).OrderBy(c => c.Order).ToList()); @@ -264,6 +261,8 @@ public ProfileConfiguration CreateProfileConfiguration(ProfileCategory category, category.AddProfileConfiguration(configuration, category.ProfileConfigurations.FirstOrDefault()); SaveProfileCategory(category); + + OnProfileAdded(new ProfileConfigurationEventArgs(configuration)); return configuration; } @@ -279,6 +278,8 @@ public void RemoveProfileConfiguration(ProfileConfiguration profileConfiguration _profileRepository.Remove(profileConfiguration.Entity); _profileCategoryRepository.Save(category.Entity); + + OnProfileRemoved(new ProfileConfigurationEventArgs(profileConfiguration)); } /// @@ -436,8 +437,9 @@ public async Task ImportProfile(Stream archiveStream, Prof /// public async Task OverwriteProfile(MemoryStream archiveStream, ProfileConfiguration profileConfiguration) { - ProfileConfiguration imported = await ImportProfile(archiveStream, profileConfiguration.Category, true, true, null, profileConfiguration); - + ProfileConfiguration imported = await ImportProfile(archiveStream, profileConfiguration.Category, true, false, null, profileConfiguration); + imported.Name = profileConfiguration.Name; + RemoveProfileConfiguration(profileConfiguration); SaveProfileCategory(imported.Category); @@ -588,6 +590,8 @@ private void LogProfileRenderExceptions() public event EventHandler? ProfileDeactivated; public event EventHandler? ProfileCategoryAdded; public event EventHandler? ProfileCategoryRemoved; + public event EventHandler? ProfileRemoved; + public event EventHandler? ProfileAdded; protected virtual void OnProfileActivated(ProfileConfigurationEventArgs e) { @@ -610,4 +614,14 @@ protected virtual void OnProfileCategoryRemoved(ProfileCategoryEventArgs e) } #endregion + + protected virtual void OnProfileRemoved(ProfileConfigurationEventArgs e) + { + ProfileRemoved?.Invoke(this, e); + } + + protected virtual void OnProfileAdded(ProfileConfigurationEventArgs e) + { + ProfileAdded?.Invoke(this, e); + } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Plugins/PluginViewModel.cs b/src/Artemis.UI/Screens/Plugins/PluginViewModel.cs index 4cecb945e..ae312a2be 100644 --- a/src/Artemis.UI/Screens/Plugins/PluginViewModel.cs +++ b/src/Artemis.UI/Screens/Plugins/PluginViewModel.cs @@ -11,8 +11,6 @@ using Artemis.UI.Shared; using Artemis.UI.Shared.Services; using Artemis.UI.Shared.Services.Builders; -using Artemis.WebClient.Workshop.Models; -using Artemis.WebClient.Workshop.Services; using Avalonia.Controls; using Avalonia.Threading; using Material.Icons; @@ -26,7 +24,6 @@ public partial class PluginViewModel : ActivatableViewModelBase private readonly ICoreService _coreService; private readonly INotificationService _notificationService; private readonly IPluginManagementService _pluginManagementService; - private readonly IWorkshopService _workshopService; private readonly IWindowService _windowService; private Window? _settingsWindow; [Notify] private bool _canInstallPrerequisites; @@ -39,15 +36,13 @@ public PluginViewModel(Plugin plugin, ICoreService coreService, IWindowService windowService, INotificationService notificationService, - IPluginManagementService pluginManagementService, - IWorkshopService workshopService) + IPluginManagementService pluginManagementService) { _plugin = plugin; _coreService = coreService; _windowService = windowService; _notificationService = notificationService; _pluginManagementService = pluginManagementService; - _workshopService = workshopService; Platforms = new ObservableCollection(); if (Plugin.Info.Platforms != null) @@ -260,11 +255,7 @@ private async Task ExecuteRemove() _windowService.ShowExceptionDialog("Failed to remove plugin", e); throw; } - - InstalledEntry? entry = _workshopService.GetInstalledEntries().FirstOrDefault(e => e.TryGetMetadata("PluginId", out Guid pluginId) && pluginId == Plugin.Guid); - if (entry != null) - _workshopService.RemoveInstalledEntry(entry); - + _notificationService.CreateNotification().WithTitle("Removed plugin.").Show(); } diff --git a/src/Artemis.UI/Screens/Workshop/Entries/Details/EntryInfoView.axaml b/src/Artemis.UI/Screens/Workshop/Entries/Details/EntryInfoView.axaml index a105aa15e..7792f300c 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/Details/EntryInfoView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Entries/Details/EntryInfoView.axaml @@ -15,24 +15,29 @@ - - - - - + + + + + + - + - - + + @@ -46,11 +51,13 @@ - + + TextAlignment="Center" + Text="{CompiledBinding Entry.Name, FallbackValue=Title}" + Margin="0 15" /> diff --git a/src/Artemis.UI/Screens/Workshop/EntryReleases/Dialogs/DependenciesDialogView.axaml b/src/Artemis.UI/Screens/Workshop/EntryReleases/Dialogs/DependenciesDialogView.axaml new file mode 100644 index 000000000..15a7de760 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/EntryReleases/Dialogs/DependenciesDialogView.axaml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/EntryReleases/Dialogs/DependenciesDialogView.axaml.cs b/src/Artemis.UI/Screens/Workshop/EntryReleases/Dialogs/DependenciesDialogView.axaml.cs new file mode 100644 index 000000000..592d73ea4 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/EntryReleases/Dialogs/DependenciesDialogView.axaml.cs @@ -0,0 +1,14 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.EntryReleases.Dialogs; + +public partial class DependenciesDialogView : ReactiveUserControl +{ + public DependenciesDialogView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/EntryReleases/Dialogs/DependenciesDialogViewModel.cs b/src/Artemis.UI/Screens/Workshop/EntryReleases/Dialogs/DependenciesDialogViewModel.cs new file mode 100644 index 000000000..65db4712d --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/EntryReleases/Dialogs/DependenciesDialogViewModel.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using Artemis.UI.Screens.Workshop.Entries.List; +using Artemis.UI.Shared; +using Artemis.UI.Shared.Routing; +using Artemis.WebClient.Workshop; +using Humanizer; +using ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.EntryReleases.Dialogs; + +public class DependenciesDialogViewModel : ContentDialogViewModelBase +{ + public DependenciesDialogViewModel(IEntrySummary dependant, List dependencies, Func getEntryListItemViewModel, IRouter router) + { + Dependant = dependant; + DependantType = dependant.EntryType.Humanize(LetterCasing.LowerCase); + EntryType = dependencies.First().EntryType.Humanize(LetterCasing.LowerCase); + EntryTypePlural = dependencies.First().EntryType.Humanize(LetterCasing.LowerCase).Pluralize(); + Dependencies = new ObservableCollection(dependencies.Select(getEntryListItemViewModel)); + + this.WhenActivated(d => router.CurrentPath.Skip(1).Subscribe(s => ContentDialog?.Hide()).DisposeWith(d)); + } + + public string DependantType { get; } + public string EntryType { get; } + public string EntryTypePlural { get; } + public bool Multiple => Dependencies.Count > 1; + + public IEntrySummary Dependant { get; } + public ObservableCollection Dependencies { get; } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseInfoViewModel.cs b/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseInfoViewModel.cs index fa1968f6e..db9f6d8c2 100644 --- a/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseInfoViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseInfoViewModel.cs @@ -1,13 +1,14 @@ using System; -using System.ComponentModel; +using System.Collections.Generic; using System.Linq; -using System.Reactive.Linq; +using System.Reactive.Disposables; using System.Threading; using System.Threading.Tasks; using Artemis.Core; using Artemis.Core.Services; using Artemis.UI.DryIoc.Factories; using Artemis.UI.Screens.Plugins; +using Artemis.UI.Screens.Workshop.EntryReleases.Dialogs; using Artemis.UI.Shared; using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; @@ -17,7 +18,6 @@ using Artemis.WebClient.Workshop.Handlers.InstallationHandlers; using Artemis.WebClient.Workshop.Models; using Artemis.WebClient.Workshop.Services; -using Humanizer; using PropertyChanged.SourceGenerator; using ReactiveUI; @@ -30,13 +30,12 @@ public partial class EntryReleaseInfoViewModel : ActivatableViewModelBase private readonly IWindowService _windowService; private readonly IWorkshopService _workshopService; private readonly IPluginManagementService _pluginManagementService; - private readonly EntryInstallationHandlerFactory _factory; private readonly ISettingsVmFactory _settingsVmFactory; private readonly Progress _progress = new(); - private readonly ObservableAsPropertyHelper _isCurrentVersion; [Notify] private IReleaseDetails? _release; [Notify] private float _installProgress; + [Notify] private bool _isCurrentVersion; [Notify] private bool _installationInProgress; [Notify] private bool _inDetailsScreen; @@ -47,7 +46,6 @@ public EntryReleaseInfoViewModel(IRouter router, IWindowService windowService, IWorkshopService workshopService, IPluginManagementService pluginManagementService, - EntryInstallationHandlerFactory factory, ISettingsVmFactory settingsVmFactory) { _router = router; @@ -55,18 +53,31 @@ public EntryReleaseInfoViewModel(IRouter router, _windowService = windowService; _workshopService = workshopService; _pluginManagementService = pluginManagementService; - _factory = factory; _settingsVmFactory = settingsVmFactory; _progress.ProgressChanged += (_, f) => InstallProgress = f.ProgressPercentage; - _isCurrentVersion = this.WhenAnyValue(vm => vm.Release, vm => vm.InstallationInProgress, (release, _) => release) - .Select(r => r != null && _workshopService.GetInstalledEntry(r.Entry.Id)?.ReleaseId == r.Id) - .ToProperty(this, vm => vm.IsCurrentVersion); + this.WhenActivated(d => + { + _workshopService.OnEntryInstalled += WorkshopServiceOnOnEntryInstalled; + _workshopService.OnEntryUninstalled += WorkshopServiceOnOnEntryInstalled; + Disposable.Create(() => + { + _workshopService.OnEntryInstalled -= WorkshopServiceOnOnEntryInstalled; + _workshopService.OnEntryUninstalled -= WorkshopServiceOnOnEntryInstalled; + }).DisposeWith(d); + + IsCurrentVersion = Release != null && _workshopService.GetInstalledEntry(Release.Entry.Id)?.ReleaseId == Release.Id; + }); + + this.WhenAnyValue(vm => vm.Release).Subscribe(r => IsCurrentVersion = r != null && _workshopService.GetInstalledEntry(r.Entry.Id)?.ReleaseId == r.Id); InDetailsScreen = true; } - public bool IsCurrentVersion => _isCurrentVersion.Value; + private void WorkshopServiceOnOnEntryInstalled(object? sender, InstalledEntry e) + { + IsCurrentVersion = Release != null && _workshopService.GetInstalledEntry(Release.Entry.Id)?.ReleaseId == Release.Id; + } public async Task Close() { @@ -79,15 +90,15 @@ public async Task Install() return; // If the entry has missing dependencies, show a dialog - foreach (IGetEntryById_Entry_LatestRelease_Dependencies dependency in Release.Dependencies) + List missing = Release.Dependencies.Where(d => _workshopService.GetInstalledEntry(d.Id) == null).Cast().ToList(); + if (missing.Count > 0) { - if (_workshopService.GetInstalledEntry(dependency.Id) == null) - { - if (await _windowService.ShowConfirmContentDialog("Missing dependencies", - $"One or more dependencies are missing, this {Release.Entry.EntryType.Humanize(LetterCasing.LowerCase)} won't work without them", "View dependencies")) - await _router.GoUp(); - return; - } + await _windowService.CreateContentDialog() + .WithTitle("Requirements missing") + .WithViewModel(out DependenciesDialogViewModel _, Release.Entry, missing) + .WithCloseButtonText("Cancel installation") + .ShowAsync(); + return; } _cts = new CancellationTokenSource(); @@ -95,8 +106,7 @@ public async Task Install() InstallationInProgress = true; try { - IEntryInstallationHandler handler = _factory.CreateHandler(Release.Entry.EntryType); - EntryInstallResult result = await handler.InstallAsync(Release.Entry, Release, _progress, _cts.Token); + EntryInstallResult result = await _workshopService.InstallEntry(Release.Entry, Release, _progress, _cts.Token); if (result.IsSuccess) { _notificationService.CreateNotification().WithTitle("Installation succeeded").WithSeverity(NotificationSeverity.Success).Show(); @@ -145,8 +155,9 @@ public async Task Uninstall() if (installedEntry.EntryType == EntryType.Plugin) await UninstallPluginPrerequisites(installedEntry); - IEntryInstallationHandler handler = _factory.CreateHandler(installedEntry.EntryType); - await handler.UninstallAsync(installedEntry, CancellationToken.None); + await _workshopService.UninstallEntry(installedEntry, CancellationToken.None); + + _notificationService.CreateNotification().WithTitle("Entry uninstalled").WithSeverity(NotificationSeverity.Success).Show(); } finally { diff --git a/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseItemViewModel.cs b/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseItemViewModel.cs index bafa65507..40d9ccae3 100644 --- a/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseItemViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseItemViewModel.cs @@ -1,6 +1,4 @@ -using System; -using System.Reactive.Disposables; -using System.Reactive.Linq; +using System.Reactive.Disposables; using Artemis.UI.Shared; using Artemis.WebClient.Workshop; using Artemis.WebClient.Workshop.Models; @@ -22,19 +20,24 @@ public EntryReleaseItemViewModel(IWorkshopService workshopService, IEntryDetails _entry = entry; Release = release; - UpdateIsCurrentVersion(); this.WhenActivated(d => { - Observable.FromEventPattern(x => _workshopService.OnInstalledEntrySaved += x, x => _workshopService.OnInstalledEntrySaved -= x) - .Subscribe(_ => UpdateIsCurrentVersion()) - .DisposeWith(d); + _workshopService.OnEntryInstalled += WorkshopServiceOnOnEntryInstalled; + _workshopService.OnEntryUninstalled += WorkshopServiceOnOnEntryInstalled; + Disposable.Create(() => + { + _workshopService.OnEntryInstalled -= WorkshopServiceOnOnEntryInstalled; + _workshopService.OnEntryUninstalled -= WorkshopServiceOnOnEntryInstalled; + }).DisposeWith(d); + + IsCurrentVersion = _workshopService.GetInstalledEntry(_entry.Id)?.ReleaseId == Release.Id; }); } public IRelease Release { get; } - private void UpdateIsCurrentVersion() + private void WorkshopServiceOnOnEntryInstalled(object? sender, InstalledEntry e) { IsCurrentVersion = _workshopService.GetInstalledEntry(_entry.Id)?.ReleaseId == Release.Id; } diff --git a/src/Artemis.UI/Screens/Workshop/LayoutFinder/LayoutFinderDeviceViewModel.cs b/src/Artemis.UI/Screens/Workshop/LayoutFinder/LayoutFinderDeviceViewModel.cs index c15e72493..743d62144 100644 --- a/src/Artemis.UI/Screens/Workshop/LayoutFinder/LayoutFinderDeviceViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/LayoutFinder/LayoutFinderDeviceViewModel.cs @@ -11,7 +11,6 @@ using Artemis.WebClient.Workshop.Providers; using Artemis.WebClient.Workshop.Services; using Material.Icons; -using Material.Icons.Avalonia; using PropertyChanged.SourceGenerator; using StrawberryShake; @@ -23,7 +22,6 @@ public partial class LayoutFinderDeviceViewModel : ViewModelBase private readonly IDeviceService _deviceService; private readonly IWorkshopService _workshopService; private readonly WorkshopLayoutProvider _layoutProvider; - private readonly EntryInstallationHandlerFactory _factory; [Notify] private bool _searching; [Notify] private bool _hasLayout; @@ -33,18 +31,12 @@ public partial class LayoutFinderDeviceViewModel : ViewModelBase [Notify] private string? _logicalLayout; [Notify] private string? _physicalLayout; - public LayoutFinderDeviceViewModel(ArtemisDevice device, - IWorkshopClient client, - IDeviceService deviceService, - IWorkshopService workshopService, - WorkshopLayoutProvider layoutProvider, - EntryInstallationHandlerFactory factory) + public LayoutFinderDeviceViewModel(ArtemisDevice device, IWorkshopClient client, IDeviceService deviceService, IWorkshopService workshopService, WorkshopLayoutProvider layoutProvider) { _client = client; _deviceService = deviceService; _workshopService = workshopService; _layoutProvider = layoutProvider; - _factory = factory; Device = device; DeviceIcon = DetermineDeviceIcon(); @@ -116,8 +108,7 @@ private async Task InstallAndApplyEntry(IEntrySummary entry, IRelease release) InstalledEntry? installedEntry = _workshopService.GetInstalledEntry(entry.Id); if (installedEntry == null) { - IEntryInstallationHandler installationHandler = _factory.CreateHandler(EntryType.Layout); - EntryInstallResult result = await installationHandler.InstallAsync(entry, release, new Progress(), CancellationToken.None); + EntryInstallResult result = await _workshopService.InstallEntry(entry, release, new Progress(), CancellationToken.None); installedEntry = result.Entry; } diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabItemViewModel.cs b/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabItemViewModel.cs index 840b4e220..951bc149b 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabItemViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabItemViewModel.cs @@ -11,7 +11,6 @@ using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; using Artemis.WebClient.Workshop; -using Artemis.WebClient.Workshop.Handlers.InstallationHandlers; using Artemis.WebClient.Workshop.Models; using Artemis.WebClient.Workshop.Services; using PropertyChanged.SourceGenerator; @@ -23,23 +22,19 @@ public partial class InstalledTabItemViewModel : ViewModelBase { private readonly IWorkshopService _workshopService; private readonly IRouter _router; - private readonly EntryInstallationHandlerFactory _factory; private readonly IWindowService _windowService; private readonly IPluginManagementService _pluginManagementService; private readonly ISettingsVmFactory _settingsVmFactory; - [Notify(Setter.Private)] private bool _isRemoved; public InstalledTabItemViewModel(InstalledEntry installedEntry, IWorkshopService workshopService, IRouter router, - EntryInstallationHandlerFactory factory, IWindowService windowService, IPluginManagementService pluginManagementService, ISettingsVmFactory settingsVmFactory) { _workshopService = workshopService; _router = router; - _factory = factory; _windowService = windowService; _pluginManagementService = pluginManagementService; _settingsVmFactory = settingsVmFactory; @@ -78,9 +73,7 @@ private async Task ExecuteUninstall(CancellationToken cancellationToken) if (InstalledEntry.EntryType == EntryType.Plugin) await UninstallPluginPrerequisites(); - IEntryInstallationHandler handler = _factory.CreateHandler(InstalledEntry.EntryType); - await handler.UninstallAsync(InstalledEntry, cancellationToken); - IsRemoved = true; + await _workshopService.UninstallEntry(InstalledEntry, cancellationToken); } private async Task UninstallPluginPrerequisites() diff --git a/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabViewModel.cs b/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabViewModel.cs index bec0203d8..982ec1aba 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Library/Tabs/InstalledTabViewModel.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Reactive; +using System.Reactive.Disposables; using System.Reactive.Linq; using Artemis.UI.Shared.Routing; using Artemis.WebClient.Workshop.Models; @@ -15,31 +16,41 @@ namespace Artemis.UI.Screens.Workshop.Library.Tabs; public partial class InstalledTabViewModel : RoutableScreen { + private SourceList _installedEntries = new(); + [Notify] private string? _searchEntryInput; public InstalledTabViewModel(IWorkshopService workshopService, IRouter router, Func getInstalledTabItemViewModel) { - SourceList installedEntries = new(); IObservable> pluginFilter = this.WhenAnyValue(vm => vm.SearchEntryInput).Throttle(TimeSpan.FromMilliseconds(100)).Select(CreatePredicate); - installedEntries.Connect() + _installedEntries.Connect() .Filter(pluginFilter) .Sort(SortExpressionComparer.Descending(p => p.InstalledAt)) .Transform(getInstalledTabItemViewModel) - .AutoRefresh(vm => vm.IsRemoved) - .Filter(vm => !vm.IsRemoved) .Bind(out ReadOnlyObservableCollection installedEntryViewModels) .Subscribe(); List entries = workshopService.GetInstalledEntries(); - installedEntries.AddRange(entries); + _installedEntries.AddRange(entries); Empty = entries.Count == 0; InstalledEntries = installedEntryViewModels; + this.WhenActivated(d => + { + workshopService.OnEntryUninstalled += WorkshopServiceOnOnEntryUninstalled; + Disposable.Create(() => workshopService.OnEntryUninstalled -= WorkshopServiceOnOnEntryUninstalled).DisposeWith(d); + }); + OpenWorkshop = ReactiveCommand.CreateFromTask(async () => await router.Navigate("workshop")); } + private void WorkshopServiceOnOnEntryUninstalled(object? sender, InstalledEntry e) + { + _installedEntries.Remove(e); + } + public bool Empty { get; } public ReactiveCommand OpenWorkshop { get; } public ReadOnlyObservableCollection InstalledEntries { get; } diff --git a/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/ProfileEntryInstallationHandler.cs b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/ProfileEntryInstallationHandler.cs index 1264c43b7..6df1aaead 100644 --- a/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/ProfileEntryInstallationHandler.cs +++ b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/ProfileEntryInstallationHandler.cs @@ -58,7 +58,7 @@ public async Task InstallAsync(IEntrySummary entry, IRelease ProfileCategory category = _profileService.ProfileCategories.FirstOrDefault(c => c.Name == "Workshop") ?? _profileService.CreateProfileCategory("Workshop", true); ProfileConfiguration imported = await _profileService.ImportProfile(stream, category, true, true, null); installedEntry.SetMetadata("ProfileId", imported.ProfileId); - + // Update the release and return the profile configuration UpdateRelease(installedEntry, release); return EntryInstallResult.FromSuccess(installedEntry); @@ -66,17 +66,17 @@ public async Task InstallAsync(IEntrySummary entry, IRelease public async Task UninstallAsync(InstalledEntry installedEntry, CancellationToken cancellationToken) { - if (!installedEntry.TryGetMetadata("ProfileId", out Guid profileId)) - return EntryUninstallResult.FromFailure("Local reference does not contain a GUID"); - return await Task.Run(() => { try { // Find the profile if still there - ProfileConfiguration? profile = _profileService.ProfileCategories.SelectMany(c => c.ProfileConfigurations).FirstOrDefault(c => c.ProfileId == profileId); - if (profile != null) - _profileService.RemoveProfileConfiguration(profile); + if (installedEntry.TryGetMetadata("ProfileId", out Guid profileId)) + { + ProfileConfiguration? profile = _profileService.ProfileCategories.SelectMany(c => c.ProfileConfigurations).FirstOrDefault(c => c.ProfileId == profileId); + if (profile != null) + _profileService.RemoveProfileConfiguration(profile); + } // Remove the release _workshopService.RemoveInstalledEntry(installedEntry); diff --git a/src/Artemis.WebClient.Workshop/Providers/WorkshopLayoutProvider.cs b/src/Artemis.WebClient.Workshop/Providers/WorkshopLayoutProvider.cs index 26fc0d5a0..893b823c1 100644 --- a/src/Artemis.WebClient.Workshop/Providers/WorkshopLayoutProvider.cs +++ b/src/Artemis.WebClient.Workshop/Providers/WorkshopLayoutProvider.cs @@ -18,7 +18,10 @@ public WorkshopLayoutProvider(IWorkshopService workshopService) /// public ArtemisLayout? GetDeviceLayout(ArtemisDevice device) { - InstalledEntry? layoutEntry = _workshopService.GetInstalledEntries().FirstOrDefault(e => e.EntryId.ToString() == device.LayoutSelection.Parameter); + if (!long.TryParse(device.LayoutSelection.Parameter, out long entryId)) + return null; + + InstalledEntry? layoutEntry = _workshopService.GetInstalledEntry(entryId); if (layoutEntry == null) return null; diff --git a/src/Artemis.WebClient.Workshop/Services/Interfaces/IWorkshopService.cs b/src/Artemis.WebClient.Workshop/Services/Interfaces/IWorkshopService.cs index 304f4ef05..15ceb2ab8 100644 --- a/src/Artemis.WebClient.Workshop/Services/Interfaces/IWorkshopService.cs +++ b/src/Artemis.WebClient.Workshop/Services/Interfaces/IWorkshopService.cs @@ -1,25 +1,136 @@ +using Artemis.Core; +using Artemis.UI.Shared.Utilities; +using Artemis.WebClient.Workshop.Handlers.InstallationHandlers; using Artemis.WebClient.Workshop.Handlers.UploadHandlers; using Artemis.WebClient.Workshop.Models; namespace Artemis.WebClient.Workshop.Services; +/// +/// Provides an interface for managing workshop services. +/// public interface IWorkshopService { + /// + /// Gets the icon for a specific entry. + /// + /// The ID of the entry. + /// The cancellation token. + /// A stream containing the icon. Task GetEntryIcon(long entryId, CancellationToken cancellationToken); + + /// + /// Sets the icon for a specific entry. + /// + /// The ID of the entry. + /// The stream containing the icon. + /// The cancellation token. + /// An API result. Task SetEntryIcon(long entryId, Stream icon, CancellationToken cancellationToken); + + /// + /// Uploads an image for a specific entry. + /// + /// The ID of the entry. + /// The image upload request. + /// The cancellation token. + /// An API result. Task UploadEntryImage(long entryId, ImageUploadRequest request, CancellationToken cancellationToken); + + /// + /// Deletes an image by its ID. + /// + /// The ID of the image. + /// The cancellation token. Task DeleteEntryImage(Guid id, CancellationToken cancellationToken); + + /// + /// Gets the status of the workshop. + /// + /// The cancellation token. + /// The status of the workshop. Task GetWorkshopStatus(CancellationToken cancellationToken); + + /// + /// Validates the status of the workshop. + /// + /// The cancellation token. + /// A boolean indicating whether the workshop is reachable. Task ValidateWorkshopStatus(CancellationToken cancellationToken); + + /// + /// Navigates to a specific entry. + /// + /// The ID of the entry. + /// The type of the entry. Task NavigateToEntry(long entryId, EntryType entryType); + /// + /// Installs a specific entry. + /// + /// The entry to install. + /// The release of the entry. + /// The progress of the installation. + /// The cancellation token. + Task InstallEntry(IEntrySummary entry, IRelease release, Progress progress, CancellationToken cancellationToken); + + /// + /// Uninstalls a specific entry. + /// + /// The installed entry to uninstall. + /// The cancellation token. + Task UninstallEntry(InstalledEntry installedEntry, CancellationToken cancellationToken); + + /// + /// Gets all installed entries. + /// + /// A list of all installed entries. List GetInstalledEntries(); + + /// + /// Gets a specific installed entry. + /// + /// The ID of the entry. + /// The installed entry. InstalledEntry? GetInstalledEntry(long entryId); + + /// + /// Gets the installed plugin entry for a specific plugin. + /// + /// The plugin. + /// The installed entry. + InstalledEntry? GetInstalledEntryByPlugin(Plugin plugin); + + /// + /// Gets the installed plugin entry for a specific profile. + /// + /// The profile. + /// The installed entry. + InstalledEntry? GetInstalledEntryByProfile(ProfileConfiguration profileConfiguration); + + /// + /// Removes a specific installed entry for storage. + /// + /// The installed entry to remove. void RemoveInstalledEntry(InstalledEntry installedEntry); + + /// + /// Saves a specific installed entry to storage. + /// + /// The installed entry to save. void SaveInstalledEntry(InstalledEntry entry); + + /// + /// Initializes the workshop service. + /// void Initialize(); + /// + /// Represents the status of the workshop. + /// public record WorkshopStatus(bool IsReachable, string Message); - - event EventHandler? OnInstalledEntrySaved; + + public event EventHandler? OnInstalledEntrySaved; + public event EventHandler? OnEntryUninstalled; + public event EventHandler? OnEntryInstalled; } \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Services/WorkshopService.cs b/src/Artemis.WebClient.Workshop/Services/WorkshopService.cs index 715a5a1bb..7912ad307 100644 --- a/src/Artemis.WebClient.Workshop/Services/WorkshopService.cs +++ b/src/Artemis.WebClient.Workshop/Services/WorkshopService.cs @@ -4,7 +4,9 @@ using Artemis.Storage.Entities.Workshop; using Artemis.Storage.Repositories.Interfaces; using Artemis.UI.Shared.Routing; +using Artemis.UI.Shared.Utilities; using Artemis.WebClient.Workshop.Exceptions; +using Artemis.WebClient.Workshop.Handlers.InstallationHandlers; using Artemis.WebClient.Workshop.Handlers.UploadHandlers; using Artemis.WebClient.Workshop.Models; using Serilog; @@ -17,16 +19,26 @@ public class WorkshopService : IWorkshopService private readonly IHttpClientFactory _httpClientFactory; private readonly IRouter _router; private readonly IEntryRepository _entryRepository; - private readonly IPluginManagementService _pluginManagementService; + private readonly Lazy _pluginManagementService; + private readonly Lazy _profileService; + private readonly EntryInstallationHandlerFactory _factory; private bool _initialized; - public WorkshopService(ILogger logger, IHttpClientFactory httpClientFactory, IRouter router, IEntryRepository entryRepository, IPluginManagementService pluginManagementService) + public WorkshopService(ILogger logger, + IHttpClientFactory httpClientFactory, + IRouter router, + IEntryRepository entryRepository, + Lazy pluginManagementService, + Lazy profileService, + EntryInstallationHandlerFactory factory) { _logger = logger; _httpClientFactory = httpClientFactory; _router = router; _entryRepository = entryRepository; _pluginManagementService = pluginManagementService; + _profileService = profileService; + _factory = factory; } public async Task GetEntryIcon(long entryId, CancellationToken cancellationToken) @@ -145,6 +157,32 @@ public async Task NavigateToEntry(long entryId, EntryType entryType) } } + /// + public async Task InstallEntry(IEntrySummary entry, IRelease release, Progress progress, CancellationToken cancellationToken) + { + IEntryInstallationHandler handler = _factory.CreateHandler(entry.EntryType); + EntryInstallResult result = await handler.InstallAsync(entry, release, progress, cancellationToken); + if (result.IsSuccess && result.Entry != null) + OnEntryInstalled?.Invoke(this, result.Entry); + else + _logger.Warning("Failed to install entry {EntryId}: {Message}", entry.Id, result.Message); + + return result; + } + + /// + public async Task UninstallEntry(InstalledEntry installedEntry, CancellationToken cancellationToken) + { + IEntryInstallationHandler handler = _factory.CreateHandler(installedEntry.EntryType); + EntryUninstallResult result = await handler.UninstallAsync(installedEntry, cancellationToken); + if (result.IsSuccess) + OnEntryUninstalled?.Invoke(this, installedEntry); + else + _logger.Warning("Failed to uninstall entry {EntryId}: {Message}", installedEntry.EntryId, result.Message); + + return result; + } + /// public List GetInstalledEntries() { @@ -161,6 +199,18 @@ public List GetInstalledEntries() return new InstalledEntry(entity); } + /// + public InstalledEntry? GetInstalledEntryByPlugin(Plugin plugin) + { + return GetInstalledEntries().FirstOrDefault(e => e.TryGetMetadata("PluginId", out Guid pluginId) && pluginId == plugin.Guid); + } + + /// + public InstalledEntry? GetInstalledEntryByProfile(ProfileConfiguration profileConfiguration) + { + return GetInstalledEntries().FirstOrDefault(e => e.TryGetMetadata("ProfileId", out Guid pluginId) && pluginId == profileConfiguration.ProfileId); + } + /// public void RemoveInstalledEntry(InstalledEntry installedEntry) { @@ -172,7 +222,7 @@ public void SaveInstalledEntry(InstalledEntry entry) { entry.Save(); _entryRepository.Save(entry.Entity); - + OnInstalledEntrySaved?.Invoke(this, entry); } @@ -189,10 +239,13 @@ public void Initialize() RemoveOrphanedFiles(); - _pluginManagementService.AdditionalPluginDirectories.AddRange(GetInstalledEntries() + _pluginManagementService.Value.AdditionalPluginDirectories.AddRange(GetInstalledEntries() .Where(e => e.EntryType == EntryType.Plugin) .Select(e => e.GetReleaseDirectory())); + _pluginManagementService.Value.PluginRemoved += PluginManagementServiceOnPluginRemoved; + _profileService.Value.ProfileRemoved += ProfileServiceOnProfileRemoved; + _initialized = true; } catch (Exception e) @@ -233,6 +286,28 @@ private void RemoveOrphanedDirectory(string directory) _logger.Warning(e, "Failed to remove orphaned workshop entry at {Directory}", directory); } } - + + private void ProfileServiceOnProfileRemoved(object? sender, ProfileConfigurationEventArgs e) + { + InstalledEntry? entry = GetInstalledEntryByProfile(e.ProfileConfiguration); + if (entry == null) + return; + + _logger.Information("Profile {Profile} was removed, uninstalling entry", e.ProfileConfiguration); + Task.Run(() => UninstallEntry(entry, CancellationToken.None)); + } + + private void PluginManagementServiceOnPluginRemoved(object? sender, PluginEventArgs e) + { + InstalledEntry? entry = GetInstalledEntryByPlugin(e.Plugin); + if (entry == null) + return; + + _logger.Information("Plugin {Plugin} was removed, uninstalling entry", e.Plugin); + Task.Run(() => UninstallEntry(entry, CancellationToken.None)); + } + public event EventHandler? OnInstalledEntrySaved; + public event EventHandler? OnEntryUninstalled; + public event EventHandler? OnEntryInstalled; } \ No newline at end of file From 00948de9d6782bddb776c1f90c3c48239f1e7047 Mon Sep 17 00:00:00 2001 From: Robert Date: Tue, 2 Jul 2024 22:34:25 +0200 Subject: [PATCH 5/5] Workshop - Fix installing new versions of profiles --- .../Services/Storage/Interfaces/IProfileService.cs | 8 -------- src/Artemis.Core/Services/Storage/ProfileService.cs | 12 ------------ .../ProfileEntryInstallationHandler.cs | 11 ++++++++--- 3 files changed, 8 insertions(+), 23 deletions(-) diff --git a/src/Artemis.Core/Services/Storage/Interfaces/IProfileService.cs b/src/Artemis.Core/Services/Storage/Interfaces/IProfileService.cs index d6fa0eca7..71209fc8d 100644 --- a/src/Artemis.Core/Services/Storage/Interfaces/IProfileService.cs +++ b/src/Artemis.Core/Services/Storage/Interfaces/IProfileService.cs @@ -120,14 +120,6 @@ public interface IProfileService : IArtemisService Task ImportProfile(Stream archiveStream, ProfileCategory category, bool makeUnique, bool markAsFreshImport, string? nameAffix = "imported", ProfileConfiguration? target = null); - /// - /// Imports the provided ZIP archive stream into the provided profile configuration - /// - /// The zip archive containing the profile to import. - /// The profile configuration to overwrite. - /// The resulting profile configuration. - Task OverwriteProfile(MemoryStream archiveStream, ProfileConfiguration profileConfiguration); - /// /// Adapts a given profile to the currently active devices. /// diff --git a/src/Artemis.Core/Services/Storage/ProfileService.cs b/src/Artemis.Core/Services/Storage/ProfileService.cs index f3c4dca4e..981f247a5 100644 --- a/src/Artemis.Core/Services/Storage/ProfileService.cs +++ b/src/Artemis.Core/Services/Storage/ProfileService.cs @@ -434,18 +434,6 @@ public async Task ImportProfile(Stream archiveStream, Prof return profileConfiguration; } - /// - public async Task OverwriteProfile(MemoryStream archiveStream, ProfileConfiguration profileConfiguration) - { - ProfileConfiguration imported = await ImportProfile(archiveStream, profileConfiguration.Category, true, false, null, profileConfiguration); - imported.Name = profileConfiguration.Name; - - RemoveProfileConfiguration(profileConfiguration); - SaveProfileCategory(imported.Category); - - return imported; - } - /// public void AdaptProfile(Profile profile) { diff --git a/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/ProfileEntryInstallationHandler.cs b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/ProfileEntryInstallationHandler.cs index 6df1aaead..d702681a4 100644 --- a/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/ProfileEntryInstallationHandler.cs +++ b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/ProfileEntryInstallationHandler.cs @@ -42,11 +42,16 @@ public async Task InstallAsync(IEntrySummary entry, IRelease ProfileConfiguration? existing = _profileService.ProfileCategories.SelectMany(c => c.ProfileConfigurations).FirstOrDefault(c => c.ProfileId == profileId); if (existing != null) { - ProfileConfiguration overwritten = await _profileService.OverwriteProfile(stream, existing); + ProfileConfiguration overwritten = await _profileService.ImportProfile(stream, existing.Category, true, false, null, existing); + overwritten.Name = existing.Name; + + // Update the release installedEntry.SetMetadata("ProfileId", overwritten.ProfileId); - - // Update the release and return the profile configuration UpdateRelease(installedEntry, release); + + // With everything updated, remove the old profile + _profileService.RemoveProfileConfiguration(existing); + return EntryInstallResult.FromSuccess(installedEntry); } }