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

Merge branch 'development'

This commit is contained in:
Robert 2024-07-04 20:16:21 +02:00
commit bd904178c8
46 changed files with 1172 additions and 326 deletions

View File

@ -38,12 +38,14 @@ internal class DeviceService : IDeviceService
_renderService = renderService;
_getLayoutProviders = getLayoutProviders;
SuspendedDeviceProviders = new ReadOnlyCollection<DeviceProvider>(_suspendedDeviceProviders);
EnabledDevices = new ReadOnlyCollection<ArtemisDevice>(_enabledDevices);
Devices = new ReadOnlyCollection<ArtemisDevice>(_devices);
RenderScale.RenderScaleMultiplierChanged += RenderScaleOnRenderScaleMultiplierChanged;
}
public IReadOnlyCollection<DeviceProvider> SuspendedDeviceProviders { get; }
public IReadOnlyCollection<ArtemisDevice> EnabledDevices { get; }
public IReadOnlyCollection<ArtemisDevice> Devices { get; }

View File

@ -10,6 +10,11 @@ namespace Artemis.Core.Services;
/// </summary>
public interface IDeviceService : IArtemisService
{
/// <summary>
/// Gets a read-only collection containing all enabled but suspended device providers
/// </summary>
IReadOnlyCollection<DeviceProvider> SuspendedDeviceProviders { get; }
/// <summary>
/// Gets a read-only collection containing all enabled devices
/// </summary>
@ -42,7 +47,7 @@ public interface IDeviceService : IArtemisService
/// Applies auto-arranging logic to the surface
/// </summary>
void AutoArrangeDevices();
/// <summary>
/// Apples the best available to the provided <see cref="ArtemisDevice" />
/// </summary>
@ -111,7 +116,7 @@ public interface IDeviceService : IArtemisService
/// Occurs when a device provider was removed.
/// </summary>
event EventHandler<DeviceProviderEventArgs> DeviceProviderRemoved;
/// <summary>
/// Occurs when the surface has had modifications to its LED collection
/// </summary>

View File

@ -186,6 +186,11 @@ public interface IPluginManagementService : IArtemisService, IDisposable
/// </summary>
event EventHandler<PluginEventArgs> PluginDisabled;
/// <summary>
/// Occurs when a plugin is removed
/// </summary>
event EventHandler<PluginEventArgs> PluginRemoved;
/// <summary>
/// Occurs when a plugin feature is being enabled
/// </summary>

View File

@ -686,6 +686,8 @@ internal class PluginManagementService : IPluginManagementService
if (removeSettings)
RemovePluginSettings(plugin);
OnPluginRemoved(new PluginEventArgs(plugin));
}
public void RemovePluginSettings(Plugin plugin)
@ -850,6 +852,7 @@ internal class PluginManagementService : IPluginManagementService
public event EventHandler<PluginEventArgs>? PluginEnabling;
public event EventHandler<PluginEventArgs>? PluginEnabled;
public event EventHandler<PluginEventArgs>? PluginDisabled;
public event EventHandler<PluginEventArgs>? PluginRemoved;
public event EventHandler<PluginFeatureEventArgs>? PluginFeatureEnabling;
public event EventHandler<PluginFeatureEventArgs>? PluginFeatureEnabled;
@ -890,6 +893,11 @@ internal class PluginManagementService : IPluginManagementService
{
PluginDisabled?.Invoke(this, e);
}
protected virtual void OnPluginRemoved(PluginEventArgs e)
{
PluginRemoved?.Invoke(this, e);
}
protected virtual void OnPluginFeatureEnabling(PluginFeatureEventArgs e)
{

View File

@ -30,7 +30,7 @@ public interface IProfileService : IArtemisService
/// Gets or sets a value indicating whether the currently focused profile should receive updates.
/// </summary>
bool UpdateFocusProfile { get; set; }
/// <summary>
/// Gets or sets whether profiles are rendered each frame by calling their Render method
/// </summary>
@ -54,7 +54,7 @@ public interface IProfileService : IArtemisService
/// </summary>
/// <param name="profileConfiguration">The profile configuration of the profile to activate.</param>
void DeactivateProfile(ProfileConfiguration profileConfiguration);
/// <summary>
/// Saves the provided <see cref="ProfileCategory" /> and it's <see cref="ProfileConfiguration" />s but not the
/// <see cref="Profile" />s themselves.
@ -117,15 +117,8 @@ public interface IProfileService : IArtemisService
/// <param name="nameAffix">Text to add after the name of the profile (separated by a dash).</param>
/// <param name="target">The profile before which to import the profile into the category.</param>
/// <returns>The resulting profile configuration.</returns>
Task<ProfileConfiguration> ImportProfile(Stream archiveStream, ProfileCategory category, bool makeUnique, bool markAsFreshImport, string? nameAffix = "imported", ProfileConfiguration? target = null);
/// <summary>
/// Imports the provided ZIP archive stream into the provided profile configuration
/// </summary>
/// <param name="archiveStream">The zip archive containing the profile to import.</param>
/// <param name="profileConfiguration">The profile configuration to overwrite.</param>
/// <returns>The resulting profile configuration.</returns>
Task<ProfileConfiguration> OverwriteProfile(MemoryStream archiveStream, ProfileConfiguration profileConfiguration);
Task<ProfileConfiguration> ImportProfile(Stream archiveStream, ProfileCategory category, bool makeUnique, bool markAsFreshImport, string? nameAffix = "imported",
ProfileConfiguration? target = null);
/// <summary>
/// Adapts a given profile to the currently active devices.
@ -163,5 +156,14 @@ public interface IProfileService : IArtemisService
/// Occurs whenever a profile category is removed.
/// </summary>
public event EventHandler<ProfileCategoryEventArgs>? ProfileCategoryRemoved;
/// <summary>
/// Occurs whenever a profile is added.
/// </summary>
public event EventHandler<ProfileConfigurationEventArgs>? ProfileRemoved;
/// <summary>
/// Occurs whenever a profile is removed.
/// </summary>
public event EventHandler<ProfileConfigurationEventArgs>? ProfileAdded;
}

View File

@ -26,7 +26,6 @@ internal class ProfileService : IProfileService
private readonly IPluginManagementService _pluginManagementService;
private readonly IDeviceService _deviceService;
private readonly List<ArtemisKeyboardKeyEventArgs> _pendingKeyboardEvents = new();
private readonly List<IProfileMigration> _profileMigrators;
private readonly List<Exception> _renderExceptions = new();
private readonly List<Exception> _updateExceptions = new();
@ -38,15 +37,13 @@ internal class ProfileService : IProfileService
IProfileRepository profileRepository,
IPluginManagementService pluginManagementService,
IInputService inputService,
IDeviceService deviceService,
List<IProfileMigration> profileMigrators)
IDeviceService deviceService)
{
_logger = logger;
_profileCategoryRepository = profileCategoryRepository;
_profileRepository = profileRepository;
_pluginManagementService = pluginManagementService;
_deviceService = deviceService;
_profileMigrators = profileMigrators;
ProfileCategories = new ReadOnlyCollection<ProfileCategory>(_profileCategoryRepository.GetAll().Select(c => new ProfileCategory(c)).OrderBy(c => c.Order).ToList());
@ -264,6 +261,8 @@ internal class ProfileService : IProfileService
category.AddProfileConfiguration(configuration, category.ProfileConfigurations.FirstOrDefault());
SaveProfileCategory(category);
OnProfileAdded(new ProfileConfigurationEventArgs(configuration));
return configuration;
}
@ -279,6 +278,8 @@ internal class ProfileService : IProfileService
_profileRepository.Remove(profileConfiguration.Entity);
_profileCategoryRepository.Save(category.Entity);
OnProfileRemoved(new ProfileConfigurationEventArgs(profileConfiguration));
}
/// <inheritdoc />
@ -433,17 +434,6 @@ internal class ProfileService : IProfileService
return profileConfiguration;
}
/// <inheritdoc />
public async Task<ProfileConfiguration> OverwriteProfile(MemoryStream archiveStream, ProfileConfiguration profileConfiguration)
{
ProfileConfiguration imported = await ImportProfile(archiveStream, profileConfiguration.Category, true, true, null, profileConfiguration);
RemoveProfileConfiguration(profileConfiguration);
SaveProfileCategory(imported.Category);
return imported;
}
/// <inheritdoc />
public void AdaptProfile(Profile profile)
{
@ -588,6 +578,8 @@ internal class ProfileService : IProfileService
public event EventHandler<ProfileConfigurationEventArgs>? ProfileDeactivated;
public event EventHandler<ProfileCategoryEventArgs>? ProfileCategoryAdded;
public event EventHandler<ProfileCategoryEventArgs>? ProfileCategoryRemoved;
public event EventHandler<ProfileConfigurationEventArgs>? ProfileRemoved;
public event EventHandler<ProfileConfigurationEventArgs>? ProfileAdded;
protected virtual void OnProfileActivated(ProfileConfigurationEventArgs e)
{
@ -610,4 +602,14 @@ internal class ProfileService : IProfileService
}
#endregion
protected virtual void OnProfileRemoved(ProfileConfigurationEventArgs e)
{
ProfileRemoved?.Invoke(this, e);
}
protected virtual void OnProfileAdded(ProfileConfigurationEventArgs e)
{
ProfileAdded?.Invoke(this, e);
}
}

View File

@ -50,7 +50,7 @@ public interface IRouter
/// Asynchronously navigates upwards to the parent route.
/// </summary>
/// <returns></returns>
Task<bool> GoUp();
Task<bool> GoUp(RouterNavigationOptions? options = null);
/// <summary>
/// Clears the navigation history.

View File

@ -8,9 +8,10 @@ namespace Artemis.UI.Shared.Routing;
/// </summary>
public class NavigationArguments
{
internal NavigationArguments(IRouter router, string path, object[] routeParameters)
internal NavigationArguments(IRouter router, RouterNavigationOptions options, string path, object[] routeParameters)
{
Router = router;
Options = options;
Path = path;
RouteParameters = routeParameters;
SegmentParameters = Array.Empty<object>();
@ -21,6 +22,11 @@ public class NavigationArguments
/// </summary>
public IRouter Router { get; }
/// <summary>
/// Gets the options that are being used for this navigation.
/// </summary>
public RouterNavigationOptions Options { get; }
/// <summary>
/// Gets the path of the route that is being navigated to.
/// </summary>

View File

@ -114,7 +114,7 @@ internal class Router : CorePropertyChanged, IRouter, IDisposable
return;
}
NavigationArguments args = new(this, resolution.Path, resolution.GetAllParameters());
NavigationArguments args = new(this, options, resolution.Path, resolution.GetAllParameters());
if (!await RequestClose(_root, args))
return;
@ -169,7 +169,7 @@ internal class Router : CorePropertyChanged, IRouter, IDisposable
}
/// <inheritdoc />
public async Task<bool> GoUp()
public async Task<bool> GoUp(RouterNavigationOptions? options = null)
{
string? currentPath = _currentRouteSubject.Value;
@ -180,7 +180,7 @@ internal class Router : CorePropertyChanged, IRouter, IDisposable
RouteResolution resolution = Resolve(parentPath);
if (resolution.Success)
{
await Navigate(parentPath, new RouterNavigationOptions {AddToHistory = false});
await Navigate(parentPath, options);
return true;
}

View File

@ -35,6 +35,11 @@ public class RouterNavigationOptions
/// </summary>
public bool EnableLogging { get; set; } = true;
/// <summary>
/// Gets or sets any additional arguments to pass to the screen.
/// </summary>
public object? AdditionalArguments { get; set; }
/// <summary>
/// Determines whether the given two paths are considered equal using these navigation options.
/// </summary>

View File

@ -11,8 +11,6 @@ using Artemis.UI.Exceptions;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Services;
using Artemis.UI.Shared.Services.Builders;
using Artemis.WebClient.Workshop.Models;
using Artemis.WebClient.Workshop.Services;
using Avalonia.Controls;
using Avalonia.Threading;
using Material.Icons;
@ -26,7 +24,6 @@ public partial class PluginViewModel : ActivatableViewModelBase
private readonly ICoreService _coreService;
private readonly INotificationService _notificationService;
private readonly IPluginManagementService _pluginManagementService;
private readonly IWorkshopService _workshopService;
private readonly IWindowService _windowService;
private Window? _settingsWindow;
[Notify] private bool _canInstallPrerequisites;
@ -39,15 +36,13 @@ public partial class PluginViewModel : ActivatableViewModelBase
ICoreService coreService,
IWindowService windowService,
INotificationService notificationService,
IPluginManagementService pluginManagementService,
IWorkshopService workshopService)
IPluginManagementService pluginManagementService)
{
_plugin = plugin;
_coreService = coreService;
_windowService = windowService;
_notificationService = notificationService;
_pluginManagementService = pluginManagementService;
_workshopService = workshopService;
Platforms = new ObservableCollection<PluginPlatformViewModel>();
if (Plugin.Info.Platforms != null)
@ -249,7 +244,7 @@ public partial class PluginViewModel : ActivatableViewModelBase
return;
// If the plugin or any of its features has uninstall actions, offer to run these
await ExecuteRemovePrerequisites(true);
await ExecuteRemovePrerequisites(true);
try
{
@ -260,10 +255,6 @@ public partial class PluginViewModel : ActivatableViewModelBase
_windowService.ShowExceptionDialog("Failed to remove plugin", e);
throw;
}
InstalledEntry? entry = _workshopService.GetInstalledEntries().FirstOrDefault(e => e.TryGetMetadata("PluginId", out Guid pluginId) && pluginId == Plugin.Guid);
if (entry != null)
_workshopService.RemoveInstalledEntry(entry);
_notificationService.CreateNotification().WithTitle("Removed plugin.").Show();
}
@ -303,4 +294,19 @@ public partial class PluginViewModel : ActivatableViewModelBase
_settingsWindow?.Close();
});
}
public async Task AutoEnable()
{
if (IsEnabled)
return;
await UpdateEnabled(true);
// If enabling failed, don't offer to show the settings
if (!IsEnabled || Plugin.ConfigurationDialog == null)
return;
if (await _windowService.ShowConfirmContentDialog("Open plugin settings", "This plugin has settings, would you like to view them?", "Yes", "No"))
ExecuteOpenSettings();
}
}

View File

@ -26,6 +26,8 @@ public partial class StartupWizardViewModel : DialogViewModelBase<bool>
private readonly ISettingsService _settingsService;
private readonly IWindowService _windowService;
private readonly IDeviceService _deviceService;
private readonly Func<PluginFeatureInfo, WizardPluginFeatureViewModel> _getPluginFeatureViewModel;
[Notify] private int _currentStep;
[Notify] private bool _showContinue;
[Notify] private bool _showFinish;
@ -36,12 +38,13 @@ public partial class StartupWizardViewModel : DialogViewModelBase<bool>
IPluginManagementService pluginManagementService,
IWindowService windowService,
IDeviceService deviceService,
ISettingsVmFactory settingsVmFactory,
LayoutFinderViewModel layoutFinderViewModel)
LayoutFinderViewModel layoutFinderViewModel,
Func<PluginFeatureInfo, WizardPluginFeatureViewModel> getPluginFeatureViewModel)
{
_settingsService = settingsService;
_windowService = windowService;
_deviceService = deviceService;
_getPluginFeatureViewModel = getPluginFeatureViewModel;
_autoRunProvider = container.Resolve<IAutoRunProvider>(IfUnresolved.ReturnDefault);
_protocolProvider = container.Resolve<IProtocolProvider>(IfUnresolved.ReturnDefault);
@ -51,11 +54,12 @@ public partial class StartupWizardViewModel : DialogViewModelBase<bool>
SelectLayout = ReactiveCommand.Create<string>(ExecuteSelectLayout);
Version = $"Version {Constants.CurrentVersion}";
// Take all compatible plugins that have an always-enabled device provider
DeviceProviders = new ObservableCollection<PluginViewModel>(pluginManagementService.GetAllPlugins()
.Where(p => p.Info.IsCompatible && p.Features.Any(f => f.AlwaysEnabled && f.FeatureType.IsAssignableTo(typeof(DeviceProvider))))
.OrderBy(p => p.Info.Name)
.Select(p => settingsVmFactory.PluginViewModel(p, ReactiveCommand.Create(() => new Unit()))));
// Take all compatible device providers and create a view model for them
DeviceProviders = new ObservableCollection<WizardPluginFeatureViewModel>(pluginManagementService.GetAllPlugins()
.Where(p => p.Info.IsCompatible)
.SelectMany(p => p.Features.Where(f => f.FeatureType.IsAssignableTo(typeof(DeviceProvider))))
.OrderBy(f => f.Name)
.Select(f => _getPluginFeatureViewModel(f)));
LayoutFinderViewModel = layoutFinderViewModel;
CurrentStep = 1;
@ -84,7 +88,7 @@ public partial class StartupWizardViewModel : DialogViewModelBase<bool>
public ReactiveCommand<string, Unit> SelectLayout { get; }
public string Version { get; }
public ObservableCollection<PluginViewModel> DeviceProviders { get; }
public ObservableCollection<WizardPluginFeatureViewModel> DeviceProviders { get; }
public LayoutFinderViewModel LayoutFinderViewModel { get; }
public bool IsAutoRunSupported => _autoRunProvider != null;

View File

@ -0,0 +1,79 @@
<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:shared="clr-namespace:Artemis.UI.Shared;assembly=Artemis.UI.Shared"
xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:startupWizard="clr-namespace:Artemis.UI.Screens.StartupWizard"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.StartupWizard.WizardPluginFeatureView"
x:DataType="startupWizard:WizardPluginFeatureViewModel">
<Grid RowDefinitions="*,Auto">
<Grid Grid.Row="0" RowDefinitions="Auto,Auto,*" ColumnDefinitions="80,*, Auto">
<shared:ArtemisIcon Icon="{CompiledBinding Plugin.Info.ResolvedIcon}"
Width="48"
Height="48"
Margin="0 5 0 0"
Grid.Row="0"
Grid.RowSpan="3"
VerticalAlignment="Top" />
<TextBlock Grid.Column="1" Grid.Row="0" Classes="no-margin">
<Run Classes="h5" Text="{CompiledBinding PluginFeature.Name}" />
<Run Classes="subtitle" Text="{CompiledBinding Plugin.Info.Name}" />
</TextBlock>
<ItemsControl Grid.Column="2" Grid.Row="0" IsVisible="{CompiledBinding Platforms.Count}" ItemsSource="{CompiledBinding Platforms}" HorizontalAlignment="Right">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="5" Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<TextBlock Grid.Column="1"
Grid.ColumnSpan="2"
Grid.Row="1"
Classes="subtitle"
Text="{CompiledBinding Plugin.Info.Author}"
IsVisible="{CompiledBinding Plugin.Info.Author, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<TextBlock Grid.Column="1"
Grid.ColumnSpan="2"
Grid.Row="2"
TextWrapping="Wrap"
Margin="0 5"
Text="{CompiledBinding Plugin.Info.Description}" />
</Grid>
<Grid Grid.Row="1" ColumnDefinitions="*,Auto">
<Button Grid.Row="0"
Grid.Column="0"
IsVisible="{CompiledBinding OpenSettings.CanExecute^}" Command="{CompiledBinding OpenSettings}">
Settings
</Button>
<CheckBox Name="EnabledToggle"
Grid.Row="0"
Grid.Column="1"
HorizontalAlignment="Right"
IsVisible="{CompiledBinding !Enabling}"
IsChecked="{CompiledBinding IsEnabled, Mode=OneWay}"
IsEnabled="{CompiledBinding Plugin.Info.IsCompatible}">
<StackPanel x:Name="EnableText" Orientation="Horizontal">
<TextBlock>Enable feature</TextBlock>
<avalonia:MaterialIcon Kind="ShieldHalfFull"
Margin="5 0 0 0"
ToolTip.Tip="Plugin requires admin rights"
IsVisible="{CompiledBinding Plugin.Info.RequiresAdmin}" />
</StackPanel>
</CheckBox>
<ProgressBar Grid.Row="0"
Grid.Column="1"
HorizontalAlignment="Right"
IsVisible="{CompiledBinding Enabling}"
IsIndeterminate="True" />
</Grid>
</Grid>
</UserControl>

View File

@ -0,0 +1,22 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
using Avalonia.Threading;
namespace Artemis.UI.Screens.StartupWizard;
public partial class WizardPluginFeatureView : ReactiveUserControl<WizardPluginFeatureViewModel>
{
public WizardPluginFeatureView()
{
InitializeComponent();
EnabledToggle.Click += EnabledToggleOnClick;
}
private void EnabledToggleOnClick(object? sender, RoutedEventArgs e)
{
Dispatcher.UIThread.Post(() => ViewModel?.UpdateEnabled(!ViewModel.IsEnabled));
}
}

View File

@ -0,0 +1,208 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Reactive;
using System.Reactive.Disposables;
using System.Threading.Tasks;
using Artemis.Core;
using Artemis.Core.Services;
using Artemis.UI.Exceptions;
using Artemis.UI.Screens.Plugins;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Services;
using Artemis.UI.Shared.Services.Builders;
using Avalonia.Controls;
using Avalonia.Threading;
using Material.Icons;
using PropertyChanged.SourceGenerator;
using ReactiveUI;
namespace Artemis.UI.Screens.StartupWizard;
public partial class WizardPluginFeatureViewModel : ActivatableViewModelBase
{
private readonly ICoreService _coreService;
private readonly IPluginManagementService _pluginManagementService;
private readonly IWindowService _windowService;
private Window? _settingsWindow;
[Notify] private bool _canInstallPrerequisites;
[Notify] private bool _canRemovePrerequisites;
[Notify] private bool _enabling;
public WizardPluginFeatureViewModel(PluginFeatureInfo pluginFeature, ICoreService coreService, IWindowService windowService, IPluginManagementService pluginManagementService)
{
PluginFeature = pluginFeature;
Plugin = pluginFeature.Plugin;
_coreService = coreService;
_windowService = windowService;
_pluginManagementService = pluginManagementService;
Platforms = new ObservableCollection<PluginPlatformViewModel>();
if (Plugin.Info.Platforms != null)
{
if (Plugin.Info.Platforms.Value.HasFlag(PluginPlatform.Windows))
Platforms.Add(new PluginPlatformViewModel("Windows", MaterialIconKind.MicrosoftWindows));
if (Plugin.Info.Platforms.Value.HasFlag(PluginPlatform.Linux))
Platforms.Add(new PluginPlatformViewModel("Linux", MaterialIconKind.Linux));
if (Plugin.Info.Platforms.Value.HasFlag(PluginPlatform.OSX))
Platforms.Add(new PluginPlatformViewModel("OSX", MaterialIconKind.Apple));
}
OpenSettings = ReactiveCommand.Create(ExecuteOpenSettings, this.WhenAnyValue(vm => vm.IsEnabled, e => e && Plugin.ConfigurationDialog != null));
this.WhenActivated(d =>
{
pluginManagementService.PluginFeatureEnabled += PluginManagementServiceOnPluginFeatureChanged;
pluginManagementService.PluginFeatureDisabled += PluginManagementServiceOnPluginFeatureChanged;
Disposable.Create(() =>
{
pluginManagementService.PluginFeatureEnabled -= PluginManagementServiceOnPluginFeatureChanged;
pluginManagementService.PluginFeatureDisabled -= PluginManagementServiceOnPluginFeatureChanged;
_settingsWindow?.Close();
}).DisposeWith(d);
});
}
public ReactiveCommand<Unit, Unit> OpenSettings { get; }
public ObservableCollection<PluginPlatformViewModel> Platforms { get; }
public Plugin Plugin { get; }
public PluginFeatureInfo PluginFeature { get; }
public bool IsEnabled => PluginFeature.Instance != null && PluginFeature.Instance.IsEnabled;
public async Task UpdateEnabled(bool enable)
{
if (Enabling)
return;
if (!enable)
{
try
{
if (PluginFeature.AlwaysEnabled)
await Task.Run(() => _pluginManagementService.DisablePlugin(Plugin, true));
else if (PluginFeature.Instance != null)
await Task.Run(() => _pluginManagementService.DisablePluginFeature(PluginFeature.Instance, true));
}
catch (Exception e)
{
await ShowUpdateEnableFailure(enable, e);
}
finally
{
this.RaisePropertyChanged(nameof(IsEnabled));
}
return;
}
try
{
Enabling = true;
if (Plugin.Info.RequiresAdmin && !_coreService.IsElevated)
{
bool confirmed = await _windowService.ShowConfirmContentDialog("Enable feature", "This feature requires admin rights, are you sure you want to enable it?");
if (!confirmed)
return;
}
// Check if all prerequisites are met async
List<IPrerequisitesSubject> subjects = new() {Plugin.Info};
subjects.AddRange(Plugin.Features.Where(f => f.AlwaysEnabled || f.EnabledInStorage));
if (subjects.Any(s => !s.ArePrerequisitesMet()))
{
await PluginPrerequisitesInstallDialogViewModel.Show(_windowService, subjects);
if (!subjects.All(s => s.ArePrerequisitesMet()))
return;
}
await Task.Run(() =>
{
if (!Plugin.IsEnabled)
_pluginManagementService.EnablePlugin(Plugin, true, true);
if (PluginFeature.Instance != null && !PluginFeature.Instance.IsEnabled)
_pluginManagementService.EnablePluginFeature(PluginFeature.Instance, true);
});
}
catch (Exception e)
{
await ShowUpdateEnableFailure(enable, e);
}
finally
{
Enabling = false;
this.RaisePropertyChanged(nameof(IsEnabled));
}
}
private void ExecuteOpenSettings()
{
if (Plugin.ConfigurationDialog == null)
return;
if (_settingsWindow != null)
{
_settingsWindow.WindowState = WindowState.Normal;
_settingsWindow.Activate();
return;
}
try
{
if (Plugin.Resolve(Plugin.ConfigurationDialog.Type) is not PluginConfigurationViewModel viewModel)
throw new ArtemisUIException($"The type of a plugin configuration dialog must inherit {nameof(PluginConfigurationViewModel)}");
_settingsWindow = _windowService.ShowWindow(new PluginSettingsWindowViewModel(viewModel));
_settingsWindow.Closed += (_, _) => _settingsWindow = null;
}
catch (Exception e)
{
_windowService.ShowExceptionDialog("An exception occured while trying to show the plugin's settings window", e);
throw;
}
}
private async Task ShowUpdateEnableFailure(bool enable, Exception e)
{
string action = enable ? "enable" : "disable";
ContentDialogBuilder builder = _windowService.CreateContentDialog()
.WithTitle($"Failed to {action} plugin {Plugin.Info.Name}")
.WithContent(e.Message)
.HavingPrimaryButton(b => b.WithText("View logs").WithAction(ShowLogsFolder));
// If available, add a secondary button pointing to the support page
if (Plugin.Info.HelpPage != null)
builder = builder.HavingSecondaryButton(b => b.WithText("Open support page").WithAction(() => Utilities.OpenUrl(Plugin.Info.HelpPage.ToString())));
await builder.ShowAsync();
}
private void ShowLogsFolder()
{
try
{
Utilities.OpenFolder(Constants.LogsFolder);
}
catch (Exception e)
{
_windowService.ShowExceptionDialog("Welp, we couldn\'t open the logs folder for you", e);
}
}
private void PluginManagementServiceOnPluginFeatureChanged(object? sender, PluginFeatureEventArgs e)
{
if (e.PluginFeature.Info != PluginFeature)
return;
Dispatcher.UIThread.Post(() =>
{
this.RaisePropertyChanged(nameof(IsEnabled));
if (!IsEnabled)
_settingsWindow?.Close();
});
}
}

View File

@ -15,24 +15,29 @@
</UserControl.Resources>
<Panel>
<StackPanel IsVisible="{CompiledBinding Entry, Converter={x:Static ObjectConverters.IsNull}}">
<Border Classes="skeleton-text" Margin="0 0 10 0" Width="80" Height="80"></Border>
<Border Classes="skeleton-text title" HorizontalAlignment="Stretch"/>
<Border Classes="skeleton-text" Width="120"/>
<Border Classes="skeleton-text" Width="140" Margin="0 8"/>
<Border Classes="skeleton-text" Width="80"/>
<Border Classes="skeleton-text"
HorizontalAlignment="Center"
Margin="30 30 30 0"
Width="80"
Height="80">
</Border>
<Border Classes="skeleton-text title" HorizontalAlignment="Stretch" Margin="0 20" />
<Border Classes="skeleton-text" Width="120" />
<Border Classes="skeleton-text" Width="140" Margin="0 8" />
<Border Classes="skeleton-text" Width="80" />
<Border Classes="card-separator" Margin="0 15 0 17"></Border>
<Border Classes="skeleton-text" Width="120"/>
<Border Classes="skeleton-text" Width="120" />
<StackPanel Margin="0 10 0 0">
<Border Classes="skeleton-text" Width="160"/>
<Border Classes="skeleton-text" Width="160"/>
<Border Classes="skeleton-text" Width="160" />
<Border Classes="skeleton-text" Width="160" />
</StackPanel>
<Border Classes="skeleton-button"></Border>
</StackPanel>
<StackPanel IsVisible="{CompiledBinding Entry, Converter={x:Static ObjectConverters.IsNotNull}}">
<Panel>
<Border CornerRadius="6"
HorizontalAlignment="Left"
Margin="0 0 10 0"
HorizontalAlignment="Center"
Margin="30 30 30 0"
Width="80"
Height="80"
ClipToBounds="True">
@ -46,11 +51,13 @@
<avalonia:MaterialIcon Kind="ShareVariant" />
</Button>
</Panel>
<TextBlock Theme="{StaticResource TitleTextBlockStyle}"
MaxLines="3"
TextTrimming="CharacterEllipsis"
Text="{CompiledBinding Entry.Name, FallbackValue=Title}"/>
TextAlignment="Center"
Text="{CompiledBinding Entry.Name, FallbackValue=Title}"
Margin="0 15" />
<TextBlock Classes="subtitle" TextTrimming="CharacterEllipsis" Text="{CompiledBinding Entry.Author, FallbackValue=Author}" />

View File

@ -0,0 +1,44 @@
<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.EntryReleases.Dialogs"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Workshop.EntryReleases.Dialogs.DependenciesDialogView"
x:DataType="dialogs:DependenciesDialogViewModel">
<StackPanel>
<TextBlock IsVisible="{CompiledBinding Multiple}">
<Run Text="Some or all of the required" />
<Run Text="{CompiledBinding EntryTypePlural}" />
<Run Text="are not installed. This" />
<Run Text="{CompiledBinding DependantType}" />
<Run Text="will not work properly without them. The missing" />
<Run Text="{CompiledBinding EntryTypePlural}" />
<Run Text="are listed below and you can click on them to view them" />
</TextBlock>
<TextBlock IsVisible="{CompiledBinding !Multiple}">
<Run Text="A required" />
<Run Text="{CompiledBinding EntryType}" />
<Run Text="is not installed. This" />
<Run Text="{CompiledBinding DependantType}" />
<Run Text="will not work properly without it. The missing" />
<Run Text="{CompiledBinding EntryType}" />
<Run Text="is listed below and you can click on it to view it" />
</TextBlock>
<ScrollViewer MaxHeight="500" Margin="0 30 0 0">
<ItemsControl ItemsSource="{CompiledBinding Dependencies}" >
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<ContentControl Content="{CompiledBinding}" Margin="0 0 0 5"></ContentControl>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</StackPanel>
</UserControl>

View File

@ -0,0 +1,14 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
namespace Artemis.UI.Screens.Workshop.EntryReleases.Dialogs;
public partial class DependenciesDialogView : ReactiveUserControl<DependenciesDialogViewModel>
{
public DependenciesDialogView()
{
InitializeComponent();
}
}

View File

@ -0,0 +1,36 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using Artemis.UI.Screens.Workshop.Entries.List;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Routing;
using Artemis.WebClient.Workshop;
using Humanizer;
using ReactiveUI;
namespace Artemis.UI.Screens.Workshop.EntryReleases.Dialogs;
public class DependenciesDialogViewModel : ContentDialogViewModelBase
{
public DependenciesDialogViewModel(IEntrySummary dependant, List<IEntrySummary> dependencies, Func<IEntrySummary, EntryListItemViewModel> getEntryListItemViewModel, IRouter router)
{
Dependant = dependant;
DependantType = dependant.EntryType.Humanize(LetterCasing.LowerCase);
EntryType = dependencies.First().EntryType.Humanize(LetterCasing.LowerCase);
EntryTypePlural = dependencies.First().EntryType.Humanize(LetterCasing.LowerCase).Pluralize();
Dependencies = new ObservableCollection<EntryListItemViewModel>(dependencies.Select(getEntryListItemViewModel));
this.WhenActivated(d => router.CurrentPath.Skip(1).Subscribe(s => ContentDialog?.Hide()).DisposeWith(d));
}
public string DependantType { get; }
public string EntryType { get; }
public string EntryTypePlural { get; }
public bool Multiple => Dependencies.Count > 1;
public IEntrySummary Dependant { get; }
public ObservableCollection<EntryListItemViewModel> Dependencies { get; }
}

View File

@ -0,0 +1,111 @@
<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:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:entryReleases="clr-namespace:Artemis.UI.Screens.Workshop.EntryReleases"
xmlns:converters="clr-namespace:Artemis.UI.Converters"
xmlns:converters1="clr-namespace:Artemis.UI.Shared.Converters;assembly=Artemis.UI.Shared"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Workshop.EntryReleases.EntryReleaseInfoView"
x:DataType="entryReleases:EntryReleaseInfoViewModel">
<UserControl.Resources>
<converters:DateTimeConverter x:Key="DateTimeConverter" />
<converters1:BytesToStringConverter x:Key="BytesToStringConverter" />
</UserControl.Resources>
<UserControl.Styles>
<Style Selector="Grid.info-container">
<Setter Property="Margin" Value="10" />
</Style>
<Style Selector="avalonia|MaterialIcon.info-icon">
<Setter Property="VerticalAlignment" Value="Top" />
<Setter Property="Margin" Value="0 3 10 0" />
</Style>
<Style Selector="TextBlock.info-title">
<Setter Property="Margin" Value="0 0 0 5" />
<Setter Property="Opacity" Value="0.8" />
</Style>
<Style Selector="TextBlock.info-body">
</Style>
<Style Selector="TextBlock.info-link">
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Foreground" Value="{DynamicResource SystemAccentColorLight3}" />
</Style>
<Style Selector="TextBlock.info-link:pointerover">
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Foreground" Value="{DynamicResource SystemAccentColorLight1}" />
</Style>
</UserControl.Styles>
<StackPanel>
<Grid ColumnDefinitions="Auto,*,Auto">
<Button Grid.Row="0" Grid.Column="0" VerticalAlignment="Center" Classes="icon-button" Margin="0 0 5 0" Command="{CompiledBinding Close}" IsVisible="{CompiledBinding InDetailsScreen}">
<avalonia:MaterialIcon Kind="ArrowBack" />
</Button>
<TextBlock Grid.Row="0" Grid.Column="1" Theme="{StaticResource SubtitleTextBlockStyle}" IsVisible="{CompiledBinding InDetailsScreen}">Release info</TextBlock>
<TextBlock Grid.Row="0" Grid.Column="1" Theme="{StaticResource SubtitleTextBlockStyle}" IsVisible="{CompiledBinding !InDetailsScreen}">Latest release</TextBlock>
<StackPanel Grid.Column="2">
<!-- Install progress -->
<StackPanel Orientation="Horizontal"
Spacing="5"
IsVisible="{CompiledBinding InstallationInProgress}">
<ProgressBar VerticalAlignment="Center"
Width="300"
Value="{CompiledBinding InstallProgress, FallbackValue=0}">
</ProgressBar>
<Button
Classes="accent"
Margin="15 0 0 0"
Width="80"
VerticalAlignment="Center"
Command="{CompiledBinding Cancel}">
Cancel
</Button>
</StackPanel>
<!-- Install button -->
<Panel IsVisible="{CompiledBinding !InstallationInProgress}" HorizontalAlignment="Right">
<Button IsVisible="{CompiledBinding !IsCurrentVersion}" Classes="accent" Width="80" Command="{CompiledBinding Install}">
Install
</Button>
<StackPanel IsVisible="{CompiledBinding IsCurrentVersion}" Orientation="Horizontal" Spacing="10">
<Button Classes="accent" Width="80" Command="{CompiledBinding Reinstall}">
Re-install
</Button>
<Button Width="80" Command="{CompiledBinding Uninstall}">Uninstall</Button>
</StackPanel>
</Panel>
</StackPanel>
</Grid>
<Border Classes="card-separator" />
<Grid Margin="-5 -10" ColumnDefinitions="*,*,*">
<Grid Grid.Column="0" ColumnDefinitions="*,*" RowDefinitions="*,*" Classes="info-container" HorizontalAlignment="Left">
<avalonia:MaterialIcon Kind="TickNetworkOutline" Grid.Column="0" Grid.RowSpan="2" Classes="info-icon" />
<TextBlock Grid.Column="1" Grid.Row="0" Classes="info-title">Version</TextBlock>
<TextBlock Grid.Column="1"
Grid.Row="1"
Classes="info-body"
Cursor="Hand"
Text="{CompiledBinding Release.Version}" />
</Grid>
<Grid Grid.Column="1" ColumnDefinitions="*,*" RowDefinitions="*,*,*" Classes="info-container" HorizontalAlignment="Center">
<avalonia:MaterialIcon Kind="Calendar" Grid.Column="0" Grid.RowSpan="2" Classes="info-icon" />
<TextBlock Grid.Column="1" Grid.Row="0" Classes="info-title">Release date</TextBlock>
<TextBlock Grid.Column="1"
Grid.Row="1"
Classes="info-body"
Text="{CompiledBinding Release.CreatedAt, Converter={StaticResource DateTimeConverter}}" />
</Grid>
<Grid Grid.Column="2" ColumnDefinitions="*,*" RowDefinitions="*,*" Classes="info-container" HorizontalAlignment="Right">
<avalonia:MaterialIcon Kind="File" Grid.Column="0" Grid.RowSpan="2" Classes="info-icon" />
<TextBlock Grid.Column="1" Grid.Row="0" Classes="info-title">File size</TextBlock>
<TextBlock Grid.Column="1"
Grid.Row="1"
Classes="info-body"
Text="{CompiledBinding Release.DownloadSize, Converter={StaticResource BytesToStringConverter}, Mode=OneWay}" />
</Grid>
</Grid>
</StackPanel>
</UserControl>

View File

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

View File

@ -0,0 +1,184 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reactive.Disposables;
using System.Threading;
using System.Threading.Tasks;
using Artemis.Core;
using Artemis.Core.Services;
using Artemis.UI.DryIoc.Factories;
using Artemis.UI.Screens.Plugins;
using Artemis.UI.Screens.Workshop.EntryReleases.Dialogs;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Routing;
using Artemis.UI.Shared.Services;
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.Models;
using Artemis.WebClient.Workshop.Services;
using PropertyChanged.SourceGenerator;
using ReactiveUI;
namespace Artemis.UI.Screens.Workshop.EntryReleases;
public partial class EntryReleaseInfoViewModel : ActivatableViewModelBase
{
private readonly IRouter _router;
private readonly INotificationService _notificationService;
private readonly IWindowService _windowService;
private readonly IWorkshopService _workshopService;
private readonly IPluginManagementService _pluginManagementService;
private readonly ISettingsVmFactory _settingsVmFactory;
private readonly Progress<StreamProgress> _progress = new();
[Notify] private IReleaseDetails? _release;
[Notify] private float _installProgress;
[Notify] private bool _isCurrentVersion;
[Notify] private bool _installationInProgress;
[Notify] private bool _inDetailsScreen;
private CancellationTokenSource? _cts;
public EntryReleaseInfoViewModel(IRouter router,
INotificationService notificationService,
IWindowService windowService,
IWorkshopService workshopService,
IPluginManagementService pluginManagementService,
ISettingsVmFactory settingsVmFactory)
{
_router = router;
_notificationService = notificationService;
_windowService = windowService;
_workshopService = workshopService;
_pluginManagementService = pluginManagementService;
_settingsVmFactory = settingsVmFactory;
_progress.ProgressChanged += (_, f) => InstallProgress = f.ProgressPercentage;
this.WhenActivated(d =>
{
_workshopService.OnEntryInstalled += WorkshopServiceOnOnEntryInstalled;
_workshopService.OnEntryUninstalled += WorkshopServiceOnOnEntryInstalled;
Disposable.Create(() =>
{
_workshopService.OnEntryInstalled -= WorkshopServiceOnOnEntryInstalled;
_workshopService.OnEntryUninstalled -= WorkshopServiceOnOnEntryInstalled;
}).DisposeWith(d);
IsCurrentVersion = Release != null && _workshopService.GetInstalledEntry(Release.Entry.Id)?.ReleaseId == Release.Id;
});
this.WhenAnyValue(vm => vm.Release).Subscribe(r => IsCurrentVersion = r != null && _workshopService.GetInstalledEntry(r.Entry.Id)?.ReleaseId == r.Id);
InDetailsScreen = true;
}
private void WorkshopServiceOnOnEntryInstalled(object? sender, InstalledEntry e)
{
IsCurrentVersion = Release != null && _workshopService.GetInstalledEntry(Release.Entry.Id)?.ReleaseId == Release.Id;
}
public async Task Close()
{
await _router.GoUp();
}
public async Task Install()
{
if (Release == null)
return;
// If the entry has missing dependencies, show a dialog
List<IEntrySummary> missing = Release.Dependencies.Where(d => _workshopService.GetInstalledEntry(d.Id) == null).Cast<IEntrySummary>().ToList();
if (missing.Count > 0)
{
await _windowService.CreateContentDialog()
.WithTitle("Requirements missing")
.WithViewModel(out DependenciesDialogViewModel _, Release.Entry, missing)
.WithCloseButtonText("Cancel installation")
.ShowAsync();
return;
}
_cts = new CancellationTokenSource();
InstallProgress = 0;
InstallationInProgress = true;
try
{
EntryInstallResult result = await _workshopService.InstallEntry(Release.Entry, Release, _progress, _cts.Token);
if (result.IsSuccess)
{
_notificationService.CreateNotification().WithTitle("Installation succeeded").WithSeverity(NotificationSeverity.Success).Show();
InstallationInProgress = false;
await Manage();
}
else if (!_cts.IsCancellationRequested)
_notificationService.CreateNotification().WithTitle("Installation failed").WithMessage(result.Message).WithSeverity(NotificationSeverity.Error).Show();
}
catch (Exception e)
{
_windowService.ShowExceptionDialog("Failed to install workshop entry", e);
}
finally
{
InstallationInProgress = false;
}
}
public async Task Manage()
{
if (Release?.Entry.EntryType != EntryType.Profile)
await _router.Navigate("../../manage", new RouterNavigationOptions {AdditionalArguments = true});
}
public async Task Reinstall()
{
if (await _windowService.ShowConfirmContentDialog("Reinstall entry", "Are you sure you want to reinstall this entry?"))
await Install();
}
public async Task Uninstall()
{
InstalledEntry? installedEntry = _workshopService.GetInstalledEntry(Release!.Entry.Id);
if (installedEntry == null)
return;
InstallationInProgress = true;
try
{
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(installedEntry);
await _workshopService.UninstallEntry(installedEntry, CancellationToken.None);
_notificationService.CreateNotification().WithTitle("Entry uninstalled").WithSeverity(NotificationSeverity.Success).Show();
}
finally
{
InstallationInProgress = false;
}
}
public void Cancel()
{
_cts?.Cancel();
}
private async Task UninstallPluginPrerequisites(InstalledEntry installedEntry)
{
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

@ -1,6 +1,4 @@
using System;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using System.Reactive.Disposables;
using Artemis.UI.Shared;
using Artemis.WebClient.Workshop;
using Artemis.WebClient.Workshop.Models;
@ -22,19 +20,24 @@ public partial class EntryReleaseItemViewModel : ActivatableViewModelBase
_entry = entry;
Release = release;
UpdateIsCurrentVersion();
this.WhenActivated(d =>
{
Observable.FromEventPattern<InstalledEntry>(x => _workshopService.OnInstalledEntrySaved += x, x => _workshopService.OnInstalledEntrySaved -= x)
.Subscribe(_ => UpdateIsCurrentVersion())
.DisposeWith(d);
_workshopService.OnEntryInstalled += WorkshopServiceOnOnEntryInstalled;
_workshopService.OnEntryUninstalled += WorkshopServiceOnOnEntryInstalled;
Disposable.Create(() =>
{
_workshopService.OnEntryInstalled -= WorkshopServiceOnOnEntryInstalled;
_workshopService.OnEntryUninstalled -= WorkshopServiceOnOnEntryInstalled;
}).DisposeWith(d);
IsCurrentVersion = _workshopService.GetInstalledEntry(_entry.Id)?.ReleaseId == Release.Id;
});
}
public IRelease Release { get; }
private void UpdateIsCurrentVersion()
private void WorkshopServiceOnOnEntryInstalled(object? sender, InstalledEntry e)
{
IsCurrentVersion = _workshopService.GetInstalledEntry(_entry.Id)?.ReleaseId == Release.Id;
}

View File

@ -4,110 +4,12 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia.Tight"
xmlns:entryReleases="clr-namespace:Artemis.UI.Screens.Workshop.EntryReleases"
xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:converters="clr-namespace:Artemis.UI.Converters"
xmlns:sharedConverters="clr-namespace:Artemis.UI.Shared.Converters;assembly=Artemis.UI.Shared"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Workshop.EntryReleases.EntryReleaseView"
x:DataType="entryReleases:EntryReleaseViewModel">
<UserControl.Resources>
<converters:DateTimeConverter x:Key="DateTimeConverter" />
<sharedConverters:BytesToStringConverter x:Key="BytesToStringConverter" />
</UserControl.Resources>
<UserControl.Styles>
<Style Selector="Grid.info-container">
<Setter Property="Margin" Value="10" />
</Style>
<Style Selector="avalonia|MaterialIcon.info-icon">
<Setter Property="VerticalAlignment" Value="Top" />
<Setter Property="Margin" Value="0 3 10 0" />
</Style>
<Style Selector="TextBlock.info-title">
<Setter Property="Margin" Value="0 0 0 5" />
<Setter Property="Opacity" Value="0.8" />
</Style>
<Style Selector="TextBlock.info-body">
</Style>
<Style Selector="TextBlock.info-link">
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Foreground" Value="{DynamicResource SystemAccentColorLight3}" />
</Style>
<Style Selector="TextBlock.info-link:pointerover">
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Foreground" Value="{DynamicResource SystemAccentColorLight1}" />
</Style>
</UserControl.Styles>
<Grid RowDefinitions="Auto,Auto">
<Border Grid.Row="0" Classes="card" Margin="0 0 0 10">
<StackPanel>
<Grid ColumnDefinitions="Auto,*,Auto">
<Button Grid.Row="0" Grid.Column="0" VerticalAlignment="Center" Classes="icon-button" Margin="0 0 5 0" Command="{CompiledBinding Close}">
<avalonia:MaterialIcon Kind="ArrowBack" />
</Button>
<TextBlock Grid.Row="0" Grid.Column="1" Theme="{StaticResource SubtitleTextBlockStyle}">Release info</TextBlock>
<StackPanel Grid.Column="2">
<!-- Install progress -->
<StackPanel Orientation="Horizontal"
Spacing="5"
IsVisible="{CompiledBinding InstallationInProgress}">
<ProgressBar VerticalAlignment="Center"
Width="300"
Value="{CompiledBinding InstallProgress, FallbackValue=0}">
</ProgressBar>
<Button
Classes="accent"
Margin="15 0 0 0"
Width="80"
VerticalAlignment="Center"
Command="{CompiledBinding Cancel}">
Cancel
</Button>
</StackPanel>
<!-- Install button -->
<Panel IsVisible="{CompiledBinding !InstallationInProgress}" HorizontalAlignment="Right">
<Button IsVisible="{CompiledBinding !IsCurrentVersion}" Classes="accent" Width="80" Command="{CompiledBinding Install}">
Install
</Button>
<Button IsVisible="{CompiledBinding IsCurrentVersion}" Classes="accent" Width="80" Command="{CompiledBinding Reinstall}">
Re-install
</Button>
</Panel>
</StackPanel>
</Grid>
<Border Classes="card-separator" />
<Grid Margin="-5 -10" ColumnDefinitions="*,*,*">
<Grid Grid.Column="0" ColumnDefinitions="*,*" RowDefinitions="*,*" Classes="info-container" HorizontalAlignment="Left">
<avalonia:MaterialIcon Kind="TickNetworkOutline" Grid.Column="0" Grid.RowSpan="2" Classes="info-icon" />
<TextBlock Grid.Column="1" Grid.Row="0" Classes="info-title">Version</TextBlock>
<TextBlock Grid.Column="1"
Grid.Row="1"
Classes="info-body"
Cursor="Hand"
Text="{CompiledBinding Release.Version}" />
</Grid>
<Grid Grid.Column="1" ColumnDefinitions="*,*" RowDefinitions="*,*,*" Classes="info-container" HorizontalAlignment="Center">
<avalonia:MaterialIcon Kind="Calendar" Grid.Column="0" Grid.RowSpan="2" Classes="info-icon" />
<TextBlock Grid.Column="1" Grid.Row="0" Classes="info-title">Release date</TextBlock>
<TextBlock Grid.Column="1"
Grid.Row="1"
Classes="info-body"
Text="{CompiledBinding Release.CreatedAt, Converter={StaticResource DateTimeConverter}}" />
</Grid>
<Grid Grid.Column="2" ColumnDefinitions="*,*" RowDefinitions="*,*" Classes="info-container" HorizontalAlignment="Right">
<avalonia:MaterialIcon Kind="File" Grid.Column="0" Grid.RowSpan="2" Classes="info-icon" />
<TextBlock Grid.Column="1" Grid.Row="0" Classes="info-title">File size</TextBlock>
<TextBlock Grid.Column="1"
Grid.Row="1"
Classes="info-body"
Text="{CompiledBinding Release.DownloadSize, Converter={StaticResource BytesToStringConverter}, Mode=OneWay}" />
</Grid>
</Grid>
</StackPanel>
<ContentControl Content="{CompiledBinding EntryReleaseInfoViewModel}"/>
</Border>
<Border Grid.Row="1" Classes="card">

View File

@ -1,14 +1,9 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Artemis.UI.Screens.Workshop.Parameters;
using Artemis.UI.Shared.Routing;
using Artemis.UI.Shared.Services;
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 PropertyChanged.SourceGenerator;
using StrawberryShake;
@ -17,89 +12,27 @@ namespace Artemis.UI.Screens.Workshop.EntryReleases;
public partial class EntryReleaseViewModel : RoutableScreen<ReleaseDetailParameters>
{
private readonly IWorkshopClient _client;
private readonly IRouter _router;
private readonly INotificationService _notificationService;
private readonly IWindowService _windowService;
private readonly IWorkshopService _workshopService;
private readonly EntryInstallationHandlerFactory _factory;
private readonly Progress<StreamProgress> _progress = new();
[Notify] private IGetReleaseById_Release? _release;
[Notify] private float _installProgress;
[Notify] private bool _installationInProgress;
[Notify] private bool _isCurrentVersion;
private CancellationTokenSource? _cts;
public EntryReleaseViewModel(IWorkshopClient client, IRouter router, INotificationService notificationService, IWindowService windowService, IWorkshopService workshopService,
EntryInstallationHandlerFactory factory)
public EntryReleaseViewModel(IWorkshopClient client, INotificationService notificationService, EntryReleaseInfoViewModel entryReleaseInfoViewModel)
{
EntryReleaseInfoViewModel = entryReleaseInfoViewModel;
_client = client;
_router = router;
_notificationService = notificationService;
_windowService = windowService;
_workshopService = workshopService;
_factory = factory;
_progress.ProgressChanged += (_, f) => InstallProgress = f.ProgressPercentage;
}
public async Task Close()
{
await _router.GoUp();
}
public async Task Install()
{
if (Release == null)
return;
_cts = new CancellationTokenSource();
InstallProgress = 0;
InstallationInProgress = true;
try
{
IEntryInstallationHandler handler = _factory.CreateHandler(Release.Entry.EntryType);
EntryInstallResult result = await handler.InstallAsync(Release.Entry, Release, _progress, _cts.Token);
if (result.IsSuccess)
{
_notificationService.CreateNotification().WithTitle("Installation succeeded").WithSeverity(NotificationSeverity.Success).Show();
IsCurrentVersion = true;
InstallationInProgress = false;
await Manage();
}
else if (!_cts.IsCancellationRequested)
_notificationService.CreateNotification().WithTitle("Installation failed").WithMessage(result.Message).WithSeverity(NotificationSeverity.Error).Show();
}
catch (Exception e)
{
InstallationInProgress = false;
_windowService.ShowExceptionDialog("Failed to install workshop entry", e);
}
}
public async Task Manage()
{
if (Release?.Entry.EntryType != EntryType.Profile)
await _router.Navigate("../../manage");
}
public async Task Reinstall()
{
if (await _windowService.ShowConfirmContentDialog("Reinstall entry", "Are you sure you want to reinstall this entry?"))
await Install();
}
public void Cancel()
{
_cts?.Cancel();
}
public EntryReleaseInfoViewModel EntryReleaseInfoViewModel { get; }
/// <inheritdoc />
public override async Task OnNavigating(ReleaseDetailParameters parameters, NavigationArguments args, CancellationToken cancellationToken)
{
IOperationResult<IGetReleaseByIdResult> result = await _client.GetReleaseById.ExecuteAsync(parameters.ReleaseId, cancellationToken);
Release = result.Data?.Release;
IsCurrentVersion = Release != null && _workshopService.GetInstalledEntry(Release.Entry.Id)?.ReleaseId == Release.Id;
EntryReleaseInfoViewModel.Release = Release;
}
#region Overrides of RoutableScreen
@ -107,7 +40,7 @@ public partial class EntryReleaseViewModel : RoutableScreen<ReleaseDetailParamet
/// <inheritdoc />
public override Task OnClosing(NavigationArguments args)
{
if (!InstallationInProgress)
if (!EntryReleaseInfoViewModel.InstallationInProgress)
return Task.CompletedTask;
args.Cancel();

View File

@ -8,6 +8,10 @@
x:Class="Artemis.UI.Screens.Workshop.Layout.LayoutDescriptionView"
x:DataType="layout:LayoutDescriptionViewModel">
<StackPanel Spacing="10">
<Border Classes="card">
<ContentControl Content="{CompiledBinding ReleaseInfoViewModel}"/>
</Border>
<Border Classes="card">
<mdxaml:MarkdownScrollViewer Markdown="{CompiledBinding Entry.Description}" MarkdownStyleName="FluentAvalonia">
<mdxaml:MarkdownScrollViewer.Styles>

View File

@ -1,3 +1,4 @@
using Artemis.UI.Screens.Workshop.EntryReleases;
using Artemis.UI.Shared.Routing;
using Artemis.WebClient.Workshop;
using PropertyChanged.SourceGenerator;
@ -7,4 +8,12 @@ namespace Artemis.UI.Screens.Workshop.Layout;
public partial class LayoutDescriptionViewModel : RoutableScreen
{
[Notify] private IEntryDetails? _entry;
public LayoutDescriptionViewModel(EntryReleaseInfoViewModel releaseInfoViewModel)
{
ReleaseInfoViewModel = releaseInfoViewModel;
ReleaseInfoViewModel.InDetailsScreen = false;
}
public EntryReleaseInfoViewModel ReleaseInfoViewModel { get; }
}

View File

@ -1,4 +1,5 @@
using System;
using System.Reactive.Linq;
using System.Threading;
using System.Threading.Tasks;
using Artemis.UI.Screens.Workshop.Entries.Details;
@ -7,6 +8,7 @@ using Artemis.UI.Screens.Workshop.Parameters;
using Artemis.UI.Shared.Routing;
using Artemis.WebClient.Workshop;
using PropertyChanged.SourceGenerator;
using ReactiveUI;
using StrawberryShake;
namespace Artemis.UI.Screens.Workshop.Layout;
@ -31,7 +33,7 @@ public partial class LayoutDetailsViewModel : RoutableHostScreen<RoutableScreen,
_layoutDescriptionViewModel = layoutDescriptionViewModel;
_getEntryReleasesViewModel = getEntryReleasesViewModel;
_getEntryImagesViewModel = getEntryImagesViewModel;
RecycleScreen = false;
EntryInfoViewModel = entryInfoViewModel;
}
@ -60,5 +62,6 @@ public partial class LayoutDetailsViewModel : RoutableHostScreen<RoutableScreen,
EntryReleasesViewModel = Entry != null ? _getEntryReleasesViewModel(Entry) : null;
EntryImagesViewModel = Entry != null ? _getEntryImagesViewModel(Entry) : null;
_layoutDescriptionViewModel.Entry = Entry;
_layoutDescriptionViewModel.ReleaseInfoViewModel.Release = result.Data?.Entry?.LatestRelease;
}
}

View File

@ -11,7 +11,6 @@ using Artemis.WebClient.Workshop.Models;
using Artemis.WebClient.Workshop.Providers;
using Artemis.WebClient.Workshop.Services;
using Material.Icons;
using Material.Icons.Avalonia;
using PropertyChanged.SourceGenerator;
using StrawberryShake;
@ -23,7 +22,6 @@ public partial class LayoutFinderDeviceViewModel : ViewModelBase
private readonly IDeviceService _deviceService;
private readonly IWorkshopService _workshopService;
private readonly WorkshopLayoutProvider _layoutProvider;
private readonly EntryInstallationHandlerFactory _factory;
[Notify] private bool _searching;
[Notify] private bool _hasLayout;
@ -33,18 +31,12 @@ public partial class LayoutFinderDeviceViewModel : ViewModelBase
[Notify] private string? _logicalLayout;
[Notify] private string? _physicalLayout;
public LayoutFinderDeviceViewModel(ArtemisDevice device,
IWorkshopClient client,
IDeviceService deviceService,
IWorkshopService workshopService,
WorkshopLayoutProvider layoutProvider,
EntryInstallationHandlerFactory factory)
public LayoutFinderDeviceViewModel(ArtemisDevice device, IWorkshopClient client, IDeviceService deviceService, IWorkshopService workshopService, WorkshopLayoutProvider layoutProvider)
{
_client = client;
_deviceService = deviceService;
_workshopService = workshopService;
_layoutProvider = layoutProvider;
_factory = factory;
Device = device;
DeviceIcon = DetermineDeviceIcon();
@ -116,8 +108,7 @@ public partial class LayoutFinderDeviceViewModel : ViewModelBase
InstalledEntry? installedEntry = _workshopService.GetInstalledEntry(entry.Id);
if (installedEntry == null)
{
IEntryInstallationHandler installationHandler = _factory.CreateHandler(EntryType.Layout);
EntryInstallResult result = await installationHandler.InstallAsync(entry, release, new Progress<StreamProgress>(), CancellationToken.None);
EntryInstallResult result = await _workshopService.InstallEntry(entry, release, new Progress<StreamProgress>(), CancellationToken.None);
installedEntry = result.Entry;
}

View File

@ -11,7 +11,6 @@ using Artemis.UI.Shared;
using Artemis.UI.Shared.Routing;
using Artemis.UI.Shared.Services;
using Artemis.WebClient.Workshop;
using Artemis.WebClient.Workshop.Handlers.InstallationHandlers;
using Artemis.WebClient.Workshop.Models;
using Artemis.WebClient.Workshop.Services;
using PropertyChanged.SourceGenerator;
@ -23,23 +22,19 @@ public partial class InstalledTabItemViewModel : ViewModelBase
{
private readonly IWorkshopService _workshopService;
private readonly IRouter _router;
private readonly EntryInstallationHandlerFactory _factory;
private readonly IWindowService _windowService;
private readonly IPluginManagementService _pluginManagementService;
private readonly ISettingsVmFactory _settingsVmFactory;
[Notify(Setter.Private)] private bool _isRemoved;
public InstalledTabItemViewModel(InstalledEntry installedEntry,
IWorkshopService workshopService,
IRouter router,
EntryInstallationHandlerFactory factory,
IWindowService windowService,
IPluginManagementService pluginManagementService,
ISettingsVmFactory settingsVmFactory)
{
_workshopService = workshopService;
_router = router;
_factory = factory;
_windowService = windowService;
_pluginManagementService = pluginManagementService;
_settingsVmFactory = settingsVmFactory;
@ -78,9 +73,7 @@ public partial class InstalledTabItemViewModel : ViewModelBase
if (InstalledEntry.EntryType == EntryType.Plugin)
await UninstallPluginPrerequisites();
IEntryInstallationHandler handler = _factory.CreateHandler(InstalledEntry.EntryType);
await handler.UninstallAsync(InstalledEntry, cancellationToken);
IsRemoved = true;
await _workshopService.UninstallEntry(InstalledEntry, cancellationToken);
}
private async Task UninstallPluginPrerequisites()

View File

@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Reactive;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using Artemis.UI.Shared.Routing;
using Artemis.WebClient.Workshop.Models;
@ -15,31 +16,41 @@ namespace Artemis.UI.Screens.Workshop.Library.Tabs;
public partial class InstalledTabViewModel : RoutableScreen
{
private SourceList<InstalledEntry> _installedEntries = new();
[Notify] private string? _searchEntryInput;
public InstalledTabViewModel(IWorkshopService workshopService, IRouter router, Func<InstalledEntry, InstalledTabItemViewModel> getInstalledTabItemViewModel)
{
SourceList<InstalledEntry> installedEntries = new();
IObservable<Func<InstalledEntry, bool>> pluginFilter = this.WhenAnyValue(vm => vm.SearchEntryInput).Throttle(TimeSpan.FromMilliseconds(100)).Select(CreatePredicate);
installedEntries.Connect()
_installedEntries.Connect()
.Filter(pluginFilter)
.Sort(SortExpressionComparer<InstalledEntry>.Descending(p => p.InstalledAt))
.Transform(getInstalledTabItemViewModel)
.AutoRefresh(vm => vm.IsRemoved)
.Filter(vm => !vm.IsRemoved)
.Bind(out ReadOnlyObservableCollection<InstalledTabItemViewModel> installedEntryViewModels)
.Subscribe();
List<InstalledEntry> entries = workshopService.GetInstalledEntries();
installedEntries.AddRange(entries);
_installedEntries.AddRange(entries);
Empty = entries.Count == 0;
InstalledEntries = installedEntryViewModels;
this.WhenActivated(d =>
{
workshopService.OnEntryUninstalled += WorkshopServiceOnOnEntryUninstalled;
Disposable.Create(() => workshopService.OnEntryUninstalled -= WorkshopServiceOnOnEntryUninstalled).DisposeWith(d);
});
OpenWorkshop = ReactiveCommand.CreateFromTask(async () => await router.Navigate("workshop"));
}
private void WorkshopServiceOnOnEntryUninstalled(object? sender, InstalledEntry e)
{
_installedEntries.Remove(e);
}
public bool Empty { get; }
public ReactiveCommand<Unit, Unit> OpenWorkshop { get; }
public ReadOnlyObservableCollection<InstalledTabItemViewModel> InstalledEntries { get; }

View File

@ -8,6 +8,10 @@
x:Class="Artemis.UI.Screens.Workshop.Plugins.PluginDescriptionView"
x:DataType="plugins:PluginDescriptionViewModel">
<StackPanel Spacing="10">
<Border Classes="card">
<ContentControl Content="{CompiledBinding ReleaseInfoViewModel}"/>
</Border>
<Border Classes="card">
<mdxaml:MarkdownScrollViewer Markdown="{CompiledBinding Entry.Description}" MarkdownStyleName="FluentAvalonia">
<mdxaml:MarkdownScrollViewer.Styles>

View File

@ -5,6 +5,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Artemis.UI.Screens.Workshop.Entries.List;
using Artemis.UI.Screens.Workshop.EntryReleases;
using Artemis.UI.Shared.Routing;
using Artemis.WebClient.Workshop;
using PropertyChanged.SourceGenerator;
@ -18,12 +19,16 @@ public partial class PluginDescriptionViewModel : RoutableScreen
private readonly IWorkshopClient _client;
private readonly Func<IEntrySummary, EntryListItemViewModel> _getEntryListViewModel;
public PluginDescriptionViewModel(IWorkshopClient client, Func<IEntrySummary, EntryListItemViewModel> getEntryListViewModel)
public PluginDescriptionViewModel(IWorkshopClient client, EntryReleaseInfoViewModel releaseInfoViewModel, Func<IEntrySummary, EntryListItemViewModel> getEntryListViewModel)
{
_client = client;
_getEntryListViewModel = getEntryListViewModel;
ReleaseInfoViewModel = releaseInfoViewModel;
ReleaseInfoViewModel.InDetailsScreen = false;
}
public EntryReleaseInfoViewModel ReleaseInfoViewModel { get; }
public async Task SetEntry(IEntryDetails? entry, CancellationToken cancellationToken)
{
Entry = entry;

View File

@ -64,5 +64,6 @@ public partial class PluginDetailsViewModel : RoutableHostScreen<RoutableScreen,
EntryImagesViewModel = Entry != null ? _getEntryImagesViewModel(Entry) : null;
await _pluginDescriptionViewModel.SetEntry(Entry, cancellationToken);
_pluginDescriptionViewModel.ReleaseInfoViewModel.Release = result.Data?.Entry?.LatestRelease;
}
}

View File

@ -73,5 +73,11 @@ public partial class PluginManageViewModel : RoutableScreen<WorkshopDetailParame
PluginViewModel = _settingsVmFactory.PluginViewModel(plugin, ReactiveCommand.Create(() => { }));
PluginFeatures = new ObservableCollection<PluginFeatureViewModel>(plugin.Features.Select(f => _settingsVmFactory.PluginFeatureViewModel(f, false)));
// If additional arguments were provided and it is a boolean, auto-enable the plugin
if (args.Options.AdditionalArguments is true)
{
await PluginViewModel.AutoEnable();
}
}
}

View File

@ -8,6 +8,10 @@
x:Class="Artemis.UI.Screens.Workshop.Profile.ProfileDescriptionView"
x:DataType="profile:ProfileDescriptionViewModel">
<StackPanel Spacing="10">
<Border Classes="card">
<ContentControl Content="{CompiledBinding ReleaseInfoViewModel}"/>
</Border>
<Border Classes="card">
<mdxaml:MarkdownScrollViewer Markdown="{CompiledBinding Entry.Description}" MarkdownStyleName="FluentAvalonia" Name="MarkdownScrollViewer" >
<mdxaml:MarkdownScrollViewer.Styles>

View File

@ -5,6 +5,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Artemis.UI.Screens.Workshop.Entries.List;
using Artemis.UI.Screens.Workshop.EntryReleases;
using Artemis.UI.Shared.Routing;
using Artemis.WebClient.Workshop;
using PropertyChanged.SourceGenerator;
@ -18,12 +19,16 @@ public partial class ProfileDescriptionViewModel : RoutableScreen
[Notify] private IEntryDetails? _entry;
[Notify] private List<EntryListItemViewModel>? _dependencies;
public ProfileDescriptionViewModel(IWorkshopClient client, Func<IEntrySummary, EntryListItemViewModel> getEntryListViewModel)
public ProfileDescriptionViewModel(IWorkshopClient client, EntryReleaseInfoViewModel releaseInfoViewModel, Func<IEntrySummary, EntryListItemViewModel> getEntryListViewModel)
{
_client = client;
_getEntryListViewModel = getEntryListViewModel;
ReleaseInfoViewModel = releaseInfoViewModel;
ReleaseInfoViewModel.InDetailsScreen = false;
}
public EntryReleaseInfoViewModel ReleaseInfoViewModel { get; }
public async Task SetEntry(IEntryDetails? entry, CancellationToken cancellationToken)
{
Entry = entry;

View File

@ -65,5 +65,6 @@ public partial class ProfileDetailsViewModel : RoutableHostScreen<RoutableScreen
EntryImagesViewModel = Entry != null ? _getEntryImagesViewModel(Entry) : null;
await _profileDescriptionViewModel.SetEntry(Entry, cancellationToken);
_profileDescriptionViewModel.ReleaseInfoViewModel.Release = result.Data?.Entry?.LatestRelease;
}
}

View File

@ -42,11 +42,16 @@ public class ProfileEntryInstallationHandler : IEntryInstallationHandler
ProfileConfiguration? existing = _profileService.ProfileCategories.SelectMany(c => c.ProfileConfigurations).FirstOrDefault(c => c.ProfileId == profileId);
if (existing != null)
{
ProfileConfiguration overwritten = await _profileService.OverwriteProfile(stream, existing);
ProfileConfiguration overwritten = await _profileService.ImportProfile(stream, existing.Category, true, false, null, existing);
overwritten.Name = existing.Name;
// Update the release
installedEntry.SetMetadata("ProfileId", overwritten.ProfileId);
// Update the release and return the profile configuration
UpdateRelease(installedEntry, release);
// With everything updated, remove the old profile
_profileService.RemoveProfileConfiguration(existing);
return EntryInstallResult.FromSuccess(installedEntry);
}
}
@ -58,7 +63,7 @@ public class ProfileEntryInstallationHandler : IEntryInstallationHandler
ProfileCategory category = _profileService.ProfileCategories.FirstOrDefault(c => c.Name == "Workshop") ?? _profileService.CreateProfileCategory("Workshop", true);
ProfileConfiguration imported = await _profileService.ImportProfile(stream, category, true, true, null);
installedEntry.SetMetadata("ProfileId", imported.ProfileId);
// Update the release and return the profile configuration
UpdateRelease(installedEntry, release);
return EntryInstallResult.FromSuccess(installedEntry);
@ -66,17 +71,17 @@ public class ProfileEntryInstallationHandler : IEntryInstallationHandler
public async Task<EntryUninstallResult> UninstallAsync(InstalledEntry installedEntry, CancellationToken cancellationToken)
{
if (!installedEntry.TryGetMetadata("ProfileId", out Guid profileId))
return EntryUninstallResult.FromFailure("Local reference does not contain a GUID");
return await Task.Run(() =>
{
try
{
// Find the profile if still there
ProfileConfiguration? profile = _profileService.ProfileCategories.SelectMany(c => c.ProfileConfigurations).FirstOrDefault(c => c.ProfileId == profileId);
if (profile != null)
_profileService.RemoveProfileConfiguration(profile);
if (installedEntry.TryGetMetadata("ProfileId", out Guid profileId))
{
ProfileConfiguration? profile = _profileService.ProfileCategories.SelectMany(c => c.ProfileConfigurations).FirstOrDefault(c => c.ProfileId == profileId);
if (profile != null)
_profileService.RemoveProfileConfiguration(profile);
}
// Remove the release
_workshopService.RemoveInstalledEntry(installedEntry);

View File

@ -18,7 +18,10 @@ public class WorkshopLayoutProvider : ILayoutProvider
/// <inheritdoc />
public ArtemisLayout? GetDeviceLayout(ArtemisDevice device)
{
InstalledEntry? layoutEntry = _workshopService.GetInstalledEntries().FirstOrDefault(e => e.EntryId.ToString() == device.LayoutSelection.Parameter);
if (!long.TryParse(device.LayoutSelection.Parameter, out long entryId))
return null;
InstalledEntry? layoutEntry = _workshopService.GetInstalledEntry(entryId);
if (layoutEntry == null)
return null;

View File

@ -70,6 +70,17 @@ fragment release on Release {
createdAt
}
fragment releaseDetails on Release {
...release
changelog
entry {
...entrySummary
}
dependencies {
...entrySummary
}
}
fragment pluginInfo on PluginInfo {
requiresAdmin
supportsWindows

View File

@ -1,6 +1,9 @@
query GetEntryById($id: Long!) {
entry(id: $id) {
...entryDetails
latestRelease {
...releaseDetails
}
}
}
@ -9,7 +12,10 @@ query GetPluginEntryById($id: Long!) {
...entryDetails
pluginInfo {
...pluginInfo
}
}
latestRelease {
...releaseDetails
}
}
}

View File

@ -1,9 +1,5 @@
query GetReleaseById($id: Long!) {
release(id: $id) {
...release
changelog
entry {
...entrySummary
}
...releaseDetails
}
}

View File

@ -1,25 +1,136 @@
using Artemis.Core;
using Artemis.UI.Shared.Utilities;
using Artemis.WebClient.Workshop.Handlers.InstallationHandlers;
using Artemis.WebClient.Workshop.Handlers.UploadHandlers;
using Artemis.WebClient.Workshop.Models;
namespace Artemis.WebClient.Workshop.Services;
/// <summary>
/// Provides an interface for managing workshop services.
/// </summary>
public interface IWorkshopService
{
/// <summary>
/// Gets the icon for a specific entry.
/// </summary>
/// <param name="entryId">The ID of the entry.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A stream containing the icon.</returns>
Task<Stream?> GetEntryIcon(long entryId, CancellationToken cancellationToken);
/// <summary>
/// Sets the icon for a specific entry.
/// </summary>
/// <param name="entryId">The ID of the entry.</param>
/// <param name="icon">The stream containing the icon.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>An API result.</returns>
Task<ApiResult> SetEntryIcon(long entryId, Stream icon, CancellationToken cancellationToken);
/// <summary>
/// Uploads an image for a specific entry.
/// </summary>
/// <param name="entryId">The ID of the entry.</param>
/// <param name="request">The image upload request.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>An API result.</returns>
Task<ApiResult> UploadEntryImage(long entryId, ImageUploadRequest request, CancellationToken cancellationToken);
/// <summary>
/// Deletes an image by its ID.
/// </summary>
/// <param name="id">The ID of the image.</param>
/// <param name="cancellationToken">The cancellation token.</param>
Task DeleteEntryImage(Guid id, CancellationToken cancellationToken);
/// <summary>
/// Gets the status of the workshop.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The status of the workshop.</returns>
Task<WorkshopStatus> GetWorkshopStatus(CancellationToken cancellationToken);
/// <summary>
/// Validates the status of the workshop.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A boolean indicating whether the workshop is reachable.</returns>
Task<bool> ValidateWorkshopStatus(CancellationToken cancellationToken);
/// <summary>
/// Navigates to a specific entry.
/// </summary>
/// <param name="entryId">The ID of the entry.</param>
/// <param name="entryType">The type of the entry.</param>
Task NavigateToEntry(long entryId, EntryType entryType);
/// <summary>
/// Installs a specific entry.
/// </summary>
/// <param name="entry">The entry to install.</param>
/// <param name="release">The release of the entry.</param>
/// <param name="progress">The progress of the installation.</param>
/// <param name="cancellationToken">The cancellation token.</param>
Task<EntryInstallResult> InstallEntry(IEntrySummary entry, IRelease release, Progress<StreamProgress> progress, CancellationToken cancellationToken);
/// <summary>
/// Uninstalls a specific entry.
/// </summary>
/// <param name="installedEntry">The installed entry to uninstall.</param>
/// <param name="cancellationToken">The cancellation token.</param>
Task<EntryUninstallResult> UninstallEntry(InstalledEntry installedEntry, CancellationToken cancellationToken);
/// <summary>
/// Gets all installed entries.
/// </summary>
/// <returns>A list of all installed entries.</returns>
List<InstalledEntry> GetInstalledEntries();
/// <summary>
/// Gets a specific installed entry.
/// </summary>
/// <param name="entryId">The ID of the entry.</param>
/// <returns>The installed entry.</returns>
InstalledEntry? GetInstalledEntry(long entryId);
/// <summary>
/// Gets the installed plugin entry for a specific plugin.
/// </summary>
/// <param name="plugin">The plugin.</param>
/// <returns>The installed entry.</returns>
InstalledEntry? GetInstalledEntryByPlugin(Plugin plugin);
/// <summary>
/// Gets the installed plugin entry for a specific profile.
/// </summary>
/// <param name="profileConfiguration">The profile.</param>
/// <returns>The installed entry.</returns>
InstalledEntry? GetInstalledEntryByProfile(ProfileConfiguration profileConfiguration);
/// <summary>
/// Removes a specific installed entry for storage.
/// </summary>
/// <param name="installedEntry">The installed entry to remove.</param>
void RemoveInstalledEntry(InstalledEntry installedEntry);
/// <summary>
/// Saves a specific installed entry to storage.
/// </summary>
/// <param name="entry">The installed entry to save.</param>
void SaveInstalledEntry(InstalledEntry entry);
/// <summary>
/// Initializes the workshop service.
/// </summary>
void Initialize();
/// <summary>
/// Represents the status of the workshop.
/// </summary>
public record WorkshopStatus(bool IsReachable, string Message);
event EventHandler<InstalledEntry>? OnInstalledEntrySaved;
public event EventHandler<InstalledEntry>? OnInstalledEntrySaved;
public event EventHandler<InstalledEntry>? OnEntryUninstalled;
public event EventHandler<InstalledEntry>? OnEntryInstalled;
}

View File

@ -4,7 +4,9 @@ using Artemis.Core.Services;
using Artemis.Storage.Entities.Workshop;
using Artemis.Storage.Repositories.Interfaces;
using Artemis.UI.Shared.Routing;
using Artemis.UI.Shared.Utilities;
using Artemis.WebClient.Workshop.Exceptions;
using Artemis.WebClient.Workshop.Handlers.InstallationHandlers;
using Artemis.WebClient.Workshop.Handlers.UploadHandlers;
using Artemis.WebClient.Workshop.Models;
using Serilog;
@ -17,16 +19,26 @@ public class WorkshopService : IWorkshopService
private readonly IHttpClientFactory _httpClientFactory;
private readonly IRouter _router;
private readonly IEntryRepository _entryRepository;
private readonly IPluginManagementService _pluginManagementService;
private readonly Lazy<IPluginManagementService> _pluginManagementService;
private readonly Lazy<IProfileService> _profileService;
private readonly EntryInstallationHandlerFactory _factory;
private bool _initialized;
public WorkshopService(ILogger logger, IHttpClientFactory httpClientFactory, IRouter router, IEntryRepository entryRepository, IPluginManagementService pluginManagementService)
public WorkshopService(ILogger logger,
IHttpClientFactory httpClientFactory,
IRouter router,
IEntryRepository entryRepository,
Lazy<IPluginManagementService> pluginManagementService,
Lazy<IProfileService> profileService,
EntryInstallationHandlerFactory factory)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_router = router;
_entryRepository = entryRepository;
_pluginManagementService = pluginManagementService;
_profileService = profileService;
_factory = factory;
}
public async Task<Stream?> GetEntryIcon(long entryId, CancellationToken cancellationToken)
@ -145,6 +157,32 @@ public class WorkshopService : IWorkshopService
}
}
/// <inheritdoc />
public async Task<EntryInstallResult> InstallEntry(IEntrySummary entry, IRelease release, Progress<StreamProgress> progress, CancellationToken cancellationToken)
{
IEntryInstallationHandler handler = _factory.CreateHandler(entry.EntryType);
EntryInstallResult result = await handler.InstallAsync(entry, release, progress, cancellationToken);
if (result.IsSuccess && result.Entry != null)
OnEntryInstalled?.Invoke(this, result.Entry);
else
_logger.Warning("Failed to install entry {EntryId}: {Message}", entry.Id, result.Message);
return result;
}
/// <inheritdoc />
public async Task<EntryUninstallResult> UninstallEntry(InstalledEntry installedEntry, CancellationToken cancellationToken)
{
IEntryInstallationHandler handler = _factory.CreateHandler(installedEntry.EntryType);
EntryUninstallResult result = await handler.UninstallAsync(installedEntry, cancellationToken);
if (result.IsSuccess)
OnEntryUninstalled?.Invoke(this, installedEntry);
else
_logger.Warning("Failed to uninstall entry {EntryId}: {Message}", installedEntry.EntryId, result.Message);
return result;
}
/// <inheritdoc />
public List<InstalledEntry> GetInstalledEntries()
{
@ -161,6 +199,18 @@ public class WorkshopService : IWorkshopService
return new InstalledEntry(entity);
}
/// <inheritdoc />
public InstalledEntry? GetInstalledEntryByPlugin(Plugin plugin)
{
return GetInstalledEntries().FirstOrDefault(e => e.TryGetMetadata("PluginId", out Guid pluginId) && pluginId == plugin.Guid);
}
/// <inheritdoc />
public InstalledEntry? GetInstalledEntryByProfile(ProfileConfiguration profileConfiguration)
{
return GetInstalledEntries().FirstOrDefault(e => e.TryGetMetadata("ProfileId", out Guid pluginId) && pluginId == profileConfiguration.ProfileId);
}
/// <inheritdoc />
public void RemoveInstalledEntry(InstalledEntry installedEntry)
{
@ -172,7 +222,7 @@ public class WorkshopService : IWorkshopService
{
entry.Save();
_entryRepository.Save(entry.Entity);
OnInstalledEntrySaved?.Invoke(this, entry);
}
@ -189,10 +239,13 @@ public class WorkshopService : IWorkshopService
RemoveOrphanedFiles();
_pluginManagementService.AdditionalPluginDirectories.AddRange(GetInstalledEntries()
_pluginManagementService.Value.AdditionalPluginDirectories.AddRange(GetInstalledEntries()
.Where(e => e.EntryType == EntryType.Plugin)
.Select(e => e.GetReleaseDirectory()));
_pluginManagementService.Value.PluginRemoved += PluginManagementServiceOnPluginRemoved;
_profileService.Value.ProfileRemoved += ProfileServiceOnProfileRemoved;
_initialized = true;
}
catch (Exception e)
@ -233,6 +286,28 @@ public class WorkshopService : IWorkshopService
_logger.Warning(e, "Failed to remove orphaned workshop entry at {Directory}", directory);
}
}
private void ProfileServiceOnProfileRemoved(object? sender, ProfileConfigurationEventArgs e)
{
InstalledEntry? entry = GetInstalledEntryByProfile(e.ProfileConfiguration);
if (entry == null)
return;
_logger.Information("Profile {Profile} was removed, uninstalling entry", e.ProfileConfiguration);
Task.Run(() => UninstallEntry(entry, CancellationToken.None));
}
private void PluginManagementServiceOnPluginRemoved(object? sender, PluginEventArgs e)
{
InstalledEntry? entry = GetInstalledEntryByPlugin(e.Plugin);
if (entry == null)
return;
_logger.Information("Plugin {Plugin} was removed, uninstalling entry", e.Plugin);
Task.Run(() => UninstallEntry(entry, CancellationToken.None));
}
public event EventHandler<InstalledEntry>? OnInstalledEntrySaved;
public event EventHandler<InstalledEntry>? OnEntryUninstalled;
public event EventHandler<InstalledEntry>? OnEntryInstalled;
}

View File

@ -5,58 +5,61 @@
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="AsyncImageLoader.Avalonia" Version="3.2.1" />
<PackageVersion Include="Avalonia" Version="11.0.9" />
<PackageVersion Include="Avalonia" Version="11.0.11" />
<PackageVersion Include="Avalonia.AvaloniaEdit" Version="11.0.6" />
<PackageVersion Include="Avalonia.Controls.ItemsRepeater" Version="11.0.9" />
<PackageVersion Include="Avalonia.Controls.PanAndZoom" Version="11.0.0.2" />
<PackageVersion Include="Avalonia.Desktop" Version="11.0.9" />
<PackageVersion Include="Avalonia.Controls.ItemsRepeater" Version="11.0.11" />
<PackageVersion Include="Avalonia.Controls.PanAndZoom" Version="11.0.0.3" />
<PackageVersion Include="Avalonia.Desktop" Version="11.0.11" />
<PackageVersion Include="Avalonia.Diagnostics" Version="11.0.9" />
<PackageVersion Include="Avalonia.ReactiveUI" Version="11.0.9" />
<PackageVersion Include="Avalonia.ReactiveUI" Version="11.0.11" />
<PackageVersion Include="Avalonia.Skia.Lottie" Version="11.0.0" />
<PackageVersion Include="Avalonia.Win32" Version="11.0.9" />
<PackageVersion Include="Avalonia.Win32" Version="11.0.11" />
<PackageVersion Include="Microsoft.Win32.SystemEvents" Version="8.0.0" />
<PackageVersion Include="Avalonia.Xaml.Behaviors" Version="11.0.6" />
<PackageVersion Include="Avalonia.Xaml.Behaviors" Version="11.0.10.9" />
<PackageVersion Include="AvaloniaEdit.TextMate" Version="11.0.6" />
<PackageVersion Include="DryIoc.Microsoft.DependencyInjection" Version="6.2.0" />
<PackageVersion Include="DryIoc.dll" Version="5.4.3" />
<PackageVersion Include="DynamicData" Version="8.3.27" />
<PackageVersion Include="DynamicData" Version="8.4.1" />
<PackageVersion Include="EmbedIO" Version="3.5.2" />
<PackageVersion Include="FluentAvalonia.ProgressRing" Version="1.69.2" />
<PackageVersion Include="FluentAvaloniaUI" Version="2.0.5" />
<PackageVersion Include="HidSharp" Version="2.1.0" />
<PackageVersion Include="Humanizer.Core" Version="2.14.1" />
<PackageVersion Include="IdentityModel" Version="6.2.0" />
<PackageVersion Include="IdentityModel" Version="7.0.0" />
<PackageVersion Include="JetBrains.Annotations" Version="2023.3.0" />
<PackageVersion Include="LiteDB" Version="5.0.17" />
<PackageVersion Include="Markdown.Avalonia.Tight" Version="11.0.2" />
<PackageVersion Include="Material.Icons.Avalonia" Version="2.1.0" />
<PackageVersion Include="Material.Icons.Avalonia" Version="2.1.10" />
<PackageVersion Include="McMaster.NETCore.Plugins" Version="1.4.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="8.0.2" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.2" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.2" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.6" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageVersion>
<PackageVersion Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageVersion Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.3" />
<PackageVersion Include="Microsoft.Win32" Version="2.0.1" />
<PackageVersion Include="Microsoft.Windows.Compatibility" Version="8.0.2" />
<PackageVersion Include="Microsoft.Windows.Compatibility" Version="8.0.6" />
<PackageVersion Include="NoStringEvaluating" Version="2.5.2" />
<PackageVersion Include="Octopus.Octodiff" Version="2.0.546" />
<PackageVersion Include="PropertyChanged.SourceGenerator" Version="1.1.0" />
<PackageVersion Include="RGB.NET.Core" Version="2.0.4-prerelease.16" />
<PackageVersion Include="RGB.NET.Layout" Version="2.0.4-prerelease.16" />
<PackageVersion Include="RGB.NET.Presets" Version="2.0.4-prerelease.16" />
<PackageVersion Include="RGB.NET.Core" Version="2.1.0" />
<PackageVersion Include="RGB.NET.Layout" Version="2.1.0" />
<PackageVersion Include="RGB.NET.Presets" Version="2.1.0" />
<PackageVersion Include="RawInput.Sharp" Version="0.1.3" />
<PackageVersion Include="ReactiveUI" Version="19.5.41" />
<PackageVersion Include="ReactiveUI.Validation" Version="3.1.7" />
<PackageVersion Include="Serilog" Version="3.1.1" />
<PackageVersion Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageVersion Include="Serilog.Sinks.Debug" Version="2.0.0" />
<PackageVersion Include="ReactiveUI" Version="20.1.1" />
<PackageVersion Include="ReactiveUI.Validation" Version="4.0.9" />
<PackageVersion Include="Serilog" Version="4.0.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageVersion Include="Serilog.Sinks.Debug" Version="3.0.0" />
<PackageVersion Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageVersion Include="SkiaSharp" Version="2.88.7" />
<PackageVersion Include="SkiaSharp.Vulkan.SharpVk" Version="2.88.7" />
<PackageVersion Include="Splat.DryIoc" Version="14.8.12" />
<PackageVersion Include="StrawberryShake.Server" Version="13.9.0" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="7.3.1" />
<PackageVersion Include="System.Text.Json" Version="8.0.2" />
<PackageVersion Include="TextMateSharp.Grammars" Version="1.0.56" />
<PackageVersion Include="SkiaSharp" Version="2.88.8" />
<PackageVersion Include="SkiaSharp.Vulkan.SharpVk" Version="2.88.8" />
<PackageVersion Include="Splat.DryIoc" Version="15.1.1" />
<PackageVersion Include="StrawberryShake.Server" Version="13.9.6" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="7.6.2" />
<PackageVersion Include="System.Text.Json" Version="8.0.3" />
<PackageVersion Include="TextMateSharp.Grammars" Version="1.0.57" />
</ItemGroup>
</Project>