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 @@ internal class DeviceService : IDeviceService _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 @@ internal class PluginManagementService : IPluginManagementService if (removeSettings) RemovePluginSettings(plugin); + + OnPluginRemoved(new PluginEventArgs(plugin)); } public void RemovePluginSettings(Plugin plugin) @@ -850,6 +852,7 @@ internal class PluginManagementService : IPluginManagementService 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 @@ internal class PluginManagementService : IPluginManagementService { 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 @@ internal class ProfileService : IProfileService 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 @@ internal class ProfileService : IProfileService category.AddProfileConfiguration(configuration, category.ProfileConfigurations.FirstOrDefault()); SaveProfileCategory(category); + + OnProfileAdded(new ProfileConfigurationEventArgs(configuration)); return configuration; } @@ -279,6 +278,8 @@ internal class ProfileService : IProfileService _profileRepository.Remove(profileConfiguration.Entity); _profileCategoryRepository.Save(category.Entity); + + OnProfileRemoved(new ProfileConfigurationEventArgs(profileConfiguration)); } /// @@ -436,8 +437,9 @@ internal class ProfileService : IProfileService /// 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 @@ internal class ProfileService : IProfileService 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 @@ internal class ProfileService : IProfileService } #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.Exceptions; 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 partial class PluginViewModel : ActivatableViewModelBase 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 @@ public partial class PluginViewModel : ActivatableViewModelBase _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; 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 partial class EntryReleaseInfoViewModel : ActivatableViewModelBase IWindowService windowService, IWorkshopService workshopService, IPluginManagementService pluginManagementService, - EntryInstallationHandlerFactory factory, ISettingsVmFactory settingsVmFactory) { _router = router; @@ -55,18 +53,31 @@ public partial class EntryReleaseInfoViewModel : ActivatableViewModelBase _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 partial class EntryReleaseInfoViewModel : ActivatableViewModelBase 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 partial class EntryReleaseInfoViewModel : ActivatableViewModelBase 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 partial class EntryReleaseInfoViewModel : ActivatableViewModelBase 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 partial class EntryReleaseItemViewModel : ActivatableViewModelBase _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.Models; 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 @@ public partial class LayoutFinderDeviceViewModel : ViewModelBase 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; 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 @@ public partial class InstalledTabItemViewModel : ViewModelBase 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 class ProfileEntryInstallationHandler : IEntryInstallationHandler 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 class ProfileEntryInstallationHandler : IEntryInstallationHandler 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 class WorkshopLayoutProvider : ILayoutProvider /// 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.Core.Services; 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 class WorkshopService : IWorkshopService } } + /// + 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 class WorkshopService : IWorkshopService 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 class WorkshopService : IWorkshopService { entry.Save(); _entryRepository.Save(entry.Entity); - + OnInstalledEntrySaved?.Invoke(this, entry); } @@ -189,10 +239,13 @@ public class WorkshopService : IWorkshopService 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 @@ public class WorkshopService : IWorkshopService _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