From 6bf5a111084d4f3df10bab7ed11084a71f60d3a6 Mon Sep 17 00:00:00 2001 From: Robert Date: Mon, 22 Dec 2025 20:13:49 +0100 Subject: [PATCH] Workshop - Improved workshop entry compatibility handling Workshop - Added minimum Artemis version to releases --- src/Artemis.Core/Plugins/PluginInfo.cs | 3 +- .../Services/Interfaces/IWindowService.cs | 3 +- .../Services/Window/ExceptionDialogView.axaml | 4 +- .../Window/ExceptionDialogViewModel.cs | 4 +- .../Services/Window/WindowService.cs | 4 +- .../Extensions/IReleaseExtensions.cs | 23 ++++++++++ .../Extensions/VersionExtensions.cs | 45 +++++++++++++++++++ .../Entries/List/EntryListItemView.axaml | 4 +- .../Entries/List/EntryListViewModel.cs | 2 +- .../EntryReleases/EntryReleaseInfoView.axaml | 16 +++++-- .../EntryReleaseInfoViewModel.cs | 16 ++++++- .../EntryReleases/EntryReleaseItemView.axaml | 1 + .../EntryReleaseItemViewModel.cs | 9 +++- .../Plugin/PluginSelectionStepView.axaml | 42 ++++++++++------- .../Updating/WorkshopUpdateService.cs | 7 +++ .../EntryInstallResult.cs | 15 +++++++ .../LayoutEntryInstallationHandler.cs | 2 +- .../PluginEntryInstallationHandler.cs | 7 +-- .../ProfileEntryInstallationHandler.cs | 2 +- .../Queries/Fragments.graphql | 1 + src/Artemis.WebClient.Workshop/schema.graphql | 9 ++-- 21 files changed, 174 insertions(+), 45 deletions(-) create mode 100644 src/Artemis.UI/Extensions/IReleaseExtensions.cs create mode 100644 src/Artemis.UI/Extensions/VersionExtensions.cs diff --git a/src/Artemis.Core/Plugins/PluginInfo.cs b/src/Artemis.Core/Plugins/PluginInfo.cs index 279ea6c43..1d70aa0b8 100644 --- a/src/Artemis.Core/Plugins/PluginInfo.cs +++ b/src/Artemis.Core/Plugins/PluginInfo.cs @@ -113,7 +113,8 @@ public class PluginInfo : IPrerequisitesSubject /// /// Gets the minimum version of Artemis required by this plugin /// - public Version? MinimumVersion { get; internal init; } = new(1, 0, 0); + [JsonInclude] + public Version? MinimumVersion { get; internal init; } /// /// Gets the plugin this info is associated with diff --git a/src/Artemis.UI.Shared/Services/Interfaces/IWindowService.cs b/src/Artemis.UI.Shared/Services/Interfaces/IWindowService.cs index 15756d2a0..f68bba209 100644 --- a/src/Artemis.UI.Shared/Services/Interfaces/IWindowService.cs +++ b/src/Artemis.UI.Shared/Services/Interfaces/IWindowService.cs @@ -29,7 +29,8 @@ public interface IWindowService : IArtemisSharedUIService /// /// The title of the dialog /// The exception to display - void ShowExceptionDialog(string title, Exception exception); + /// + void ShowExceptionDialog(string title, Exception exception, string? customMessage = null); /// /// Creates a view model instance of type and shows its corresponding View as a diff --git a/src/Artemis.UI.Shared/Services/Window/ExceptionDialogView.axaml b/src/Artemis.UI.Shared/Services/Window/ExceptionDialogView.axaml index 871d01354..98c4fbe9e 100644 --- a/src/Artemis.UI.Shared/Services/Window/ExceptionDialogView.axaml +++ b/src/Artemis.UI.Shared/Services/Window/ExceptionDialogView.axaml @@ -22,9 +22,7 @@ Awww :( - - It looks like Artemis ran into an unexpected error. If this keeps happening feel free to hit us up on Discord. - + diff --git a/src/Artemis.UI.Shared/Services/Window/ExceptionDialogViewModel.cs b/src/Artemis.UI.Shared/Services/Window/ExceptionDialogViewModel.cs index 3bb88df15..61ee716ce 100644 --- a/src/Artemis.UI.Shared/Services/Window/ExceptionDialogViewModel.cs +++ b/src/Artemis.UI.Shared/Services/Window/ExceptionDialogViewModel.cs @@ -9,15 +9,17 @@ internal class ExceptionDialogViewModel : DialogViewModelBase { private readonly INotificationService _notificationService; - public ExceptionDialogViewModel(string title, Exception exception, INotificationService notificationService) + public ExceptionDialogViewModel(string title, Exception exception, string? customMessage, INotificationService notificationService) { _notificationService = notificationService; Title = $"Artemis | {title}"; + Message = customMessage ?? "It looks like Artemis ran into an unexpected error. If this keeps happening feel free to hit us up on Discord."; Exception = exception; } public string Title { get; } + public string Message { get; } public Exception Exception { get; } public async Task CopyException() diff --git a/src/Artemis.UI.Shared/Services/Window/WindowService.cs b/src/Artemis.UI.Shared/Services/Window/WindowService.cs index 45f5cd6cc..53bad360e 100644 --- a/src/Artemis.UI.Shared/Services/Window/WindowService.cs +++ b/src/Artemis.UI.Shared/Services/Window/WindowService.cs @@ -125,7 +125,7 @@ internal class WindowService : IWindowService return await window.ShowDialog(parent); } - public void ShowExceptionDialog(string title, Exception exception) + public void ShowExceptionDialog(string title, Exception exception, string? customMessage = null) { if (_exceptionDialogOpen) return; @@ -136,7 +136,7 @@ internal class WindowService : IWindowService { try { - await ShowDialogAsync(new ExceptionDialogViewModel(title, exception, _container.Resolve())); + await ShowDialogAsync(new ExceptionDialogViewModel(title, exception, customMessage, _container.Resolve())); } finally { diff --git a/src/Artemis.UI/Extensions/IReleaseExtensions.cs b/src/Artemis.UI/Extensions/IReleaseExtensions.cs new file mode 100644 index 000000000..fb3adba27 --- /dev/null +++ b/src/Artemis.UI/Extensions/IReleaseExtensions.cs @@ -0,0 +1,23 @@ +using System; +using Artemis.Core; +using Artemis.WebClient.Workshop; + +namespace Artemis.UI.Extensions; + +public static class ReleaseExtensions +{ + extension(IRelease release) + { + /// + /// Determines whether the release is compatible with the current version of Artemis. + /// + /// A value indicating whether the release is compatible with the current version of Artemis. + public bool IsCompatible() + { + if (release.MinimumVersion == null || Constants.CurrentVersion == "local") + return true; + + return release.MinimumVersion <= Version.Parse(Constants.CurrentVersion).ArtemisVersionToLong(); + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Extensions/VersionExtensions.cs b/src/Artemis.UI/Extensions/VersionExtensions.cs new file mode 100644 index 000000000..2342c28af --- /dev/null +++ b/src/Artemis.UI/Extensions/VersionExtensions.cs @@ -0,0 +1,45 @@ +using System; + +namespace Artemis.UI.Extensions; + +public static class VersionExtensions +{ + /// The version to convert + extension(Version version) + { + /// + /// Convert a Version to a long representation for easy comparison in PostgreSQL + /// Assumes format: major.year.dayOfYear.revision (e.g., 1.2024.0225.2) + /// + /// A long value that preserves version comparison order + public long ArtemisVersionToLong() + { + // Format: major.year.dayOfYear.revision + // Convert to: majorYYYYDDDRRRR (16 digits) + // Major: 1 digit (0-9) + // Year: 4 digits (e.g., 2024) + // Day: 3 digits (001-366, padded) + // Revision: 4 digits (0000-9999, padded) + + long major = Math.Max(0, Math.Min(9, version.Major)); + long year = Math.Max(1000, Math.Min(9999, version.Minor)); + long day = Math.Max(1, Math.Min(366, version.Build)); + long revision = Math.Max(0, Math.Min(9999, version.Revision >= 0 ? version.Revision : 0)); + + return major * 100000000000L + + year * 10000000L + + day * 10000L + + revision; + } + + public static Version FromLong(long versionLong) + { + int major = (int)(versionLong / 100000000000L); + int year = (int)((versionLong / 10000000L) % 10000); + int day = (int)((versionLong / 10000L) % 1000); + int revision = (int)(versionLong % 10000L); + + return new Version(major, year, day, revision); + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Entries/List/EntryListItemView.axaml b/src/Artemis.UI/Screens/Workshop/Entries/List/EntryListItemView.axaml index af26a3da1..762754985 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/List/EntryListItemView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Entries/List/EntryListItemView.axaml @@ -90,11 +90,11 @@ - + installed - + update available diff --git a/src/Artemis.UI/Screens/Workshop/Entries/List/EntryListViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/List/EntryListViewModel.cs index da6c2ee78..6124c212b 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/List/EntryListViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Entries/List/EntryListViewModel.cs @@ -30,7 +30,7 @@ public partial class EntryListViewModel : RoutableScreen [Notify] private bool _initializing = true; [Notify] private bool _fetchingMore; [Notify] private int _entriesPerFetch; - [Notify] private bool _includeDefaultEntries; + [Notify] private bool _includeDefaultEntries = true; [Notify] private Vector _scrollOffset; protected EntryListViewModel(EntryType entryType, diff --git a/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseInfoView.axaml b/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseInfoView.axaml index 6487fc19c..8ce85d944 100644 --- a/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseInfoView.axaml +++ b/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseInfoView.axaml @@ -37,13 +37,17 @@ - + Release info Latest release - + + + + + - - diff --git a/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseInfoViewModel.cs b/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseInfoViewModel.cs index 8d29dc48a..7370361fc 100644 --- a/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseInfoViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseInfoViewModel.cs @@ -7,6 +7,7 @@ using System.Threading; using System.Threading.Tasks; using Artemis.Core; using Artemis.Core.Services; +using Artemis.UI.Extensions; using Artemis.UI.Screens.Workshop.EntryReleases.Dialogs; using Artemis.UI.Services.Interfaces; using Artemis.UI.Shared; @@ -38,6 +39,7 @@ public partial class EntryReleaseInfoViewModel : ActivatableViewModelBase [Notify] private bool _isCurrentVersion; [Notify] private bool _installationInProgress; [Notify] private bool _inDetailsScreen; + [Notify] private string? _incompatibilityReason; private CancellationTokenSource? _cts; @@ -67,9 +69,11 @@ public partial class EntryReleaseInfoViewModel : ActivatableViewModelBase }).DisposeWith(d); IsCurrentVersion = Release != null && _workshopService.GetInstalledEntry(Release.Entry.Id)?.ReleaseId == Release.Id; + IncompatibilityReason = Release != null && !Release.IsCompatible() ? $"Requires Artemis v{Version.FromLong(Release.MinimumVersion!.Value)} or later" : null; }); this.WhenAnyValue(vm => vm.Release).Subscribe(r => IsCurrentVersion = r != null && _workshopService.GetInstalledEntry(r.Entry.Id)?.ReleaseId == r.Id); + this.WhenAnyValue(vm => vm.Release).Subscribe(r => IncompatibilityReason = r != null && !r.IsCompatible() ? $"Requires Artemis v{Version.FromLong(r.MinimumVersion!.Value)} or later" : null); InDetailsScreen = true; } @@ -131,7 +135,17 @@ public partial class EntryReleaseInfoViewModel : ActivatableViewModelBase } else if (!_cts.IsCancellationRequested) { - _notificationService.CreateNotification().WithTitle("Installation failed").WithMessage(result.Message).WithSeverity(NotificationSeverity.Error).Show(); + if (result.Exception != null) + { + // Not taking the fall on this one :') + _windowService.ShowExceptionDialog( + "Failed to install workshop entry", + result.Exception, + "Make sure the entry is compatible with this version of Artemis or reach out to the author for support." + ); + } + else + await _windowService.ShowConfirmContentDialog("Failed to install workshop entry", result.Message ?? "Unknown error", "Close", null); } } catch (Exception e) diff --git a/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseItemView.axaml b/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseItemView.axaml index 1a328d64a..652eef48f 100644 --- a/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseItemView.axaml +++ b/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseItemView.axaml @@ -30,5 +30,6 @@ + diff --git a/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseItemViewModel.cs b/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseItemViewModel.cs index 333f07d06..478da6d7c 100644 --- a/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseItemViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/EntryReleases/EntryReleaseItemViewModel.cs @@ -1,5 +1,7 @@ -using System.Reactive.Disposables; +using System; +using System.Reactive.Disposables; using System.Reactive.Disposables.Fluent; +using Artemis.UI.Extensions; using Artemis.UI.Shared; using Artemis.WebClient.Workshop; using Artemis.WebClient.Workshop.Models; @@ -13,8 +15,10 @@ public partial class EntryReleaseItemViewModel : ActivatableViewModelBase { private readonly IWorkshopService _workshopService; private readonly IEntryDetails _entry; + [Notify] private bool _isCurrentVersion; - + [Notify] private string? _incompatibilityReason; + public EntryReleaseItemViewModel(IWorkshopService workshopService, IEntryDetails entry, IRelease release) { _workshopService = workshopService; @@ -33,6 +37,7 @@ public partial class EntryReleaseItemViewModel : ActivatableViewModelBase }).DisposeWith(d); IsCurrentVersion = _workshopService.GetInstalledEntry(_entry.Id)?.ReleaseId == Release.Id; + IncompatibilityReason = !Release.IsCompatible() ? $"Requires Artemis v{Version.FromLong(Release.MinimumVersion!.Value)} or later" : null; }); } diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Plugin/PluginSelectionStepView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Plugin/PluginSelectionStepView.axaml index 06598cd93..04987eaf5 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Plugin/PluginSelectionStepView.axaml +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Plugin/PluginSelectionStepView.axaml @@ -24,22 +24,32 @@ - - Path - - - Name - - - Description - - - Main entry point - - - Version - - + + + Path + + + + Name + + + + Description + + + + Main entry point + + + + Version + + + + Min. Artemis version + + + diff --git a/src/Artemis.UI/Services/Updating/WorkshopUpdateService.cs b/src/Artemis.UI/Services/Updating/WorkshopUpdateService.cs index 6f1f267e5..8850de70e 100644 --- a/src/Artemis.UI/Services/Updating/WorkshopUpdateService.cs +++ b/src/Artemis.UI/Services/Updating/WorkshopUpdateService.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Tasks; using Artemis.Core; using Artemis.Core.Services; +using Artemis.UI.Extensions; using Artemis.UI.Services.Interfaces; using Artemis.UI.Shared.Utilities; using Artemis.WebClient.Workshop; @@ -69,6 +70,12 @@ public class WorkshopUpdateService : IWorkshopUpdateService if (latestRelease.Id == installedEntry.ReleaseId) return false; + if (!latestRelease.IsCompatible()) + { + _logger.Information("Skipping auto-update of entry {Entry} because it requires a newer version of Artemis ({RequiredVersion})", entry, latestRelease.MinimumVersion); + return false; + } + _logger.Information("Auto-updating entry {Entry} to version {Version}", entry, latestRelease.Version); EntryInstallResult updateResult = await _workshopService.InstallEntry(entry, latestRelease, new Progress(), CancellationToken.None); diff --git a/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/EntryInstallResult.cs b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/EntryInstallResult.cs index 7e05fe28b..1277c08ee 100644 --- a/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/EntryInstallResult.cs +++ b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/EntryInstallResult.cs @@ -15,6 +15,11 @@ public class EntryInstallResult /// public string? Message { get; private set; } + /// + /// Gets an exception thrown during the installation process, if any. + /// + public Exception? Exception { get; private set; } + /// /// Gets the entry that was installed, if any. /// @@ -34,6 +39,16 @@ public class EntryInstallResult Message = message }; } + + public static EntryInstallResult FromException(Exception exception) + { + return new EntryInstallResult + { + IsSuccess = false, + Message = exception.Message, + Exception = exception + }; + } public static EntryInstallResult FromSuccess(InstalledEntry installedEntry, object? result) { diff --git a/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/LayoutEntryInstallationHandler.cs b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/LayoutEntryInstallationHandler.cs index 7636c5def..a336fa3b3 100644 --- a/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/LayoutEntryInstallationHandler.cs +++ b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/LayoutEntryInstallationHandler.cs @@ -37,7 +37,7 @@ public class LayoutEntryInstallationHandler : IEntryInstallationHandler } catch (Exception e) { - return EntryInstallResult.FromFailure(e.Message); + return EntryInstallResult.FromException(e); } // Ensure there is an installed entry diff --git a/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/PluginEntryInstallationHandler.cs b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/PluginEntryInstallationHandler.cs index b4451d9cf..80a3cb012 100644 --- a/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/PluginEntryInstallationHandler.cs +++ b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/PluginEntryInstallationHandler.cs @@ -51,7 +51,7 @@ public class PluginEntryInstallationHandler : IEntryInstallationHandler } catch (Exception e) { - return EntryInstallResult.FromFailure(e.Message); + return EntryInstallResult.FromException(e); } // Create the release directory @@ -102,8 +102,9 @@ public class PluginEntryInstallationHandler : IEntryInstallationHandler // ignored, will get cleaned up as an orphaned file } - _workshopService.RemoveInstalledEntry(installedEntry); - return EntryInstallResult.FromFailure(e.Message); + if (installedEntry.Entity.Id != Guid.Empty) + _workshopService.RemoveInstalledEntry(installedEntry); + return EntryInstallResult.FromException(e); } return ApplyAndSave(plugin, installedEntry, release); diff --git a/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/ProfileEntryInstallationHandler.cs b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/ProfileEntryInstallationHandler.cs index f16aa8d36..6bbd35754 100644 --- a/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/ProfileEntryInstallationHandler.cs +++ b/src/Artemis.WebClient.Workshop/Handlers/InstallationHandlers/Implementations/ProfileEntryInstallationHandler.cs @@ -32,7 +32,7 @@ public class ProfileEntryInstallationHandler : IEntryInstallationHandler } catch (Exception e) { - return EntryInstallResult.FromFailure(e.Message); + return EntryInstallResult.FromException(e); } // Find existing installation to potentially replace the profile diff --git a/src/Artemis.WebClient.Workshop/Queries/Fragments.graphql b/src/Artemis.WebClient.Workshop/Queries/Fragments.graphql index f1c94bfa2..afa95d404 100644 --- a/src/Artemis.WebClient.Workshop/Queries/Fragments.graphql +++ b/src/Artemis.WebClient.Workshop/Queries/Fragments.graphql @@ -79,6 +79,7 @@ fragment entryDetails on Entry { fragment release on Release { id version + minimumVersion downloadSize md5Hash createdAt diff --git a/src/Artemis.WebClient.Workshop/schema.graphql b/src/Artemis.WebClient.Workshop/schema.graphql index ebc1d6535..4962e89b2 100644 --- a/src/Artemis.WebClient.Workshop/schema.graphql +++ b/src/Artemis.WebClient.Workshop/schema.graphql @@ -138,8 +138,6 @@ type PluginInfo { entryId: Long! entry: Entry! pluginGuid: UUID! - api: Int - minmumVersion: String website: String helpPage: String repository: String @@ -253,6 +251,7 @@ type Release { downloads: Long! downloadSize: Long! md5Hash: String + minimumVersion: Long entry: Entry! entryId: Long! dependencies: [Entry!]! @@ -532,8 +531,6 @@ input PluginInfoFilterInput { entryId: LongOperationFilterInput entry: EntryFilterInput pluginGuid: UuidOperationFilterInput - api: IntOperationFilterInput - minmumVersion: StringOperationFilterInput website: StringOperationFilterInput helpPage: StringOperationFilterInput repository: StringOperationFilterInput @@ -547,8 +544,6 @@ input PluginInfoSortInput { entryId: SortEnumType @cost(weight: "10") entry: EntrySortInput @cost(weight: "10") pluginGuid: SortEnumType @cost(weight: "10") - api: SortEnumType @cost(weight: "10") - minmumVersion: SortEnumType @cost(weight: "10") website: SortEnumType @cost(weight: "10") helpPage: SortEnumType @cost(weight: "10") repository: SortEnumType @cost(weight: "10") @@ -575,6 +570,7 @@ input ReleaseFilterInput { downloads: LongOperationFilterInput downloadSize: LongOperationFilterInput md5Hash: StringOperationFilterInput + minimumVersion: LongOperationFilterInput entry: EntryFilterInput entryId: LongOperationFilterInput dependencies: ListFilterInputTypeOfEntryFilterInput @@ -588,6 +584,7 @@ input ReleaseSortInput { downloads: SortEnumType @cost(weight: "10") downloadSize: SortEnumType @cost(weight: "10") md5Hash: SortEnumType @cost(weight: "10") + minimumVersion: SortEnumType @cost(weight: "10") entry: EntrySortInput @cost(weight: "10") entryId: SortEnumType @cost(weight: "10") }