1
0
mirror of https://github.com/Artemis-RGB/Artemis synced 2025-12-13 05:48:35 +00:00

Streamlines plugin management and installation

Refactors plugin handling for improved reliability and user experience.

- Removes built-in plugin copying, migrating to workshop-based installation
- Introduces a dedicated service for plugin interactions (enabling, disabling, removal, settings)
- Simplifies plugin enabling/disabling logic in the UI, centralizing the logic
This commit is contained in:
Robert 2025-12-12 18:29:24 +01:00
parent 7f5b677cc3
commit bda247d3c3
18 changed files with 497 additions and 293 deletions

View File

@ -63,7 +63,6 @@ internal class CoreService : ICoreService
_logger.Debug("Forcing plugins to use HidSharp {HidSharpVersion}", hidSharpVersion);
// Initialize the services
_pluginManagementService.CopyBuiltInPlugins();
_pluginManagementService.LoadPlugins(IsElevated);
_pluginManagementService.StartHotReload();
_renderService.Initialize();

View File

@ -21,13 +21,12 @@ public interface IPluginManagementService : IArtemisService, IDisposable
/// Indicates whether or not plugins are currently being loaded
/// </summary>
bool LoadingPlugins { get; }
/// <summary>
/// Copy built-in plugins from the executable directory to the plugins directory if the version is higher
/// (higher or equal if compiled as debug)
/// Indicates whether or not plugins are currently loaded
/// </summary>
void CopyBuiltInPlugins();
bool LoadedPlugins { get; }
/// <summary>
/// Loads all installed plugins. If plugins already loaded this will reload them all
/// </summary>
@ -150,12 +149,7 @@ public interface IPluginManagementService : IArtemisService, IDisposable
/// <param name="device"></param>
/// <returns></returns>
DeviceProvider GetDeviceProviderByDevice(IRGBDevice device);
/// <summary>
/// Occurs when built-in plugins are being loaded
/// </summary>
event EventHandler CopyingBuildInPlugins;
/// <summary>
/// Occurs when a plugin has started loading
/// </summary>

View File

@ -46,114 +46,8 @@ internal class PluginManagementService : IPluginManagementService
public List<DirectoryInfo> AdditionalPluginDirectories { get; } = new();
public bool LoadingPlugins { get; private set; }
#region Built in plugins
public void CopyBuiltInPlugins()
{
OnCopyingBuildInPlugins();
DirectoryInfo pluginDirectory = new(Constants.PluginsFolder);
if (Directory.Exists(Path.Combine(pluginDirectory.FullName, "Artemis.Plugins.Modules.Overlay-29e3ff97")))
Directory.Delete(Path.Combine(pluginDirectory.FullName, "Artemis.Plugins.Modules.Overlay-29e3ff97"), true);
if (Directory.Exists(Path.Combine(pluginDirectory.FullName, "Artemis.Plugins.DataModelExpansions.TestData-ab41d601")))
Directory.Delete(Path.Combine(pluginDirectory.FullName, "Artemis.Plugins.DataModelExpansions.TestData-ab41d601"), true);
// Iterate built-in plugins
DirectoryInfo builtInPluginDirectory = new(Path.Combine(Constants.ApplicationFolder, "Plugins"));
if (!builtInPluginDirectory.Exists)
{
_logger.Warning("No built-in plugins found at {pluginDir}, skipping CopyBuiltInPlugins", builtInPluginDirectory.FullName);
return;
}
foreach (FileInfo zipFile in builtInPluginDirectory.EnumerateFiles("*.zip"))
{
try
{
ExtractBuiltInPlugin(zipFile, pluginDirectory);
}
catch (Exception e)
{
_logger.Error(e, "Failed to copy built-in plugin from {ZipFile}", zipFile.FullName);
}
}
}
private void ExtractBuiltInPlugin(FileInfo zipFile, DirectoryInfo pluginDirectory)
{
// Find the metadata file in the zip
using ZipArchive archive = ZipFile.OpenRead(zipFile.FullName);
ZipArchiveEntry? metaDataFileEntry = archive.Entries.FirstOrDefault(e => e.Name == "plugin.json");
if (metaDataFileEntry == null)
throw new ArtemisPluginException("Couldn't find a plugin.json in " + zipFile.FullName);
using StreamReader reader = new(metaDataFileEntry.Open());
PluginInfo builtInPluginInfo = CoreJson.Deserialize<PluginInfo>(reader.ReadToEnd())!;
string preferred = builtInPluginInfo.PreferredPluginDirectory;
// Find the matching plugin in the plugin folder
DirectoryInfo? match = pluginDirectory.EnumerateDirectories().FirstOrDefault(d => d.Name == preferred);
if (match == null)
{
CopyBuiltInPlugin(archive, preferred);
}
else
{
string metadataFile = Path.Combine(match.FullName, "plugin.json");
if (!File.Exists(metadataFile))
{
_logger.Debug("Copying missing built-in plugin {builtInPluginInfo}", builtInPluginInfo);
CopyBuiltInPlugin(archive, preferred);
}
else if (metaDataFileEntry.LastWriteTime > File.GetLastWriteTime(metadataFile))
{
try
{
_logger.Debug("Copying updated built-in plugin {builtInPluginInfo}", builtInPluginInfo);
CopyBuiltInPlugin(archive, preferred);
}
catch (Exception e)
{
throw new ArtemisPluginException($"Failed to install built-in plugin: {e.Message}", e);
}
}
}
}
private void CopyBuiltInPlugin(ZipArchive zipArchive, string targetDirectory)
{
ZipArchiveEntry metaDataFileEntry = zipArchive.Entries.First(e => e.Name == "plugin.json");
DirectoryInfo pluginDirectory = new(Path.Combine(Constants.PluginsFolder, targetDirectory));
bool createLockFile = File.Exists(Path.Combine(pluginDirectory.FullName, "artemis.lock"));
// Remove the old directory if it exists
if (Directory.Exists(pluginDirectory.FullName))
pluginDirectory.Delete(true);
// Extract everything in the same archive directory to the unique plugin directory
Utilities.CreateAccessibleDirectory(pluginDirectory.FullName);
string metaDataDirectory = metaDataFileEntry.FullName.Replace(metaDataFileEntry.Name, "");
foreach (ZipArchiveEntry zipArchiveEntry in zipArchive.Entries)
{
if (zipArchiveEntry.FullName.StartsWith(metaDataDirectory) && !zipArchiveEntry.FullName.EndsWith("/"))
{
string target = Path.Combine(pluginDirectory.FullName, zipArchiveEntry.FullName.Remove(0, metaDataDirectory.Length));
// Create folders
Utilities.CreateAccessibleDirectory(Path.GetDirectoryName(target)!);
// Extract files
zipArchiveEntry.ExtractToFile(target);
}
}
if (createLockFile)
File.Create(Path.Combine(pluginDirectory.FullName, "artemis.lock")).Close();
}
#endregion
public bool LoadedPlugins { get; private set; }
public List<Plugin> GetAllPlugins()
{
@ -328,8 +222,10 @@ internal class PluginManagementService : IPluginManagementService
// ReSharper restore InconsistentlySynchronizedField
LoadingPlugins = false;
LoadedPlugins = true;
}
public void UnloadPlugins()
{
// Unload all plugins
@ -686,7 +582,7 @@ internal class PluginManagementService : IPluginManagementService
if (removeSettings)
RemovePluginSettings(plugin);
OnPluginRemoved(new PluginEventArgs(plugin));
}
@ -893,7 +789,7 @@ internal class PluginManagementService : IPluginManagementService
{
PluginDisabled?.Invoke(this, e);
}
protected virtual void OnPluginRemoved(PluginEventArgs e)
{
PluginRemoved?.Invoke(this, e);

View File

@ -6,11 +6,11 @@ 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.Services;
using Artemis.UI.Services.Interfaces;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Services;
using Artemis.UI.Shared.Services.Builders;
using Avalonia.Controls;
using Avalonia.Threading;
using Material.Icons;
@ -21,9 +21,7 @@ namespace Artemis.UI.Screens.Plugins;
public partial class PluginViewModel : ActivatableViewModelBase
{
private readonly ICoreService _coreService;
private readonly INotificationService _notificationService;
private readonly IPluginManagementService _pluginManagementService;
private readonly IPluginInteractionService _pluginInteractionService;
private readonly IWindowService _windowService;
private Window? _settingsWindow;
[Notify] private bool _canInstallPrerequisites;
@ -31,18 +29,11 @@ public partial class PluginViewModel : ActivatableViewModelBase
[Notify] private bool _enabling;
[Notify] private Plugin _plugin;
public PluginViewModel(Plugin plugin,
ReactiveCommand<Unit, Unit>? reload,
ICoreService coreService,
IWindowService windowService,
INotificationService notificationService,
IPluginManagementService pluginManagementService)
public PluginViewModel(Plugin plugin, ReactiveCommand<Unit, Unit>? reload, IWindowService windowService, IPluginInteractionService pluginInteractionService)
{
_plugin = plugin;
_coreService = coreService;
_windowService = windowService;
_notificationService = notificationService;
_pluginManagementService = pluginManagementService;
_pluginInteractionService = pluginInteractionService;
Platforms = new ObservableCollection<PluginPlatformViewModel>();
if (Plugin.Info.Platforms != null)
@ -88,7 +79,6 @@ public partial class PluginViewModel : ActivatableViewModelBase
public ReactiveCommand<Unit, Unit> OpenPluginDirectory { get; }
public ObservableCollection<PluginPlatformViewModel> Platforms { get; }
public string Type => Plugin.GetType().BaseType?.Name ?? Plugin.GetType().Name;
public bool IsEnabled => Plugin.IsEnabled;
public async Task UpdateEnabled(bool enable)
@ -97,55 +87,15 @@ public partial class PluginViewModel : ActivatableViewModelBase
return;
if (!enable)
{
try
{
await Task.Run(() => _pluginManagementService.DisablePlugin(Plugin, true));
}
catch (Exception e)
{
await ShowUpdateEnableFailure(enable, e);
}
finally
{
this.RaisePropertyChanged(nameof(IsEnabled));
}
return;
}
try
await _pluginInteractionService.DisablePlugin(Plugin);
else
{
Enabling = true;
if (Plugin.Info.RequiresAdmin && !_coreService.IsElevated)
{
bool confirmed = await _windowService.ShowConfirmContentDialog("Enable plugin", "This plugin requires admin rights, are you sure you want to enable it? Artemis will need to restart.", "Confirm and restart");
if (!confirmed)
return;
}
// Check if all prerequisites are met async
List<IPrerequisitesSubject> 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(() => _pluginManagementService.EnablePlugin(Plugin, true, true));
}
catch (Exception e)
{
await ShowUpdateEnableFailure(enable, e);
}
finally
{
await _pluginInteractionService.EnablePlugin(Plugin, false);
Enabling = false;
this.RaisePropertyChanged(nameof(IsEnabled));
}
this.RaisePropertyChanged(nameof(IsEnabled));
}
public void CheckPrerequisites()
@ -220,43 +170,12 @@ public partial class PluginViewModel : ActivatableViewModelBase
private async Task ExecuteRemoveSettings()
{
bool confirmed = await _windowService.ShowConfirmContentDialog("Clear plugin settings", "Are you sure you want to clear the settings of this plugin?");
if (!confirmed)
return;
bool wasEnabled = IsEnabled;
if (IsEnabled)
await UpdateEnabled(false);
_pluginManagementService.RemovePluginSettings(Plugin);
if (wasEnabled)
await UpdateEnabled(true);
_notificationService.CreateNotification().WithTitle("Cleared plugin settings.").Show();
await _pluginInteractionService.RemovePluginSettings(Plugin);
}
private async Task ExecuteRemove()
{
bool confirmed = await _windowService.ShowConfirmContentDialog("Remove plugin", "Are you sure you want to remove this plugin?");
if (!confirmed)
return;
// If the plugin or any of its features has uninstall actions, offer to run these
await ExecuteRemovePrerequisites(true);
try
{
_pluginManagementService.RemovePlugin(Plugin, false);
}
catch (Exception e)
{
_windowService.ShowExceptionDialog("Failed to remove plugin", e);
throw;
}
_notificationService.CreateNotification().WithTitle("Removed plugin.").Show();
await _pluginInteractionService.RemovePlugin(Plugin);
}
private void ExecuteShowLogsFolder()
@ -271,20 +190,6 @@ public partial class PluginViewModel : ActivatableViewModelBase
}
}
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").WithCommand(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 OnPluginToggled(object? sender, EventArgs e)
{
Dispatcher.UIThread.Post(() =>
@ -299,9 +204,9 @@ public partial class PluginViewModel : ActivatableViewModelBase
{
if (IsEnabled)
return;
await UpdateEnabled(true);
// If enabling failed, don't offer to show the settings
if (!IsEnabled || Plugin.ConfigurationDialog == null)
return;

View File

@ -83,7 +83,7 @@ public class RootViewModel : RoutableHostScreen<RoutableScreen>, IMainWindowProv
_coreService.Initialized += (_, _) => Dispatcher.UIThread.InvokeAsync(OpenMainWindow);
}
Task.Run(() =>
Task.Run(async () =>
{
try
{
@ -93,7 +93,7 @@ public class RootViewModel : RoutableHostScreen<RoutableScreen>, IMainWindowProv
return;
// Workshop service goes first so it has a chance to clean up old workshop entries and introduce new ones
workshopService.Initialize();
await workshopService.Initialize();
// Core is initialized now that everything is ready to go
coreService.Initialize();

View File

@ -2,6 +2,7 @@
using Artemis.Core;
using Artemis.Core.Services;
using Artemis.UI.Shared;
using Artemis.WebClient.Workshop.Services;
using PropertyChanged.SourceGenerator;
namespace Artemis.UI.Screens.Root;
@ -10,12 +11,12 @@ public partial class SplashViewModel : ViewModelBase
{
[Notify] private string _status;
public SplashViewModel(ICoreService coreService, IPluginManagementService pluginManagementService)
public SplashViewModel(ICoreService coreService, IPluginManagementService pluginManagementService, IWorkshopService workshopService)
{
CoreService = coreService;
_status = "Initializing Core";
pluginManagementService.CopyingBuildInPlugins += OnPluginManagementServiceOnCopyingBuildInPluginsManagement;
workshopService.MigratingBuildInPlugins += WorkshopServiceOnMigratingBuildInPlugins;
pluginManagementService.PluginLoading += OnPluginManagementServiceOnPluginManagementLoading;
pluginManagementService.PluginLoaded += OnPluginManagementServiceOnPluginManagementLoaded;
pluginManagementService.PluginEnabling += PluginManagementServiceOnPluginManagementEnabling;
@ -25,6 +26,11 @@ public partial class SplashViewModel : ViewModelBase
}
public ICoreService CoreService { get; }
private void WorkshopServiceOnMigratingBuildInPlugins(object? sender, EventArgs args)
{
Status = "Migrating built-in plugins";
}
private void OnPluginManagementServiceOnPluginManagementLoaded(object? sender, PluginEventArgs args)
{
@ -55,9 +61,4 @@ public partial class SplashViewModel : ViewModelBase
{
Status = "Initializing UI";
}
private void OnPluginManagementServiceOnCopyingBuildInPluginsManagement(object? sender, EventArgs args)
{
Status = "Updating built-in plugins";
}
}

View File

@ -5,9 +5,10 @@ using System.Threading;
using System.Threading.Tasks;
using Artemis.Core;
using Artemis.Core.Services;
using Artemis.UI.DryIoc.Factories;
using Artemis.UI.Exceptions;
using Artemis.UI.Screens.Plugins;
using Artemis.UI.Services;
using Artemis.UI.Services.Interfaces;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Services;
using Artemis.UI.Shared.Utilities;
@ -28,7 +29,7 @@ public partial class DefaultEntryItemViewModel : ActivatableViewModelBase
private readonly IWindowService _windowService;
private readonly IPluginManagementService _pluginManagementService;
private readonly IProfileService _profileService;
private readonly ISettingsVmFactory _settingsVmFactory;
private readonly IPluginInteractionService _pluginInteractionService;
private readonly Progress<StreamProgress> _progress = new();
[Notify] private bool _isInstalled;
@ -41,14 +42,14 @@ public partial class DefaultEntryItemViewModel : ActivatableViewModelBase
IWindowService windowService,
IPluginManagementService pluginManagementService,
IProfileService profileService,
ISettingsVmFactory settingsVmFactory)
IPluginInteractionService pluginInteractionService)
{
_logger = logger;
_workshopService = workshopService;
_windowService = windowService;
_pluginManagementService = pluginManagementService;
_profileService = profileService;
_settingsVmFactory = settingsVmFactory;
_pluginInteractionService = pluginInteractionService;
Entry = entry;
_progress.ProgressChanged += (_, f) => InstallProgress = f.ProgressPercentage;
@ -62,10 +63,7 @@ public partial class DefaultEntryItemViewModel : ActivatableViewModelBase
if (IsInstalled || !ShouldInstall || Entry.LatestRelease == null)
return true;
// Most entries install so fast it looks broken without a small delay
Task minimumDelay = Task.Delay(100, cancellationToken);
EntryInstallResult result = await _workshopService.InstallEntry(Entry, Entry.LatestRelease, _progress, cancellationToken);
await minimumDelay;
if (!result.IsSuccess)
{
@ -95,8 +93,7 @@ public partial class DefaultEntryItemViewModel : ActivatableViewModelBase
throw new InvalidOperationException($"Plugin with id '{pluginId}' does not exist.");
// There's quite a bit of UI involved in enabling a plugin, borrowing the PluginSettingsViewModel for this
PluginViewModel pluginViewModel = _settingsVmFactory.PluginViewModel(plugin, ReactiveCommand.Create(() => { }));
await pluginViewModel.UpdateEnabled(true);
await _pluginInteractionService.EnablePlugin(plugin, true);
// Find features without prerequisites to enable
foreach (PluginFeatureInfo pluginFeatureInfo in plugin.Features)
@ -113,15 +110,6 @@ public partial class DefaultEntryItemViewModel : ActivatableViewModelBase
_logger.Warning(e, "Failed to enable plugin feature '{FeatureName}', skipping", pluginFeatureInfo.Name);
}
}
// If the plugin has a mandatory settings window, open it and wait
if (plugin.ConfigurationDialog != null && plugin.ConfigurationDialog.IsMandatory)
{
if (plugin.Resolve(plugin.ConfigurationDialog.Type) is not PluginConfigurationViewModel viewModel)
throw new ArtemisUIException($"The type of a plugin configuration dialog must inherit {nameof(PluginConfigurationViewModel)}");
await _windowService.ShowDialogAsync(new PluginSettingsWindowViewModel(viewModel));
}
}
private void PrepareProfile(InstalledEntry entry)

View File

@ -6,9 +6,8 @@ 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.Services.Interfaces;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Routing;
using Artemis.UI.Shared.Services;
@ -30,7 +29,7 @@ public partial class EntryReleaseInfoViewModel : ActivatableViewModelBase
private readonly IWindowService _windowService;
private readonly IWorkshopService _workshopService;
private readonly IPluginManagementService _pluginManagementService;
private readonly ISettingsVmFactory _settingsVmFactory;
private readonly IPluginInteractionService _pluginInteractionService;
private readonly Progress<StreamProgress> _progress = new();
[Notify] private IReleaseDetails? _release;
@ -46,14 +45,14 @@ public partial class EntryReleaseInfoViewModel : ActivatableViewModelBase
IWindowService windowService,
IWorkshopService workshopService,
IPluginManagementService pluginManagementService,
ISettingsVmFactory settingsVmFactory)
IPluginInteractionService pluginInteractionService)
{
_router = router;
_notificationService = notificationService;
_windowService = windowService;
_workshopService = workshopService;
_pluginManagementService = pluginManagementService;
_settingsVmFactory = settingsVmFactory;
_pluginInteractionService = pluginInteractionService;
_progress.ProgressChanged += (_, f) => InstallProgress = f.ProgressPercentage;
this.WhenActivated(d =>
@ -124,7 +123,10 @@ public partial class EntryReleaseInfoViewModel : ActivatableViewModelBase
_workshopService.SetAutoUpdate(result.Entry, !disableAutoUpdates);
_notificationService.CreateNotification().WithTitle("Installation succeeded").WithSeverity(NotificationSeverity.Success).Show();
InstallationInProgress = false;
await Manage();
// Auto-enable plugins as the installation handler won't deal with the required UI interactions
if (result.Installed is Plugin installedPlugin)
await AutoEnablePlugin(installedPlugin);
}
else if (!_cts.IsCancellationRequested)
{
@ -141,12 +143,6 @@ public partial class EntryReleaseInfoViewModel : ActivatableViewModelBase
}
}
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?"))
@ -193,7 +189,28 @@ public partial class EntryReleaseInfoViewModel : ActivatableViewModelBase
if (plugin == null)
return;
PluginViewModel pluginViewModel = _settingsVmFactory.PluginViewModel(plugin, ReactiveCommand.Create(() => { }));
await pluginViewModel.ExecuteRemovePrerequisites(true);
await _pluginInteractionService.RemovePluginPrerequisites(plugin, true);
}
private async Task AutoEnablePlugin(Plugin plugin)
{
// There's quite a bit of UI involved in enabling a plugin, borrowing the PluginSettingsViewModel for this
await _pluginInteractionService.EnablePlugin(plugin, true);
// Find features without prerequisites to enable
foreach (PluginFeatureInfo pluginFeatureInfo in plugin.Features)
{
if (pluginFeatureInfo.Instance == null || pluginFeatureInfo.Instance.IsEnabled || pluginFeatureInfo.Prerequisites.Count != 0)
continue;
try
{
_pluginManagementService.EnablePluginFeature(pluginFeatureInfo.Instance, true);
}
catch (Exception e)
{
_notificationService.CreateNotification().WithTitle("Failed to enable plugin feature").WithMessage(e.Message).WithSeverity(NotificationSeverity.Error).Show();
}
}
}
}

View File

@ -0,0 +1,44 @@
using System.Threading.Tasks;
using Artemis.Core;
namespace Artemis.UI.Services.Interfaces;
public interface IPluginInteractionService : IArtemisUIService
{
/// <summary>
/// Enables a plugin, showing prerequisites and config windows as necessary.
/// </summary>
/// <param name="plugin">The plugin to enable.</param>
/// <param name="showMandatoryConfigWindow"></param>
/// <returns>A task representing the asynchronous operation.</returns>
Task<bool> EnablePlugin(Plugin plugin, bool showMandatoryConfigWindow);
/// <summary>
/// Disables a plugin, stopping all its features and services.
/// </summary>
/// <param name="plugin">The plugin to disable.</param>
/// <returns>A task representing the asynchronous operation with a boolean indicating success.</returns>
Task<bool> DisablePlugin(Plugin plugin);
/// <summary>
/// Removes a plugin from the system, optionally running uninstall actions for prerequisites.
/// </summary>
/// <param name="plugin">The plugin to remove.</param>
/// <returns>A task representing the asynchronous operation with a boolean indicating success.</returns>
Task<bool> RemovePlugin(Plugin plugin);
/// <summary>
/// Removes all settings and configuration data for a plugin, temporarily disabling it during the process.
/// </summary>
/// <param name="plugin">The plugin whose settings should be cleared.</param>
/// <returns>A task representing the asynchronous operation with a boolean indicating success.</returns>
Task<bool> RemovePluginSettings(Plugin plugin);
/// <summary>
/// Removes the prerequisites for a plugin.
/// </summary>
/// <param name="plugin">The plugin whose prerequisites should be removed.</param>
/// <param name="forPluginRemoval">Whether the prerequisites are being removed for a plugin removal.</param>
/// <returns>A task representing the asynchronous operation with a boolean indicating success.</returns>
Task<bool> RemovePluginPrerequisites(Plugin plugin, bool forPluginRemoval);
}

View File

@ -0,0 +1,170 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Artemis.Core;
using Artemis.Core.Services;
using Artemis.UI.Exceptions;
using Artemis.UI.Screens.Plugins;
using Artemis.UI.Services.Interfaces;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Services;
using Artemis.UI.Shared.Services.Builders;
namespace Artemis.UI.Services;
public class PluginInteractionService : IPluginInteractionService
{
private readonly ICoreService _coreService;
private readonly IPluginManagementService _pluginManagementService;
private readonly IWindowService _windowService;
private readonly INotificationService _notificationService;
public PluginInteractionService(ICoreService coreService, IPluginManagementService pluginManagementService, IWindowService windowService, INotificationService notificationService)
{
_coreService = coreService;
_pluginManagementService = pluginManagementService;
_windowService = windowService;
_notificationService = notificationService;
}
/// <inheritdoc />
public async Task<bool> EnablePlugin(Plugin plugin, bool showMandatoryConfigWindow)
{
try
{
if (plugin.Info.RequiresAdmin && !_coreService.IsElevated)
{
bool confirmed = await _windowService.ShowConfirmContentDialog(
"Enable plugin",
"This plugin requires admin rights, are you sure you want to enable it? Artemis will need to restart.",
"Confirm and restart"
);
if (!confirmed)
return false;
}
// Check if all prerequisites are met async
List<IPrerequisitesSubject> subjects = [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 false;
}
await Task.Run(() => _pluginManagementService.EnablePlugin(plugin, true, true));
// If the plugin has a mandatory settings window, open it and wait
if (showMandatoryConfigWindow && plugin.ConfigurationDialog != null && plugin.ConfigurationDialog.IsMandatory)
{
if (plugin.Resolve(plugin.ConfigurationDialog.Type) is not PluginConfigurationViewModel viewModel)
throw new ArtemisUIException($"The type of a plugin configuration dialog must inherit {nameof(PluginConfigurationViewModel)}");
await _windowService.ShowDialogAsync(new PluginSettingsWindowViewModel(viewModel));
}
return true;
}
catch (Exception e)
{
await ShowPluginToggleFailure(plugin, true, e);
}
return false;
}
/// <inheritdoc />
public async Task<bool> DisablePlugin(Plugin plugin)
{
try
{
await Task.Run(() => _pluginManagementService.DisablePlugin(plugin, true));
return true;
}
catch (Exception e)
{
await ShowPluginToggleFailure(plugin, false, e);
}
return false;
}
/// <inheritdoc />
public async Task<bool> RemovePlugin(Plugin plugin)
{
bool confirmed = await _windowService.ShowConfirmContentDialog("Remove plugin", "Are you sure you want to remove this plugin?");
if (!confirmed)
return false;
// If the plugin or any of its features has uninstall actions, offer to run these
await RemovePrerequisites(plugin, true);
try
{
_pluginManagementService.RemovePlugin(plugin, false);
}
catch (Exception e)
{
_windowService.ShowExceptionDialog("Failed to remove plugin", e);
throw;
}
_notificationService.CreateNotification().WithTitle("Removed plugin.").Show();
return true;
}
/// <inheritdoc />
public async Task<bool> RemovePluginSettings(Plugin plugin)
{
bool confirmed = await _windowService.ShowConfirmContentDialog("Clear plugin settings", "Are you sure you want to clear the settings of this plugin?");
if (!confirmed)
return false;
bool wasEnabled = plugin.IsEnabled;
if (wasEnabled)
_pluginManagementService.DisablePlugin(plugin, false);
_pluginManagementService.RemovePluginSettings(plugin);
if (wasEnabled)
_pluginManagementService.EnablePlugin(plugin, false);
_notificationService.CreateNotification().WithTitle("Cleared plugin settings.").Show();
return true;
}
/// <inheritdoc />
public async Task<bool> RemovePluginPrerequisites(Plugin plugin, bool forPluginRemoval)
{
await RemovePrerequisites(plugin, false);
return true;
}
private async Task ShowPluginToggleFailure(Plugin plugin, 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(() => Utilities.OpenFolder(Constants.LogsFolder)));
// 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 async Task RemovePrerequisites(Plugin plugin, bool forPluginRemoval)
{
List<IPrerequisitesSubject> subjects = [plugin.Info];
subjects.AddRange(!forPluginRemoval ? plugin.Features.Where(f => f.AlwaysEnabled) : plugin.Features);
if (subjects.Any(s => s.PlatformPrerequisites.Any(p => p.UninstallActions.Any())))
await PluginPrerequisitesUninstallDialogViewModel.Show(_windowService, subjects, forPluginRemoval ? "Skip, remove plugin" : "Cancel");
}
}

View File

@ -0,0 +1,106 @@
using Artemis.Core;
using Artemis.Storage.Entities.Plugins;
using Artemis.Storage.Repositories.Interfaces;
using Artemis.UI.Shared.Utilities;
using Artemis.WebClient.Workshop.Services;
using Serilog;
using StrawberryShake;
namespace Artemis.WebClient.Workshop;
public static class BuiltInPluginsMigrator
{
private static readonly Guid[] ObsoleteBuiltInPlugins =
[
new("4e1e54fd-6636-40ad-afdc-b3b0135feab2"),
new("cad475d3-c621-4ec7-bbfc-784e3b4723ce"),
new("ab41d601-35e0-4a73-bf0b-94509b006ab0"),
new("27d124e3-48e8-4b0a-8a5e-d5e337a88d4a")
];
public static async Task<bool> Migrate(IWorkshopService workshopService, IWorkshopClient workshopClient, ILogger logger, IPluginRepository pluginRepository)
{
// If no default plugins are present (later installs), do nothing
DirectoryInfo pluginDirectory = new(Constants.PluginsFolder);
if (!pluginDirectory.Exists)
{
return true;
}
// Load plugin info, the plugin management service isn't available yet (which is exactly what we want)
List<(PluginInfo PluginInfo, DirectoryInfo Directory)> plugins = [];
foreach (DirectoryInfo subDirectory in pluginDirectory.EnumerateDirectories())
{
try
{
// Load the metadata
string metadataFile = Path.Combine(subDirectory.FullName, "plugin.json");
if (File.Exists(metadataFile))
plugins.Add((CoreJson.Deserialize<PluginInfo>(await File.ReadAllTextAsync(metadataFile))!, subDirectory));
}
catch (Exception)
{
// ignored, who knows what old stuff people might have in their plugins folder
}
}
if (plugins.Count == 0)
{
return true;
}
IWorkshopService.WorkshopStatus workshopStatus = await workshopService.GetWorkshopStatus(CancellationToken.None);
if (!workshopStatus.IsReachable)
{
logger.Warning("MigrateBuiltInPlugins - Cannot migrate built-in plugins because the workshop is unreachable");
return false;
}
logger.Information("MigrateBuiltInPlugins - Migrating built-in plugins to workshop entries");
IOperationResult<IGetDefaultPluginsResult> result = await workshopClient.GetDefaultPlugins.ExecuteAsync(100, null, CancellationToken.None);
List<IGetDefaultPlugins_EntriesV2_Edges_Node> entries = result.Data?.EntriesV2?.Edges?.Select(e => e.Node).ToList() ?? [];
while (result.Data?.EntriesV2?.PageInfo is {HasNextPage: true})
{
result = await workshopClient.GetDefaultPlugins.ExecuteAsync(100, result.Data.EntriesV2.PageInfo.EndCursor, CancellationToken.None);
if (result.Data?.EntriesV2?.Edges != null)
entries.AddRange(result.Data.EntriesV2.Edges.Select(e => e.Node));
}
logger.Information("MigrateBuiltInPlugins - Found {Count} default plugins in the workshop", entries.Count);
foreach (IGetDefaultPlugins_EntriesV2_Edges_Node entry in entries)
{
// Skip entries without plugin info or releases, shouldn't happen but theoretically possible
if (entry.PluginInfo == null || entry.LatestRelease == null)
continue;
// Find a built-in plugin
(PluginInfo? pluginInfo, DirectoryInfo? directory) = plugins.FirstOrDefault(p => p.PluginInfo.Guid == entry.PluginInfo.PluginGuid);
if (pluginInfo == null || directory == null)
continue;
// If the plugin is enabled, install the workshop equivalent (the built-in plugin will be removed by the install process)
PluginEntity? entity = pluginRepository.GetPluginByPluginGuid(pluginInfo.Guid);
if (entity != null && entity.IsEnabled)
{
logger.Information("MigrateBuiltInPlugins - Migrating built-in plugin {Plugin} to workshop entry {Entry}", pluginInfo.Name, entry);
await workshopService.InstallEntry(entry, entry.LatestRelease, new Progress<StreamProgress>(), CancellationToken.None);
}
// Remove the built-in plugin, it's no longer needed
directory.Delete(true);
}
// Remove obsolete built-in plugins
foreach (Guid obsoleteBuiltInPlugin in ObsoleteBuiltInPlugins)
{
(PluginInfo? pluginInfo, DirectoryInfo? directory) = plugins.FirstOrDefault(p => p.PluginInfo.Guid == obsoleteBuiltInPlugin);
if (pluginInfo == null || directory == null)
continue;
directory.Delete(true);
}
logger.Information("MigrateBuiltInPlugins - Finished migrating built-in plugins to workshop entries");
return true;
}
}

View File

@ -1,13 +1,31 @@
using Artemis.WebClient.Workshop.Models;
using Artemis.Core;
using Artemis.WebClient.Workshop.Models;
using Artemis.WebClient.Workshop.Services;
namespace Artemis.WebClient.Workshop.Handlers.InstallationHandlers;
public class EntryInstallResult
{
public bool IsSuccess { get; set; }
public string? Message { get; set; }
public InstalledEntry? Entry { get; set; }
/// <summary>
/// Gets a value indicating whether the installation was successful.
/// </summary>
public bool IsSuccess { get; private set; }
/// <summary>
/// Gets a message describing the result of the installation.
/// </summary>
public string? Message { get; private set; }
/// <summary>
/// Gets the entry that was installed, if any.
/// </summary>
public InstalledEntry? Entry { get; private set; }
/// <summary>
/// Gets the result object returned by the installation handler, if any.
/// <remarks>This'll be a <see cref="ProfileConfiguration"/>, <see cref="ArtemisLayout"/> or <see cref="Plugin"/> depending on the entry type.</remarks>
/// </summary>
public object? Installed { get; private set; }
public static EntryInstallResult FromFailure(string? message)
{
@ -18,12 +36,13 @@ public class EntryInstallResult
};
}
public static EntryInstallResult FromSuccess(InstalledEntry installedEntry)
public static EntryInstallResult FromSuccess(InstalledEntry installedEntry, object? result)
{
return new EntryInstallResult
{
IsSuccess = true,
Entry = installedEntry
Entry = installedEntry,
Installed = result
};
}

View File

@ -59,7 +59,7 @@ public class LayoutEntryInstallationHandler : IEntryInstallationHandler
{
installedEntry.ApplyRelease(release);
_workshopService.SaveInstalledEntry(installedEntry);
return EntryInstallResult.FromSuccess(installedEntry);
return EntryInstallResult.FromSuccess(installedEntry, layout);
}
// If the layout ended up being invalid yoink it out again, shoooo

View File

@ -30,7 +30,7 @@ public class PluginEntryInstallationHandler : IEntryInstallationHandler
{
// If the folder already exists, we're not going to reinstall the plugin since files may be in use, consider our job done
if (installedEntry.GetReleaseDirectory(release).Exists)
return ApplyAndSave(installedEntry, release);
return ApplyAndSave(null, installedEntry, release);
}
else
{
@ -64,7 +64,12 @@ public class PluginEntryInstallationHandler : IEntryInstallationHandler
archive.ExtractToDirectory(releaseDirectory.FullName);
PluginInfo pluginInfo = CoreJson.Deserialize<PluginInfo>(await File.ReadAllTextAsync(Path.Combine(releaseDirectory.FullName, "plugin.json"), cancellationToken))!;
installedEntry.SetMetadata("PluginId", pluginInfo.Guid);
// If the plugin management service isn't loaded yet (happens while migrating from built-in plugins) we're done here
if (!_pluginManagementService.LoadedPlugins)
return ApplyAndSave(null, installedEntry, release);
// If there is already a version of the plugin installed, remove it
Plugin? currentVersion = _pluginManagementService.GetAllPlugins().FirstOrDefault(p => p.Guid == pluginInfo.Guid);
if (currentVersion != null)
@ -78,13 +83,12 @@ public class PluginEntryInstallationHandler : IEntryInstallationHandler
}
// Load the plugin, next time during startup this will happen automatically
Plugin? plugin = null;
try
{
Plugin? plugin = _pluginManagementService.LoadPlugin(releaseDirectory);
plugin = _pluginManagementService.LoadPlugin(releaseDirectory);
if (plugin == null)
throw new ArtemisWorkshopException("Failed to load plugin, it may be incompatible");
installedEntry.SetMetadata("PluginId", plugin.Guid);
}
catch (Exception e)
{
@ -102,7 +106,7 @@ public class PluginEntryInstallationHandler : IEntryInstallationHandler
return EntryInstallResult.FromFailure(e.Message);
}
return ApplyAndSave(installedEntry, release);
return ApplyAndSave(plugin, installedEntry, release);
}
public Task<EntryUninstallResult> UninstallAsync(InstalledEntry installedEntry, CancellationToken cancellationToken)
@ -133,10 +137,10 @@ public class PluginEntryInstallationHandler : IEntryInstallationHandler
return Task.FromResult(EntryUninstallResult.FromSuccess(message));
}
private EntryInstallResult ApplyAndSave(InstalledEntry installedEntry, IRelease release)
private EntryInstallResult ApplyAndSave(Plugin? plugin, InstalledEntry installedEntry, IRelease release)
{
installedEntry.ApplyRelease(release);
_workshopService.SaveInstalledEntry(installedEntry);
return EntryInstallResult.FromSuccess(installedEntry);
return EntryInstallResult.FromSuccess(installedEntry, plugin);
}
}

View File

@ -52,7 +52,7 @@ public class ProfileEntryInstallationHandler : IEntryInstallationHandler
// With everything updated, remove the old profile
_profileService.RemoveProfileConfiguration(existing);
return EntryInstallResult.FromSuccess(installedEntry);
return EntryInstallResult.FromSuccess(installedEntry, overwritten);
}
}
@ -66,7 +66,7 @@ public class ProfileEntryInstallationHandler : IEntryInstallationHandler
// Update the release and return the profile configuration
UpdateRelease(installedEntry, release);
return EntryInstallResult.FromSuccess(installedEntry);
return EntryInstallResult.FromSuccess(installedEntry, imported);
}
public async Task<EntryUninstallResult> UninstallAsync(InstalledEntry installedEntry, CancellationToken cancellationToken)

View File

@ -39,4 +39,33 @@ query GetDefaultEntries($first: Int, $after: String) {
}
}
}
}
query GetDefaultPlugins($first: Int, $after: String) {
entriesV2(
includeDefaults: true
where: {
and: [
{ defaultEntryInfo: { entryId: { gt: 0 } } }
{ entryType: {eq: PLUGIN} }
]
}
first: $first
after: $after
) {
totalCount
pageInfo {
hasNextPage
endCursor
}
edges {
cursor
node {
...entrySummary
pluginInfo {
pluginGuid
}
}
}
}
}

View File

@ -124,7 +124,7 @@ public interface IWorkshopService
/// <summary>
/// Initializes the workshop service.
/// </summary>
void Initialize();
Task Initialize();
/// <summary>
/// Represents the status of the workshop.
@ -134,6 +134,7 @@ public interface IWorkshopService
public event EventHandler<InstalledEntry>? OnInstalledEntrySaved;
public event EventHandler<InstalledEntry>? OnEntryUninstalled;
public event EventHandler<InstalledEntry>? OnEntryInstalled;
public event EventHandler? MigratingBuildInPlugins;
void SetAutoUpdate(InstalledEntry installedEntry, bool autoUpdate);
}

View File

@ -1,6 +1,7 @@
using System.Net.Http.Headers;
using Artemis.Core;
using Artemis.Core.Services;
using Artemis.Storage.Entities.Plugins;
using Artemis.Storage.Entities.Workshop;
using Artemis.Storage.Repositories.Interfaces;
using Artemis.UI.Shared.Routing;
@ -10,6 +11,7 @@ using Artemis.WebClient.Workshop.Handlers.InstallationHandlers;
using Artemis.WebClient.Workshop.Handlers.UploadHandlers;
using Artemis.WebClient.Workshop.Models;
using Serilog;
using StrawberryShake;
namespace Artemis.WebClient.Workshop.Services;
@ -22,15 +24,24 @@ public class WorkshopService : IWorkshopService
private readonly Lazy<IPluginManagementService> _pluginManagementService;
private readonly Lazy<IProfileService> _profileService;
private readonly EntryInstallationHandlerFactory _factory;
private readonly IPluginRepository _pluginRepository;
private readonly IWorkshopClient _workshopClient;
private readonly PluginSetting<bool> _migratedBuiltInPlugins;
private bool _initialized;
public WorkshopService(ILogger logger,
IHttpClientFactory httpClientFactory,
IRouter router,
IEntryRepository entryRepository,
ISettingsService settingsService,
Lazy<IPluginManagementService> pluginManagementService,
Lazy<IProfileService> profileService,
EntryInstallationHandlerFactory factory)
EntryInstallationHandlerFactory factory,
IPluginRepository pluginRepository,
IWorkshopClient workshopClient)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
@ -39,6 +50,10 @@ public class WorkshopService : IWorkshopService
_pluginManagementService = pluginManagementService;
_profileService = profileService;
_factory = factory;
_pluginRepository = pluginRepository;
_workshopClient = workshopClient;
_migratedBuiltInPlugins = settingsService.GetSetting("Workshop.MigratedBuiltInPlugins", false);
}
public async Task<Stream?> GetEntryIcon(long entryId, CancellationToken cancellationToken)
@ -166,7 +181,7 @@ public class WorkshopService : IWorkshopService
OnEntryInstalled?.Invoke(this, result.Entry);
else
_logger.Warning("Failed to install entry {Entry}: {Message}", entry, result.Message);
return result;
}
@ -227,7 +242,7 @@ public class WorkshopService : IWorkshopService
}
/// <inheritdoc />
public void Initialize()
public async Task Initialize()
{
if (_initialized)
throw new ArtemisWorkshopException("Workshop service is already initialized");
@ -238,6 +253,7 @@ public class WorkshopService : IWorkshopService
Directory.CreateDirectory(Constants.WorkshopFolder);
RemoveOrphanedFiles();
await MigrateBuiltInPlugins();
_pluginManagementService.Value.AdditionalPluginDirectories.AddRange(GetInstalledEntries()
.Where(e => e.EntryType == EntryType.Plugin)
@ -259,7 +275,7 @@ public class WorkshopService : IWorkshopService
{
if (installedEntry.AutoUpdate == autoUpdate)
return;
installedEntry.AutoUpdate = autoUpdate;
SaveInstalledEntry(installedEntry);
}
@ -297,6 +313,19 @@ public class WorkshopService : IWorkshopService
}
}
private async Task MigrateBuiltInPlugins()
{
// If already migrated, do nothing
if (_migratedBuiltInPlugins.Value)
return;
MigratingBuildInPlugins?.Invoke(this, EventArgs.Empty);
bool migrated = await BuiltInPluginsMigrator.Migrate(this, _workshopClient, _logger, _pluginRepository);
_migratedBuiltInPlugins.Value = migrated;
_migratedBuiltInPlugins.Save();
}
private void ProfileServiceOnProfileRemoved(object? sender, ProfileConfigurationEventArgs e)
{
InstalledEntry? entry = GetInstalledEntryByProfile(e.ProfileConfiguration);
@ -322,4 +351,6 @@ public class WorkshopService : IWorkshopService
public event EventHandler<InstalledEntry>? OnEntryUninstalled;
public event EventHandler<InstalledEntry>? OnEntryInstalled;
public event EventHandler? MigratingBuildInPlugins;
}