diff --git a/src/Artemis.Core/Artemis.Core.csproj b/src/Artemis.Core/Artemis.Core.csproj index 86d6f2a97..8581e4835 100644 --- a/src/Artemis.Core/Artemis.Core.csproj +++ b/src/Artemis.Core/Artemis.Core.csproj @@ -1,6 +1,6 @@ - net7.0 + net8.0 false false Artemis.Core @@ -43,9 +43,9 @@ - - - + + + diff --git a/src/Artemis.Core/Constants.cs b/src/Artemis.Core/Constants.cs index 8386f5fcf..ce374ca88 100644 --- a/src/Artemis.Core/Constants.cs +++ b/src/Artemis.Core/Constants.cs @@ -155,10 +155,4 @@ public static class Constants /// Gets the startup arguments provided to the application /// public static ReadOnlyCollection StartupArguments { get; set; } = null!; - - /// - /// Gets the graphics context to be used for rendering by SkiaSharp. - /// - public static IManagedGraphicsContext? ManagedGraphicsContext { get; internal set; } - } \ No newline at end of file diff --git a/src/Artemis.Core/Models/Profile/Layer.cs b/src/Artemis.Core/Models/Profile/Layer.cs index 85683eb49..58c13265f 100644 --- a/src/Artemis.Core/Models/Profile/Layer.cs +++ b/src/Artemis.Core/Models/Profile/Layer.cs @@ -18,7 +18,7 @@ public sealed class Layer : RenderProfileElement { private const string BROKEN_STATE_BRUSH_NOT_FOUND = "Failed to load layer brush, ensure the plugin is enabled"; private const string BROKEN_STATE_INIT_FAILED = "Failed to initialize layer brush"; - + private readonly List _renderCopies = new(); private LayerGeneralProperties _general = new(); private LayerTransformProperties _transform = new(); @@ -735,6 +735,9 @@ public sealed class Layer : RenderProfileElement if (Disposed) throw new ObjectDisposedException("Layer"); + if (_leds.Contains(led)) + return; + _leds.Add(led); CalculateRenderProperties(); } @@ -761,7 +764,9 @@ public sealed class Layer : RenderProfileElement if (Disposed) throw new ObjectDisposedException("Layer"); - _leds.Remove(led); + if (!_leds.Remove(led)) + return; + CalculateRenderProperties(); } @@ -773,6 +778,9 @@ public sealed class Layer : RenderProfileElement if (Disposed) throw new ObjectDisposedException("Layer"); + if (!_leds.Any()) + return; + _leds.Clear(); CalculateRenderProperties(); } @@ -790,7 +798,7 @@ public sealed class Layer : RenderProfileElement { ArtemisLed? match = availableLeds.FirstOrDefault(a => a.Device.Identifier == ledEntity.DeviceIdentifier && a.RgbLed.Id.ToString() == ledEntity.LedName); - if (match != null) + if (match != null && !leds.Contains(match)) leds.Add(match); else _missingLeds.Add(ledEntity); diff --git a/src/Artemis.Core/Models/Profile/Renderer.cs b/src/Artemis.Core/Models/Profile/Renderer.cs deleted file mode 100644 index b72d48319..000000000 --- a/src/Artemis.Core/Models/Profile/Renderer.cs +++ /dev/null @@ -1,117 +0,0 @@ -using System; -using SkiaSharp; - -namespace Artemis.Core; - -internal class Renderer : IDisposable -{ - private bool _disposed; - private SKRect _lastBounds; - private GRContext? _lastGraphicsContext; - private SKRect _lastParentBounds; - private bool _valid; - public SKSurface? Surface { get; private set; } - public SKPaint? Paint { get; private set; } - public SKPath? Path { get; private set; } - public SKPoint TargetLocation { get; private set; } - - public bool IsOpen { get; private set; } - - /// - /// Opens the render context using the dimensions of the provided path - /// - public void Open(SKPath path, Folder? parent) - { - if (_disposed) - throw new ObjectDisposedException("Renderer"); - - if (IsOpen) - throw new ArtemisCoreException("Cannot open render context because it is already open"); - - if (path.Bounds != _lastBounds || (parent != null && parent.Bounds != _lastParentBounds) || _lastGraphicsContext != Constants.ManagedGraphicsContext?.GraphicsContext) - Invalidate(); - - if (!_valid || Surface == null) - { - SKRect pathBounds = path.Bounds; - int width = (int) pathBounds.Width; - int height = (int) pathBounds.Height; - - SKImageInfo imageInfo = new(width, height); - if (Constants.ManagedGraphicsContext?.GraphicsContext == null) - Surface = SKSurface.Create(imageInfo); - else - Surface = SKSurface.Create(Constants.ManagedGraphicsContext.GraphicsContext, true, imageInfo); - - Path = new SKPath(path); - Path.Transform(SKMatrix.CreateTranslation(pathBounds.Left * -1, pathBounds.Top * -1)); - - TargetLocation = new SKPoint(pathBounds.Location.X, pathBounds.Location.Y); - if (parent != null) - TargetLocation -= parent.Bounds.Location; - - Surface.Canvas.ClipPath(Path); - - _lastParentBounds = parent?.Bounds ?? new SKRect(); - _lastBounds = path.Bounds; - _lastGraphicsContext = Constants.ManagedGraphicsContext?.GraphicsContext; - _valid = true; - } - - Paint = new SKPaint(); - - Surface.Canvas.Clear(); - Surface.Canvas.Save(); - - IsOpen = true; - } - - public void Close() - { - if (_disposed) - throw new ObjectDisposedException("Renderer"); - - Surface?.Canvas.Restore(); - - // Looks like every part of the paint needs to be disposed :( - Paint?.ColorFilter?.Dispose(); - Paint?.ImageFilter?.Dispose(); - Paint?.MaskFilter?.Dispose(); - Paint?.PathEffect?.Dispose(); - Paint?.Dispose(); - - Paint = null; - - IsOpen = false; - } - - public void Invalidate() - { - if (_disposed) - throw new ObjectDisposedException("Renderer"); - - _valid = false; - } - - ~Renderer() - { - if (IsOpen) - Close(); - } - - public void Dispose() - { - if (IsOpen) - Close(); - - Surface?.Dispose(); - Paint?.Dispose(); - Path?.Dispose(); - - Surface = null; - Paint = null; - Path = null; - - _disposed = true; - } -} \ No newline at end of file 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.Core/Services/RenderService.cs b/src/Artemis.Core/Services/RenderService.cs index 26e23ca6f..bd73a0f0d 100644 --- a/src/Artemis.Core/Services/RenderService.cs +++ b/src/Artemis.Core/Services/RenderService.cs @@ -123,6 +123,7 @@ internal class RenderService : IRenderService, IRenderer, IDisposable return; } + _logger.Information("Applying {Name} graphics context", _preferredGraphicsContext.Value); if (_preferredGraphicsContext.Value == "Software") { GraphicsContext = null; diff --git a/src/Artemis.Storage/Artemis.Storage.csproj b/src/Artemis.Storage/Artemis.Storage.csproj index 679f1c3c7..d682d5570 100644 --- a/src/Artemis.Storage/Artemis.Storage.csproj +++ b/src/Artemis.Storage/Artemis.Storage.csproj @@ -1,12 +1,12 @@  - net7.0 + net8.0 false x64 - + \ No newline at end of file diff --git a/src/Artemis.UI.Linux/Artemis.UI.Linux.csproj b/src/Artemis.UI.Linux/Artemis.UI.Linux.csproj index c346e5027..c41b62a40 100644 --- a/src/Artemis.UI.Linux/Artemis.UI.Linux.csproj +++ b/src/Artemis.UI.Linux/Artemis.UI.Linux.csproj @@ -1,7 +1,7 @@ WinExe - net7.0 + net8.0 enable x64 x64 diff --git a/src/Artemis.UI.MacOS/Artemis.UI.MacOS.csproj b/src/Artemis.UI.MacOS/Artemis.UI.MacOS.csproj index b5cc49ac7..d762afde1 100644 --- a/src/Artemis.UI.MacOS/Artemis.UI.MacOS.csproj +++ b/src/Artemis.UI.MacOS/Artemis.UI.MacOS.csproj @@ -1,7 +1,7 @@  WinExe - net7.0 + net8.0 enable x64 x64 diff --git a/src/Artemis.UI.Shared/Artemis.UI.Shared.csproj b/src/Artemis.UI.Shared/Artemis.UI.Shared.csproj index 1be9809f0..67242b85a 100644 --- a/src/Artemis.UI.Shared/Artemis.UI.Shared.csproj +++ b/src/Artemis.UI.Shared/Artemis.UI.Shared.csproj @@ -1,7 +1,7 @@  Library - net7.0 + net8.0 enable bin\ x64 @@ -10,16 +10,16 @@ - + - - - + + + - + diff --git a/src/Artemis.UI.Shared/Routing/Router/Router.cs b/src/Artemis.UI.Shared/Routing/Router/Router.cs index 2f84eb145..39e067ec5 100644 --- a/src/Artemis.UI.Shared/Routing/Router/Router.cs +++ b/src/Artemis.UI.Shared/Routing/Router/Router.cs @@ -199,6 +199,8 @@ internal class Router : CorePropertyChanged, IRouter, IDisposable { if (_previousWindowRoute != null && _currentRouteSubject.Value == "blank") Dispatcher.UIThread.InvokeAsync(async () => await Navigate(_previousWindowRoute, new RouterNavigationOptions {AddToHistory = false, EnableLogging = false})); + else if (_currentRouteSubject.Value == null || _currentRouteSubject.Value == "blank") + Dispatcher.UIThread.InvokeAsync(async () => await Navigate("home", new RouterNavigationOptions {AddToHistory = false, EnableLogging = true})); } private void MainWindowServiceOnMainWindowClosed(object? sender, EventArgs e) diff --git a/src/Artemis.UI.Windows/Artemis.UI.Windows.csproj b/src/Artemis.UI.Windows/Artemis.UI.Windows.csproj index b0d0e9593..b9abf03e3 100644 --- a/src/Artemis.UI.Windows/Artemis.UI.Windows.csproj +++ b/src/Artemis.UI.Windows/Artemis.UI.Windows.csproj @@ -1,7 +1,7 @@ WinExe - net7.0-windows10.0.17763.0 + net8.0-windows10.0.17763.0 enable x64 bin @@ -21,11 +21,11 @@ - + - + diff --git a/src/Artemis.UI.Windows/Providers/AutoRunProvider.cs b/src/Artemis.UI.Windows/Providers/AutoRunProvider.cs index 3108bf5fe..bf5206039 100644 --- a/src/Artemis.UI.Windows/Providers/AutoRunProvider.cs +++ b/src/Artemis.UI.Windows/Providers/AutoRunProvider.cs @@ -105,7 +105,7 @@ public class AutoRunProvider : IAutoRunProvider /// public async Task EnableAutoRun(bool recreate, int autoRunDelay) { - if (Constants.CurrentVersion == "development") + if (Constants.CurrentVersion == "local") return; await CleanupOldAutorun(); diff --git a/src/Artemis.UI.Windows/Providers/WindowsUpdateNotificationProvider.cs b/src/Artemis.UI.Windows/Providers/WindowsUpdateNotificationProvider.cs index 8ca86dd87..cd7d5c186 100644 --- a/src/Artemis.UI.Windows/Providers/WindowsUpdateNotificationProvider.cs +++ b/src/Artemis.UI.Windows/Providers/WindowsUpdateNotificationProvider.cs @@ -60,10 +60,10 @@ public class WindowsUpdateNotificationProvider : IUpdateNotificationProvider Dispatcher.UIThread.Invoke(async () => { _mainWindowService.OpenMainWindow(); - if (releaseId != null) + if (releaseId != null && releaseId.Value != Guid.Empty) await _router.Navigate($"settings/releases/{releaseId}"); else - await _router.Navigate($"settings/releases"); + await _router.Navigate("settings/releases"); }); } @@ -110,7 +110,7 @@ public class WindowsUpdateNotificationProvider : IUpdateNotificationProvider // If the main window is not open the user isn't busy, restart straight away if (!_mainWindowService.IsMainWindowOpen) { - _updateService.RestartForUpdate(true); + _updateService.RestartForUpdate("WindowsNotification", true); return; } @@ -165,6 +165,6 @@ public class WindowsUpdateNotificationProvider : IUpdateNotificationProvider else if (action == "cancel") _cancellationTokenSource?.Cancel(); else if (action == "restart-for-update") - _updateService.RestartForUpdate(false); + _updateService.RestartForUpdate("WindowsNotification", false); } } \ No newline at end of file diff --git a/src/Artemis.UI/Artemis.UI.csproj b/src/Artemis.UI/Artemis.UI.csproj index 0048cffb7..88b39600b 100644 --- a/src/Artemis.UI/Artemis.UI.csproj +++ b/src/Artemis.UI/Artemis.UI.csproj @@ -1,7 +1,7 @@ Library - net7.0 + net8.0 enable bin/ x64 @@ -19,11 +19,11 @@ - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -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 e129b384c..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}"), @@ -64,6 +67,7 @@ public static class Routes new RouteRegistration("{releaseId:guid}") } }, + new RouteRegistration("account"), new RouteRegistration("about") } }, diff --git a/src/Artemis.UI/Screens/Debugger/Tabs/Performance/PerformanceDebugViewModel.cs b/src/Artemis.UI/Screens/Debugger/Tabs/Performance/PerformanceDebugViewModel.cs index cefb569dc..0b2bd65a9 100644 --- a/src/Artemis.UI/Screens/Debugger/Tabs/Performance/PerformanceDebugViewModel.cs +++ b/src/Artemis.UI/Screens/Debugger/Tabs/Performance/PerformanceDebugViewModel.cs @@ -63,7 +63,7 @@ public partial class PerformanceDebugViewModel : ActivatableViewModelBase private void HandleActivation() { - Renderer = Constants.ManagedGraphicsContext != null ? Constants.ManagedGraphicsContext.GetType().Name : "Software"; + Renderer = _renderService.GraphicsContext?.GetType().Name ?? "Software"; _renderService.FrameRendered += RenderServiceOnFrameRendered; } diff --git a/src/Artemis.UI/Screens/Debugger/Tabs/Render/RenderDebugViewModel.cs b/src/Artemis.UI/Screens/Debugger/Tabs/Render/RenderDebugViewModel.cs index b4594eee3..dbf76e823 100644 --- a/src/Artemis.UI/Screens/Debugger/Tabs/Render/RenderDebugViewModel.cs +++ b/src/Artemis.UI/Screens/Debugger/Tabs/Render/RenderDebugViewModel.cs @@ -35,7 +35,7 @@ public partial class RenderDebugViewModel : ActivatableViewModelBase private void HandleActivation() { - Renderer = Constants.ManagedGraphicsContext != null ? Constants.ManagedGraphicsContext.GetType().Name : "Software"; + Renderer = _renderService.GraphicsContext?.GetType().Name ?? "Software"; _renderService.FrameRendered += RenderServiceOnFrameRendered; } diff --git a/src/Artemis.UI/Screens/Device/Tabs/Layout/LayoutProviders/WorkshopLayoutView.axaml b/src/Artemis.UI/Screens/Device/Tabs/Layout/LayoutProviders/WorkshopLayoutView.axaml index 07a8a7325..38c1e2eef 100644 --- a/src/Artemis.UI/Screens/Device/Tabs/Layout/LayoutProviders/WorkshopLayoutView.axaml +++ b/src/Artemis.UI/Screens/Device/Tabs/Layout/LayoutProviders/WorkshopLayoutView.axaml @@ -4,6 +4,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:services="clr-namespace:Artemis.WebClient.Workshop.Services;assembly=Artemis.WebClient.Workshop" xmlns:layoutProviders="clr-namespace:Artemis.UI.Screens.Device.Layout.LayoutProviders" + xmlns:models="clr-namespace:Artemis.WebClient.Workshop.Models;assembly=Artemis.WebClient.Workshop" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Artemis.UI.Screens.Device.Layout.LayoutProviders.WorkshopLayoutView" x:DataType="layoutProviders:WorkshopLayoutViewModel"> @@ -19,7 +20,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..6b01838a9 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) { @@ -60,11 +62,10 @@ public class RootViewModel : RoutableHostScreen, IMainWindowProv router.SetRoot(this); mainWindowService.ConfigureMainWindowProvider(this); - DisplayAccordingToSettings(); 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()) @@ -72,17 +73,27 @@ public class RootViewModel : RoutableHostScreen, IMainWindowProv .Select(vm => vm ?? _defaultTitleBarViewModel) .ToProperty(this, vm => vm.TitleBarViewModel); + if (ShouldShowUI()) + { + ShowSplashScreen(); + _coreService.Initialized += (_, _) => Dispatcher.UIThread.InvokeAsync(OpenMainWindow); + } + Task.Run(() => { - if (_updateService.Initialize()) + // Before doing heavy lifting, initialize the update service which may prompt a restart + // Only initialize with an update check if we're not going to show the UI + if (_updateService.Initialize(!ShouldShowUI())) 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(); - - _router.Navigate("home"); }); } @@ -111,17 +122,15 @@ public class RootViewModel : RoutableHostScreen, IMainWindowProv OnMainWindowClosed(); } - private void DisplayAccordingToSettings() + private bool ShouldShowUI() { bool autoRunning = Constants.StartupArguments.Contains("--autorun"); bool minimized = Constants.StartupArguments.Contains("--minimized"); bool showOnAutoRun = _settingsService.GetSetting("UI.ShowOnStartup", true).Value; - if ((autoRunning && !showOnAutoRun) || minimized) - return; - - ShowSplashScreen(); - _coreService.Initialized += (_, _) => Dispatcher.UIThread.InvokeAsync(OpenMainWindow); + if (autoRunning) + return showOnAutoRun; + return !minimized; } private void ShowSplashScreen() @@ -135,7 +144,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 +193,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/ChangeEmailAddressView.axaml b/src/Artemis.UI/Screens/Settings/Account/ChangeEmailAddressView.axaml new file mode 100644 index 000000000..2f933b14b --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Account/ChangeEmailAddressView.axaml @@ -0,0 +1,14 @@ + + + + + An email will be sent to the new address to confirm the change. + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Account/ChangeEmailAddressView.axaml.cs b/src/Artemis.UI/Screens/Settings/Account/ChangeEmailAddressView.axaml.cs new file mode 100644 index 000000000..ed3f4a7d0 --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Account/ChangeEmailAddressView.axaml.cs @@ -0,0 +1,16 @@ +using Artemis.UI.Shared.Extensions; +using Avalonia.ReactiveUI; +using Avalonia.Threading; +using ReactiveUI; + +namespace Artemis.UI.Screens.Settings.Account; + +public partial class ChangeEmailAddressView : ReactiveUserControl +{ + public ChangeEmailAddressView() + { + InitializeComponent(); + this.WhenActivated(_ => this.ClearAllDataValidationErrors()); + } + +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Account/ChangeEmailAddressViewModel.cs b/src/Artemis.UI/Screens/Settings/Account/ChangeEmailAddressViewModel.cs new file mode 100644 index 000000000..3dc39d788 --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Account/ChangeEmailAddressViewModel.cs @@ -0,0 +1,50 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Reactive; +using System.Threading; +using System.Threading.Tasks; +using Artemis.UI.Shared; +using Artemis.UI.Shared.Services; +using Artemis.WebClient.Workshop.Handlers.UploadHandlers; +using Artemis.WebClient.Workshop.Services; +using IdentityModel; +using PropertyChanged.SourceGenerator; +using ReactiveUI; +using ReactiveUI.Validation.Extensions; + +namespace Artemis.UI.Screens.Settings.Account; + +public partial class ChangeEmailAddressViewModel : ContentDialogViewModelBase +{ + private readonly IUserManagementService _userManagementService; + private readonly IWindowService _windowService; + [Notify] private string _emailAddress = string.Empty; + + public ChangeEmailAddressViewModel(IUserManagementService userManagementService, IAuthenticationService authenticationService, IWindowService windowService) + { + _userManagementService = userManagementService; + _windowService = windowService; + Submit = ReactiveCommand.CreateFromTask(ExecuteSubmit, ValidationContext.Valid); + + string? currentEmail = authenticationService.Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.Email)?.Value; + this.ValidationRule(vm => vm.EmailAddress, e => !string.IsNullOrWhiteSpace(e), + "You must specify a new email address"); + this.ValidationRule(vm => vm.EmailAddress, e => string.IsNullOrWhiteSpace(e) || new EmailAddressAttribute().IsValid(e), + "You must specify a valid email address"); + this.ValidationRule(vm => vm.EmailAddress, + e => string.IsNullOrWhiteSpace(e) || currentEmail == null || !string.Equals(e, currentEmail, StringComparison.InvariantCultureIgnoreCase), + "New email address must be different from the old one"); + } + + public ReactiveCommand Submit { get; } + + private async Task ExecuteSubmit(CancellationToken cts) + { + ApiResult result = await _userManagementService.ChangeEmailAddress(EmailAddress, cts); + if (result.IsSuccess) + await _windowService.ShowConfirmContentDialog("Confirmation required", "Before being applied, you must confirm your new email address. Please check your inbox.", cancel: null); + else + await _windowService.ShowConfirmContentDialog("Failed to update email address", result.Message ?? "An unexpected error occured", cancel: null); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Account/ChangePasswordView.axaml b/src/Artemis.UI/Screens/Settings/Account/ChangePasswordView.axaml new file mode 100644 index 000000000..3f350cc23 --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Account/ChangePasswordView.axaml @@ -0,0 +1,17 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Account/ChangePasswordView.axaml.cs b/src/Artemis.UI/Screens/Settings/Account/ChangePasswordView.axaml.cs new file mode 100644 index 000000000..2b13f8a7a --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Account/ChangePasswordView.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 ChangePasswordView : ReactiveUserControl +{ + public ChangePasswordView() + { + InitializeComponent(); + this.WhenActivated(_ => this.ClearAllDataValidationErrors()); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Account/ChangePasswordViewModel.cs b/src/Artemis.UI/Screens/Settings/Account/ChangePasswordViewModel.cs new file mode 100644 index 000000000..cd405c2c5 --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Account/ChangePasswordViewModel.cs @@ -0,0 +1,47 @@ +using System.Reactive; +using System.Threading; +using System.Threading.Tasks; +using Artemis.UI.Shared; +using Artemis.UI.Shared.Services; +using Artemis.WebClient.Workshop.Handlers.UploadHandlers; +using Artemis.WebClient.Workshop.Services; +using PropertyChanged.SourceGenerator; +using ReactiveUI; +using ReactiveUI.Validation.Extensions; + +namespace Artemis.UI.Screens.Settings.Account; + +public partial class ChangePasswordViewModel : ContentDialogViewModelBase +{ + private readonly IUserManagementService _userManagementService; + private readonly IWindowService _windowService; + [Notify] private string _currentPassword = string.Empty; + [Notify] private string _newPassword = string.Empty; + [Notify] private string _newPasswordConfirmation = string.Empty; + + public ChangePasswordViewModel(IUserManagementService userManagementService, IWindowService windowService) + { + _userManagementService = userManagementService; + _windowService = windowService; + Submit = ReactiveCommand.CreateFromTask(ExecuteSubmit, ValidationContext.Valid); + + this.ValidationRule(vm => vm.CurrentPassword, e => !string.IsNullOrWhiteSpace(e), "You must specify your current password"); + this.ValidationRule(vm => vm.NewPassword, e => !string.IsNullOrWhiteSpace(e), "You must specify a new password"); + this.ValidationRule( + vm => vm.NewPasswordConfirmation, + this.WhenAnyValue(vm => vm.NewPassword, vm => vm.NewPasswordConfirmation, (password, confirmation) => password == confirmation), + "The passwords must match" + ); + } + + public ReactiveCommand Submit { get; } + + private async Task ExecuteSubmit(CancellationToken cts) + { + ApiResult result = await _userManagementService.ChangePassword(CurrentPassword, NewPassword, cts); + if (result.IsSuccess) + await _windowService.ShowConfirmContentDialog("Password changed", "Your password has been changed", cancel: null); + else + await _windowService.ShowConfirmContentDialog("Failed to change password", result.Message ?? "An unexpected error occured", cancel: null); + } +} \ No newline at end of file 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/Account/RemoveAccountView.axaml b/src/Artemis.UI/Screens/Settings/Account/RemoveAccountView.axaml new file mode 100644 index 000000000..c53b8505d --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Account/RemoveAccountView.axaml @@ -0,0 +1,14 @@ + + + + + This is a destructive action that cannot be undone. + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Account/RemoveAccountView.axaml.cs b/src/Artemis.UI/Screens/Settings/Account/RemoveAccountView.axaml.cs new file mode 100644 index 000000000..5c4491cab --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Account/RemoveAccountView.axaml.cs @@ -0,0 +1,16 @@ +using Artemis.UI.Shared.Extensions; +using Avalonia.ReactiveUI; +using Avalonia.Threading; +using ReactiveUI; + +namespace Artemis.UI.Screens.Settings.Account; + +public partial class RemoveAccountView : ReactiveUserControl +{ + public RemoveAccountView() + { + InitializeComponent(); + this.WhenActivated(_ => this.ClearAllDataValidationErrors()); + } + +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Account/RemoveAccountViewModel.cs b/src/Artemis.UI/Screens/Settings/Account/RemoveAccountViewModel.cs new file mode 100644 index 000000000..cff7e35ed --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Account/RemoveAccountViewModel.cs @@ -0,0 +1,52 @@ +using System; +using System.Linq; +using System.Reactive; +using System.Threading; +using System.Threading.Tasks; +using Artemis.UI.Shared; +using Artemis.UI.Shared.Services; +using Artemis.WebClient.Workshop.Handlers.UploadHandlers; +using Artemis.WebClient.Workshop.Services; +using IdentityModel; +using PropertyChanged.SourceGenerator; +using ReactiveUI; +using ReactiveUI.Validation.Extensions; + +namespace Artemis.UI.Screens.Settings.Account; + +public partial class RemoveAccountViewModel : ContentDialogViewModelBase +{ + private readonly IUserManagementService _userManagementService; + private readonly IAuthenticationService _authenticationService; + private readonly IWindowService _windowService; + [Notify] private string _emailAddress = string.Empty; + + public RemoveAccountViewModel(IUserManagementService userManagementService, IAuthenticationService authenticationService, IWindowService windowService) + { + _userManagementService = userManagementService; + _authenticationService = authenticationService; + _windowService = windowService; + + Submit = ReactiveCommand.CreateFromTask(ExecuteSubmit, ValidationContext.Valid); + string? currentEmail = authenticationService.Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.Email)?.Value; + + this.ValidationRule(vm => vm.EmailAddress, e => !string.IsNullOrWhiteSpace(e), "You must enter your email address"); + this.ValidationRule(vm => vm.EmailAddress, + e => string.IsNullOrWhiteSpace(e) || string.Equals(e, currentEmail, StringComparison.InvariantCultureIgnoreCase), + "The entered email address is not correct"); + } + + public ReactiveCommand Submit { get; } + + private async Task ExecuteSubmit(CancellationToken cts) + { + ApiResult result = await _userManagementService.RemoveAccount(cts); + if (result.IsSuccess) + { + await _windowService.ShowConfirmContentDialog("Account removed", "Hopefully we'll see you again!", cancel: null); + _authenticationService.Logout(); + } + else + await _windowService.ShowConfirmContentDialog("Failed to remove account", result.Message ?? "An unexpected error occured", cancel: null); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/SettingsViewModel.cs b/src/Artemis.UI/Screens/Settings/SettingsViewModel.cs index 166fcfe63..e83502417 100644 --- a/src/Artemis.UI/Screens/Settings/SettingsViewModel.cs +++ b/src/Artemis.UI/Screens/Settings/SettingsViewModel.cs @@ -26,6 +26,7 @@ public partial class SettingsViewModel : RoutableHostScreen, IMa new("Plugins", "settings/plugins"), new("Devices", "settings/devices"), new("Releases", "settings/releases"), + new("Account", "settings/account"), new("About", "settings/about"), }; diff --git a/src/Artemis.UI/Screens/Settings/Tabs/AccountTabView.axaml b/src/Artemis.UI/Screens/Settings/Tabs/AccountTabView.axaml new file mode 100644 index 000000000..eeb650355 --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Tabs/AccountTabView.axaml @@ -0,0 +1,158 @@ + + + + + + + + + + + + You are not logged in + + In order to manage your account you must be logged in. + + Creating an account is free and we'll not bother you with a newsletter or crap like that. + + + + + + Click Log In below to (create an account) and log in. + + You'll also be able to log in with Google or Discord. + + + + + + + + + + + + + + + + + + + + Account management + + + + + + + Credentials + + + + + + + + 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/AccountTabView.axaml.cs b/src/Artemis.UI/Screens/Settings/Tabs/AccountTabView.axaml.cs new file mode 100644 index 000000000..040547f9f --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Tabs/AccountTabView.axaml.cs @@ -0,0 +1,14 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Settings; + +public partial class AccountTabView : ReactiveUserControl +{ + public AccountTabView() + { + InitializeComponent(); + } +} \ 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 new file mode 100644 index 000000000..afab57079 --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Tabs/AccountTabViewModel.cs @@ -0,0 +1,146 @@ +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; +using System.Threading.Tasks; +using Artemis.UI.Screens.Settings.Account; +using Artemis.UI.Screens.Workshop.CurrentUser; +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; +using ReactiveUI; + +namespace Artemis.UI.Screens.Settings; + +public partial class AccountTabViewModel : RoutableScreen +{ + private readonly IWindowService _windowService; + private readonly IAuthenticationService _authenticationService; + private readonly IUserManagementService _userManagementService; + private ObservableAsPropertyHelper? _canChangePassword; + + [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) + { + _windowService = windowService; + _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); + _canChangePassword.DisposeWith(d); + }); + this.WhenActivated(d => _authenticationService.IsLoggedIn.Subscribe(_ => LoadCurrentUser()).DisposeWith(d)); + } + + 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(); + if (result == null) + return; + + try + { + AvatarUrl = $"{WorkshopConstants.AUTHORITY_URL}/user/avatar/{Guid.Empty}"; + await using FileStream fileStream = new(result.First(), FileMode.Open); + ApiResult changeResult = await _userManagementService.ChangeAvatar(fileStream, CancellationToken.None); + if (!changeResult.IsSuccess) + await _windowService.ShowConfirmContentDialog("Failed to change image", changeResult.Message ?? "An unexpected error occured", cancel: null); + } + finally + { + string? userId = _authenticationService.Claims.FirstOrDefault(c => c.Type == "sub")?.Value; + AvatarUrl = $"{WorkshopConstants.AUTHORITY_URL}/user/avatar/{userId}"; + } + } + + public async Task ChangeEmailAddress() + { + await _windowService.CreateContentDialog().WithTitle("Change email address") + .WithViewModel(out ChangeEmailAddressViewModel vm) + .WithCloseButtonText("Cancel") + .HavingPrimaryButton(b => b.WithText("Submit").WithCommand(vm.Submit)) + .ShowAsync(); + } + + public async Task ChangePasswordAddress() + { + await _windowService.CreateContentDialog().WithTitle("Change password") + .WithViewModel(out ChangePasswordViewModel vm) + .WithCloseButtonText("Cancel") + .HavingPrimaryButton(b => b.WithText("Submit").WithCommand(vm.Submit)) + .ShowAsync(); + } + + public async Task RemoveAccount() + { + await _windowService.CreateContentDialog().WithTitle("Remove account") + .WithViewModel(out RemoveAccountViewModel vm) + .WithCloseButtonText("Cancel") + .HavingPrimaryButton(b => b.WithText("Permanently remove account").WithCommand(vm.Submit)) + .ShowAsync(); + } + + 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/Settings/Updating/ReleaseDetailsViewModel.cs b/src/Artemis.UI/Screens/Settings/Updating/ReleaseDetailsViewModel.cs index cf3026879..23d3b9fbb 100644 --- a/src/Artemis.UI/Screens/Settings/Updating/ReleaseDetailsViewModel.cs +++ b/src/Artemis.UI/Screens/Settings/Updating/ReleaseDetailsViewModel.cs @@ -87,7 +87,7 @@ public partial class ReleaseDetailsViewModel : RoutableScreen - 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/CurrentUser/CurrentUserView.axaml b/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserView.axaml index 9692ebdc0..15127badd 100644 --- a/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserView.axaml +++ b/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserView.axaml @@ -34,8 +34,8 @@ Name="UserMenu"> - - + + @@ -46,10 +46,20 @@ IsVisible="{CompiledBinding AllowLogout}" Grid.Column="1" Grid.Row="2" + Margin="-8 4 0 0" + Padding="6 4" + Click="Signout_OnClick"> + Sign out + + + - Sign out + Click="Manage_OnClick"> + Manage account diff --git a/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserView.axaml.cs b/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserView.axaml.cs index b690eb8dc..63fe16098 100644 --- a/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserView.axaml.cs +++ b/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserView.axaml.cs @@ -13,9 +13,15 @@ public partial class CurrentUserView : ReactiveUserControl InitializeComponent(); } - private void Button_OnClick(object? sender, RoutedEventArgs e) + private void Signout_OnClick(object? sender, RoutedEventArgs e) { UserMenu.ContextFlyout?.Hide(); ViewModel?.Logout(); } + + private void Manage_OnClick(object? sender, RoutedEventArgs e) + { + UserMenu.ContextFlyout?.Hide(); + ViewModel?.ManageAccount(); + } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserViewModel.cs b/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserViewModel.cs index c6cbf3c8b..8a782486c 100644 --- a/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserViewModel.cs @@ -6,6 +6,7 @@ using System.Reactive.Disposables; using System.Threading; using System.Threading.Tasks; using Artemis.UI.Shared; +using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; using Artemis.WebClient.Workshop; using Artemis.WebClient.Workshop.Services; @@ -23,6 +24,7 @@ public partial class CurrentUserViewModel : ActivatableViewModelBase private readonly ObservableAsPropertyHelper _isAnonymous; private readonly ILogger _logger; private readonly IWindowService _windowService; + private readonly IRouter _router; [Notify] private bool _allowLogout = true; [Notify(Setter.Private)] private Bitmap? _avatar; [Notify(Setter.Private)] private string? _email; @@ -31,11 +33,12 @@ public partial class CurrentUserViewModel : ActivatableViewModelBase [Notify(Setter.Private)] private string? _userId; [Notify(Setter.Private)] private string? _avatarUrl; - public CurrentUserViewModel(ILogger logger, IAuthenticationService authenticationService, IWindowService windowService) + public CurrentUserViewModel(ILogger logger, IAuthenticationService authenticationService, IWindowService windowService, IRouter router) { _logger = logger; _authenticationService = authenticationService; _windowService = windowService; + _router = router; Login = ReactiveCommand.CreateFromTask(ExecuteLogin); _isAnonymous = this.WhenAnyValue(vm => vm.Loading, vm => vm.Name, (l, n) => l || n == null).ToProperty(this, vm => vm.IsAnonymous); @@ -92,4 +95,9 @@ public partial class CurrentUserViewModel : ActivatableViewModelBase Email = _authenticationService.Claims.FirstOrDefault(c => c.Type == "email")?.Value; AvatarUrl = $"{WorkshopConstants.AUTHORITY_URL}/user/avatar/{UserId}"; } + + public void ManageAccount() + { + _router.Navigate("settings/account"); + } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Entries/Details/EntryReleasesViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/Details/EntryReleasesViewModel.cs index e771ee5d9..21be13b28 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/Details/EntryReleasesViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Entries/Details/EntryReleasesViewModel.cs @@ -8,7 +8,7 @@ using Artemis.UI.Shared.Services.Builders; using Artemis.UI.Shared.Utilities; using Artemis.WebClient.Workshop; using Artemis.WebClient.Workshop.Handlers.InstallationHandlers; -using Artemis.WebClient.Workshop.Services; +using Artemis.WebClient.Workshop.Models; using Humanizer; using ReactiveUI; @@ -28,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) @@ -40,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); @@ -63,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 @@ + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + diff --git a/src/Artemis.UI/Screens/Workshop/Plugins/Dialogs/PluginDialogView.axaml b/src/Artemis.UI/Screens/Workshop/Plugins/Dialogs/PluginDialogView.axaml new file mode 100644 index 000000000..3c0336a37 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Plugins/Dialogs/PluginDialogView.axaml @@ -0,0 +1,19 @@ + + + + + + + Plugin features + + + + + diff --git a/src/Artemis.UI/Screens/Workshop/Plugins/Dialogs/PluginDialogView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Plugins/Dialogs/PluginDialogView.axaml.cs new file mode 100644 index 000000000..19329553e --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Plugins/Dialogs/PluginDialogView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Plugins.Dialogs; + +public partial class PluginDialogView : ReactiveUserControl +{ + public PluginDialogView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Plugins/Dialogs/PluginDialogViewModel.cs b/src/Artemis.UI/Screens/Workshop/Plugins/Dialogs/PluginDialogViewModel.cs new file mode 100644 index 000000000..863417326 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Plugins/Dialogs/PluginDialogViewModel.cs @@ -0,0 +1,23 @@ +using System.Collections.ObjectModel; +using System.Linq; +using System.Reactive.Linq; +using Artemis.Core; +using Artemis.UI.DryIoc.Factories; +using Artemis.UI.Screens.Plugins; +using Artemis.UI.Screens.Plugins.Features; +using Artemis.UI.Shared; +using ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Plugins.Dialogs; + +public class PluginDialogViewModel : ContentDialogViewModelBase +{ + public PluginDialogViewModel(Plugin plugin, ISettingsVmFactory settingsVmFactory) + { + PluginViewModel = settingsVmFactory.PluginViewModel(plugin, ReactiveCommand.Create(() => {}, Observable.Empty())); + PluginFeatures = new ObservableCollection(plugin.Features.Select(f => settingsVmFactory.PluginFeatureViewModel(f, false))); + } + + public PluginViewModel PluginViewModel { get; } + public ObservableCollection PluginFeatures { get; } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Plugins/PluginDetailsView.axaml b/src/Artemis.UI/Screens/Workshop/Plugins/PluginDetailsView.axaml new file mode 100644 index 000000000..78a50487c --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Plugins/PluginDetailsView.axaml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Artemis.UI/Screens/Workshop/Plugins/PluginDetailsView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Plugins/PluginDetailsView.axaml.cs new file mode 100644 index 000000000..c50ffbe89 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Plugins/PluginDetailsView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Plugins; + +public partial class PluginDetailsView : ReactiveUserControl +{ + public PluginDetailsView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Plugins/PluginDetailsViewModel.cs b/src/Artemis.UI/Screens/Workshop/Plugins/PluginDetailsViewModel.cs new file mode 100644 index 000000000..bfce68a94 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Plugins/PluginDetailsViewModel.cs @@ -0,0 +1,97 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Artemis.Core; +using Artemis.Core.Services; +using Artemis.UI.Screens.Workshop.Entries.Details; +using Artemis.UI.Screens.Workshop.Parameters; +using Artemis.UI.Screens.Workshop.Plugins.Dialogs; +using Artemis.UI.Shared.Routing; +using Artemis.UI.Shared.Services; +using Artemis.WebClient.Workshop; +using Artemis.WebClient.Workshop.Models; +using PropertyChanged.SourceGenerator; +using StrawberryShake; + +namespace Artemis.UI.Screens.Workshop.Plugins; + +public partial class PluginDetailsViewModel : RoutableScreen +{ + private readonly IWorkshopClient _client; + private readonly IWindowService _windowService; + private readonly IPluginManagementService _pluginManagementService; + private readonly Func _getEntryInfoViewModel; + private readonly Func _getEntryReleasesViewModel; + private readonly Func _getEntryImagesViewModel; + [Notify] private IGetEntryById_Entry? _entry; + [Notify] private EntryInfoViewModel? _entryInfoViewModel; + [Notify] private EntryReleasesViewModel? _entryReleasesViewModel; + [Notify] private EntryImagesViewModel? _entryImagesViewModel; + + public PluginDetailsViewModel(IWorkshopClient client, + IWindowService windowService, + IPluginManagementService pluginManagementService, + Func getEntryInfoViewModel, + Func getEntryReleasesViewModel, + Func getEntryImagesViewModel) + { + _client = client; + _windowService = windowService; + _pluginManagementService = pluginManagementService; + _getEntryInfoViewModel = getEntryInfoViewModel; + _getEntryReleasesViewModel = getEntryReleasesViewModel; + _getEntryImagesViewModel = getEntryImagesViewModel; + } + + public override async Task OnNavigating(WorkshopDetailParameters parameters, NavigationArguments args, CancellationToken cancellationToken) + { + await GetEntry(parameters.EntryId, cancellationToken); + } + + private async Task GetEntry(long entryId, CancellationToken cancellationToken) + { + IOperationResult result = await _client.GetEntryById.ExecuteAsync(entryId, cancellationToken); + if (result.IsErrorResult()) + return; + + Entry = result.Data?.Entry; + if (Entry == null) + { + EntryInfoViewModel = null; + EntryReleasesViewModel = null; + } + else + { + EntryInfoViewModel = _getEntryInfoViewModel(Entry); + EntryReleasesViewModel = _getEntryReleasesViewModel(Entry); + EntryImagesViewModel = _getEntryImagesViewModel(Entry); + + EntryReleasesViewModel.OnInstallationStarted = OnInstallationStarted; + EntryReleasesViewModel.OnInstallationFinished = OnInstallationFinished; + } + } + + private async Task OnInstallationStarted(IEntryDetails entryDetails) + { + bool confirm = await _windowService.ShowConfirmContentDialog( + "Installing plugin", + $"You are about to install version {entryDetails.LatestRelease?.Version} of {entryDetails.Name}. \r\n\r\n" + + "Plugins are NOT verified by Artemis and could harm your PC, if you have doubts about a plugin please ask on Discord!", + "I trust this plugin, install it" + ); + + return !confirm; + } + + private async Task OnInstallationFinished(InstalledEntry installedEntry) + { + if (!installedEntry.TryGetMetadata("PluginId", out Guid pluginId)) + return; + Plugin? plugin = _pluginManagementService.GetAllPlugins().FirstOrDefault(p => p.Guid == pluginId); + if (plugin == null) + return; + + await _windowService.CreateContentDialog().WithTitle("Manage plugin").WithViewModel(out PluginDialogViewModel _, plugin).WithFullScreen().ShowAsync(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Models/SubmissionWizardState.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Models/SubmissionWizardState.cs index 89d2f4fa9..e06f14ce9 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Models/SubmissionWizardState.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Models/SubmissionWizardState.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; 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.UI.Shared.Services; using Artemis.WebClient.Workshop; @@ -56,7 +57,9 @@ public class SubmissionWizardState : IDisposable public void StartForCurrentEntry() { - if (EntryType == EntryType.Profile) + if (EntryType == EntryType.Plugin) + ChangeScreen(); + else if (EntryType == EntryType.Profile) ChangeScreen(); else if (EntryType == EntryType.Layout) ChangeScreen(); diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeStepView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeStepView.axaml index c1720ccfa..c33d2b2db 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeStepView.axaml +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/EntryTypeStepView.axaml @@ -44,5 +44,14 @@ + + + + Plugin + A plugin that adds new features to Artemis. + + + diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Plugin/PluginSelectionStepView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Plugin/PluginSelectionStepView.axaml new file mode 100644 index 000000000..06598cd93 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Plugin/PluginSelectionStepView.axaml @@ -0,0 +1,46 @@ + + + + + + + + + + Plugin selection + + + Please select the plugin you want to share, a preview will be shown below. + + + + + + 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.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs index 2a21cde43..81e464311 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs @@ -82,8 +82,8 @@ public partial class UploadStepViewModel : SubmissionViewModel FailureMessage = e.Message; Failed = true; - // If something went wrong halfway through, delete the entry - if (_entryId != null) + // If something went wrong halfway through, delete the entry if it was new + if (State.EntryId == null && _entryId != null) await _workshopClient.RemoveEntry.ExecuteAsync(_entryId.Value, CancellationToken.None); } finally @@ -129,11 +129,11 @@ public partial class UploadStepViewModel : SubmissionViewModel return entryId; } - private async Task TryImageUpload(Func> action) + private async Task TryImageUpload(Func> action) { try { - ImageUploadResult result = await action(); + ApiResult result = await action(); if (!result.IsSuccess) throw new ArtemisWorkshopException(result.Message); } diff --git a/src/Artemis.UI/SerilogAvaloniaSink.cs b/src/Artemis.UI/SerilogAvaloniaSink.cs index d47382bf2..f40c83b28 100644 --- a/src/Artemis.UI/SerilogAvaloniaSink.cs +++ b/src/Artemis.UI/SerilogAvaloniaSink.cs @@ -22,8 +22,13 @@ public class SerilogAvaloniaSink : ILogSink { SerilogLogLevel logLevel = GetSerilogLogLevel(level, area); + #if DEBUG // Except with binding errors, ignore anything that is information or lower return (area == "Binding" || logLevel > SerilogLogLevel.Information) && _logger.IsEnabled(logLevel); + #else + // Ignore binding errors in release builds, shoo + return area != "Binding" && logLevel > SerilogLogLevel.Information && _logger.IsEnabled(logLevel); + #endif } /// diff --git a/src/Artemis.UI/Services/Updating/IUpdateService.cs b/src/Artemis.UI/Services/Updating/IUpdateService.cs index 225ecbd97..840253d3f 100644 --- a/src/Artemis.UI/Services/Updating/IUpdateService.cs +++ b/src/Artemis.UI/Services/Updating/IUpdateService.cs @@ -42,12 +42,14 @@ public interface IUpdateService : IArtemisUIService /// /// Restarts the application to install a pending update. /// + /// The source from which the restart is requested. /// A boolean indicating whether to perform a silent install of the update. - void RestartForUpdate(bool silent); + void RestartForUpdate(string source, bool silent); /// /// Initializes the update service. /// + /// /// A boolean indicating whether a restart will occur to install a pending update. - bool Initialize(); + bool Initialize(bool performAutoUpdate); } \ No newline at end of file diff --git a/src/Artemis.UI/Services/Updating/UpdateService.cs b/src/Artemis.UI/Services/Updating/UpdateService.cs index 15e66985e..deb6484ea 100644 --- a/src/Artemis.UI/Services/Updating/UpdateService.cs +++ b/src/Artemis.UI/Services/Updating/UpdateService.cs @@ -104,19 +104,19 @@ public class UpdateService : IUpdateService { ReleaseInstaller installer = _getReleaseInstaller(release.Id); await installer.InstallAsync(CancellationToken.None); - RestartForUpdate(true); + RestartForUpdate("AutoInstallUpdate", true); } private async void HandleAutoUpdateEvent(object? sender, EventArgs e) { if (Constants.CurrentVersion == "local") return; - - // The event can trigger from multiple sources with a timer acting as a fallback, only actual perform an action once per max 59 minutes + + // The event can trigger from multiple sources with a timer acting as a fallback, only actually perform an action once per max 59 minutes if (DateTime.UtcNow - _lastAutoUpdateCheck < TimeSpan.FromMinutes(59)) return; _lastAutoUpdateCheck = DateTime.UtcNow; - + if (!_autoCheck.Value || _suspendAutoCheck) return; @@ -157,7 +157,7 @@ public class UpdateService : IUpdateService public async Task CheckForUpdate() { _logger.Information("Performing auto-update check"); - + IOperationResult result = await _updatingClient.GetNextRelease.ExecuteAsync(Constants.CurrentVersion, Channel, _updatePlatform); result.EnsureNoErrors(); @@ -171,7 +171,7 @@ public class UpdateService : IUpdateService // Unless auto install is enabled, only offer it once per session if (!_autoInstall.Value) _suspendAutoCheck = true; - + // If the window is open show the changelog, don't auto-update while the user is busy if (_mainWindowService.IsMainWindowOpen || !_autoInstall.Value) { @@ -194,8 +194,10 @@ public class UpdateService : IUpdateService } /// - public void RestartForUpdate(bool silent) + public void RestartForUpdate(string source, bool silent) { + _logger.Information("Restarting for update required by {Source}, silent: {Silent}", source, silent); + if (!Directory.Exists(Path.Combine(Constants.UpdatingFolder, "pending"))) throw new ArtemisUIException("Cannot install update, none is pending."); @@ -204,11 +206,11 @@ public class UpdateService : IUpdateService } /// - public bool Initialize() + public bool Initialize(bool performAutoUpdate) { if (Constants.CurrentVersion == "local") return false; - + string? channelArgument = Constants.StartupArguments.FirstOrDefault(a => a.StartsWith("--channel=")); if (channelArgument != null) Channel = channelArgument.Split("=")[1]; @@ -235,7 +237,7 @@ public class UpdateService : IUpdateService _logger.Information("Installing pending update"); try { - RestartForUpdate(true); + RestartForUpdate("PendingFolder", true); return true; } catch (Exception e) @@ -246,9 +248,10 @@ public class UpdateService : IUpdateService } ProcessReleaseStatus(); - + // Trigger the auto update event so that it doesn't take an hour for the first check to happen - HandleAutoUpdateEvent(this, EventArgs.Empty); + if (performAutoUpdate) + HandleAutoUpdateEvent(this, EventArgs.Empty); _logger.Information("Update service initialized for {Channel} channel", Channel); return false; diff --git a/src/Artemis.VisualScripting/Artemis.VisualScripting.csproj b/src/Artemis.VisualScripting/Artemis.VisualScripting.csproj index 1e5aea19c..90b14e4fa 100644 --- a/src/Artemis.VisualScripting/Artemis.VisualScripting.csproj +++ b/src/Artemis.VisualScripting/Artemis.VisualScripting.csproj @@ -1,6 +1,6 @@  - net7.0 + net8.0 enable enable x64 diff --git a/src/Artemis.VisualScripting/Nodes/DataModel/DataModelEventCycleNode.cs b/src/Artemis.VisualScripting/Nodes/DataModel/DataModelEventCycleNode.cs index b0c8b684a..6ab264a77 100644 --- a/src/Artemis.VisualScripting/Nodes/DataModel/DataModelEventCycleNode.cs +++ b/src/Artemis.VisualScripting/Nodes/DataModel/DataModelEventCycleNode.cs @@ -11,6 +11,7 @@ public class DataModelEventCycleNode : Node public void Dispose() { - if (_dataModelPath?.GetValue() is IDataModelEvent newEvent) - newEvent.EventTriggered -= OnEventTriggered; + if (_subscribedEvent != null) + { + _subscribedEvent.EventTriggered -= OnEventTriggered; + _subscribedEvent = null; + } + _dataModelPath?.Dispose(); } } \ No newline at end of file diff --git a/src/Artemis.WebClient.Updating/Artemis.WebClient.Updating.csproj b/src/Artemis.WebClient.Updating/Artemis.WebClient.Updating.csproj index 732b2c721..23da69ca1 100644 --- a/src/Artemis.WebClient.Updating/Artemis.WebClient.Updating.csproj +++ b/src/Artemis.WebClient.Updating/Artemis.WebClient.Updating.csproj @@ -1,6 +1,6 @@ - net7.0 + net8.0 enable enable x64 @@ -9,8 +9,8 @@ - - + + diff --git a/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj b/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj index b9931c626..840e6d736 100644 --- a/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj +++ b/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj @@ -1,6 +1,6 @@ - net7.0 + net8.0 enable enable x64 @@ -10,8 +10,8 @@ - - + + diff --git a/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs b/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs index 65449d45b..bebc8caf1 100644 --- a/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs +++ b/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs @@ -33,6 +33,11 @@ public static class ContainerExtensions .AddWorkshopClient() .AddHttpMessageHandler() .ConfigureHttpClient(client => client.BaseAddress = new Uri(WorkshopConstants.WORKSHOP_URL + "/graphql")); + + serviceCollection.AddHttpClient(WorkshopConstants.IDENTITY_CLIENT_NAME) + .AddHttpMessageHandler() + .ConfigureHttpClient(client => client.BaseAddress = new Uri(WorkshopConstants.AUTHORITY_URL)); + serviceCollection.AddHttpClient(WorkshopConstants.WORKSHOP_CLIENT_NAME) .AddHttpMessageHandler() .ConfigureHttpClient(client => client.BaseAddress = new Uri(WorkshopConstants.WORKSHOP_URL)); @@ -49,7 +54,8 @@ public static class ContainerExtensions container.Register(Reuse.Singleton); container.Register(Reuse.Singleton); container.Register(Reuse.Singleton); - + container.Register(); + container.Register(Reuse.Transient); container.RegisterMany(workshopAssembly, type => type.IsAssignableTo(), Reuse.Transient); container.RegisterMany(workshopAssembly, type => type.IsAssignableTo(), Reuse.Transient); diff --git a/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/EntryInstallResult.cs b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/EntryInstallResult.cs index 482d34edb..53717506f 100644 --- a/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/EntryInstallResult.cs +++ b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/EntryInstallResult.cs @@ -1,4 +1,5 @@ -using Artemis.WebClient.Workshop.Services; +using Artemis.WebClient.Workshop.Models; +using Artemis.WebClient.Workshop.Services; namespace Artemis.WebClient.Workshop.Handlers.InstallationHandlers; 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/IEntryInstallationHandler.cs b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/IEntryInstallationHandler.cs index 0e4e2d4f4..aacd0d580 100644 --- a/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/IEntryInstallationHandler.cs +++ b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/IEntryInstallationHandler.cs @@ -1,4 +1,5 @@ using Artemis.UI.Shared.Utilities; +using Artemis.WebClient.Workshop.Models; using Artemis.WebClient.Workshop.Services; namespace Artemis.WebClient.Workshop.Handlers.InstallationHandlers; diff --git a/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/LayoutEntryInstallationHandler.cs b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/LayoutEntryInstallationHandler.cs index 370183824..aa755b8a0 100644 --- a/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/LayoutEntryInstallationHandler.cs +++ b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/LayoutEntryInstallationHandler.cs @@ -4,6 +4,7 @@ using Artemis.Core.Providers; using Artemis.Core.Services; using Artemis.UI.Shared.Extensions; using Artemis.UI.Shared.Utilities; +using Artemis.WebClient.Workshop.Models; using Artemis.WebClient.Workshop.Providers; using Artemis.WebClient.Workshop.Services; 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/InstallationHandlers/Implementations/ProfileEntryInstallationHandler.cs b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/ProfileEntryInstallationHandler.cs index 9abd616cf..78c31e98c 100644 --- a/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/ProfileEntryInstallationHandler.cs +++ b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/ProfileEntryInstallationHandler.cs @@ -2,6 +2,7 @@ using Artemis.Core.Services; using Artemis.UI.Shared.Extensions; using Artemis.UI.Shared.Utilities; +using Artemis.WebClient.Workshop.Models; using Artemis.WebClient.Workshop.Services; namespace Artemis.WebClient.Workshop.Handlers.InstallationHandlers; diff --git a/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/ImageUploadResult.cs b/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/ApiResult.cs similarity index 51% rename from src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/ImageUploadResult.cs rename to src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/ApiResult.cs index 7ebc08d2f..709357d2f 100644 --- a/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/ImageUploadResult.cs +++ b/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/ApiResult.cs @@ -1,21 +1,21 @@ namespace Artemis.WebClient.Workshop.Handlers.UploadHandlers; -public class ImageUploadResult +public class ApiResult { public bool IsSuccess { get; set; } public string? Message { get; set; } - public static ImageUploadResult FromFailure(string? message) + public static ApiResult FromFailure(string? message) { - return new ImageUploadResult + return new ApiResult { IsSuccess = false, Message = message }; } - public static ImageUploadResult FromSuccess() + public static ApiResult FromSuccess() { - return new ImageUploadResult {IsSuccess = true}; + return new ApiResult {IsSuccess = true}; } } \ 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/Services/AccessToken.cs b/src/Artemis.WebClient.Workshop/Models/AccessToken.cs similarity index 92% rename from src/Artemis.WebClient.Workshop/Services/AccessToken.cs rename to src/Artemis.WebClient.Workshop/Models/AccessToken.cs index 7f4f7ba04..60e9f31d9 100644 --- a/src/Artemis.WebClient.Workshop/Services/AccessToken.cs +++ b/src/Artemis.WebClient.Workshop/Models/AccessToken.cs @@ -1,8 +1,7 @@ -using Artemis.Core; using Artemis.WebClient.Workshop.Exceptions; using IdentityModel.Client; -namespace Artemis.WebClient.Workshop.Services; +namespace Artemis.WebClient.Workshop.Models; internal class AuthenticationToken { diff --git a/src/Artemis.WebClient.Workshop/Services/InstalledEntry.cs b/src/Artemis.WebClient.Workshop/Models/InstalledEntry.cs similarity index 98% rename from src/Artemis.WebClient.Workshop/Services/InstalledEntry.cs rename to src/Artemis.WebClient.Workshop/Models/InstalledEntry.cs index 6373a8d2a..90aa4108e 100644 --- a/src/Artemis.WebClient.Workshop/Services/InstalledEntry.cs +++ b/src/Artemis.WebClient.Workshop/Models/InstalledEntry.cs @@ -2,7 +2,7 @@ using Artemis.Core; using Artemis.Storage.Entities.Workshop; -namespace Artemis.WebClient.Workshop.Services; +namespace Artemis.WebClient.Workshop.Models; public class InstalledEntry { @@ -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 new file mode 100644 index 000000000..12a859ad0 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Models/PersonalAccessToken.cs @@ -0,0 +1,9 @@ +namespace Artemis.WebClient.Workshop.Models; + +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/Providers/WorkshopLayoutProvider.cs b/src/Artemis.WebClient.Workshop/Providers/WorkshopLayoutProvider.cs index 4d028f4a1..26fc0d5a0 100644 --- a/src/Artemis.WebClient.Workshop/Providers/WorkshopLayoutProvider.cs +++ b/src/Artemis.WebClient.Workshop/Providers/WorkshopLayoutProvider.cs @@ -1,5 +1,6 @@ using Artemis.Core; using Artemis.Core.Providers; +using Artemis.WebClient.Workshop.Models; using Artemis.WebClient.Workshop.Services; namespace Artemis.WebClient.Workshop.Providers; diff --git a/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs b/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs index d3fd43ac6..80a7bb470 100644 --- a/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs +++ b/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs @@ -8,6 +8,7 @@ using System.Security.Cryptography; using System.Text; using Artemis.Core; using Artemis.WebClient.Workshop.Exceptions; +using Artemis.WebClient.Workshop.Models; using Artemis.WebClient.Workshop.Repositories; using DynamicData; using IdentityModel; @@ -122,7 +123,7 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi public IObservable GetClaim(string type) { return _claims.Connect() - .Filter(c => c.Type == JwtClaimTypes.Email) + .Filter(c => c.Type == type) .ToCollection() .Select(f => f.FirstOrDefault()); } diff --git a/src/Artemis.WebClient.Workshop/Services/Interfaces/IUserManagementService.cs b/src/Artemis.WebClient.Workshop/Services/Interfaces/IUserManagementService.cs new file mode 100644 index 000000000..46452f833 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Services/Interfaces/IUserManagementService.cs @@ -0,0 +1,16 @@ +using Artemis.Core.Services; +using Artemis.WebClient.Workshop.Handlers.UploadHandlers; +using Artemis.WebClient.Workshop.Models; + +namespace Artemis.WebClient.Workshop.Services; + +public interface IUserManagementService : IProtectedArtemisService +{ + Task ChangePassword(string currentPassword, string newPassword, CancellationToken cancellationToken); + Task ChangeEmailAddress(string emailAddress, CancellationToken cancellationToken); + Task ChangeAvatar(Stream avatar, CancellationToken cancellationToken); + Task RemoveAccount(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 976e37805..7bc1a04c1 100644 --- a/src/Artemis.WebClient.Workshop/Services/Interfaces/IWorkshopService.cs +++ b/src/Artemis.WebClient.Workshop/Services/Interfaces/IWorkshopService.cs @@ -1,12 +1,13 @@ using Artemis.WebClient.Workshop.Handlers.UploadHandlers; +using Artemis.WebClient.Workshop.Models; namespace Artemis.WebClient.Workshop.Services; public interface IWorkshopService { Task GetEntryIcon(long entryId, CancellationToken cancellationToken); - Task SetEntryIcon(long entryId, Stream icon, CancellationToken cancellationToken); - Task UploadEntryImage(long entryId, ImageUploadRequest request, CancellationToken cancellationToken); + Task SetEntryIcon(long entryId, Stream icon, CancellationToken cancellationToken); + Task UploadEntryImage(long entryId, ImageUploadRequest request, CancellationToken cancellationToken); Task DeleteEntryImage(Guid id, CancellationToken cancellationToken); Task GetWorkshopStatus(CancellationToken cancellationToken); Task ValidateWorkshopStatus(CancellationToken cancellationToken); @@ -16,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 new file mode 100644 index 000000000..402f476a7 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Services/UserManagementService.cs @@ -0,0 +1,103 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Artemis.WebClient.Workshop.Exceptions; +using Artemis.WebClient.Workshop.Handlers.UploadHandlers; +using Artemis.WebClient.Workshop.Models; + +namespace Artemis.WebClient.Workshop.Services; + +internal class UserManagementService : IUserManagementService +{ + private readonly IHttpClientFactory _httpClientFactory; + + public UserManagementService(IHttpClientFactory httpClientFactory) + { + _httpClientFactory = httpClientFactory; + } + + /// + public async Task ChangePassword(string currentPassword, string newPassword, CancellationToken cancellationToken) + { + HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.IDENTITY_CLIENT_NAME); + HttpResponseMessage response = await client.PostAsync("user/credentials", JsonContent.Create(new {CurrentPassword = currentPassword, NewPassword = newPassword}), cancellationToken); + return response.IsSuccessStatusCode + ? ApiResult.FromSuccess() + : ApiResult.FromFailure(await response.Content.ReadAsStringAsync(cancellationToken)); + } + + /// + public async Task ChangeEmailAddress(string emailAddress, CancellationToken cancellationToken) + { + HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.IDENTITY_CLIENT_NAME); + HttpResponseMessage response = await client.PostAsync("user/email", JsonContent.Create(new {EmailAddress = emailAddress}), cancellationToken); + return response.IsSuccessStatusCode + ? ApiResult.FromSuccess() + : ApiResult.FromFailure(await response.Content.ReadAsStringAsync(cancellationToken)); + } + + public async Task ChangeAvatar(Stream avatar, CancellationToken cancellationToken) + { + avatar.Seek(0, SeekOrigin.Begin); + + // Submit the archive + HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.IDENTITY_CLIENT_NAME); + + // Construct the request + MultipartFormDataContent content = new(); + StreamContent streamContent = new(avatar); + streamContent.Headers.ContentType = new MediaTypeHeaderValue("image/png"); + content.Add(streamContent, "file", "file.png"); + + // Submit + HttpResponseMessage response = await client.PutAsync($"user/avatar", content, cancellationToken); + return response.IsSuccessStatusCode + ? ApiResult.FromSuccess() + : ApiResult.FromFailure(await response.Content.ReadAsStringAsync(cancellationToken)); + } + + /// + public async Task RemoveAccount(CancellationToken cancellationToken) + { + HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.IDENTITY_CLIENT_NAME); + HttpResponseMessage response = await client.DeleteAsync("user", cancellationToken); + return response.IsSuccessStatusCode + ? ApiResult.FromSuccess() + : ApiResult.FromFailure(await response.Content.ReadAsStringAsync(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.ReadAsStringAsync(cancellationToken); + if (result == null) + throw new ArtemisWebClientException("Failed to deserialize access token"); + return result; + } + + /// + public async Task DeletePersonAccessToken(PersonalAccessToken personalAccessToken, CancellationToken cancellationToken) + { + HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.IDENTITY_CLIENT_NAME); + HttpResponseMessage response = await client.DeleteAsync($"user/access-token/{personalAccessToken.Key}", cancellationToken); + return response.IsSuccessStatusCode + ? ApiResult.FromSuccess() + : ApiResult.FromFailure(await response.Content.ReadAsStringAsync(cancellationToken)); + } + + /// + public async Task> GetPersonAccessTokens(CancellationToken cancellationToken) + { + HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.IDENTITY_CLIENT_NAME); + HttpResponseMessage response = await client.GetAsync("user/access-token", cancellationToken); + response.EnsureSuccessStatusCode(); + + List? result = await response.Content.ReadFromJsonAsync>(cancellationToken: cancellationToken); + if (result == null) + throw new ArtemisWebClientException("Failed to deserialize access tokens"); + return result; + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Services/WorkshopService.cs b/src/Artemis.WebClient.Workshop/Services/WorkshopService.cs index 236a5f6e4..0721c97f0 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.UI.Shared.Utilities; +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) @@ -36,7 +45,7 @@ public class WorkshopService : IWorkshopService } } - public async Task SetEntryIcon(long entryId, Stream icon, CancellationToken cancellationToken) + public async Task SetEntryIcon(long entryId, Stream icon, CancellationToken cancellationToken) { icon.Seek(0, SeekOrigin.Begin); @@ -52,12 +61,12 @@ public class WorkshopService : IWorkshopService // Submit HttpResponseMessage response = await client.PostAsync($"entries/{entryId}/icon", content, cancellationToken); if (!response.IsSuccessStatusCode) - return ImageUploadResult.FromFailure($"{response.StatusCode} - {await response.Content.ReadAsStringAsync(cancellationToken)}"); - return ImageUploadResult.FromSuccess(); + return ApiResult.FromFailure($"{response.StatusCode} - {await response.Content.ReadAsStringAsync(cancellationToken)}"); + return ApiResult.FromSuccess(); } /// - public async Task UploadEntryImage(long entryId, ImageUploadRequest request, CancellationToken cancellationToken) + public async Task UploadEntryImage(long entryId, ImageUploadRequest request, CancellationToken cancellationToken) { request.File.Seek(0, SeekOrigin.Begin); @@ -76,8 +85,8 @@ public class WorkshopService : IWorkshopService // Submit HttpResponseMessage response = await client.PostAsync($"entries/{entryId}/image", content, cancellationToken); if (!response.IsSuccessStatusCode) - return ImageUploadResult.FromFailure($"{response.StatusCode} - {await response.Content.ReadAsStringAsync(cancellationToken)}"); - return ImageUploadResult.FromSuccess(); + return ApiResult.FromFailure($"{response.StatusCode} - {await response.Content.ReadAsStringAsync(cancellationToken)}"); + return ApiResult.FromSuccess(); } /// @@ -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,62 @@ public class WorkshopService : IWorkshopService entry.Save(); _entryRepository.Save(entry.Entity); } + + /// + public void Initialize() + { + if (_initialized) + throw new ArtemisWorkshopException("Workshop service is already initialized"); + + try + { + if (!Directory.Exists(Constants.WorkshopFolder)) + Directory.CreateDirectory(Constants.WorkshopFolder); + + RemoveOrphanedFiles(); + + _pluginManagementService.AdditionalPluginDirectories.AddRange(GetInstalledEntries() + .Where(e => e.EntryType == EntryType.Plugin) + .Select(e => e.GetReleaseDirectory())); + + _initialized = true; + } + catch (Exception e) + { + _logger.Error(e, "Failed to initialize workshop service"); + } + } + + 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 diff --git a/src/Artemis.WebClient.Workshop/WorkshopConstants.cs b/src/Artemis.WebClient.Workshop/WorkshopConstants.cs index ced548d1f..10807064c 100644 --- a/src/Artemis.WebClient.Workshop/WorkshopConstants.cs +++ b/src/Artemis.WebClient.Workshop/WorkshopConstants.cs @@ -6,5 +6,6 @@ public static class WorkshopConstants // public const string WORKSHOP_URL = "https://localhost:7281"; public const string AUTHORITY_URL = "https://identity.artemis-rgb.com"; public const string WORKSHOP_URL = "https://workshop.artemis-rgb.com"; + public const string IDENTITY_CLIENT_NAME = "IdentityApiClient"; public const string WORKSHOP_CLIENT_NAME = "WorkshopApiClient"; } \ No newline at end of file