diff --git a/src/Artemis.Core/Services/Interfaces/IPluginManagementService.cs b/src/Artemis.Core/Services/Interfaces/IPluginManagementService.cs index 8e0faf564..bbc4afe7a 100644 --- a/src/Artemis.Core/Services/Interfaces/IPluginManagementService.cs +++ b/src/Artemis.Core/Services/Interfaces/IPluginManagementService.cs @@ -12,6 +12,11 @@ namespace Artemis.Core.Services; /// public interface IPluginManagementService : IArtemisService, IDisposable { + /// + /// Gets a list containing additional directories in which plugins are located, used while loading plugins. + /// + List AdditionalPluginDirectories { get; } + /// /// Indicates whether or not plugins are currently being loaded /// diff --git a/src/Artemis.Core/Services/PluginManagementService.cs b/src/Artemis.Core/Services/PluginManagementService.cs index 8194cef44..75cd31cb6 100644 --- a/src/Artemis.Core/Services/PluginManagementService.cs +++ b/src/Artemis.Core/Services/PluginManagementService.cs @@ -78,8 +78,11 @@ internal class PluginManagementService : IPluginManagementService File.Create(Path.Combine(pluginDirectory.FullName, "artemis.lock")).Close(); } + public List AdditionalPluginDirectories { get; } = new(); + public bool LoadingPlugins { get; private set; } + #region Built in plugins public void CopyBuiltInPlugins() @@ -276,6 +279,18 @@ internal class PluginManagementService : IPluginManagementService } } + foreach (DirectoryInfo directory in AdditionalPluginDirectories) + { + try + { + LoadPlugin(directory); + } + catch (Exception e) + { + _logger.Warning(new ArtemisPluginException($"Failed to load plugin at {directory}", e), "Plugin exception"); + } + } + // ReSharper disable InconsistentlySynchronizedField - It's read-only, idc _logger.Debug("Loaded {count} plugin(s)", _plugins.Count); @@ -597,7 +612,7 @@ internal class PluginManagementService : IPluginManagementService using StreamReader reader = new(metaDataFileEntry.Open()); PluginInfo pluginInfo = CoreJson.DeserializeObject(reader.ReadToEnd())!; if (!pluginInfo.Main.EndsWith(".dll")) - throw new ArtemisPluginException("Main entry in plugin.json must point to a .dll file" + fileName); + throw new ArtemisPluginException("Main entry in plugin.json must point to a .dll file"); Plugin? existing = _plugins.FirstOrDefault(p => p.Guid == pluginInfo.Guid); if (existing != null) diff --git a/src/Artemis.UI/Artemis.UI.csproj b/src/Artemis.UI/Artemis.UI.csproj index 0048cffb7..ec42bd185 100644 --- a/src/Artemis.UI/Artemis.UI.csproj +++ b/src/Artemis.UI/Artemis.UI.csproj @@ -35,4 +35,15 @@ + + + + DeviceProviderPickerDialogView.axaml + Code + + + DeviceSelectionDialogView.axaml + Code + + \ No newline at end of file diff --git a/src/Artemis.UI/ArtemisBootstrapper.cs b/src/Artemis.UI/ArtemisBootstrapper.cs index be365cd9f..f869bb8e3 100644 --- a/src/Artemis.UI/ArtemisBootstrapper.cs +++ b/src/Artemis.UI/ArtemisBootstrapper.cs @@ -13,6 +13,7 @@ using Artemis.UI.Shared.Services; using Artemis.VisualScripting.DryIoc; using Artemis.WebClient.Updating.DryIoc; using Artemis.WebClient.Workshop.DryIoc; +using Artemis.WebClient.Workshop.Services; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; diff --git a/src/Artemis.UI/Routing/Routes.cs b/src/Artemis.UI/Routing/Routes.cs index 1d4862f44..4353a4eef 100644 --- a/src/Artemis.UI/Routing/Routes.cs +++ b/src/Artemis.UI/Routing/Routes.cs @@ -14,6 +14,7 @@ using Artemis.UI.Screens.Workshop.Library; using Artemis.UI.Screens.Workshop.Library.Tabs; using Artemis.UI.Screens.Workshop.Profile; using Artemis.UI.Shared.Routing; +using PluginDetailsViewModel = Artemis.UI.Screens.Workshop.Plugins.PluginDetailsViewModel; namespace Artemis.UI.Routing; @@ -32,6 +33,8 @@ public static class Routes { Children = new List { + new RouteRegistration("plugins/{page:int}"), + new RouteRegistration("plugins/details/{entryId:long}"), new RouteRegistration("profiles/{page:int}"), new RouteRegistration("profiles/details/{entryId:long}"), new RouteRegistration("layouts/{page:int}"), diff --git a/src/Artemis.UI/Screens/Plugins/Dialogs/PluginPrerequisitesInstallDialogView.axaml b/src/Artemis.UI/Screens/Plugins/Dialogs/PluginPrerequisitesInstallDialogView.axaml index 06fe3c015..efa8af53e 100644 --- a/src/Artemis.UI/Screens/Plugins/Dialogs/PluginPrerequisitesInstallDialogView.axaml +++ b/src/Artemis.UI/Screens/Plugins/Dialogs/PluginPrerequisitesInstallDialogView.axaml @@ -24,7 +24,7 @@ - + @@ -36,7 +36,7 @@ IsHitTestVisible="False"> - + @@ -45,8 +45,8 @@ - - + + @@ -74,12 +74,14 @@ Grid.Column="1" Margin="10 0" IsVisible="{CompiledBinding ShowFailed, Mode=OneWay}"> - - Installing - - failed. - - You may try again to see if that helps, otherwise install the prerequisite manually or contact the plugin developer. + + Installing + + failed. + + + You may try again to see if that helps, otherwise install the prerequisite manually or contact the plugin developer. + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Plugins/Dialogs/PluginPrerequisitesInstallDialogViewModel.cs b/src/Artemis.UI/Screens/Plugins/Dialogs/PluginPrerequisitesInstallDialogViewModel.cs index c01733e92..30db350ca 100644 --- a/src/Artemis.UI/Screens/Plugins/Dialogs/PluginPrerequisitesInstallDialogViewModel.cs +++ b/src/Artemis.UI/Screens/Plugins/Dialogs/PluginPrerequisitesInstallDialogViewModel.cs @@ -30,16 +30,21 @@ public partial class PluginPrerequisitesInstallDialogViewModel : ContentDialogVi [Notify] private bool _showIntro = true; [Notify] private bool _showProgress; + private bool _finished; + public PluginPrerequisitesInstallDialogViewModel(List subjects, IPrerequisitesVmFactory prerequisitesVmFactory) { Prerequisites = new ObservableCollection(); foreach (PluginPrerequisite prerequisite in subjects.SelectMany(prerequisitesSubject => prerequisitesSubject.PlatformPrerequisites)) Prerequisites.Add(prerequisitesVmFactory.PluginPrerequisiteViewModel(prerequisite, false)); - Install = ReactiveCommand.CreateFromTask(ExecuteInstall, this.WhenAnyValue(vm => vm.CanInstall)); + Install = ReactiveCommand.Create(ExecuteInstall, this.WhenAnyValue(vm => vm.CanInstall)); Dispatcher.UIThread.Post(() => CanInstall = Prerequisites.Any(p => !p.PluginPrerequisite.IsMet()), DispatcherPriority.Background); this.WhenActivated(d => { + if (ContentDialog != null) + ContentDialog.Closing += ContentDialogOnClosing; + Disposable.Create(() => { _tokenSource?.Cancel(); @@ -51,11 +56,12 @@ public partial class PluginPrerequisitesInstallDialogViewModel : ContentDialogVi public ReactiveCommand Install { get; } public ObservableCollection Prerequisites { get; } - + public static async Task Show(IWindowService windowService, List subjects) { await windowService.CreateContentDialog() .WithTitle("Plugin prerequisites") + .WithFullScreen() .WithViewModel(out PluginPrerequisitesInstallDialogViewModel vm, subjects) .WithCloseButtonText("Cancel") .HavingPrimaryButton(b => b.WithText("Install").WithCommand(vm.Install)) @@ -63,12 +69,8 @@ public partial class PluginPrerequisitesInstallDialogViewModel : ContentDialogVi .ShowAsync(); } - private async Task ExecuteInstall() + private void ExecuteInstall() { - Deferral? deferral = null; - if (ContentDialog != null) - ContentDialog.Closing += (_, args) => deferral = args.GetDeferral(); - CanInstall = false; ShowFailed = false; ShowIntro = false; @@ -77,6 +79,11 @@ public partial class PluginPrerequisitesInstallDialogViewModel : ContentDialogVi _tokenSource?.Dispose(); _tokenSource = new CancellationTokenSource(); + Dispatcher.UIThread.InvokeAsync(async () => await InstallPrerequisites(_tokenSource.Token)); + } + + private async Task InstallPrerequisites(CancellationToken cancellationToken) + { try { foreach (PluginPrerequisiteViewModel pluginPrerequisiteViewModel in Prerequisites) @@ -86,7 +93,9 @@ public partial class PluginPrerequisitesInstallDialogViewModel : ContentDialogVi continue; ActivePrerequisite = pluginPrerequisiteViewModel; - await ActivePrerequisite.Install(_tokenSource.Token); + await ActivePrerequisite.Install(cancellationToken); + + cancellationToken.ThrowIfCancellationRequested(); if (!ActivePrerequisite.IsMet) { @@ -98,19 +107,33 @@ public partial class PluginPrerequisitesInstallDialogViewModel : ContentDialogVi // Wait after the task finished for the user to process what happened if (pluginPrerequisiteViewModel != Prerequisites.Last()) - await Task.Delay(250); + await Task.Delay(250, cancellationToken); else - await Task.Delay(1000); + await Task.Delay(1000, cancellationToken); } - if (deferral != null) - deferral.Complete(); - else - ContentDialog?.Hide(ContentDialogResult.Primary); + _finished = true; + ContentDialog?.Hide(ContentDialogResult.Primary); } - catch (OperationCanceledException) + catch (TaskCanceledException e) { // ignored } } + + private void ContentDialogOnClosing(ContentDialog sender, ContentDialogClosingEventArgs args) + { + // Cancel button is allowed to close + if (args.Result == ContentDialogResult.None) + { + _tokenSource?.Cancel(); + _tokenSource?.Dispose(); + _tokenSource = null; + } + else + { + // Keep dialog open until either ready + args.Cancel = !_finished; + } + } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Plugins/Dialogs/PluginPrerequisitesUninstallDialogView.axaml b/src/Artemis.UI/Screens/Plugins/Dialogs/PluginPrerequisitesUninstallDialogView.axaml index b25fbc844..3ba9bec0c 100644 --- a/src/Artemis.UI/Screens/Plugins/Dialogs/PluginPrerequisitesUninstallDialogView.axaml +++ b/src/Artemis.UI/Screens/Plugins/Dialogs/PluginPrerequisitesUninstallDialogView.axaml @@ -24,7 +24,7 @@ - + @@ -37,8 +37,8 @@ - - + + diff --git a/src/Artemis.UI/Screens/Plugins/Dialogs/PluginPrerequisitesUninstallDialogViewModel.cs b/src/Artemis.UI/Screens/Plugins/Dialogs/PluginPrerequisitesUninstallDialogViewModel.cs index 60264ae54..fc6a88aff 100644 --- a/src/Artemis.UI/Screens/Plugins/Dialogs/PluginPrerequisitesUninstallDialogViewModel.cs +++ b/src/Artemis.UI/Screens/Plugins/Dialogs/PluginPrerequisitesUninstallDialogViewModel.cs @@ -64,6 +64,7 @@ public partial class PluginPrerequisitesUninstallDialogViewModel : ContentDialog public static async Task Show(IWindowService windowService, List subjects, string cancelLabel = "Cancel") { await windowService.CreateContentDialog() + .WithFullScreen() .WithTitle("Plugin prerequisites") .WithViewModel(out PluginPrerequisitesUninstallDialogViewModel vm, subjects) .WithCloseButtonText(cancelLabel) diff --git a/src/Artemis.UI/Screens/Plugins/PluginView.axaml b/src/Artemis.UI/Screens/Plugins/PluginView.axaml index b9dc83c55..64ef0e88c 100644 --- a/src/Artemis.UI/Screens/Plugins/PluginView.axaml +++ b/src/Artemis.UI/Screens/Plugins/PluginView.axaml @@ -19,7 +19,10 @@ Grid.RowSpan="3" VerticalAlignment="Top" /> - + + + + diff --git a/src/Artemis.UI/Screens/Plugins/PluginViewModel.cs b/src/Artemis.UI/Screens/Plugins/PluginViewModel.cs index c01e2dfc2..1a0333aca 100644 --- a/src/Artemis.UI/Screens/Plugins/PluginViewModel.cs +++ b/src/Artemis.UI/Screens/Plugins/PluginViewModel.cs @@ -11,6 +11,8 @@ 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; @@ -24,6 +26,7 @@ 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; @@ -36,13 +39,15 @@ public partial class PluginViewModel : ActivatableViewModelBase ICoreService coreService, IWindowService windowService, INotificationService notificationService, - IPluginManagementService pluginManagementService) + IPluginManagementService pluginManagementService, + IWorkshopService workshopService) { _plugin = plugin; _coreService = coreService; _windowService = windowService; _notificationService = notificationService; _pluginManagementService = pluginManagementService; + _workshopService = workshopService; Platforms = new ObservableCollection(); if (Plugin.Info.Platforms != null) @@ -90,7 +95,7 @@ public partial class PluginViewModel : ActivatableViewModelBase public ObservableCollection Platforms { get; } public string Type => Plugin.GetType().BaseType?.Name ?? Plugin.GetType().Name; public bool IsEnabled => Plugin.IsEnabled; - + public async Task UpdateEnabled(bool enable) { if (Enabling) @@ -209,7 +214,7 @@ public partial class PluginViewModel : ActivatableViewModelBase await PluginPrerequisitesInstallDialogViewModel.Show(_windowService, subjects); } - private async Task ExecuteRemovePrerequisites(bool forPluginRemoval = false) + public async Task ExecuteRemovePrerequisites(bool forPluginRemoval = false) { List subjects = new() {Plugin.Info}; subjects.AddRange(!forPluginRemoval ? Plugin.Features.Where(f => f.AlwaysEnabled) : Plugin.Features); @@ -244,9 +249,6 @@ public partial class PluginViewModel : ActivatableViewModelBase return; // If the plugin or any of its features has uninstall actions, offer to run these - List subjects = new() {Plugin.Info}; - subjects.AddRange(Plugin.Features); - if (subjects.Any(s => s.PlatformPrerequisites.Any(p => p.UninstallActions.Any()))) await ExecuteRemovePrerequisites(true); try @@ -259,6 +261,10 @@ public partial class PluginViewModel : ActivatableViewModelBase 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(); } @@ -273,7 +279,7 @@ public partial class PluginViewModel : ActivatableViewModelBase _windowService.ShowExceptionDialog("Welp, we couldn\'t open the logs folder for you", e); } } - + private async Task ShowUpdateEnableFailure(bool enable, Exception e) { string action = enable ? "enable" : "disable"; diff --git a/src/Artemis.UI/Screens/Root/RootViewModel.cs b/src/Artemis.UI/Screens/Root/RootViewModel.cs index e1fb3eeb9..f97399a7e 100644 --- a/src/Artemis.UI/Screens/Root/RootViewModel.cs +++ b/src/Artemis.UI/Screens/Root/RootViewModel.cs @@ -13,6 +13,7 @@ using Artemis.UI.Shared; using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; using Artemis.UI.Shared.Services.MainWindow; +using Artemis.WebClient.Workshop.Services; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; @@ -41,6 +42,7 @@ public class RootViewModel : RoutableHostScreen, IMainWindowProv IMainWindowService mainWindowService, IDebugService debugService, IUpdateService updateService, + IWorkshopService workshopService, SidebarViewModel sidebarViewModel, DefaultTitleBarViewModel defaultTitleBarViewModel) { @@ -64,7 +66,7 @@ public class RootViewModel : RoutableHostScreen, IMainWindowProv OpenScreen = ReactiveCommand.Create(ExecuteOpenScreen); OpenDebugger = ReactiveCommand.CreateFromTask(ExecuteOpenDebugger); Exit = ReactiveCommand.CreateFromTask(ExecuteExit); - + _titleBarViewModel = this.WhenAnyValue(vm => vm.Screen) .Select(s => s as IMainScreenViewModel) .Select(s => s?.WhenAnyValue(svm => svm.TitleBarViewModel) ?? Observable.Never()) @@ -74,10 +76,15 @@ public class RootViewModel : RoutableHostScreen, IMainWindowProv Task.Run(() => { + // Before doing heavy lifting, initialize the update service which may prompt a restart if (_updateService.Initialize()) return; + // Workshop service goes first so it has a chance to clean up old workshop entries and introduce new ones + workshopService.Initialize(); + // Core is initialized now that everything is ready to go coreService.Initialize(); + registrationService.RegisterBuiltInDataModelDisplays(); registrationService.RegisterBuiltInDataModelInputs(); registrationService.RegisterBuiltInPropertyEditors(); @@ -135,7 +142,7 @@ public class RootViewModel : RoutableHostScreen, IMainWindowProv { if (path != null) _router.ClearPreviousWindowRoute(); - + // The window will open on the UI thread at some point, respond to that to select the chosen screen MainWindowOpened += OnEventHandler; OpenMainWindow(); @@ -184,7 +191,7 @@ public class RootViewModel : RoutableHostScreen, IMainWindowProv _lifeTime.MainWindow.Activate(); if (_lifeTime.MainWindow.WindowState == WindowState.Minimized) _lifeTime.MainWindow.WindowState = WindowState.Normal; - + OnMainWindowOpened(); } diff --git a/src/Artemis.UI/Screens/Settings/Account/CreatePersonalAccessTokenView.axaml b/src/Artemis.UI/Screens/Settings/Account/CreatePersonalAccessTokenView.axaml new file mode 100644 index 000000000..184242179 --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Account/CreatePersonalAccessTokenView.axaml @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/src/Artemis.UI/Screens/Settings/Account/CreatePersonalAccessTokenView.axaml.cs b/src/Artemis.UI/Screens/Settings/Account/CreatePersonalAccessTokenView.axaml.cs new file mode 100644 index 000000000..368c39351 --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Account/CreatePersonalAccessTokenView.axaml.cs @@ -0,0 +1,17 @@ +using Artemis.UI.Shared.Extensions; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; +using ReactiveUI; + +namespace Artemis.UI.Screens.Settings.Account; + +public partial class CreatePersonalAccessTokenView : ReactiveUserControl +{ + public CreatePersonalAccessTokenView() + { + InitializeComponent(); + this.WhenActivated(_ => this.ClearAllDataValidationErrors()); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Account/CreatePersonalAccessTokenViewModel.cs b/src/Artemis.UI/Screens/Settings/Account/CreatePersonalAccessTokenViewModel.cs new file mode 100644 index 000000000..dba94f3c6 --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Account/CreatePersonalAccessTokenViewModel.cs @@ -0,0 +1,46 @@ +using System; +using System.Reactive; +using System.Threading; +using System.Threading.Tasks; +using Artemis.UI.Shared; +using Artemis.UI.Shared.Services; +using Artemis.WebClient.Workshop.Services; +using PropertyChanged.SourceGenerator; +using ReactiveUI; +using ReactiveUI.Validation.Extensions; + +namespace Artemis.UI.Screens.Settings.Account; + +public partial class CreatePersonalAccessTokenViewModel : ContentDialogViewModelBase +{ + private readonly IUserManagementService _userManagementService; + private readonly IWindowService _windowService; + [Notify] private string _description = string.Empty; + [Notify] private DateTime _expirationDate = DateTime.UtcNow.Date.AddDays(181); + + public CreatePersonalAccessTokenViewModel(IUserManagementService userManagementService, IWindowService windowService) + { + _userManagementService = userManagementService; + _windowService = windowService; + Submit = ReactiveCommand.CreateFromTask(ExecuteSubmit, ValidationContext.Valid); + + this.ValidationRule(vm => vm.Description, e => e != null, "You must specify a description"); + this.ValidationRule(vm => vm.Description, e => e == null || e.Length >= 5, "You must specify a description of at least 5 characters"); + this.ValidationRule(vm => vm.Description, e => e == null || e.Length <= 150, "You must specify a description of less than 150 characters"); + this.ValidationRule(vm => vm.ExpirationDate, e => e >= DateTime.UtcNow.Date.AddDays(1), "Expiration date must be at least 24 hours from now"); + } + + public DateTime StartDate => DateTime.UtcNow.Date.AddDays(1); + public DateTime EndDate => DateTime.UtcNow.Date.AddDays(365); + public ReactiveCommand Submit { get; } + + private async Task ExecuteSubmit(CancellationToken cts) + { + string result = await _userManagementService.CreatePersonAccessToken(Description, ExpirationDate, cts); + await _windowService.CreateContentDialog() + .WithTitle("Personal Access Token") + .WithViewModel(out PersonalAccessTokenViewModel _, result) + .WithFullScreen() + .ShowAsync(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Account/PersonalAccessTokenView.axaml b/src/Artemis.UI/Screens/Settings/Account/PersonalAccessTokenView.axaml new file mode 100644 index 000000000..c68e4e024 --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Account/PersonalAccessTokenView.axaml @@ -0,0 +1,19 @@ + + + + Your token has been created, please copy it now as you cannot view it again later. + + + + diff --git a/src/Artemis.UI/Screens/Settings/Account/PersonalAccessTokenView.axaml.cs b/src/Artemis.UI/Screens/Settings/Account/PersonalAccessTokenView.axaml.cs new file mode 100644 index 000000000..d9110ec53 --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Account/PersonalAccessTokenView.axaml.cs @@ -0,0 +1,14 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Settings.Account; + +public partial class PersonalAccessTokenView : ReactiveUserControl +{ + public PersonalAccessTokenView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Account/PersonalAccessTokenViewModel.cs b/src/Artemis.UI/Screens/Settings/Account/PersonalAccessTokenViewModel.cs new file mode 100644 index 000000000..4ae480037 --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Account/PersonalAccessTokenViewModel.cs @@ -0,0 +1,13 @@ +using Artemis.UI.Shared; + +namespace Artemis.UI.Screens.Settings.Account; + +public class PersonalAccessTokenViewModel : ContentDialogViewModelBase +{ + public string Token { get; } + + public PersonalAccessTokenViewModel(string token) + { + Token = token; + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Tabs/AccountTabView.axaml b/src/Artemis.UI/Screens/Settings/Tabs/AccountTabView.axaml index c27ce59bc..eeb650355 100644 --- a/src/Artemis.UI/Screens/Settings/Tabs/AccountTabView.axaml +++ b/src/Artemis.UI/Screens/Settings/Tabs/AccountTabView.axaml @@ -5,10 +5,15 @@ xmlns:settings="clr-namespace:Artemis.UI.Screens.Settings" xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia" xmlns:loaders="clr-namespace:AsyncImageLoader.Loaders;assembly=AsyncImageLoader.Avalonia" - xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" + xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" + xmlns:models="clr-namespace:Artemis.WebClient.Workshop.Models;assembly=Artemis.WebClient.Workshop" + xmlns:converters="clr-namespace:Artemis.UI.Converters" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="800" x:Class="Artemis.UI.Screens.Settings.AccountTabView" x:DataType="settings:AccountTabViewModel"> + + + @@ -46,7 +51,7 @@ - + @@ -66,8 +71,8 @@ + + + + Description + Created at + Expires at + + + + You have no active personal access tokens. + + + + + + + + + + + + + + + + + + + + + - \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Tabs/AccountTabViewModel.cs b/src/Artemis.UI/Screens/Settings/Tabs/AccountTabViewModel.cs index 0e361deb3..afab57079 100644 --- a/src/Artemis.UI/Screens/Settings/Tabs/AccountTabViewModel.cs +++ b/src/Artemis.UI/Screens/Settings/Tabs/AccountTabViewModel.cs @@ -1,7 +1,10 @@ using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; using System.ComponentModel; using System.IO; using System.Linq; +using System.Reactive; using System.Reactive.Disposables; using System.Reactive.Linq; using System.Threading; @@ -12,6 +15,7 @@ using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; using Artemis.WebClient.Workshop; using Artemis.WebClient.Workshop.Handlers.UploadHandlers; +using Artemis.WebClient.Workshop.Models; using Artemis.WebClient.Workshop.Services; using IdentityModel; using PropertyChanged.SourceGenerator; @@ -29,6 +33,7 @@ public partial class AccountTabViewModel : RoutableScreen [Notify(Setter.Private)] private string? _name; [Notify(Setter.Private)] private string? _email; [Notify(Setter.Private)] private string? _avatarUrl; + [Notify(Setter.Private)] private ObservableCollection _personalAccessTokens = new(); public AccountTabViewModel(IWindowService windowService, IAuthenticationService authenticationService, IUserManagementService userManagementService) { @@ -36,10 +41,11 @@ public partial class AccountTabViewModel : RoutableScreen _authenticationService = authenticationService; _userManagementService = userManagementService; _authenticationService.AutoLogin(true); - + DisplayName = "Account"; IsLoggedIn = _authenticationService.IsLoggedIn; - + DeleteToken = ReactiveCommand.CreateFromTask(ExecuteDeleteToken); + this.WhenActivated(d => { _canChangePassword = _authenticationService.GetClaim(JwtClaimTypes.AuthenticationMethod).Select(c => c?.Value == "pwd").ToProperty(this, vm => vm.CanChangePassword); @@ -50,12 +56,13 @@ public partial class AccountTabViewModel : RoutableScreen public bool CanChangePassword => _canChangePassword?.Value ?? false; public IObservable IsLoggedIn { get; } + public ReactiveCommand DeleteToken { get; } public async Task Login() { await _windowService.CreateContentDialog().WithViewModel(out WorkshopLoginViewModel _).WithTitle("Account login").ShowAsync(); } - + public async Task ChangeAvatar() { string[]? result = await _windowService.CreateOpenFileDialog().HavingFilter(f => f.WithBitmaps()).ShowAsync(); @@ -85,7 +92,7 @@ public partial class AccountTabViewModel : RoutableScreen .HavingPrimaryButton(b => b.WithText("Submit").WithCommand(vm.Submit)) .ShowAsync(); } - + public async Task ChangePasswordAddress() { await _windowService.CreateContentDialog().WithTitle("Change password") @@ -94,7 +101,7 @@ public partial class AccountTabViewModel : RoutableScreen .HavingPrimaryButton(b => b.WithText("Submit").WithCommand(vm.Submit)) .ShowAsync(); } - + public async Task RemoveAccount() { await _windowService.CreateContentDialog().WithTitle("Remove account") @@ -104,11 +111,36 @@ public partial class AccountTabViewModel : RoutableScreen .ShowAsync(); } - private void LoadCurrentUser() + public async Task GenerateToken() + { + await _windowService.CreateContentDialog().WithTitle("Create Personal Access Token") + .WithViewModel(out CreatePersonalAccessTokenViewModel vm) + .WithCloseButtonText("Cancel") + .HavingPrimaryButton(b => b.WithText("Submit").WithCommand(vm.Submit)) + .ShowAsync(); + + List personalAccessTokens = await _userManagementService.GetPersonAccessTokens(CancellationToken.None); + PersonalAccessTokens = new ObservableCollection(personalAccessTokens); + } + + private async Task ExecuteDeleteToken(PersonalAccessToken token) + { + bool confirm = await _windowService.ShowConfirmContentDialog("Delete Personal Access Token", "Are you sure you want to delete this token? Any services using it will stop working"); + if (!confirm) + return; + + await _userManagementService.DeletePersonAccessToken(token, CancellationToken.None); + PersonalAccessTokens.Remove(token); + } + + private async Task LoadCurrentUser() { string? userId = _authenticationService.Claims.FirstOrDefault(c => c.Type == "sub")?.Value; Name = _authenticationService.Claims.FirstOrDefault(c => c.Type == "name")?.Value; Email = _authenticationService.Claims.FirstOrDefault(c => c.Type == "email")?.Value; AvatarUrl = $"{WorkshopConstants.AUTHORITY_URL}/user/avatar/{userId}"; + + List personalAccessTokens = await _userManagementService.GetPersonAccessTokens(CancellationToken.None); + PersonalAccessTokens = new ObservableCollection(personalAccessTokens); } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Sidebar/SidebarViewModel.cs b/src/Artemis.UI/Screens/Sidebar/SidebarViewModel.cs index ae9de0d2c..3b7de1c0c 100644 --- a/src/Artemis.UI/Screens/Sidebar/SidebarViewModel.cs +++ b/src/Artemis.UI/Screens/Sidebar/SidebarViewModel.cs @@ -43,6 +43,7 @@ public partial class SidebarViewModel : ActivatableViewModelBase { new(MaterialIconKind.FolderVideo, "Profiles", "workshop/entries/profiles/1", "workshop/entries/profiles"), new(MaterialIconKind.KeyboardVariant, "Layouts", "workshop/entries/layouts/1", "workshop/entries/layouts"), + new(MaterialIconKind.Connection, "Plugins", "workshop/entries/plugins/1", "workshop/entries/plugins"), new(MaterialIconKind.Bookshelf, "Library", "workshop/library"), }), diff --git a/src/Artemis.UI/Screens/Workshop/Entries/Details/EntryReleasesViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/Details/EntryReleasesViewModel.cs index 311164269..21be13b28 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/Details/EntryReleasesViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Entries/Details/EntryReleasesViewModel.cs @@ -9,7 +9,6 @@ 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 ReactiveUI; @@ -29,11 +28,13 @@ public class EntryReleasesViewModel : ViewModelBase Entry = entry; DownloadLatestRelease = ReactiveCommand.CreateFromTask(ExecuteDownloadLatestRelease); + OnInstallationStarted = Confirm; } public IGetEntryById_Entry Entry { get; } public ReactiveCommand DownloadLatestRelease { get; } + public Func> OnInstallationStarted { get; set; } public Func? OnInstallationFinished { get; set; } private async Task ExecuteDownloadLatestRelease(CancellationToken cancellationToken) @@ -41,11 +42,7 @@ public class EntryReleasesViewModel : ViewModelBase if (Entry.LatestRelease == null) return; - bool confirm = await _windowService.ShowConfirmContentDialog( - "Install latest release", - $"Are you sure you want to download and install version {Entry.LatestRelease.Version} of {Entry.Name}?" - ); - if (!confirm) + if (await OnInstallationStarted(Entry)) return; IEntryInstallationHandler installationHandler = _factory.CreateHandler(Entry.EntryType); @@ -64,4 +61,14 @@ public class EntryReleasesViewModel : ViewModelBase .WithSeverity(NotificationSeverity.Error).Show(); } } + + private async Task Confirm(IEntryDetails entryDetails) + { + bool confirm = await _windowService.ShowConfirmContentDialog( + "Install latest release", + $"Are you sure you want to download and install version {entryDetails.LatestRelease?.Version} of {entryDetails.Name}?" + ); + + return !confirm; + } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntriesViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/EntriesViewModel.cs index 4ee34fee4..904348bc6 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/EntriesViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntriesViewModel.cs @@ -25,7 +25,8 @@ public partial class EntriesViewModel : RoutableHostScreen Tabs = new ObservableCollection { new("Profiles", "workshop/entries/profiles/1", "workshop/entries/profiles"), - new("Layouts", "workshop/entries/layouts/1", "workshop/entries/layouts") + new("Layouts", "workshop/entries/layouts/1", "workshop/entries/layouts"), + new("Plugins", "workshop/entries/plugins/1", "workshop/entries/plugins"), }; this.WhenActivated(d => diff --git a/src/Artemis.UI/Screens/Workshop/Entries/List/EntryListItemViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/List/EntryListItemViewModel.cs index be57fd898..757396c72 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/List/EntryListItemViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Entries/List/EntryListItemViewModel.cs @@ -34,6 +34,7 @@ public class EntryListItemViewModel : ActivatableViewModelBase await _router.Navigate($"workshop/entries/profiles/details/{Entry.Id}"); break; case EntryType.Plugin: + await _router.Navigate($"workshop/entries/plugins/details/{Entry.Id}"); break; default: throw new ArgumentOutOfRangeException(); diff --git a/src/Artemis.UI/Screens/Workshop/Entries/Tabs/LayoutListViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/Tabs/LayoutListViewModel.cs index 6cab02f27..ed2fa328b 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/Tabs/LayoutListViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Entries/Tabs/LayoutListViewModel.cs @@ -27,6 +27,7 @@ public class LayoutListViewModel : List.EntryListViewModel And = new[] { new EntryFilterInput {EntryType = new EntryTypeOperationFilterInput {Eq = EntryType.Layout}}, + new EntryFilterInput(){LatestReleaseId = new LongOperationFilterInput {Gt = 0}}, base.GetFilter() } }; diff --git a/src/Artemis.UI/Screens/Workshop/Entries/Tabs/PluginListView.axaml b/src/Artemis.UI/Screens/Workshop/Entries/Tabs/PluginListView.axaml new file mode 100644 index 000000000..e52c30641 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Entries/Tabs/PluginListView.axaml @@ -0,0 +1,65 @@ + + + + + + + + + + + Categories + + + + + + + + + + + + + + + + + + + + + + + + + + + Looks like your current filters gave no results + + Modify or clear your filters to view other plugins + + + + + + + + diff --git a/src/Artemis.UI/Screens/Workshop/Entries/Tabs/PluginListView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Entries/Tabs/PluginListView.axaml.cs new file mode 100644 index 000000000..2e32eea93 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Entries/Tabs/PluginListView.axaml.cs @@ -0,0 +1,14 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Entries.Tabs; + +public partial class PluginListView : ReactiveUserControl +{ + public PluginListView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Entries/Tabs/PluginListViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/Tabs/PluginListViewModel.cs new file mode 100644 index 000000000..206697490 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Entries/Tabs/PluginListViewModel.cs @@ -0,0 +1,34 @@ +using System; +using Artemis.UI.Screens.Workshop.Categories; +using Artemis.UI.Screens.Workshop.Entries.List; +using Artemis.UI.Shared.Routing; +using Artemis.UI.Shared.Services; +using Artemis.WebClient.Workshop; + +namespace Artemis.UI.Screens.Workshop.Entries.Tabs; + +public class PluginListViewModel : EntryListViewModel +{ + public PluginListViewModel(IWorkshopClient workshopClient, + IRouter router, + CategoriesViewModel categoriesViewModel, + EntryListInputViewModel entryListInputViewModel, + INotificationService notificationService, + Func getEntryListViewModel) + : base("workshop/entries/plugins", workshopClient, router, categoriesViewModel, entryListInputViewModel, notificationService, getEntryListViewModel) + { + entryListInputViewModel.SearchWatermark = "Search plugins"; + } + + protected override EntryFilterInput GetFilter() + { + return new EntryFilterInput + { + And = new[] + { + new EntryFilterInput {EntryType = new EntryTypeOperationFilterInput {Eq = EntryType.Plugin}}, + base.GetFilter() + } + }; + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Entries/Tabs/ProfileListViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/Tabs/ProfileListViewModel.cs index c09a587a3..f251ee10d 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/Tabs/ProfileListViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Entries/Tabs/ProfileListViewModel.cs @@ -12,7 +12,7 @@ public class ProfileListViewModel : List.EntryListViewModel public ProfileListViewModel(IWorkshopClient workshopClient, IRouter router, CategoriesViewModel categoriesViewModel, - List.EntryListInputViewModel entryListInputViewModel, + EntryListInputViewModel entryListInputViewModel, INotificationService notificationService, Func getEntryListViewModel) : base("workshop/entries/profiles", workshopClient, router, categoriesViewModel, entryListInputViewModel, notificationService, getEntryListViewModel) diff --git a/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml b/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml index 433f87c50..c2ed7ebb8 100644 --- a/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml @@ -57,6 +57,14 @@ + + + + + + Path + + + Name + + + Description + + + Main entry point + + + Version + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Plugin/PluginSelectionStepView.axaml.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Plugin/PluginSelectionStepView.axaml.cs new file mode 100644 index 000000000..79866d0e5 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Plugin/PluginSelectionStepView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Plugin; + +public partial class PluginSelectionStepView : ReactiveUserControl +{ + public PluginSelectionStepView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Plugin/PluginSelectionStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Plugin/PluginSelectionStepViewModel.cs new file mode 100644 index 000000000..a44a7e05b --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Plugin/PluginSelectionStepViewModel.cs @@ -0,0 +1,83 @@ +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Reactive; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Threading.Tasks; +using Artemis.Core; +using Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Profile; +using Artemis.UI.Shared.Services; +using Artemis.WebClient.Workshop.Handlers.UploadHandlers; +using PropertyChanged.SourceGenerator; +using ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Plugin; + +public partial class PluginSelectionStepViewModel : SubmissionViewModel +{ + private readonly IWindowService _windowService; + [Notify] private PluginInfo? _selectedPlugin; + [Notify] private string? _path; + + /// + public PluginSelectionStepViewModel(IWindowService windowService) + { + _windowService = windowService; + + GoBack = ReactiveCommand.Create(() => State.ChangeScreen()); + Continue = ReactiveCommand.Create(ExecuteContinue, this.WhenAnyValue(vm => vm.SelectedPlugin).Select(p => p != null)); + Browse = ReactiveCommand.CreateFromTask(ExecuteBrowse); + + this.WhenActivated((CompositeDisposable _) => + { + ShowGoBack = State.EntryId == null; + if (State.EntrySource is PluginEntrySource pluginEntrySource) + { + SelectedPlugin = pluginEntrySource.PluginInfo; + Path = pluginEntrySource.Path; + } + }); + } + + public ReactiveCommand Browse { get; } + + private async Task ExecuteBrowse() + { + string[]? files = await _windowService.CreateOpenFileDialog().HavingFilter(f => f.WithExtension("zip").WithName("ZIP files")).ShowAsync(); + if (files == null) + return; + + // Find the metadata file in the zip + using ZipArchive archive = ZipFile.OpenRead(files[0]); + ZipArchiveEntry? metaDataFileEntry = archive.Entries.FirstOrDefault(e => e.Name == "plugin.json"); + if (metaDataFileEntry == null) + throw new ArtemisPluginException("Couldn't find a plugin.json in " + files[0]); + + using StreamReader reader = new(metaDataFileEntry.Open()); + PluginInfo pluginInfo = CoreJson.DeserializeObject(reader.ReadToEnd())!; + if (!pluginInfo.Main.EndsWith(".dll")) + throw new ArtemisPluginException("Main entry in plugin.json must point to a .dll file"); + + SelectedPlugin = pluginInfo; + Path = files[0]; + } + + private void ExecuteContinue() + { + if (SelectedPlugin == null || Path == null) + return; + + State.EntrySource = new PluginEntrySource(SelectedPlugin, Path); + + if (string.IsNullOrWhiteSpace(State.Name)) + State.Name = SelectedPlugin.Name; + if (string.IsNullOrWhiteSpace(State.Summary)) + State.Summary = SelectedPlugin.Description ?? ""; + + if (State.EntryId == null) + State.ChangeScreen(); + else + State.ChangeScreen(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepViewModel.cs index bf9ea0f6e..678aea918 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/SpecificationsStepViewModel.cs @@ -6,6 +6,7 @@ using System.Reactive.Disposables; using Artemis.UI.Extensions; using Artemis.UI.Screens.Workshop.Entries; using Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Layout; +using Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Plugin; using Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Profile; using Artemis.WebClient.Workshop; using DynamicData; @@ -37,19 +38,14 @@ public partial class SpecificationsStepViewModel : SubmissionViewModel // Apply what's there so far ApplyToState(); - switch (State.EntryType) - { - case EntryType.Layout: - State.ChangeScreen(); - break; - case EntryType.Plugin: - break; - case EntryType.Profile: - State.ChangeScreen(); - break; - default: - throw new ArgumentOutOfRangeException(); - } + if (State.EntryType == EntryType.Layout) + State.ChangeScreen(); + else if (State.EntryType == EntryType.Plugin) + State.ChangeScreen(); + else if (State.EntryType == EntryType.Profile) + State.ChangeScreen(); + else + throw new ArgumentOutOfRangeException(); } private void ExecuteContinue() diff --git a/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/EntryInstallationHandlerFactory.cs b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/EntryInstallationHandlerFactory.cs index c1e35a274..483201cd1 100644 --- a/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/EntryInstallationHandlerFactory.cs +++ b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/EntryInstallationHandlerFactory.cs @@ -15,6 +15,7 @@ public class EntryInstallationHandlerFactory { return entryType switch { + EntryType.Plugin => _container.Resolve(), EntryType.Profile => _container.Resolve(), EntryType.Layout => _container.Resolve(), _ => throw new NotSupportedException($"EntryType '{entryType}' is not supported.") diff --git a/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/EntryUninstallResult.cs b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/EntryUninstallResult.cs index 6d28edf9a..61277da8d 100644 --- a/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/EntryUninstallResult.cs +++ b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/EntryUninstallResult.cs @@ -14,11 +14,12 @@ public class EntryUninstallResult }; } - public static EntryUninstallResult FromSuccess() + public static EntryUninstallResult FromSuccess(string? message = null) { return new EntryUninstallResult { - IsSuccess = true + IsSuccess = true, + Message = message }; } } \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/PluginEntryInstallationHandler.cs b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/PluginEntryInstallationHandler.cs new file mode 100644 index 000000000..7b9fc5539 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/PluginEntryInstallationHandler.cs @@ -0,0 +1,130 @@ +using System.IO.Compression; +using Artemis.Core; +using Artemis.Core.Services; +using Artemis.UI.Shared.Extensions; +using Artemis.UI.Shared.Utilities; +using Artemis.WebClient.Workshop.Exceptions; +using Artemis.WebClient.Workshop.Models; +using Artemis.WebClient.Workshop.Services; + +namespace Artemis.WebClient.Workshop.Handlers.InstallationHandlers; + +public class PluginEntryInstallationHandler : IEntryInstallationHandler +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly IWorkshopService _workshopService; + private readonly IPluginManagementService _pluginManagementService; + + public PluginEntryInstallationHandler(IHttpClientFactory httpClientFactory, IWorkshopService workshopService, IPluginManagementService pluginManagementService) + { + _httpClientFactory = httpClientFactory; + _workshopService = workshopService; + _pluginManagementService = pluginManagementService; + } + + public async Task InstallAsync(IEntryDetails entry, IRelease release, Progress progress, CancellationToken cancellationToken) + { + // Ensure there is an installed entry + InstalledEntry? installedEntry = _workshopService.GetInstalledEntry(entry); + if (installedEntry != null) + { + // 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 EntryInstallResult.FromSuccess(installedEntry); + } + else + { + // If none exists yet create one + installedEntry = new InstalledEntry(entry, release); + // Don't try to install a new plugin into an existing directory since files may be in use, consider our job screwed + if (installedEntry.GetReleaseDirectory(release).Exists) + return EntryInstallResult.FromFailure("Plugin is new but installation directory is not empty, try restarting Artemis"); + } + + using MemoryStream stream = new(); + + // Download the provided release + try + { + HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.WORKSHOP_CLIENT_NAME); + await client.DownloadDataAsync($"releases/download/{release.Id}", stream, progress, cancellationToken); + } + catch (Exception e) + { + return EntryInstallResult.FromFailure(e.Message); + } + + // Create the release directory + DirectoryInfo releaseDirectory = installedEntry.GetReleaseDirectory(release); + releaseDirectory.Create(); + + // Extract the archive + stream.Seek(0, SeekOrigin.Begin); + using ZipArchive archive = new(stream); + archive.ExtractToDirectory(releaseDirectory.FullName); + + // If there is already a version of the plugin installed, disable it + if (installedEntry.TryGetMetadata("PluginId", out Guid pluginId)) + { + Plugin? currentVersion = _pluginManagementService.GetAllPlugins().FirstOrDefault(p => p.Guid == pluginId); + if (currentVersion != null) + _pluginManagementService.UnloadPlugin(currentVersion); + } + + // Load the plugin, next time during startup this will happen automatically + try + { + Plugin? 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) + { + // If the plugin ended up being invalid yoink it out again, shoooo + try + { + releaseDirectory.Delete(true); + } + catch (Exception) + { + // ignored, will get cleaned up as an orphaned file + } + + _workshopService.RemoveInstalledEntry(installedEntry); + return EntryInstallResult.FromFailure(e.Message); + } + + _workshopService.SaveInstalledEntry(installedEntry); + return EntryInstallResult.FromSuccess(installedEntry); + } + + public Task UninstallAsync(InstalledEntry installedEntry, CancellationToken cancellationToken) + { + // Disable the plugin + if (installedEntry.TryGetMetadata("PluginId", out Guid pluginId)) + { + Plugin? plugin = _pluginManagementService.GetAllPlugins().FirstOrDefault(p => p.Guid == pluginId); + if (plugin != null) + _pluginManagementService.UnloadPlugin(plugin); + } + + // Attempt to remove from filesystem + DirectoryInfo directory = installedEntry.GetDirectory(); + string? message = null; + try + { + if (directory.Exists) + directory.Delete(true); + } + catch (Exception) + { + message = "Failed to clean up files, you may need to restart Artemis"; + } + + // Remove entry + _workshopService.RemoveInstalledEntry(installedEntry); + return Task.FromResult(EntryUninstallResult.FromSuccess(message)); + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/EntryUploadHandlerFactory.cs b/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/EntryUploadHandlerFactory.cs index 809c1c16b..8bc2621ea 100644 --- a/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/EntryUploadHandlerFactory.cs +++ b/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/EntryUploadHandlerFactory.cs @@ -15,6 +15,7 @@ public class EntryUploadHandlerFactory { return entryType switch { + EntryType.Plugin => _container.Resolve(), EntryType.Profile => _container.Resolve(), EntryType.Layout => _container.Resolve(), _ => throw new NotSupportedException($"EntryType '{entryType}' is not supported.") diff --git a/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/Implementations/PluginEntrySource.cs b/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/Implementations/PluginEntrySource.cs new file mode 100644 index 000000000..553a7f394 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/Implementations/PluginEntrySource.cs @@ -0,0 +1,15 @@ +using Artemis.Core; + +namespace Artemis.WebClient.Workshop.Handlers.UploadHandlers; + +public class PluginEntrySource : IEntrySource +{ + public PluginEntrySource(PluginInfo pluginInfo, string path) + { + PluginInfo = pluginInfo; + Path = path; + } + + public PluginInfo PluginInfo { get; set; } + public string Path { get; set; } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/Implementations/PluginEntryUploadHandler.cs b/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/Implementations/PluginEntryUploadHandler.cs new file mode 100644 index 000000000..50fa0a283 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/Implementations/PluginEntryUploadHandler.cs @@ -0,0 +1,40 @@ +using System.Net.Http.Headers; +using Artemis.WebClient.Workshop.Entities; +using Newtonsoft.Json; + +namespace Artemis.WebClient.Workshop.Handlers.UploadHandlers; + +public class PluginEntryUploadHandler : IEntryUploadHandler +{ + private readonly IHttpClientFactory _httpClientFactory; + + public PluginEntryUploadHandler(IHttpClientFactory httpClientFactory) + { + _httpClientFactory = httpClientFactory; + } + + /// + public async Task CreateReleaseAsync(long entryId, IEntrySource entrySource, CancellationToken cancellationToken) + { + if (entrySource is not PluginEntrySource source) + throw new InvalidOperationException("Can only create releases for plugins"); + + // Submit the archive + HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.WORKSHOP_CLIENT_NAME); + + // Construct the request + await using FileStream fileStream = File.Open(source.Path, FileMode.Open); + MultipartFormDataContent content = new(); + StreamContent streamContent = new(fileStream); + streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/zip"); + content.Add(streamContent, "file", "file.zip"); + + // Submit + HttpResponseMessage response = await client.PostAsync("releases/upload/" + entryId, content, cancellationToken); + if (!response.IsSuccessStatusCode) + return EntryUploadResult.FromFailure($"{response.StatusCode} - {await response.Content.ReadAsStringAsync(cancellationToken)}"); + + Release? release = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync(cancellationToken)); + return release != null ? EntryUploadResult.FromSuccess(release) : EntryUploadResult.FromFailure("Failed to deserialize response"); + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Models/InstalledEntry.cs b/src/Artemis.WebClient.Workshop/Models/InstalledEntry.cs index 5d961b4bf..90aa4108e 100644 --- a/src/Artemis.WebClient.Workshop/Models/InstalledEntry.cs +++ b/src/Artemis.WebClient.Workshop/Models/InstalledEntry.cs @@ -97,7 +97,7 @@ public class InstalledEntry /// The value to set. public void SetMetadata(string key, object value) { - _metadata.Add(key, value); + _metadata[key] = value; } /// @@ -118,7 +118,7 @@ public class InstalledEntry { return new DirectoryInfo(Path.Combine(Constants.WorkshopFolder, $"{EntryId}-{StringUtilities.UrlFriendly(Name)}")); } - + /// /// Returns the directory info of a release of this entry, where any files would be stored if applicable. /// diff --git a/src/Artemis.WebClient.Workshop/Models/PersonalAccessToken.cs b/src/Artemis.WebClient.Workshop/Models/PersonalAccessToken.cs index 61c8b46f2..12a859ad0 100644 --- a/src/Artemis.WebClient.Workshop/Models/PersonalAccessToken.cs +++ b/src/Artemis.WebClient.Workshop/Models/PersonalAccessToken.cs @@ -1,3 +1,9 @@ namespace Artemis.WebClient.Workshop.Models; -public record PersonalAccessToken(string Key, DateTime CreationTime, DateTime? Expiration, string? Description); \ No newline at end of file +public class PersonalAccessToken +{ + public string Key { get; init; } + public DateTime CreationTime { get; init; } + public DateTime? Expiration { get; init; } + public string? Description { get; init; } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Services/Interfaces/IUserManagementService.cs b/src/Artemis.WebClient.Workshop/Services/Interfaces/IUserManagementService.cs index 1c6cbbde4..46452f833 100644 --- a/src/Artemis.WebClient.Workshop/Services/Interfaces/IUserManagementService.cs +++ b/src/Artemis.WebClient.Workshop/Services/Interfaces/IUserManagementService.cs @@ -10,7 +10,7 @@ public interface IUserManagementService : IProtectedArtemisService Task ChangeEmailAddress(string emailAddress, CancellationToken cancellationToken); Task ChangeAvatar(Stream avatar, CancellationToken cancellationToken); Task RemoveAccount(CancellationToken cancellationToken); - Task CreatePersonAccessToken(string description, DateTimeOffset expirationDate, CancellationToken cancellationToken); + Task CreatePersonAccessToken(string description, DateTime expirationDate, CancellationToken cancellationToken); Task DeletePersonAccessToken(PersonalAccessToken personalAccessToken, CancellationToken cancellationToken); Task> GetPersonAccessTokens(CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Services/Interfaces/IWorkshopService.cs b/src/Artemis.WebClient.Workshop/Services/Interfaces/IWorkshopService.cs index 073db0414..7bc1a04c1 100644 --- a/src/Artemis.WebClient.Workshop/Services/Interfaces/IWorkshopService.cs +++ b/src/Artemis.WebClient.Workshop/Services/Interfaces/IWorkshopService.cs @@ -17,6 +17,7 @@ public interface IWorkshopService InstalledEntry? GetInstalledEntry(IEntryDetails entry); void RemoveInstalledEntry(InstalledEntry installedEntry); void SaveInstalledEntry(InstalledEntry entry); + void Initialize(); public record WorkshopStatus(bool IsReachable, string Message); } \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Services/UserManagementService.cs b/src/Artemis.WebClient.Workshop/Services/UserManagementService.cs index bbb0e443b..402f476a7 100644 --- a/src/Artemis.WebClient.Workshop/Services/UserManagementService.cs +++ b/src/Artemis.WebClient.Workshop/Services/UserManagementService.cs @@ -66,13 +66,13 @@ internal class UserManagementService : IUserManagementService } /// - public async Task CreatePersonAccessToken(string description, DateTimeOffset expirationDate, CancellationToken cancellationToken) + public async Task CreatePersonAccessToken(string description, DateTime expirationDate, CancellationToken cancellationToken) { HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.IDENTITY_CLIENT_NAME); HttpResponseMessage response = await client.PostAsync("user/access-token", JsonContent.Create(new {Description = description, ExpirationDate = expirationDate}), cancellationToken); response.EnsureSuccessStatusCode(); - string? result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + string? result = await response.Content.ReadAsStringAsync(cancellationToken); if (result == null) throw new ArtemisWebClientException("Failed to deserialize access token"); return result; diff --git a/src/Artemis.WebClient.Workshop/Services/WorkshopService.cs b/src/Artemis.WebClient.Workshop/Services/WorkshopService.cs index a1b1bf21d..e9c46ba11 100644 --- a/src/Artemis.WebClient.Workshop/Services/WorkshopService.cs +++ b/src/Artemis.WebClient.Workshop/Services/WorkshopService.cs @@ -1,23 +1,32 @@ using System.Net.Http.Headers; +using Artemis.Core; +using Artemis.Core.Services; using Artemis.Storage.Entities.Workshop; using Artemis.Storage.Repositories.Interfaces; using Artemis.UI.Shared.Routing; +using Artemis.WebClient.Workshop.Exceptions; using Artemis.WebClient.Workshop.Handlers.UploadHandlers; using Artemis.WebClient.Workshop.Models; +using Serilog; namespace Artemis.WebClient.Workshop.Services; public class WorkshopService : IWorkshopService { + private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; private readonly IRouter _router; private readonly IEntryRepository _entryRepository; + private readonly IPluginManagementService _pluginManagementService; + private bool _initialized; - public WorkshopService(IHttpClientFactory httpClientFactory, IRouter router, IEntryRepository entryRepository) + public WorkshopService(ILogger logger, IHttpClientFactory httpClientFactory, IRouter router, IEntryRepository entryRepository, IPluginManagementService pluginManagementService) { + _logger = logger; _httpClientFactory = httpClientFactory; _router = router; _entryRepository = entryRepository; + _pluginManagementService = pluginManagementService; } public async Task GetEntryIcon(long entryId, CancellationToken cancellationToken) @@ -117,6 +126,7 @@ public class WorkshopService : IWorkshopService return status.IsReachable; } + /// public async Task NavigateToEntry(long entryId, EntryType entryType) { switch (entryType) @@ -135,6 +145,7 @@ public class WorkshopService : IWorkshopService } } + /// public List GetInstalledEntries() { return _entryRepository.GetAll().Select(e => new InstalledEntry(e)).ToList(); @@ -150,12 +161,6 @@ public class WorkshopService : IWorkshopService return new InstalledEntry(entity); } - /// - public void AddOrUpdateInstalledEntry(InstalledEntry entry, IRelease release) - { - throw new NotImplementedException(); - } - /// public void RemoveInstalledEntry(InstalledEntry installedEntry) { @@ -168,4 +173,48 @@ public class WorkshopService : IWorkshopService entry.Save(); _entryRepository.Save(entry.Entity); } + + /// + public void Initialize() + { + if (_initialized) + throw new ArtemisWorkshopException("Workshop service is already initialized"); + + RemoveOrphanedFiles(); + _pluginManagementService.AdditionalPluginDirectories.AddRange(GetInstalledEntries().Where(e => e.EntryType == EntryType.Plugin).Select(e => e.GetReleaseDirectory())); + _initialized = true; + } + + private void RemoveOrphanedFiles() + { + List entries = GetInstalledEntries(); + foreach (string directory in Directory.GetDirectories(Constants.WorkshopFolder)) + { + InstalledEntry? installedEntry = entries.FirstOrDefault(e => e.GetDirectory().FullName == directory); + if (installedEntry == null) + RemoveOrphanedDirectory(directory); + else + { + DirectoryInfo currentReleaseDirectory = installedEntry.GetReleaseDirectory(); + foreach (string releaseDirectory in Directory.GetDirectories(directory)) + { + if (releaseDirectory != currentReleaseDirectory.FullName) + RemoveOrphanedDirectory(releaseDirectory); + } + } + } + } + + private void RemoveOrphanedDirectory(string directory) + { + _logger.Information("Removing orphaned workshop entry at {Directory}", directory); + try + { + Directory.Delete(directory, true); + } + catch (Exception e) + { + _logger.Warning(e, "Failed to remove orphaned workshop entry at {Directory}", directory); + } + } } \ No newline at end of file