1
0
mirror of https://github.com/Artemis-RGB/Artemis synced 2025-12-12 13:28:33 +00:00

Merge branch 'development' into feature/docs

This commit is contained in:
RobertBeekman 2024-02-20 16:35:37 +01:00
commit c11d12ded4
112 changed files with 1925 additions and 315 deletions

View File

@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<PreserveCompilationContext>false</PreserveCompilationContext>
<ShouldIncludeNativeSkiaSharp>false</ShouldIncludeNativeSkiaSharp>
<AssemblyTitle>Artemis.Core</AssemblyTitle>
@ -43,9 +43,9 @@
<PackageReference Include="JetBrains.Annotations" Version="2023.3.0" />
<PackageReference Include="McMaster.NETCore.Plugins" Version="1.4.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="RGB.NET.Core" Version="2.0.4-prerelease.1" />
<PackageReference Include="RGB.NET.Layout" Version="2.0.4-prerelease.1" />
<PackageReference Include="RGB.NET.Presets" Version="2.0.4-prerelease.1" />
<PackageReference Include="RGB.NET.Core" Version="2.0.4-prerelease.16" />
<PackageReference Include="RGB.NET.Layout" Version="2.0.4-prerelease.16" />
<PackageReference Include="RGB.NET.Presets" Version="2.0.4-prerelease.16" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />

View File

@ -155,10 +155,4 @@ public static class Constants
/// Gets the startup arguments provided to the application
/// </summary>
public static ReadOnlyCollection<string> StartupArguments { get; set; } = null!;
/// <summary>
/// Gets the graphics context to be used for rendering by SkiaSharp.
/// </summary>
public static IManagedGraphicsContext? ManagedGraphicsContext { get; internal set; }
}

View File

@ -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<Layer> _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);

View File

@ -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; }
/// <summary>
/// Opens the render context using the dimensions of the provided path
/// </summary>
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;
}
}

View File

@ -12,6 +12,11 @@ namespace Artemis.Core.Services;
/// </summary>
public interface IPluginManagementService : IArtemisService, IDisposable
{
/// <summary>
/// Gets a list containing additional directories in which plugins are located, used while loading plugins.
/// </summary>
List<DirectoryInfo> AdditionalPluginDirectories { get; }
/// <summary>
/// Indicates whether or not plugins are currently being loaded
/// </summary>

View File

@ -78,8 +78,11 @@ internal class PluginManagementService : IPluginManagementService
File.Create(Path.Combine(pluginDirectory.FullName, "artemis.lock")).Close();
}
public List<DirectoryInfo> 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<PluginInfo>(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)

View File

@ -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;

View File

@ -1,12 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<PreserveCompilationContext>false</PreserveCompilationContext>
<Platforms>x64</Platforms>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LiteDB" Version="5.0.17" />
<PackageReference Include="LiteDB" Version="5.0.18" />
<PackageReference Include="Serilog" Version="3.1.1" />
</ItemGroup>
</Project>

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<PlatformTarget>x64</PlatformTarget>
<Platforms>x64</Platforms>

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<PlatformTarget>x64</PlatformTarget>
<Platforms>x64</Platforms>

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<OutputPath>bin\</OutputPath>
<Platforms>x64</Platforms>
@ -10,16 +10,16 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.0.6" />
<PackageReference Include="Avalonia" Version="11.0.9" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.6" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.6" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.6" />
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.9" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.9" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.9" />
<PackageReference Include="Avalonia.Xaml.Behaviors" Version="11.0.6" />
<PackageReference Include="DynamicData" Version="8.3.27" />
<PackageReference Include="FluentAvaloniaUI" Version="2.0.5" />
<PackageReference Include="Material.Icons.Avalonia" Version="2.1.0" />
<PackageReference Include="ReactiveUI" Version="19.5.39" />
<PackageReference Include="ReactiveUI" Version="19.5.41" />
<PackageReference Include="ReactiveUI.Validation" Version="3.1.7" />
</ItemGroup>
<ItemGroup>

View File

@ -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)

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net7.0-windows10.0.17763.0</TargetFramework>
<TargetFramework>net8.0-windows10.0.17763.0</TargetFramework>
<Nullable>enable</Nullable>
<Platforms>x64</Platforms>
<OutputPath>bin</OutputPath>
@ -21,11 +21,11 @@
</None>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Win32" Version="11.0.6" />
<PackageReference Include="Avalonia.Win32" Version="11.0.9" />
<PackageReference Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.3" />
<PackageReference Include="Microsoft.Win32" Version="2.0.1" />
<!-- Note: Do NOT upgrade this compatibility package to 8.X before updating to net8, it WILL break -->
<PackageReference Include="Microsoft.Windows.Compatibility" Version="7.0.5" />
<PackageReference Include="Microsoft.Windows.Compatibility" Version="8.0.2" />
<PackageReference Include="RawInput.Sharp" Version="0.1.3" />
<PackageReference Include="SkiaSharp.Vulkan.SharpVk" Version="2.88.7" />
</ItemGroup>

View File

@ -105,7 +105,7 @@ public class AutoRunProvider : IAutoRunProvider
/// <inheritdoc />
public async Task EnableAutoRun(bool recreate, int autoRunDelay)
{
if (Constants.CurrentVersion == "development")
if (Constants.CurrentVersion == "local")
return;
await CleanupOldAutorun();

View File

@ -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);
}
}

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<OutputPath>bin/</OutputPath>
<Platforms>x64</Platforms>
@ -19,11 +19,11 @@
<PackageReference Include="AsyncImageLoader.Avalonia" Version="3.2.1" />
<PackageReference Include="Avalonia.AvaloniaEdit" Version="11.0.6" />
<PackageReference Include="Avalonia.Controls.PanAndZoom" Version="11.0.0.2" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.6" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.9" />
<PackageReference Include="Avalonia.Skia.Lottie" Version="11.0.0" />
<PackageReference Include="AvaloniaEdit.TextMate" Version="11.0.6" />
<PackageReference Include="Markdown.Avalonia.Tight" Version="11.0.2" />
<PackageReference Include="Octopus.Octodiff" Version="2.0.544" />
<PackageReference Include="Octopus.Octodiff" Version="2.0.546" />
<PackageReference Include="PropertyChanged.SourceGenerator" Version="1.1.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@ -35,4 +35,15 @@
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<ItemGroup>
<Compile Update="Screens\Workshop\Plugin\Dialogs\DeviceProviderPickerDialogView.axaml.cs">
<DependentUpon>DeviceProviderPickerDialogView.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
<Compile Update="Screens\Workshop\Plugin\Dialogs\DeviceSelectionDialogView.axaml.cs">
<DependentUpon>DeviceSelectionDialogView.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
</ItemGroup>
</Project>

View File

@ -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;

View File

@ -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<IRouterRegistration>
{
new RouteRegistration<PluginListViewModel>("plugins/{page:int}"),
new RouteRegistration<PluginDetailsViewModel>("plugins/details/{entryId:long}"),
new RouteRegistration<ProfileListViewModel>("profiles/{page:int}"),
new RouteRegistration<ProfileDetailsViewModel>("profiles/details/{entryId:long}"),
new RouteRegistration<LayoutListViewModel>("layouts/{page:int}"),
@ -64,6 +67,7 @@ public static class Routes
new RouteRegistration<ReleaseDetailsViewModel>("{releaseId:guid}")
}
},
new RouteRegistration<AccountTabViewModel>("account"),
new RouteRegistration<AboutTabViewModel>("about")
}
},

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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 @@
<Style Selector="ComboBox.layoutProvider /template/ ContentControl#ContentPresenter">
<Setter Property="ContentTemplate">
<Setter.Value>
<DataTemplate x:DataType="services:InstalledEntry">
<DataTemplate x:DataType="models:InstalledEntry">
<TextBlock Text="{CompiledBinding Name}" TextWrapping="Wrap" MaxWidth="350" />
</DataTemplate>
</Setter.Value>
@ -32,7 +33,7 @@
ItemsSource="{CompiledBinding Entries}"
PlaceholderText="Select an installed layout">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="services:InstalledEntry">
<DataTemplate x:DataType="models:InstalledEntry">
<StackPanel>
<TextBlock Text="{CompiledBinding Name}" TextWrapping="Wrap" MaxWidth="350" />
<TextBlock Classes="subtitle" Text="{CompiledBinding Author}" TextWrapping="Wrap" MaxWidth="350" />

View File

@ -10,6 +10,7 @@ using Artemis.UI.Shared;
using Artemis.UI.Shared.Routing;
using Artemis.UI.Shared.Services;
using Artemis.WebClient.Workshop;
using Artemis.WebClient.Workshop.Models;
using Artemis.WebClient.Workshop.Providers;
using Artemis.WebClient.Workshop.Services;
using PropertyChanged.SourceGenerator;

View File

@ -24,7 +24,7 @@
</Style>
</Styles>
</UserControl.Styles>
<Grid ColumnDefinitions="250,*">
<Grid ColumnDefinitions="350,*" Width="800">
<Grid.RowDefinitions>
<RowDefinition MinHeight="200" />
</Grid.RowDefinitions>
@ -36,7 +36,7 @@
IsHitTestVisible="False">
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type prerequisites:PluginPrerequisiteViewModel}">
<Grid ColumnDefinitions="Auto,*" Margin="0 6">
<Grid ColumnDefinitions="Auto,*" Margin="-6 0 20 0">
<Border Grid.Row="0" Grid.Column="0" Classes="status-border" IsVisible="{CompiledBinding !IsMet}" Background="#ff3838">
<avalonia:MaterialIcon Kind="Close" />
</Border>
@ -45,8 +45,8 @@
</Border>
<StackPanel Margin="8 0 0 0" Grid.Column="1" VerticalAlignment="Stretch">
<TextBlock FontWeight="Bold" Text="{CompiledBinding PluginPrerequisite.Name}" TextWrapping="Wrap" />
<TextBlock Text="{CompiledBinding PluginPrerequisite.Description}" TextWrapping="Wrap" />
<TextBlock Text="{CompiledBinding PluginPrerequisite.Name}" TextWrapping="Wrap" />
<TextBlock Text="{CompiledBinding PluginPrerequisite.Description}" TextWrapping="Wrap" Classes="subtitle"/>
</StackPanel>
</Grid>
</DataTemplate>
@ -74,12 +74,14 @@
Grid.Column="1"
Margin="10 0"
IsVisible="{CompiledBinding ShowFailed, Mode=OneWay}">
<StackPanel Orientation="Horizontal">
<TextBlock>Installing</TextBlock>
<TextBlock Text="{CompiledBinding ActivePrerequisite.PluginPrerequisite.Name, Mode=OneWay}" FontWeight="SemiBold" TextWrapping="Wrap" />
<TextBlock>failed.</TextBlock>
</StackPanel>
<TextBlock TextWrapping="Wrap">You may try again to see if that helps, otherwise install the prerequisite manually or contact the plugin developer.</TextBlock>
<TextBlock TextWrapping="Wrap" >
<Run>Installing</Run>
<Run Text="{CompiledBinding ActivePrerequisite.PluginPrerequisite.Name, Mode=OneWay}" FontWeight="SemiBold" />
<Run>failed.</Run>
<LineBreak/>
<LineBreak/>
<Run>You may try again to see if that helps, otherwise install the prerequisite manually or contact the plugin developer.</Run>
</TextBlock>
</StackPanel>
</Grid>
</UserControl>

View File

@ -30,16 +30,21 @@ public partial class PluginPrerequisitesInstallDialogViewModel : ContentDialogVi
[Notify] private bool _showIntro = true;
[Notify] private bool _showProgress;
private bool _finished;
public PluginPrerequisitesInstallDialogViewModel(List<IPrerequisitesSubject> subjects, IPrerequisitesVmFactory prerequisitesVmFactory)
{
Prerequisites = new ObservableCollection<PluginPrerequisiteViewModel>();
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<Unit, Unit> Install { get; }
public ObservableCollection<PluginPrerequisiteViewModel> Prerequisites { get; }
public static async Task Show(IWindowService windowService, List<IPrerequisitesSubject> 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;
}
}
}

View File

@ -24,7 +24,7 @@
</Style>
</Styles>
</UserControl.Styles>
<Grid ColumnDefinitions="250,*">
<Grid ColumnDefinitions="350,*" Width="800">
<Grid.RowDefinitions>
<RowDefinition MinHeight="200" />
</Grid.RowDefinitions>
@ -37,8 +37,8 @@
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type prerequisites:PluginPrerequisiteViewModel}">
<StackPanel Margin="0 6" VerticalAlignment="Stretch">
<TextBlock FontWeight="Bold" Text="{CompiledBinding PluginPrerequisite.Name}" TextWrapping="Wrap" />
<TextBlock Text="{CompiledBinding PluginPrerequisite.Description}" TextWrapping="Wrap" />
<TextBlock Text="{CompiledBinding PluginPrerequisite.Name}" TextWrapping="Wrap" />
<TextBlock Text="{CompiledBinding PluginPrerequisite.Description}" TextWrapping="Wrap" Classes="subtitle"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>

View File

@ -64,6 +64,7 @@ public partial class PluginPrerequisitesUninstallDialogViewModel : ContentDialog
public static async Task Show(IWindowService windowService, List<IPrerequisitesSubject> subjects, string cancelLabel = "Cancel")
{
await windowService.CreateContentDialog()
.WithFullScreen()
.WithTitle("Plugin prerequisites")
.WithViewModel(out PluginPrerequisitesUninstallDialogViewModel vm, subjects)
.WithCloseButtonText(cancelLabel)

View File

@ -19,7 +19,10 @@
Grid.RowSpan="3"
VerticalAlignment="Top" />
<TextBlock Grid.Column="1" Grid.Row="0" Classes="h5 no-margin" Text="{CompiledBinding Plugin.Info.Name}" />
<TextBlock Grid.Column="1" Grid.Row="0" Classes="no-margin">
<Run Classes="h5" Text="{CompiledBinding Plugin.Info.Name}"/>
<Run Classes="subtitle" Text="{CompiledBinding Plugin.Info.Version}"/>
</TextBlock>
<ItemsControl Grid.Column="2" Grid.Row="0" IsVisible="{CompiledBinding Platforms.Count}" ItemsSource="{CompiledBinding Platforms}" HorizontalAlignment="Right">
<ItemsControl.ItemsPanel>

View File

@ -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<PluginPlatformViewModel>();
if (Plugin.Info.Platforms != null)
@ -90,7 +95,7 @@ public partial class PluginViewModel : ActivatableViewModelBase
public ObservableCollection<PluginPlatformViewModel> Platforms { get; }
public string Type => Plugin.GetType().BaseType?.Name ?? Plugin.GetType().Name;
public bool IsEnabled => Plugin.IsEnabled;
public async Task UpdateEnabled(bool enable)
{
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<IPrerequisitesSubject> 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<IPrerequisitesSubject> 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";

View File

@ -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<RoutableScreen>, IMainWindowProv
IMainWindowService mainWindowService,
IDebugService debugService,
IUpdateService updateService,
IWorkshopService workshopService,
SidebarViewModel sidebarViewModel,
DefaultTitleBarViewModel defaultTitleBarViewModel)
{
@ -60,11 +62,10 @@ public class RootViewModel : RoutableHostScreen<RoutableScreen>, IMainWindowProv
router.SetRoot(this);
mainWindowService.ConfigureMainWindowProvider(this);
DisplayAccordingToSettings();
OpenScreen = ReactiveCommand.Create<string?>(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<ViewModelBase>())
@ -72,17 +73,27 @@ public class RootViewModel : RoutableHostScreen<RoutableScreen>, 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<RoutableScreen>, 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<RoutableScreen>, 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<RoutableScreen>, IMainWindowProv
_lifeTime.MainWindow.Activate();
if (_lifeTime.MainWindow.WindowState == WindowState.Minimized)
_lifeTime.MainWindow.WindowState = WindowState.Normal;
OnMainWindowOpened();
}

View File

@ -0,0 +1,14 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:account="clr-namespace:Artemis.UI.Screens.Settings.Account"
mc:Ignorable="d" d:DesignWidth="320" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Settings.Account.ChangeEmailAddressView"
x:DataType="account:ChangeEmailAddressViewModel">
<StackPanel Spacing="5" Width="300">
<Label>New email address</Label>
<TextBox Text="{CompiledBinding EmailAddress}" />
<TextBlock Classes="subtitle" Margin="0 5 0 0" TextWrapping="Wrap">An email will be sent to the new address to confirm the change.</TextBlock>
</StackPanel>
</UserControl>

View File

@ -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<ChangeEmailAddressViewModel>
{
public ChangeEmailAddressView()
{
InitializeComponent();
this.WhenActivated(_ => this.ClearAllDataValidationErrors());
}
}

View File

@ -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<Unit, Unit> 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);
}
}

View File

@ -0,0 +1,17 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:account="clr-namespace:Artemis.UI.Screens.Settings.Account"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Settings.Account.ChangePasswordView"
x:DataType="account:ChangePasswordViewModel">
<StackPanel Spacing="5" Width="300">
<Label>Current password</Label>
<TextBox Text="{CompiledBinding CurrentPassword}" PasswordChar="&#x25CF;"/>
<Label>New password</Label>
<TextBox Text="{CompiledBinding NewPassword}" PasswordChar="&#x25CF;"/>
<Label>New password confirmation</Label>
<TextBox Text="{CompiledBinding NewPasswordConfirmation}" PasswordChar="&#x25CF;"/>
</StackPanel>
</UserControl>

View File

@ -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<ChangePasswordViewModel>
{
public ChangePasswordView()
{
InitializeComponent();
this.WhenActivated(_ => this.ClearAllDataValidationErrors());
}
}

View File

@ -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<Unit, Unit> 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);
}
}

View File

@ -0,0 +1,19 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:account="clr-namespace:Artemis.UI.Screens.Settings.Account"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Settings.Account.CreatePersonalAccessTokenView"
x:DataType="account:CreatePersonalAccessTokenViewModel">
<StackPanel Spacing="5" Width="300">
<Label>Description</Label>
<TextBox Text="{CompiledBinding Description}"/>
<Label>Expiration date</Label>
<CalendarDatePicker SelectedDate="{CompiledBinding ExpirationDate}"
DisplayDateStart="{CompiledBinding StartDate}"
DisplayDateEnd="{CompiledBinding EndDate}"
HorizontalAlignment="Stretch"/>
</StackPanel>
</UserControl>

View File

@ -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<CreatePersonalAccessTokenViewModel>
{
public CreatePersonalAccessTokenView()
{
InitializeComponent();
this.WhenActivated(_ => this.ClearAllDataValidationErrors());
}
}

View File

@ -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<Unit, Unit> 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();
}
}

View File

@ -0,0 +1,19 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:account="clr-namespace:Artemis.UI.Screens.Settings.Account"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Settings.Account.PersonalAccessTokenView"
x:DataType="account:PersonalAccessTokenViewModel">
<StackPanel>
<TextBlock>
Your token has been created, please copy it now as you cannot view it again later.
</TextBlock>
<TextBox Margin="0 10 0 0"
VerticalAlignment="Top"
TextWrapping="Wrap"
IsReadOnly="True"
Text="{CompiledBinding Token, Mode=OneWay}" />
</StackPanel>
</UserControl>

View File

@ -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<PersonalAccessTokenViewModel>
{
public PersonalAccessTokenView()
{
InitializeComponent();
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,14 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:account="clr-namespace:Artemis.UI.Screens.Settings.Account"
mc:Ignorable="d" d:DesignWidth="320" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Settings.Account.RemoveAccountView"
x:DataType="account:RemoveAccountViewModel">
<StackPanel Spacing="5" Width="350">
<Label>Please enter your email address to confirm</Label>
<TextBox Text="{CompiledBinding EmailAddress}"/>
<TextBlock Classes="danger" Margin="0 5 0 0" TextWrapping="Wrap">This is a destructive action that cannot be undone.</TextBlock>
</StackPanel>
</UserControl>

View File

@ -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<RemoveAccountViewModel>
{
public RemoveAccountView()
{
InitializeComponent();
this.WhenActivated(_ => this.ClearAllDataValidationErrors());
}
}

View File

@ -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<Unit, Unit> 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);
}
}

View File

@ -26,6 +26,7 @@ public partial class SettingsViewModel : RoutableHostScreen<RoutableScreen>, IMa
new("Plugins", "settings/plugins"),
new("Devices", "settings/devices"),
new("Releases", "settings/releases"),
new("Account", "settings/account"),
new("About", "settings/about"),
};

View File

@ -0,0 +1,158 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:settings="clr-namespace:Artemis.UI.Screens.Settings"
xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia"
xmlns:loaders="clr-namespace:AsyncImageLoader.Loaders;assembly=AsyncImageLoader.Avalonia"
xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:models="clr-namespace:Artemis.WebClient.Workshop.Models;assembly=Artemis.WebClient.Workshop"
xmlns:converters="clr-namespace:Artemis.UI.Converters"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="800"
x:Class="Artemis.UI.Screens.Settings.AccountTabView"
x:DataType="settings:AccountTabViewModel">
<UserControl.Resources>
<converters:DateTimeConverter x:Key="DateTimeConverter" />
</UserControl.Resources>
<Panel>
<StackPanel IsVisible="{CompiledBinding !IsLoggedIn^}" Margin="0 50 0 0">
<StackPanel.Styles>
<Styles>
<Style Selector="TextBlock">
<Setter Property="TextAlignment" Value="Center"></Setter>
<Setter Property="TextWrapping" Value="Wrap"></Setter>
</Style>
</Styles>
</StackPanel.Styles>
<TextBlock Theme="{StaticResource TitleTextBlockStyle}">You are not logged in</TextBlock>
<TextBlock>
<Run>In order to manage your account you must be logged in.</Run>
<LineBreak />
<Run>Creating an account is free and we'll not bother you with a newsletter or crap like that.</Run>
</TextBlock>
<Lottie Path="/Assets/Animations/login-pending.json" RepeatCount="1" Width="350" Height="350"></Lottie>
<TextBlock>
<Run>Click Log In below to (create an account) and log in.</Run>
<LineBreak />
<Run>You'll also be able to log in with Google or Discord.</Run>
</TextBlock>
<Button HorizontalAlignment="Center" Command="{CompiledBinding Login}" Margin="0 25 0 0">Login</Button>
</StackPanel>
<ScrollViewer IsVisible="{CompiledBinding IsLoggedIn^}" HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<StackPanel Margin="15" MaxWidth="1000">
<Grid RowDefinitions="*,*" ColumnDefinitions="Auto,*" VerticalAlignment="Top">
<Border Grid.Row="0" Grid.Column="0" Grid.RowSpan="2" VerticalAlignment="Top" CornerRadius="92" Width="92" Height="92" Margin="0 0 15 0" ClipToBounds="True">
<asyncImageLoader:AdvancedImage Source="{CompiledBinding AvatarUrl}">
<asyncImageLoader:AdvancedImage.Loader>
<loaders:BaseWebImageLoader />
</asyncImageLoader:AdvancedImage.Loader>
</asyncImageLoader:AdvancedImage>
</Border>
<TextBlock Grid.Row="0" Grid.Column="1" Padding="0" VerticalAlignment="Bottom" Text="{CompiledBinding Name}" Classes="h3 no-margin" />
<TextBlock Classes="subtitle" Grid.Column="1" Grid.Row="1" Padding="0" VerticalAlignment="Top" Text="{CompiledBinding Email}" />
</Grid>
<TextBlock Classes="card-title">
Account management
</TextBlock>
<Border Classes="card" VerticalAlignment="Stretch" Margin="0,0,5,0">
<StackPanel>
<Grid RowDefinitions="*,*" ColumnDefinitions="*,Auto" IsVisible="{CompiledBinding CanChangePassword}">
<StackPanel Grid.Column="0" VerticalAlignment="Top">
<TextBlock>
Credentials
</TextBlock>
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="1" VerticalAlignment="Center" Spacing="10">
<Button Width="150" Content="Change email" Command="{CompiledBinding ChangeEmailAddress}" />
<Button Width="150" Content="Change password" Command="{CompiledBinding ChangePasswordAddress}" />
</StackPanel>
</Grid>
<Border Classes="card-separator" IsVisible="{CompiledBinding CanChangePassword}" />
<Grid RowDefinitions="*,*" ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" VerticalAlignment="Center">
<TextBlock>
Change avatar
</TextBlock>
<TextBlock Classes="subtitle">
Quite pointless currently, but in the future your avatar will be visible in the workshop.
</TextBlock>
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="1" VerticalAlignment="Center">
<Button Width="150" Content="Choose image" Command="{CompiledBinding ChangeAvatar}" />
</StackPanel>
</Grid>
<Border Classes="card-separator" />
<Grid RowDefinitions="*,*" ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" VerticalAlignment="Center">
<TextBlock>
Remove account
</TextBlock>
<TextBlock Classes="subtitle">
Permanently remove your account, this cannot be undone.
</TextBlock>
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="1" VerticalAlignment="Center">
<Button Classes="danger" Width="150" Content="Remove account" Command="{CompiledBinding RemoveAccount}" />
</StackPanel>
</Grid>
</StackPanel>
</Border>
<TextBlock Classes="card-title">
Personal access tokens
</TextBlock>
<Border Classes="card" VerticalAlignment="Stretch" Margin="0,0,5,0">
<StackPanel>
<DockPanel Margin="0 0 0 10">
<TextBlock VerticalAlignment="Center">
Tokens be used to communicate with Artemis APIs without using a username and password
</TextBlock>
<Button HorizontalAlignment="Right" Command="{CompiledBinding GenerateToken}">Generate token</Button>
</DockPanel>
<Grid ColumnDefinitions="2*,*,*,*" Margin="12 6">
<TextBlock Grid.Column="0">Description</TextBlock>
<TextBlock Grid.Column="1">Created at</TextBlock>
<TextBlock Grid.Column="2" Grid.ColumnSpan="2">Expires at</TextBlock>
</Grid>
<TextBlock IsVisible="{CompiledBinding !PersonalAccessTokens.Count}" TextAlignment="Center" Classes="subtitle" Margin="0 10">
You have no active personal access tokens.
</TextBlock>
<ItemsControl ItemsSource="{CompiledBinding PersonalAccessTokens}" IsVisible="{CompiledBinding PersonalAccessTokens.Count}">
<ItemsControl.Styles>
<Style Selector="ContentPresenter:nth-child(odd) > Border">
<Setter Property="Background" Value="{StaticResource ControlStrokeColorOnAccentDefault}"></Setter>
</Style>
</ItemsControl.Styles>
<ItemsControl.DataTemplates>
<DataTemplate DataType="models:PersonalAccessToken">
<Border CornerRadius="4" Padding="12 6">
<Grid ColumnDefinitions="2*,*,*,*">
<TextBlock Grid.Column="0" VerticalAlignment="Center" Text="{CompiledBinding Description}" />
<TextBlock Grid.Column="1" VerticalAlignment="Center" Text="{CompiledBinding CreationTime, Converter={StaticResource DateTimeConverter}}" />
<TextBlock Grid.Column="2" VerticalAlignment="Center" Text="{CompiledBinding Expiration, Converter={StaticResource DateTimeConverter}}" />
<Button Grid.Column="3" HorizontalAlignment="Right"
Classes="icon-button"
Command="{Binding $parent[settings:AccountTabView].DataContext.DeleteToken}"
CommandParameter="{CompiledBinding}">
<avalonia:MaterialIcon Kind="Trash" />
</Button>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.DataTemplates>
</ItemsControl>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Panel>
</UserControl>

View File

@ -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<AccountTabViewModel>
{
public AccountTabView()
{
InitializeComponent();
}
}

View File

@ -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<bool>? _canChangePassword;
[Notify(Setter.Private)] private string? _name;
[Notify(Setter.Private)] private string? _email;
[Notify(Setter.Private)] private string? _avatarUrl;
[Notify(Setter.Private)] private ObservableCollection<PersonalAccessToken> _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<PersonalAccessToken>(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<bool> IsLoggedIn { get; }
public ReactiveCommand<PersonalAccessToken,Unit> 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<PersonalAccessToken> personalAccessTokens = await _userManagementService.GetPersonAccessTokens(CancellationToken.None);
PersonalAccessTokens = new ObservableCollection<PersonalAccessToken>(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<PersonalAccessToken> personalAccessTokens = await _userManagementService.GetPersonAccessTokens(CancellationToken.None);
PersonalAccessTokens = new ObservableCollection<PersonalAccessToken>(personalAccessTokens);
}
}

View File

@ -87,7 +87,7 @@ public partial class ReleaseDetailsViewModel : RoutableScreen<ReleaseDetailsView
private void ExecuteRestart()
{
_updateService.RestartForUpdate(false);
_updateService.RestartForUpdate("ReleaseDetails", false);
}
private async Task ExecuteInstall(CancellationToken cancellationToken)

View File

@ -17,7 +17,7 @@
<TextBlock Grid.Row="1" Grid.Column="0" Text="{CompiledBinding Release.CreatedAt, Converter={StaticResource DateTimeConverter}}" VerticalAlignment="Center" Classes="subtitle" FontSize="13" />
<avalonia:MaterialIcon Classes="status-icon" Grid.Row="0" Grid.Column="1" Grid.RowSpan="2" Kind="CheckCircle" ToolTip.Tip="Current version"
IsVisible="{CompiledBinding IsCurrentVersion}" />
<avalonia:MaterialIcon Classes="status-icon" Grid.Row="0" Grid.Column="1" Grid.RowSpan="2" Kind="History" ToolTip.Tip="Previous version"
<avalonia:MaterialIcon Classes="status-icon" Grid.Row="0" Grid.Column="1" Grid.RowSpan="2" Kind="History" ToolTip.Tip="Last previously installed version"
IsVisible="{CompiledBinding IsPreviousVersion}" />
</Grid>
<StackPanel Margin="4" IsVisible="{CompiledBinding !ShowStatusIndicator}">

View File

@ -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"),
}),

View File

@ -34,8 +34,8 @@
Name="UserMenu">
<Ellipse.ContextFlyout>
<Flyout>
<Grid ColumnDefinitions="Auto,*" RowDefinitions="*,*,*" MinWidth="300">
<Ellipse Grid.Column="0" Grid.RowSpan="3" Height="50" Width="50" Margin="0 0 8 0" VerticalAlignment="Top">
<Grid ColumnDefinitions="Auto,*" RowDefinitions="*,*,*,*" MinWidth="300">
<Ellipse Grid.Column="0" Grid.RowSpan="4" Height="50" Width="50" Margin="0 0 8 0" VerticalAlignment="Top">
<Ellipse.Fill>
<ImageBrush asyncImageLoader:ImageBrushLoader.Source="{CompiledBinding AvatarUrl}" />
</Ellipse.Fill>
@ -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
</controls:HyperlinkButton>
<controls:HyperlinkButton
IsVisible="{CompiledBinding AllowLogout}"
Grid.Column="1"
Grid.Row="3"
Margin="-8 0 0 0"
Padding="6 4"
Click="Button_OnClick">
Sign out
Click="Manage_OnClick">
Manage account
</controls:HyperlinkButton>
</Grid>
</Flyout>

View File

@ -13,9 +13,15 @@ public partial class CurrentUserView : ReactiveUserControl<CurrentUserViewModel>
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();
}
}

View File

@ -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<bool> _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");
}
}

View File

@ -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<Unit, Unit> DownloadLatestRelease { get; }
public Func<IEntryDetails, Task<bool>> OnInstallationStarted { get; set; }
public Func<InstalledEntry, Task>? 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<bool> 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;
}
}

View File

@ -25,7 +25,8 @@ public partial class EntriesViewModel : RoutableHostScreen<RoutableScreen>
Tabs = new ObservableCollection<RouteViewModel>
{
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 =>

View File

@ -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();

View File

@ -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()
}
};

View File

@ -0,0 +1,65 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:tabs="clr-namespace:Artemis.UI.Screens.Workshop.Entries.Tabs"
xmlns:pagination="clr-namespace:Artemis.UI.Shared.Pagination;assembly=Artemis.UI.Shared"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Workshop.Entries.Tabs.PluginListView"
x:DataType="tabs:PluginListViewModel">
<UserControl.Styles>
<Styles>
<Style Selector="StackPanel.empty-state > TextBlock">
<Setter Property="TextAlignment" Value="Center"></Setter>
<Setter Property="TextWrapping" Value="Wrap"></Setter>
</Style>
</Styles>
</UserControl.Styles>
<Grid ColumnDefinitions="300,*" RowDefinitions="Auto,*,Auto">
<StackPanel Grid.Column="0" Grid.RowSpan="3" Margin="0 0 10 0" VerticalAlignment="Top">
<Border Classes="card" VerticalAlignment="Stretch">
<StackPanel>
<TextBlock Theme="{StaticResource SubtitleTextBlockStyle}">Categories</TextBlock>
<Border Classes="card-separator" />
<ContentControl Content="{CompiledBinding CategoriesViewModel}"></ContentControl>
</StackPanel>
</Border>
</StackPanel>
<ProgressBar Grid.Column="1" Grid.Row="0" VerticalAlignment="Top" Margin="0 0 20 0" IsVisible="{CompiledBinding IsLoading}" IsIndeterminate="True" />
<ContentControl Grid.Column="1" Grid.Row="0" Margin="0 0 20 8" Content="{CompiledBinding InputViewModel}"/>
<ScrollViewer Grid.Column="1" Grid.Row="1">
<ItemsControl ItemsSource="{CompiledBinding Entries}" Margin="0 0 20 0">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<ContentControl Content="{CompiledBinding}" Margin="0 0 0 5"></ContentControl>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Panel Grid.Column="1" Grid.Row="1" IsVisible="{CompiledBinding !IsLoading}">
<StackPanel IsVisible="{CompiledBinding !Entries.Count}" Margin="0 50 0 0" Classes="empty-state">
<TextBlock Theme="{StaticResource TitleTextBlockStyle}">Looks like your current filters gave no results</TextBlock>
<TextBlock>
<Run>Modify or clear your filters to view other plugins</Run>
</TextBlock>
<Lottie Path="/Assets/Animations/empty.json" RepeatCount="1" Width="350" Height="350"></Lottie>
</StackPanel>
</Panel>
<pagination:Pagination Grid.Column="1"
Grid.Row="2"
Margin="0 20 0 10"
IsVisible="{CompiledBinding ShowPagination}"
Value="{CompiledBinding Page}"
Maximum="{CompiledBinding TotalPages}"
HorizontalAlignment="Center" />
</Grid>
</UserControl>

View File

@ -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<PluginListViewModel>
{
public PluginListView()
{
InitializeComponent();
}
}

View File

@ -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<IGetEntries_Entries_Items, EntryListItemViewModel> 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()
}
};
}
}

View File

@ -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<IGetEntries_Entries_Items, EntryListItemViewModel> getEntryListViewModel)
: base("workshop/entries/profiles", workshopClient, router, categoriesViewModel, entryListInputViewModel, notificationService, getEntryListViewModel)

View File

@ -57,6 +57,14 @@
</StackPanel>
</Button>
<Button Width="150" Height="180" Command="{CompiledBinding Navigate}" CommandParameter="workshop/entries/plugins/1" VerticalContentAlignment="Top">
<StackPanel>
<avalonia:MaterialIcon Kind="Connection" HorizontalAlignment="Left" Width="60" Height="60" Margin="0 5" />
<TextBlock TextWrapping="Wrap" FontSize="16" Margin="0 5">Plugins</TextBlock>
<TextBlock TextWrapping="Wrap" FontSize="12" Opacity="0.8">Plugins add new functionality to Artemis.</TextBlock>
</StackPanel>
</Button>
<Button Width="150" Height="180" Command="{CompiledBinding Navigate}" CommandParameter="workshop/library" VerticalContentAlignment="Top">
<StackPanel>
<avalonia:MaterialIcon Kind="Bookshelf" HorizontalAlignment="Left" Width="60" Height="60" Margin="0 5" />

View File

@ -7,6 +7,7 @@ using Artemis.Core.Services;
using Artemis.UI.DryIoc.Factories;
using Artemis.UI.Screens.SurfaceEditor;
using Artemis.UI.Shared;
using Artemis.WebClient.Workshop.Models;
using Artemis.WebClient.Workshop.Providers;
using Artemis.WebClient.Workshop.Services;
using ReactiveUI;

View File

@ -12,6 +12,7 @@ using Artemis.UI.Screens.Workshop.Parameters;
using Artemis.UI.Shared.Routing;
using Artemis.UI.Shared.Services;
using Artemis.WebClient.Workshop;
using Artemis.WebClient.Workshop.Models;
using Artemis.WebClient.Workshop.Services;
using PropertyChanged.SourceGenerator;
using StrawberryShake;

View File

@ -213,7 +213,7 @@ public partial class SubmissionDetailViewModel : RoutableScreen<WorkshopDetailPa
{
using MemoryStream stream = new();
EntrySpecificationsViewModel.IconBitmap.Save(stream);
ImageUploadResult imageResult = await _workshopService.SetEntryIcon(Entry.Id, stream, cancellationToken);
ApiResult imageResult = await _workshopService.SetEntryIcon(Entry.Id, stream, cancellationToken);
if (!imageResult.IsSuccess)
throw new ArtemisWorkshopException("Failed to upload image. " + imageResult.Message);
}

View File

@ -20,7 +20,7 @@
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
Command="{CompiledBinding ViewWorkshopPage}">
<Grid ColumnDefinitions="Auto,*,*,*,Auto">
<Grid ColumnDefinitions="Auto,2*,*,*,*,Auto">
<Border Grid.Column="0"
CornerRadius="6"
VerticalAlignment="Center"
@ -42,12 +42,13 @@
</StackPanel>
<TextBlock Grid.Column="2" VerticalAlignment="Center" Text="{CompiledBinding InstalledEntry.EntryType}"></TextBlock>
<TextBlock Grid.Column="3" VerticalAlignment="Center">
<TextBlock Grid.Column="3" VerticalAlignment="Center" Text="{CompiledBinding InstalledEntry.ReleaseVersion}"></TextBlock>
<TextBlock Grid.Column="4" VerticalAlignment="Center">
<Run>Installed</Run>
<Run Text="{CompiledBinding InstalledEntry.InstalledAt, FallbackValue=01-01-1337, Mode=OneWay, Converter={StaticResource DateTimeConverter}}" />
</TextBlock>
<StackPanel Grid.Column="4" VerticalAlignment="Center" Orientation="Horizontal" Spacing="6">
<StackPanel Grid.Column="5" VerticalAlignment="Center" Orientation="Horizontal" Spacing="6">
<Button Command="{CompiledBinding ViewLocal}">Open</Button>
<Button Command="{CompiledBinding Uninstall}" Theme="{StaticResource TransparentButton}" Height="32">
<avalonia:MaterialIcon Kind="Trash"/>

View File

@ -1,12 +1,18 @@
using System;
using System.Linq;
using System.Reactive;
using System.Threading;
using System.Threading.Tasks;
using Artemis.Core;
using Artemis.Core.Services;
using Artemis.UI.DryIoc.Factories;
using Artemis.UI.Screens.Plugins;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Routing;
using Artemis.UI.Shared.Services;
using Artemis.WebClient.Workshop;
using Artemis.WebClient.Workshop.Handlers.InstallationHandlers;
using Artemis.WebClient.Workshop.Models;
using Artemis.WebClient.Workshop.Services;
using PropertyChanged.SourceGenerator;
using ReactiveUI;
@ -19,14 +25,24 @@ public partial class InstalledTabItemViewModel : ViewModelBase
private readonly IRouter _router;
private readonly EntryInstallationHandlerFactory _factory;
private readonly IWindowService _windowService;
private readonly IPluginManagementService _pluginManagementService;
private readonly ISettingsVmFactory _settingsVmFactory;
[Notify(Setter.Private)] private bool _isRemoved;
public InstalledTabItemViewModel(InstalledEntry installedEntry, IWorkshopService workshopService, IRouter router, EntryInstallationHandlerFactory factory, IWindowService windowService)
public InstalledTabItemViewModel(InstalledEntry installedEntry,
IWorkshopService workshopService,
IRouter router,
EntryInstallationHandlerFactory factory,
IWindowService windowService,
IPluginManagementService pluginManagementService,
ISettingsVmFactory settingsVmFactory)
{
_workshopService = workshopService;
_router = router;
_factory = factory;
_windowService = windowService;
_pluginManagementService = pluginManagementService;
_settingsVmFactory = settingsVmFactory;
InstalledEntry = installedEntry;
ViewWorkshopPage = ReactiveCommand.CreateFromTask(ExecuteViewWorkshopPage);
@ -57,9 +73,25 @@ public partial class InstalledTabItemViewModel : ViewModelBase
bool confirmed = await _windowService.ShowConfirmContentDialog("Do you want to uninstall this entry?", "Both the entry and its contents will be removed.");
if (!confirmed)
return;
// Ideally the installation handler does this but it doesn't have access to the required view models
if (InstalledEntry.EntryType == EntryType.Plugin)
await UninstallPluginPrerequisites();
IEntryInstallationHandler handler = _factory.CreateHandler(InstalledEntry.EntryType);
await handler.UninstallAsync(InstalledEntry, cancellationToken);
IsRemoved = true;
}
private async Task UninstallPluginPrerequisites()
{
if (!InstalledEntry.TryGetMetadata("PluginId", out Guid pluginId))
return;
Plugin? plugin = _pluginManagementService.GetAllPlugins().FirstOrDefault(p => p.Guid == pluginId);
if (plugin == null)
return;
PluginViewModel pluginViewModel = _settingsVmFactory.PluginViewModel(plugin, ReactiveCommand.Create(() => { }));
await pluginViewModel.ExecuteRemovePrerequisites(true);
}
}

View File

@ -4,6 +4,7 @@ using System.Collections.ObjectModel;
using System.Reactive;
using System.Reactive.Linq;
using Artemis.UI.Shared.Routing;
using Artemis.WebClient.Workshop.Models;
using Artemis.WebClient.Workshop.Services;
using DynamicData;
using DynamicData.Binding;

View File

@ -36,20 +36,24 @@
<Button HorizontalAlignment="Center" Command="{CompiledBinding AddSubmission}">Submit new entry</Button>
</StackPanel>
<ScrollViewer IsVisible="{CompiledBinding Entries.Count}">
<ItemsControl ItemsSource="{CompiledBinding Entries}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<ContentControl Content="{CompiledBinding}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</ScrollViewer>
<StackPanel Spacing="15">
<Button HorizontalAlignment="Right" Command="{CompiledBinding AddSubmission}">Submit new entry</Button>
<ScrollViewer IsVisible="{CompiledBinding Entries.Count}">
<ItemsControl ItemsSource="{CompiledBinding Entries}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<ContentControl Content="{CompiledBinding}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</ScrollViewer>
</StackPanel>
</Panel>
</Panel>

View File

@ -0,0 +1,19 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:dialogs="clr-namespace:Artemis.UI.Screens.Workshop.Plugins.Dialogs"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Workshop.Plugins.Dialogs.PluginDialogView"
x:DataType="dialogs:PluginDialogViewModel">
<Grid ColumnDefinitions="4*,5*" Width="800" Height="160">
<ContentControl Grid.Column="0" Content="{CompiledBinding PluginViewModel}" />
<Border Grid.Column="1" BorderBrush="{DynamicResource ButtonBorderBrush}" BorderThickness="1 0 0 0" Margin="10 0 0 0" Padding="10 0 0 0">
<Grid RowDefinitions="Auto,*">
<TextBlock Classes="h5">Plugin features</TextBlock>
<ListBox Grid.Row="1" MaxHeight="135" ItemsSource="{CompiledBinding PluginFeatures}" />
</Grid>
</Border>
</Grid>
</UserControl>

View File

@ -0,0 +1,11 @@
using Avalonia.ReactiveUI;
namespace Artemis.UI.Screens.Workshop.Plugins.Dialogs;
public partial class PluginDialogView : ReactiveUserControl<PluginDialogViewModel>
{
public PluginDialogView()
{
InitializeComponent();
}
}

View File

@ -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<bool>()));
PluginFeatures = new ObservableCollection<PluginFeatureViewModel>(plugin.Features.Select(f => settingsVmFactory.PluginFeatureViewModel(f, false)));
}
public PluginViewModel PluginViewModel { get; }
public ObservableCollection<PluginFeatureViewModel> PluginFeatures { get; }
}

View File

@ -0,0 +1,30 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia.Tight"
xmlns:plugins="clr-namespace:Artemis.UI.Screens.Workshop.Plugins"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Workshop.Plugins.PluginDetailsView"
x:DataType="plugins:PluginDetailsViewModel">
<Grid ColumnDefinitions="300,*, 300" RowDefinitions="Auto,*">
<StackPanel Grid.Row="1" Grid.Column="0" Spacing="10">
<Border Classes="card" VerticalAlignment="Top">
<ContentControl Content="{CompiledBinding EntryInfoViewModel}" />
</Border>
<Border Classes="card" VerticalAlignment="Top" IsVisible="{CompiledBinding Entry.LatestRelease, Converter={x:Static ObjectConverters.IsNotNull}}">
<ContentControl Content="{CompiledBinding EntryReleasesViewModel}" />
</Border>
</StackPanel>
<Border Classes="card" Grid.Row="1" Grid.Column="1" Margin="10 0">
<mdxaml:MarkdownScrollViewer Markdown="{CompiledBinding Entry.Description}" MarkdownStyleName="FluentAvalonia">
<mdxaml:MarkdownScrollViewer.Styles>
<StyleInclude Source="/Styles/Markdown.axaml" />
</mdxaml:MarkdownScrollViewer.Styles>
</mdxaml:MarkdownScrollViewer>
</Border>
<ContentControl Grid.Row="1" Grid.Column="2" IsVisible="{CompiledBinding Entry.Images.Count}" Content="{CompiledBinding EntryImagesViewModel}" />
</Grid>
</UserControl>

View File

@ -0,0 +1,11 @@
using Avalonia.ReactiveUI;
namespace Artemis.UI.Screens.Workshop.Plugins;
public partial class PluginDetailsView : ReactiveUserControl<PluginDetailsViewModel>
{
public PluginDetailsView()
{
InitializeComponent();
}
}

View File

@ -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<WorkshopDetailParameters>
{
private readonly IWorkshopClient _client;
private readonly IWindowService _windowService;
private readonly IPluginManagementService _pluginManagementService;
private readonly Func<IGetEntryById_Entry, EntryInfoViewModel> _getEntryInfoViewModel;
private readonly Func<IGetEntryById_Entry, EntryReleasesViewModel> _getEntryReleasesViewModel;
private readonly Func<IGetEntryById_Entry, EntryImagesViewModel> _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<IGetEntryById_Entry, EntryInfoViewModel> getEntryInfoViewModel,
Func<IGetEntryById_Entry, EntryReleasesViewModel> getEntryReleasesViewModel,
Func<IGetEntryById_Entry, EntryImagesViewModel> 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<IGetEntryByIdResult> 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<bool> 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();
}
}

View File

@ -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<PluginSelectionStepViewModel>();
else if (EntryType == EntryType.Profile)
ChangeScreen<ProfileSelectionStepViewModel>();
else if (EntryType == EntryType.Layout)
ChangeScreen<LayoutSelectionStepViewModel>();

View File

@ -44,5 +44,14 @@
</StackPanel>
</RadioButton.Content>
</RadioButton>
<RadioButton GroupName="EntryType"
IsChecked="{CompiledBinding SelectedEntryType, Converter={StaticResource EnumBoolConverter}, ConverterParameter={x:Static workshop:EntryType.Plugin}}">
<RadioButton.Content>
<StackPanel>
<TextBlock>Plugin</TextBlock>
<TextBlock Classes="subtitle" TextWrapping="Wrap">A plugin that adds new features to Artemis.</TextBlock>
</StackPanel>
</RadioButton.Content>
</RadioButton>
</StackPanel>
</UserControl>

View File

@ -0,0 +1,46 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:plugin="clr-namespace:Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Plugin"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Plugin.PluginSelectionStepView"
x:DataType="plugin:PluginSelectionStepViewModel">
<Grid RowDefinitions="Auto,Auto">
<StackPanel>
<StackPanel.Styles>
<Styles>
<Style Selector="TextBlock">
<Setter Property="TextWrapping" Value="Wrap"></Setter>
</Style>
</Styles>
</StackPanel.Styles>
<TextBlock Theme="{StaticResource TitleTextBlockStyle}" TextWrapping="Wrap">
Plugin selection
</TextBlock>
<TextBlock TextWrapping="Wrap">
Please select the plugin you want to share, a preview will be shown below.
</TextBlock>
<Button Command="{CompiledBinding Browse}" Margin="0 20">Browse file</Button>
</StackPanel>
<Border Grid.Row="1" Classes="card" ClipToBounds="True" IsVisible="{CompiledBinding SelectedPlugin, Converter={x:Static ObjectConverters.IsNotNull}}">
<Grid RowDefinitions="30,30,30,30,Auto" ColumnDefinitions="200,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" FontWeight="SemiBold">Path</TextBlock>
<TextBlock Grid.Row="0" Grid.Column="1" Text="{CompiledBinding Path}"></TextBlock>
<TextBlock Grid.Row="1" Grid.Column="0" FontWeight="SemiBold">Name</TextBlock>
<TextBlock Grid.Row="1" Grid.Column="1" Text="{CompiledBinding SelectedPlugin.Name}"></TextBlock>
<TextBlock Grid.Row="2" Grid.Column="0" FontWeight="SemiBold">Description</TextBlock>
<TextBlock Grid.Row="2" Grid.Column="1" Text="{CompiledBinding SelectedPlugin.Description}"></TextBlock>
<TextBlock Grid.Row="3" Grid.Column="0" FontWeight="SemiBold">Main entry point</TextBlock>
<TextBlock Grid.Row="3" Grid.Column="1" Text="{CompiledBinding SelectedPlugin.Main}"></TextBlock>
<TextBlock Grid.Row="4" Grid.Column="0" FontWeight="SemiBold">Version</TextBlock>
<TextBlock Grid.Row="4" Grid.Column="1" Text="{CompiledBinding SelectedPlugin.Version}"></TextBlock>
</Grid>
</Border>
</Grid>
</UserControl>

View File

@ -0,0 +1,11 @@
using Avalonia.ReactiveUI;
namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Plugin;
public partial class PluginSelectionStepView : ReactiveUserControl<PluginSelectionStepViewModel>
{
public PluginSelectionStepView()
{
InitializeComponent();
}
}

View File

@ -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;
/// <inheritdoc />
public PluginSelectionStepViewModel(IWindowService windowService)
{
_windowService = windowService;
GoBack = ReactiveCommand.Create(() => State.ChangeScreen<EntryTypeStepViewModel>());
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<Unit, Unit> 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<PluginInfo>(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<SpecificationsStepViewModel>();
else
State.ChangeScreen<UploadStepViewModel>();
}
}

View File

@ -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<LayoutInfoStepViewModel>();
break;
case EntryType.Plugin:
break;
case EntryType.Profile:
State.ChangeScreen<ProfileAdaptionHintsStepViewModel>();
break;
default:
throw new ArgumentOutOfRangeException();
}
if (State.EntryType == EntryType.Layout)
State.ChangeScreen<LayoutInfoStepViewModel>();
else if (State.EntryType == EntryType.Plugin)
State.ChangeScreen<PluginSelectionStepViewModel>();
else if (State.EntryType == EntryType.Profile)
State.ChangeScreen<ProfileAdaptionHintsStepViewModel>();
else
throw new ArgumentOutOfRangeException();
}
private void ExecuteContinue()

View File

@ -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<Task<ImageUploadResult>> action)
private async Task TryImageUpload(Func<Task<ApiResult>> action)
{
try
{
ImageUploadResult result = await action();
ApiResult result = await action();
if (!result.IsSuccess)
throw new ArtemisWorkshopException(result.Message);
}

View File

@ -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
}
/// <inheritdoc />

View File

@ -42,12 +42,14 @@ public interface IUpdateService : IArtemisUIService
/// <summary>
/// Restarts the application to install a pending update.
/// </summary>
/// <param name="source">The source from which the restart is requested.</param>
/// <param name="silent">A boolean indicating whether to perform a silent install of the update.</param>
void RestartForUpdate(bool silent);
void RestartForUpdate(string source, bool silent);
/// <summary>
/// Initializes the update service.
/// </summary>
/// <param name="performAutoUpdate"></param>
/// <returns>A boolean indicating whether a restart will occur to install a pending update.</returns>
bool Initialize();
bool Initialize(bool performAutoUpdate);
}

View File

@ -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<bool> CheckForUpdate()
{
_logger.Information("Performing auto-update check");
IOperationResult<IGetNextReleaseResult> 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
}
/// <inheritdoc />
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
}
/// <inheritdoc />
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;

View File

@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Platforms>x64</Platforms>

View File

@ -11,6 +11,7 @@ public class DataModelEventCycleNode : Node<DataModelPathEntity, DataModelEventC
private int _currentIndex;
private Type _currentType;
private DataModelPath? _dataModelPath;
private IDataModelEvent? _subscribedEvent;
private object? _lastPathValue;
private bool _updating;
@ -76,13 +77,19 @@ public class DataModelEventCycleNode : Node<DataModelPathEntity, DataModelEventC
{
DataModelPath? old = _dataModelPath;
if (old?.GetValue() is IDataModelEvent oldEvent)
oldEvent.EventTriggered -= OnEventTriggered;
if (_subscribedEvent != null)
{
_subscribedEvent.EventTriggered -= OnEventTriggered;
_subscribedEvent = null;
}
_dataModelPath = Storage != null ? new DataModelPath(Storage) : null;
if (_dataModelPath?.GetValue() is IDataModelEvent newEvent)
newEvent.EventTriggered += OnEventTriggered;
{
_subscribedEvent = newEvent;
_subscribedEvent.EventTriggered += OnEventTriggered;
}
old?.Dispose();
}
@ -153,8 +160,12 @@ public class DataModelEventCycleNode : Node<DataModelPathEntity, DataModelEventC
/// <inheritdoc />
public void Dispose()
{
if (_dataModelPath?.GetValue() is IDataModelEvent newEvent)
newEvent.EventTriggered -= OnEventTriggered;
if (_subscribedEvent != null)
{
_subscribedEvent.EventTriggered -= OnEventTriggered;
_subscribedEvent = null;
}
_dataModelPath?.Dispose();
}
}

View File

@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Platforms>x64</Platforms>
@ -9,8 +9,8 @@
<ItemGroup>
<PackageReference Include="DryIoc.dll" Version="5.4.3" />
<PackageReference Include="DryIoc.Microsoft.DependencyInjection" Version="6.2.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" />
<PackageReference Include="StrawberryShake.Server" Version="13.8.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="StrawberryShake.Server" Version="13.9.0" />
</ItemGroup>
<ItemGroup>

View File

@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Platforms>x64</Platforms>
@ -10,8 +10,8 @@
<PackageReference Include="DryIoc.Microsoft.DependencyInjection" Version="6.2.0" />
<PackageReference Include="IdentityModel" Version="6.2.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="StrawberryShake.Server" Version="13.8.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.2.0" />
<PackageReference Include="StrawberryShake.Server" Version="13.9.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.3.1" />
</ItemGroup>
<ItemGroup>

View File

@ -33,6 +33,11 @@ public static class ContainerExtensions
.AddWorkshopClient()
.AddHttpMessageHandler<WorkshopClientStoreAccessor, AuthenticationDelegatingHandler>()
.ConfigureHttpClient(client => client.BaseAddress = new Uri(WorkshopConstants.WORKSHOP_URL + "/graphql"));
serviceCollection.AddHttpClient(WorkshopConstants.IDENTITY_CLIENT_NAME)
.AddHttpMessageHandler<AuthenticationDelegatingHandler>()
.ConfigureHttpClient(client => client.BaseAddress = new Uri(WorkshopConstants.AUTHORITY_URL));
serviceCollection.AddHttpClient(WorkshopConstants.WORKSHOP_CLIENT_NAME)
.AddHttpMessageHandler<AuthenticationDelegatingHandler>()
.ConfigureHttpClient(client => client.BaseAddress = new Uri(WorkshopConstants.WORKSHOP_URL));
@ -49,7 +54,8 @@ public static class ContainerExtensions
container.Register<IAuthenticationService, AuthenticationService>(Reuse.Singleton);
container.Register<IWorkshopService, WorkshopService>(Reuse.Singleton);
container.Register<ILayoutProvider, WorkshopLayoutProvider>(Reuse.Singleton);
container.Register<IUserManagementService, UserManagementService>();
container.Register<EntryUploadHandlerFactory>(Reuse.Transient);
container.RegisterMany(workshopAssembly, type => type.IsAssignableTo<IEntryUploadHandler>(), Reuse.Transient);
container.RegisterMany(workshopAssembly, type => type.IsAssignableTo<IEntryInstallationHandler>(), Reuse.Transient);

View File

@ -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;

View File

@ -15,6 +15,7 @@ public class EntryInstallationHandlerFactory
{
return entryType switch
{
EntryType.Plugin => _container.Resolve<PluginEntryInstallationHandler>(),
EntryType.Profile => _container.Resolve<ProfileEntryInstallationHandler>(),
EntryType.Layout => _container.Resolve<LayoutEntryInstallationHandler>(),
_ => throw new NotSupportedException($"EntryType '{entryType}' is not supported.")

View File

@ -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
};
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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<EntryInstallResult> InstallAsync(IEntryDetails entry, IRelease release, Progress<StreamProgress> 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<EntryUninstallResult> 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));
}
}

View File

@ -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;

View File

@ -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};
}
}

View File

@ -15,6 +15,7 @@ public class EntryUploadHandlerFactory
{
return entryType switch
{
EntryType.Plugin => _container.Resolve<PluginEntryUploadHandler>(),
EntryType.Profile => _container.Resolve<ProfileEntryUploadHandler>(),
EntryType.Layout => _container.Resolve<LayoutEntryUploadHandler>(),
_ => throw new NotSupportedException($"EntryType '{entryType}' is not supported.")

Some files were not shown because too many files have changed in this diff Show More