1
0
mirror of https://github.com/Artemis-RGB/Artemis synced 2025-12-13 05:48:35 +00:00

Merge branch 'development'

This commit is contained in:
Robert 2024-07-22 11:02:05 +02:00
commit a793a08213
58 changed files with 1476 additions and 336 deletions

View File

@ -116,6 +116,11 @@ public class PluginInfo : IPrerequisitesSubject
[JsonInclude] [JsonInclude]
public Version? Api { get; internal init; } = new(1, 0, 0); public Version? Api { get; internal init; } = new(1, 0, 0);
/// <summary>
/// Gets the minimum version of Artemis required by this plugin
/// </summary>
public Version? MinimumVersion { get; internal init; } = new(1, 0, 0);
/// <summary> /// <summary>
/// Gets the plugin this info is associated with /// Gets the plugin this info is associated with
/// </summary> /// </summary>
@ -132,7 +137,7 @@ public class PluginInfo : IPrerequisitesSubject
/// Gets a boolean indicating whether this plugin is compatible with the current operating system and API version /// Gets a boolean indicating whether this plugin is compatible with the current operating system and API version
/// </summary> /// </summary>
[JsonIgnore] [JsonIgnore]
public bool IsCompatible => Platforms.MatchesCurrentOperatingSystem() && Api != null && Api.Major >= Constants.PluginApiVersion; public bool IsCompatible => Platforms.MatchesCurrentOperatingSystem() && Api != null && Api.Major >= Constants.PluginApiVersion && MatchesMinimumVersion();
/// <inheritdoc /> /// <inheritdoc />
[JsonIgnore] [JsonIgnore]
@ -156,4 +161,13 @@ public class PluginInfo : IPrerequisitesSubject
{ {
return $"{Name} v{Version} - {Guid}"; return $"{Name} v{Version} - {Guid}";
} }
private bool MatchesMinimumVersion()
{
if (Constants.CurrentVersion == "local")
return true;
Version currentVersion = new(Constants.CurrentVersion);
return currentVersion >= MinimumVersion;
}
} }

View File

@ -43,6 +43,12 @@ public class ArtemisDbContext : DbContext
.HasConversion( .HasConversion(
v => JsonSerializer.Serialize(v, JsonSerializerOptions), v => JsonSerializer.Serialize(v, JsonSerializerOptions),
v => JsonSerializer.Deserialize<Dictionary<string, JsonNode>>(v, JsonSerializerOptions) ?? new Dictionary<string, JsonNode>()); v => JsonSerializer.Deserialize<Dictionary<string, JsonNode>>(v, JsonSerializerOptions) ?? new Dictionary<string, JsonNode>());
modelBuilder.Entity<EntryEntity>()
.Property(e => e.Categories)
.HasConversion(
v => JsonSerializer.Serialize(v, JsonSerializerOptions),
v => JsonSerializer.Deserialize<List<EntryCategoryEntity>>(v, JsonSerializerOptions) ?? new List<EntryCategoryEntity>());
modelBuilder.Entity<ProfileContainerEntity>() modelBuilder.Entity<ProfileContainerEntity>()
.Property(e => e.ProfileConfiguration) .Property(e => e.ProfileConfiguration)

View File

@ -14,11 +14,20 @@ public class EntryEntity
public int EntryType { get; set; } public int EntryType { get; set; }
public string Author { get; set; } = string.Empty; public string Author { get; set; } = string.Empty;
public bool IsOfficial { get; set; }
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public string Summary { get; set; } = string.Empty;
public long Downloads { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public long? LatestReleaseId { get; set; }
public long ReleaseId { get; set; } public long ReleaseId { get; set; }
public string ReleaseVersion { get; set; } = string.Empty; public string ReleaseVersion { get; set; } = string.Empty;
public DateTimeOffset InstalledAt { get; set; } public DateTimeOffset InstalledAt { get; set; }
public bool AutoUpdate { get; set; }
public Dictionary<string, JsonNode>? Metadata { get; set; } public Dictionary<string, JsonNode>? Metadata { get; set; }
} public List<EntryCategoryEntity>? Categories { get; set; }
}
public record EntryCategoryEntity(string Name, string Icon);

View File

@ -0,0 +1,374 @@
// <auto-generated />
using System;
using Artemis.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Artemis.Storage.Migrations
{
[DbContext(typeof(ArtemisDbContext))]
[Migration("20240722084220_AutoUpdating")]
partial class AutoUpdating
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.6");
modelBuilder.Entity("Artemis.Storage.Entities.General.ReleaseEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("InstalledAt")
.HasColumnType("TEXT");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstalledAt");
b.HasIndex("Version")
.IsUnique();
b.ToTable("Releases");
});
modelBuilder.Entity("Artemis.Storage.Entities.Plugins.PluginEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<Guid>("PluginGuid")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PluginGuid")
.IsUnique();
b.ToTable("Plugins");
});
modelBuilder.Entity("Artemis.Storage.Entities.Plugins.PluginFeatureEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<Guid?>("PluginEntityId")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PluginEntityId");
b.ToTable("PluginFeatures");
});
modelBuilder.Entity("Artemis.Storage.Entities.Plugins.PluginSettingEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid>("PluginGuid")
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PluginGuid");
b.HasIndex("Name", "PluginGuid")
.IsUnique();
b.ToTable("PluginSettings");
});
modelBuilder.Entity("Artemis.Storage.Entities.Profile.ProfileCategoryEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("IsCollapsed")
.HasColumnType("INTEGER");
b.Property<bool>("IsSuspended")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("Order")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("ProfileCategories");
});
modelBuilder.Entity("Artemis.Storage.Entities.Profile.ProfileContainerEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<byte[]>("Icon")
.IsRequired()
.HasColumnType("BLOB");
b.Property<string>("Profile")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("ProfileCategoryId")
.HasColumnType("TEXT");
b.Property<string>("ProfileConfiguration")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ProfileCategoryId");
b.ToTable("ProfileContainers");
});
modelBuilder.Entity("Artemis.Storage.Entities.Surface.DeviceEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<float>("BlueScale")
.HasColumnType("REAL");
b.Property<string>("Categories")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DeviceProvider")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<float>("GreenScale")
.HasColumnType("REAL");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<string>("LayoutParameter")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("LayoutType")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("LogicalLayout")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<int>("PhysicalLayout")
.HasColumnType("INTEGER");
b.Property<float>("RedScale")
.HasColumnType("REAL");
b.Property<float>("Rotation")
.HasColumnType("REAL");
b.Property<float>("Scale")
.HasColumnType("REAL");
b.Property<float>("X")
.HasColumnType("REAL");
b.Property<float>("Y")
.HasColumnType("REAL");
b.Property<int>("ZIndex")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Devices");
});
modelBuilder.Entity("Artemis.Storage.Entities.Workshop.EntryEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Author")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("AutoUpdate")
.HasColumnType("INTEGER");
b.Property<string>("Categories")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("Downloads")
.HasColumnType("INTEGER");
b.Property<long>("EntryId")
.HasColumnType("INTEGER");
b.Property<int>("EntryType")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("InstalledAt")
.HasColumnType("TEXT");
b.Property<bool>("IsOfficial")
.HasColumnType("INTEGER");
b.Property<long?>("LatestReleaseId")
.HasColumnType("INTEGER");
b.Property<string>("Metadata")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("ReleaseId")
.HasColumnType("INTEGER");
b.Property<string>("ReleaseVersion")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Summary")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EntryId")
.IsUnique();
b.ToTable("Entries");
});
modelBuilder.Entity("Artemis.Storage.Entities.Plugins.PluginFeatureEntity", b =>
{
b.HasOne("Artemis.Storage.Entities.Plugins.PluginEntity", null)
.WithMany("Features")
.HasForeignKey("PluginEntityId");
});
modelBuilder.Entity("Artemis.Storage.Entities.Profile.ProfileContainerEntity", b =>
{
b.HasOne("Artemis.Storage.Entities.Profile.ProfileCategoryEntity", "ProfileCategory")
.WithMany("ProfileConfigurations")
.HasForeignKey("ProfileCategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ProfileCategory");
});
modelBuilder.Entity("Artemis.Storage.Entities.Surface.DeviceEntity", b =>
{
b.OwnsOne("System.Collections.Generic.List<Artemis.Storage.Entities.Surface.DeviceInputIdentifierEntity>", "InputIdentifiers", b1 =>
{
b1.Property<string>("DeviceEntityId")
.HasColumnType("TEXT");
b1.Property<int>("Capacity")
.HasColumnType("INTEGER");
b1.HasKey("DeviceEntityId");
b1.ToTable("Devices");
b1.ToJson("InputIdentifiers");
b1.WithOwner()
.HasForeignKey("DeviceEntityId");
});
b.OwnsOne("System.Collections.Generic.List<Artemis.Storage.Entities.Surface.InputMappingEntity>", "InputMappings", b1 =>
{
b1.Property<string>("DeviceEntityId")
.HasColumnType("TEXT");
b1.Property<int>("Capacity")
.HasColumnType("INTEGER");
b1.HasKey("DeviceEntityId");
b1.ToTable("Devices");
b1.ToJson("InputMappings");
b1.WithOwner()
.HasForeignKey("DeviceEntityId");
});
b.Navigation("InputIdentifiers")
.IsRequired();
b.Navigation("InputMappings")
.IsRequired();
});
modelBuilder.Entity("Artemis.Storage.Entities.Plugins.PluginEntity", b =>
{
b.Navigation("Features");
});
modelBuilder.Entity("Artemis.Storage.Entities.Profile.ProfileCategoryEntity", b =>
{
b.Navigation("ProfileConfigurations");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,110 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Artemis.Storage.Migrations
{
/// <inheritdoc />
public partial class AutoUpdating : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AutoUpdate",
table: "Entries",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "Categories",
table: "Entries",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "CreatedAt",
table: "Entries",
type: "TEXT",
nullable: false,
defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
migrationBuilder.AddColumn<long>(
name: "Downloads",
table: "Entries",
type: "INTEGER",
nullable: false,
defaultValue: 0L);
migrationBuilder.AddColumn<bool>(
name: "IsOfficial",
table: "Entries",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<long>(
name: "LatestReleaseId",
table: "Entries",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Summary",
table: "Entries",
type: "TEXT",
nullable: false,
defaultValue: "");
// Enable auto-update on all entries that are not profiles
migrationBuilder.Sql("UPDATE Entries SET AutoUpdate = 1 WHERE EntryType != 2");
// Enable auto-update on all entries of profiles that are fresh imports
migrationBuilder.Sql("""
UPDATE Entries
SET AutoUpdate = 1
WHERE EntryType = 2
AND EXISTS (
SELECT 1
FROM ProfileContainers
WHERE json_extract(ProfileContainers.Profile, '$.Id') = json_extract(Entries.Metadata, '$.ProfileId')
AND json_extract(ProfileContainers.Profile, '$.IsFreshImport') = 1
);
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AutoUpdate",
table: "Entries");
migrationBuilder.DropColumn(
name: "Categories",
table: "Entries");
migrationBuilder.DropColumn(
name: "CreatedAt",
table: "Entries");
migrationBuilder.DropColumn(
name: "Downloads",
table: "Entries");
migrationBuilder.DropColumn(
name: "IsOfficial",
table: "Entries");
migrationBuilder.DropColumn(
name: "LatestReleaseId",
table: "Entries");
migrationBuilder.DropColumn(
name: "Summary",
table: "Entries");
}
}
}

View File

@ -15,7 +15,7 @@ namespace Artemis.Storage.Migrations
protected override void BuildModel(ModelBuilder modelBuilder) protected override void BuildModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.2"); modelBuilder.HasAnnotation("ProductVersion", "8.0.6");
modelBuilder.Entity("Artemis.Storage.Entities.General.ReleaseEntity", b => modelBuilder.Entity("Artemis.Storage.Entities.General.ReleaseEntity", b =>
{ {
@ -38,7 +38,7 @@ namespace Artemis.Storage.Migrations
b.HasIndex("Version") b.HasIndex("Version")
.IsUnique(); .IsUnique();
b.ToTable("Releases", (string)null); b.ToTable("Releases");
}); });
modelBuilder.Entity("Artemis.Storage.Entities.Plugins.PluginEntity", b => modelBuilder.Entity("Artemis.Storage.Entities.Plugins.PluginEntity", b =>
@ -58,7 +58,7 @@ namespace Artemis.Storage.Migrations
b.HasIndex("PluginGuid") b.HasIndex("PluginGuid")
.IsUnique(); .IsUnique();
b.ToTable("Plugins", (string)null); b.ToTable("Plugins");
}); });
modelBuilder.Entity("Artemis.Storage.Entities.Plugins.PluginFeatureEntity", b => modelBuilder.Entity("Artemis.Storage.Entities.Plugins.PluginFeatureEntity", b =>
@ -81,7 +81,7 @@ namespace Artemis.Storage.Migrations
b.HasIndex("PluginEntityId"); b.HasIndex("PluginEntityId");
b.ToTable("PluginFeatures", (string)null); b.ToTable("PluginFeatures");
}); });
modelBuilder.Entity("Artemis.Storage.Entities.Plugins.PluginSettingEntity", b => modelBuilder.Entity("Artemis.Storage.Entities.Plugins.PluginSettingEntity", b =>
@ -109,7 +109,7 @@ namespace Artemis.Storage.Migrations
b.HasIndex("Name", "PluginGuid") b.HasIndex("Name", "PluginGuid")
.IsUnique(); .IsUnique();
b.ToTable("PluginSettings", (string)null); b.ToTable("PluginSettings");
}); });
modelBuilder.Entity("Artemis.Storage.Entities.Profile.ProfileCategoryEntity", b => modelBuilder.Entity("Artemis.Storage.Entities.Profile.ProfileCategoryEntity", b =>
@ -137,7 +137,7 @@ namespace Artemis.Storage.Migrations
b.HasIndex("Name") b.HasIndex("Name")
.IsUnique(); .IsUnique();
b.ToTable("ProfileCategories", (string)null); b.ToTable("ProfileCategories");
}); });
modelBuilder.Entity("Artemis.Storage.Entities.Profile.ProfileContainerEntity", b => modelBuilder.Entity("Artemis.Storage.Entities.Profile.ProfileContainerEntity", b =>
@ -165,7 +165,7 @@ namespace Artemis.Storage.Migrations
b.HasIndex("ProfileCategoryId"); b.HasIndex("ProfileCategoryId");
b.ToTable("ProfileContainers", (string)null); b.ToTable("ProfileContainers");
}); });
modelBuilder.Entity("Artemis.Storage.Entities.Surface.DeviceEntity", b => modelBuilder.Entity("Artemis.Storage.Entities.Surface.DeviceEntity", b =>
@ -227,7 +227,7 @@ namespace Artemis.Storage.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Devices", (string)null); b.ToTable("Devices");
}); });
modelBuilder.Entity("Artemis.Storage.Entities.Workshop.EntryEntity", b => modelBuilder.Entity("Artemis.Storage.Entities.Workshop.EntryEntity", b =>
@ -240,6 +240,18 @@ namespace Artemis.Storage.Migrations
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<bool>("AutoUpdate")
.HasColumnType("INTEGER");
b.Property<string>("Categories")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("Downloads")
.HasColumnType("INTEGER");
b.Property<long>("EntryId") b.Property<long>("EntryId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@ -249,6 +261,12 @@ namespace Artemis.Storage.Migrations
b.Property<DateTimeOffset>("InstalledAt") b.Property<DateTimeOffset>("InstalledAt")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<bool>("IsOfficial")
.HasColumnType("INTEGER");
b.Property<long?>("LatestReleaseId")
.HasColumnType("INTEGER");
b.Property<string>("Metadata") b.Property<string>("Metadata")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@ -263,12 +281,16 @@ namespace Artemis.Storage.Migrations
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Summary")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("EntryId") b.HasIndex("EntryId")
.IsUnique(); .IsUnique();
b.ToTable("Entries", (string)null); b.ToTable("Entries");
}); });
modelBuilder.Entity("Artemis.Storage.Entities.Plugins.PluginFeatureEntity", b => modelBuilder.Entity("Artemis.Storage.Entities.Plugins.PluginFeatureEntity", b =>
@ -291,7 +313,7 @@ namespace Artemis.Storage.Migrations
modelBuilder.Entity("Artemis.Storage.Entities.Surface.DeviceEntity", b => modelBuilder.Entity("Artemis.Storage.Entities.Surface.DeviceEntity", b =>
{ {
b.OwnsOne("Artemis.Storage.Entities.Surface.DeviceEntity.InputIdentifiers#System.Collections.Generic.List<Artemis.Storage.Entities.Surface.DeviceInputIdentifierEntity>", "InputIdentifiers", b1 => b.OwnsOne("System.Collections.Generic.List<Artemis.Storage.Entities.Surface.DeviceInputIdentifierEntity>", "InputIdentifiers", b1 =>
{ {
b1.Property<string>("DeviceEntityId") b1.Property<string>("DeviceEntityId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@ -301,7 +323,7 @@ namespace Artemis.Storage.Migrations
b1.HasKey("DeviceEntityId"); b1.HasKey("DeviceEntityId");
b1.ToTable("Devices", (string)null); b1.ToTable("Devices");
b1.ToJson("InputIdentifiers"); b1.ToJson("InputIdentifiers");
@ -309,7 +331,7 @@ namespace Artemis.Storage.Migrations
.HasForeignKey("DeviceEntityId"); .HasForeignKey("DeviceEntityId");
}); });
b.OwnsOne("Artemis.Storage.Entities.Surface.DeviceEntity.InputMappings#System.Collections.Generic.List<Artemis.Storage.Entities.Surface.InputMappingEntity>", "InputMappings", b1 => b.OwnsOne("System.Collections.Generic.List<Artemis.Storage.Entities.Surface.InputMappingEntity>", "InputMappings", b1 =>
{ {
b1.Property<string>("DeviceEntityId") b1.Property<string>("DeviceEntityId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@ -319,7 +341,7 @@ namespace Artemis.Storage.Migrations
b1.HasKey("DeviceEntityId"); b1.HasKey("DeviceEntityId");
b1.ToTable("Devices", (string)null); b1.ToTable("Devices");
b1.ToJson("InputMappings"); b1.ToJson("InputMappings");

View File

@ -79,7 +79,7 @@ internal class Router : CorePropertyChanged, IRouter, IDisposable
path = NavigateUp(_currentRouteSubject.Value, path); path = NavigateUp(_currentRouteSubject.Value, path);
else else
path = path.ToLower().Trim(' ', '/', '\\'); path = path.ToLower().Trim(' ', '/', '\\');
options ??= new RouterNavigationOptions(); options ??= new RouterNavigationOptions();
// Routing takes place on the UI thread with processing heavy tasks offloaded by the router itself // Routing takes place on the UI thread with processing heavy tasks offloaded by the router itself
@ -90,7 +90,7 @@ internal class Router : CorePropertyChanged, IRouter, IDisposable
public async Task Reload() public async Task Reload()
{ {
string path = _currentRouteSubject.Value ?? "blank"; string path = _currentRouteSubject.Value ?? "blank";
// Routing takes place on the UI thread with processing heavy tasks offloaded by the router itself // Routing takes place on the UI thread with processing heavy tasks offloaded by the router itself
await Dispatcher.UIThread.InvokeAsync(async () => await Dispatcher.UIThread.InvokeAsync(async () =>
{ {
@ -128,8 +128,12 @@ internal class Router : CorePropertyChanged, IRouter, IDisposable
await navigation.Navigate(args); await navigation.Navigate(args);
// If it was cancelled before completion, don't add it to history or update the current path // If it was cancelled before completion, don't add it to history or update the current path
// Do reload the current path because it may have been partially navigated away from
if (navigation.Cancelled) if (navigation.Cancelled)
{
await Reload();
return; return;
}
if (options.AddToHistory && previousPath != null) if (options.AddToHistory && previousPath != null)
{ {
@ -172,7 +176,7 @@ internal class Router : CorePropertyChanged, IRouter, IDisposable
public async Task<bool> GoUp(RouterNavigationOptions? options = null) public async Task<bool> GoUp(RouterNavigationOptions? options = null)
{ {
string? currentPath = _currentRouteSubject.Value; string? currentPath = _currentRouteSubject.Value;
// Keep removing segments until we find a parent route that resolves // Keep removing segments until we find a parent route that resolves
while (currentPath != null && currentPath.Contains('/')) while (currentPath != null && currentPath.Contains('/'))
{ {
@ -223,8 +227,8 @@ internal class Router : CorePropertyChanged, IRouter, IDisposable
_logger.Debug("Router disposed, should that be? Stacktrace: \r\n{StackTrace}", Environment.StackTrace); _logger.Debug("Router disposed, should that be? Stacktrace: \r\n{StackTrace}", Environment.StackTrace);
} }
private string NavigateUp(string current, string path) private string NavigateUp(string current, string path)
{ {
string[] pathParts = current.Split('/'); string[] pathParts = current.Split('/');

View File

@ -173,11 +173,6 @@ public interface IProfileEditorService : IArtemisSharedUIService
/// <returns>The command scope that will group any commands until disposed.</returns> /// <returns>The command scope that will group any commands until disposed.</returns>
ProfileEditorCommandScope CreateCommandScope(string name); ProfileEditorCommandScope CreateCommandScope(string name);
/// <summary>
/// Saves the current profile.
/// </summary>
void SaveProfile();
/// <summary> /// <summary>
/// Asynchronously saves the current profile. /// Asynchronously saves the current profile.
/// </summary> /// </summary>

View File

@ -391,19 +391,12 @@ internal class ProfileEditorService : IProfileEditorService
_pixelsPerSecondSubject.OnNext(pixelsPerSecond); _pixelsPerSecondSubject.OnNext(pixelsPerSecond);
} }
/// <inheritdoc />
public void SaveProfile()
{
Profile? profile = _profileConfigurationSubject.Value?.Profile;
if (profile != null)
_profileService.SaveProfile(profile, true);
}
/// <inheritdoc /> /// <inheritdoc />
public async Task SaveProfileAsync() public async Task SaveProfileAsync()
{ {
await Task.Run(SaveProfile); Profile? profile = _profileConfigurationSubject.Value?.Profile;
if (profile != null)
await Task.Run(() => _profileService.SaveProfile(profile, true));
} }
/// <inheritdoc /> /// <inheritdoc />

View File

@ -1,17 +1,15 @@
using System; using System;
using System.ComponentModel; using System.ComponentModel;
using System.Globalization; using System.Globalization;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Windows.UI.Notifications; using Windows.UI.Notifications;
using Artemis.UI.Screens.Settings; using Artemis.UI.Services.Interfaces;
using Artemis.UI.Services.Updating; using Artemis.UI.Services.Updating;
using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Routing;
using Artemis.UI.Shared.Services.MainWindow; using Artemis.UI.Shared.Services.MainWindow;
using Avalonia.Threading; using Avalonia.Threading;
using Microsoft.Toolkit.Uwp.Notifications; using Microsoft.Toolkit.Uwp.Notifications;
using ReactiveUI;
namespace Artemis.UI.Windows.Providers; namespace Artemis.UI.Windows.Providers;
@ -20,18 +18,34 @@ public class WindowsUpdateNotificationProvider : IUpdateNotificationProvider
private readonly Func<Guid, ReleaseInstaller> _getReleaseInstaller; private readonly Func<Guid, ReleaseInstaller> _getReleaseInstaller;
private readonly IMainWindowService _mainWindowService; private readonly IMainWindowService _mainWindowService;
private readonly IUpdateService _updateService; private readonly IUpdateService _updateService;
private readonly IWorkshopUpdateService _workshopUpdateService;
private readonly IRouter _router; private readonly IRouter _router;
private CancellationTokenSource? _cancellationTokenSource; private CancellationTokenSource? _cancellationTokenSource;
public WindowsUpdateNotificationProvider(IMainWindowService mainWindowService, IUpdateService updateService, IRouter router, Func<Guid, ReleaseInstaller> getReleaseInstaller) public WindowsUpdateNotificationProvider(IMainWindowService mainWindowService,
IUpdateService updateService,
IWorkshopUpdateService workshopUpdateService,
IRouter router, Func<Guid, ReleaseInstaller> getReleaseInstaller)
{ {
_mainWindowService = mainWindowService; _mainWindowService = mainWindowService;
_updateService = updateService; _updateService = updateService;
_workshopUpdateService = workshopUpdateService;
_router = router; _router = router;
_getReleaseInstaller = getReleaseInstaller; _getReleaseInstaller = getReleaseInstaller;
ToastNotificationManagerCompat.OnActivated += ToastNotificationManagerCompatOnOnActivated; ToastNotificationManagerCompat.OnActivated += ToastNotificationManagerCompatOnOnActivated;
} }
/// <inheritdoc />
public void ShowWorkshopNotification(int updatedEntries)
{
new ToastContentBuilder().AddText(updatedEntries == 1 ? "Workshop update installed" : "Workshop updates installed")
.AddText(updatedEntries == 1 ? "A workshop update has been installed" : $"{updatedEntries} workshop updates have been installed")
.AddArgument("action", "view-library")
.AddButton(new ToastButton().SetContent("View changes").AddArgument("action", "view-library"))
.AddButton(new ToastButton().SetContent("Don't show again").AddArgument("action", "disable-workshop-notifications"))
.Show();
}
/// <inheritdoc /> /// <inheritdoc />
public void ShowNotification(Guid releaseId, string releaseVersion) public void ShowNotification(Guid releaseId, string releaseVersion)
{ {
@ -57,14 +71,8 @@ public class WindowsUpdateNotificationProvider : IUpdateNotificationProvider
private void ViewRelease(Guid? releaseId) private void ViewRelease(Guid? releaseId)
{ {
Dispatcher.UIThread.Invoke(async () => string route = releaseId != null && releaseId.Value != Guid.Empty ? $"settings/releases/{releaseId}" : "settings/releases";
{ NavigateToRoute(route);
_mainWindowService.OpenMainWindow();
if (releaseId != null && releaseId.Value != Guid.Empty)
await _router.Navigate($"settings/releases/{releaseId}");
else
await _router.Navigate("settings/releases");
});
} }
private async Task InstallRelease(Guid releaseId, string releaseVersion) private async Task InstallRelease(Guid releaseId, string releaseVersion)
@ -153,11 +161,9 @@ public class WindowsUpdateNotificationProvider : IUpdateNotificationProvider
ToastArguments args = ToastArguments.Parse(e.Argument); ToastArguments args = ToastArguments.Parse(e.Argument);
Guid releaseId = args.Contains("releaseId") ? Guid.Parse(args.Get("releaseId")) : Guid.Empty; Guid releaseId = args.Contains("releaseId") ? Guid.Parse(args.Get("releaseId")) : Guid.Empty;
string releaseVersion = args.Get("releaseVersion"); string releaseVersion = args.Contains("releaseVersion") ? args.Get("releaseVersion") : string.Empty;
string action = "view-changes"; string action = args.Contains("action") ? args.Get("action") : "view-changes";
if (args.Contains("action"))
action = args.Get("action");
if (action == "install") if (action == "install")
await InstallRelease(releaseId, releaseVersion); await InstallRelease(releaseId, releaseVersion);
else if (action == "view-changes") else if (action == "view-changes")
@ -166,5 +172,18 @@ public class WindowsUpdateNotificationProvider : IUpdateNotificationProvider
_cancellationTokenSource?.Cancel(); _cancellationTokenSource?.Cancel();
else if (action == "restart-for-update") else if (action == "restart-for-update")
_updateService.RestartForUpdate("WindowsNotification", false); _updateService.RestartForUpdate("WindowsNotification", false);
else if (action == "disable-workshop-notifications")
_workshopUpdateService.DisableNotifications();
else if (action == "view-library")
NavigateToRoute("workshop/library");
}
private void NavigateToRoute(string route)
{
Dispatcher.UIThread.Invoke(async () =>
{
_mainWindowService.OpenMainWindow();
await _router.Navigate(route);
});
} }
} }

View File

@ -36,7 +36,7 @@ public partial class WorkshopLayoutViewModel : ActivatableViewModelBase, ILayout
Entries = new ObservableCollection<InstalledEntry>(workshopService.GetInstalledEntries().Where(e => e.EntryType == EntryType.Layout)); Entries = new ObservableCollection<InstalledEntry>(workshopService.GetInstalledEntries().Where(e => e.EntryType == EntryType.Layout));
this.WhenAnyValue(vm => vm.SelectedEntry).Subscribe(ApplyEntry); this.WhenAnyValue(vm => vm.SelectedEntry).Subscribe(ApplyEntry);
this.WhenActivated((CompositeDisposable _) => SelectedEntry = Entries.FirstOrDefault(e => e.EntryId.ToString() == Device.LayoutSelection.Parameter)); this.WhenActivated((CompositeDisposable _) => SelectedEntry = Entries.FirstOrDefault(e => e.Id.ToString() == Device.LayoutSelection.Parameter));
} }
/// <inheritdoc /> /// <inheritdoc />
@ -70,7 +70,7 @@ public partial class WorkshopLayoutViewModel : ActivatableViewModelBase, ILayout
private void ApplyEntry(InstalledEntry? entry) private void ApplyEntry(InstalledEntry? entry)
{ {
if (entry == null || Device.LayoutSelection.Parameter == entry.EntryId.ToString()) if (entry == null || Device.LayoutSelection.Parameter == entry.Id.ToString())
return; return;
_layoutProvider.ConfigureDevice(Device, entry); _layoutProvider.ConfigureDevice(Device, entry);
Save(); Save();

View File

@ -4,8 +4,10 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:home="clr-namespace:Artemis.UI.Screens.Home"
mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="900" mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="900"
x:Class="Artemis.UI.Screens.Home.HomeView"> x:Class="Artemis.UI.Screens.Home.HomeView"
x:DataType="home:HomeViewModel">
<Border Classes="router-container"> <Border Classes="router-container">
<Grid RowDefinitions="200,*"> <Grid RowDefinitions="200,*">
<Image Grid.Row="0" <Image Grid.Row="0"
@ -40,7 +42,7 @@
Under Settings > Plugins you can find your currently installed plugins, these default plugins are created by Artemis developers. We're also keeping track of a list of third-party plugins on our wiki. Under Settings > Plugins you can find your currently installed plugins, these default plugins are created by Artemis developers. We're also keeping track of a list of third-party plugins on our wiki.
</TextBlock> </TextBlock>
</StackPanel> </StackPanel>
<controls:HyperlinkButton Grid.Row="1" Grid.ColumnSpan="2" Grid.Column="0" NavigateUri="https://wiki.artemis-rgb.com/en/guides/user/plugins?mtm_campaign=artemis&amp;mtm_kwd=home" HorizontalAlignment="Right"> <controls:HyperlinkButton Grid.Row="1" Grid.ColumnSpan="2" Grid.Column="0" HorizontalAlignment="Right" Command="{CompiledBinding GetMorePlugins}">
<controls:HyperlinkButton.ContextMenu> <controls:HyperlinkButton.ContextMenu>
<ContextMenu> <ContextMenu>
<MenuItem Header="Test"></MenuItem> <MenuItem Header="Test"></MenuItem>

View File

@ -1,4 +1,5 @@
using Artemis.Core.Services; using System.Threading.Tasks;
using Artemis.Core.Services;
using Artemis.UI.Screens.StartupWizard; using Artemis.UI.Screens.StartupWizard;
using Artemis.UI.Shared; using Artemis.UI.Shared;
using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Routing;
@ -9,12 +10,20 @@ namespace Artemis.UI.Screens.Home;
public class HomeViewModel : RoutableScreen, IMainScreenViewModel public class HomeViewModel : RoutableScreen, IMainScreenViewModel
{ {
public HomeViewModel(ISettingsService settingsService, IWindowService windowService) private readonly IRouter _router;
public HomeViewModel(IRouter router, ISettingsService settingsService, IWindowService windowService)
{ {
_router = router;
// Show the startup wizard if it hasn't been completed // Show the startup wizard if it hasn't been completed
if (!settingsService.GetSetting("UI.SetupWizardCompleted", false).Value) if (!settingsService.GetSetting("UI.SetupWizardCompleted", false).Value)
Dispatcher.UIThread.InvokeAsync(async () => await windowService.ShowDialogAsync<StartupWizardViewModel, bool>()); Dispatcher.UIThread.InvokeAsync(async () => await windowService.ShowDialogAsync<StartupWizardViewModel, bool>());
} }
public ViewModelBase? TitleBarViewModel => null; public ViewModelBase? TitleBarViewModel => null;
public async Task GetMorePlugins()
{
await _router.Navigate("workshop/entries/plugins");
}
} }

View File

@ -12,7 +12,6 @@ using Artemis.UI.Shared;
using Artemis.UI.Shared.Services; using Artemis.UI.Shared.Services;
using Artemis.UI.Shared.Services.ProfileEditor; using Artemis.UI.Shared.Services.ProfileEditor;
using Artemis.UI.Shared.Services.ProfileEditor.Commands; using Artemis.UI.Shared.Services.ProfileEditor.Commands;
using Avalonia.Threading;
using ReactiveUI; using ReactiveUI;
namespace Artemis.UI.Screens.ProfileEditor.Properties.DataBinding; namespace Artemis.UI.Screens.ProfileEditor.Properties.DataBinding;
@ -23,9 +22,9 @@ public class DataBindingViewModel : ActivatableViewModelBase
private readonly IProfileEditorService _profileEditorService; private readonly IProfileEditorService _profileEditorService;
private readonly IWindowService _windowService; private readonly IWindowService _windowService;
private ObservableAsPropertyHelper<bool>? _dataBindingEnabled; private ObservableAsPropertyHelper<bool>? _dataBindingEnabled;
private bool _editorOpen;
private ObservableAsPropertyHelper<ILayerProperty?>? _layerProperty; private ObservableAsPropertyHelper<ILayerProperty?>? _layerProperty;
private ObservableAsPropertyHelper<NodeScriptViewModel?>? _nodeScriptViewModel; private ObservableAsPropertyHelper<NodeScriptViewModel?>? _nodeScriptViewModel;
private bool _editorOpen;
private bool _playing; private bool _playing;
public DataBindingViewModel(IProfileEditorService profileEditorService, INodeVmFactory nodeVmFactory, IWindowService windowService, ISettingsService settingsService) public DataBindingViewModel(IProfileEditorService profileEditorService, INodeVmFactory nodeVmFactory, IWindowService windowService, ISettingsService settingsService)
@ -106,6 +105,6 @@ public class DataBindingViewModel : ActivatableViewModelBase
private void Save() private void Save()
{ {
if (!_editorOpen) if (!_editorOpen)
_profileEditorService.SaveProfile(); _profileEditorService.SaveProfileAsync();
} }
} }

View File

@ -15,8 +15,11 @@ using Artemis.UI.Screens.ProfileEditor.StatusBar;
using Artemis.UI.Screens.ProfileEditor.VisualEditor; using Artemis.UI.Screens.ProfileEditor.VisualEditor;
using Artemis.UI.Shared; using Artemis.UI.Shared;
using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Routing;
using Artemis.UI.Shared.Services;
using Artemis.UI.Shared.Services.MainWindow; using Artemis.UI.Shared.Services.MainWindow;
using Artemis.UI.Shared.Services.ProfileEditor; using Artemis.UI.Shared.Services.ProfileEditor;
using Artemis.WebClient.Workshop.Models;
using Artemis.WebClient.Workshop.Services;
using DynamicData; using DynamicData;
using DynamicData.Binding; using DynamicData.Binding;
using PropertyChanged.SourceGenerator; using PropertyChanged.SourceGenerator;
@ -30,10 +33,12 @@ public partial class ProfileEditorViewModel : RoutableScreen<ProfileEditorViewMo
private readonly IProfileService _profileService; private readonly IProfileService _profileService;
private readonly ISettingsService _settingsService; private readonly ISettingsService _settingsService;
private readonly IMainWindowService _mainWindowService; private readonly IMainWindowService _mainWindowService;
private readonly IWorkshopService _workshopService;
private readonly IWindowService _windowService;
private readonly SourceList<IToolViewModel> _tools; private readonly SourceList<IToolViewModel> _tools;
private ObservableAsPropertyHelper<ProfileEditorHistory?>? _history; private ObservableAsPropertyHelper<ProfileEditorHistory?>? _history;
private ObservableAsPropertyHelper<bool>? _suspendedEditing; private ObservableAsPropertyHelper<bool>? _suspendedEditing;
[Notify] private ProfileConfiguration? _profileConfiguration; [Notify] private ProfileConfiguration? _profileConfiguration;
/// <inheritdoc /> /// <inheritdoc />
@ -48,12 +53,16 @@ public partial class ProfileEditorViewModel : RoutableScreen<ProfileEditorViewMo
StatusBarViewModel statusBarViewModel, StatusBarViewModel statusBarViewModel,
IEnumerable<IToolViewModel> toolViewModels, IEnumerable<IToolViewModel> toolViewModels,
IMainWindowService mainWindowService, IMainWindowService mainWindowService,
IInputService inputService) IInputService inputService,
IWorkshopService workshopService,
IWindowService windowService)
{ {
_profileService = profileService; _profileService = profileService;
_profileEditorService = profileEditorService; _profileEditorService = profileEditorService;
_settingsService = settingsService; _settingsService = settingsService;
_mainWindowService = mainWindowService; _mainWindowService = mainWindowService;
_workshopService = workshopService;
_windowService = windowService;
_tools = new SourceList<IToolViewModel>(); _tools = new SourceList<IToolViewModel>();
_tools.AddRange(toolViewModels); _tools.AddRange(toolViewModels);
@ -144,7 +153,7 @@ public partial class ProfileEditorViewModel : RoutableScreen<ProfileEditorViewMo
{ {
if (!Shared.UI.KeyBindingsEnabled || !_mainWindowService.IsMainWindowFocused) if (!Shared.UI.KeyBindingsEnabled || !_mainWindowService.IsMainWindowFocused)
return; return;
if (e.Modifiers == KeyboardModifierKey.Control && e.Key == KeyboardKey.Z) if (e.Modifiers == KeyboardModifierKey.Control && e.Key == KeyboardKey.Z)
History?.Undo.Execute().Subscribe(); History?.Undo.Execute().Subscribe();
else if (e.Modifiers == KeyboardModifierKey.Control && e.Key == KeyboardKey.Y) else if (e.Modifiers == KeyboardModifierKey.Control && e.Key == KeyboardKey.Y)
@ -195,6 +204,23 @@ public partial class ProfileEditorViewModel : RoutableScreen<ProfileEditorViewMo
return; return;
} }
// If the profile is from the workshop, warn the user that auto-updates will be disabled
InstalledEntry? workshopEntry = _workshopService.GetInstalledEntryByProfile(profileConfiguration);
if (workshopEntry != null && workshopEntry.AutoUpdate)
{
bool confirmed = await _windowService.ShowConfirmContentDialog(
"Editing a workshop profile",
"You are about to edit a profile from the workshop, to preserve your changes auto-updating will be disabled.",
"Disable auto-update");
if (confirmed)
_workshopService.SetAutoUpdate(workshopEntry, false);
else
{
args.Cancel();
return;
}
}
await _profileEditorService.ChangeCurrentProfileConfiguration(profileConfiguration); await _profileEditorService.ChangeCurrentProfileConfiguration(profileConfiguration);
ProfileConfiguration = profileConfiguration; ProfileConfiguration = profileConfiguration;
} }

View File

@ -194,7 +194,7 @@
Auto-install updates Auto-install updates
</TextBlock> </TextBlock>
<TextBlock Classes="subtitle" TextWrapping="Wrap"> <TextBlock Classes="subtitle" TextWrapping="Wrap">
If enabled, new updates will automatically be installed. Automatically install new versions of Artemis in the background when available.
</TextBlock> </TextBlock>
</StackPanel> </StackPanel>
<StackPanel Grid.Row="0" Grid.Column="1" VerticalAlignment="Center"> <StackPanel Grid.Row="0" Grid.Column="1" VerticalAlignment="Center">
@ -202,6 +202,21 @@
</StackPanel> </StackPanel>
</Grid> </Grid>
<Border Classes="card-separator" /> <Border Classes="card-separator" />
<Grid RowDefinitions="*,*" ColumnDefinitions="*,Auto" IsVisible="{CompiledBinding IsWindows}">
<StackPanel Grid.Column="0">
<TextBlock>
Show workshop update notifications
</TextBlock>
<TextBlock Classes="subtitle" TextWrapping="Wrap">
Show a desktop notification whenever workshop updates are installed.
</TextBlock>
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="1" VerticalAlignment="Center">
<ToggleSwitch IsChecked="{CompiledBinding WorkshopShowNotifications.Value}" MinWidth="0" OnContent="Yes" OffContent="No" />
</StackPanel>
</Grid>
<Border Classes="card-separator" />
<Grid RowDefinitions="*,*" ColumnDefinitions="*,Auto"> <Grid RowDefinitions="*,*" ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" VerticalAlignment="Center"> <StackPanel Grid.Column="0" VerticalAlignment="Center">

View File

@ -102,6 +102,7 @@ public class GeneralTabViewModel : RoutableScreen
public bool IsAutoRunSupported => _autoRunProvider != null; public bool IsAutoRunSupported => _autoRunProvider != null;
public bool IsWindows11 => OSVersionHelper.IsWindows11(); public bool IsWindows11 => OSVersionHelper.IsWindows11();
public bool IsWindows => OSVersionHelper.IsWindows();
public ObservableCollection<LayerBrushDescriptor> LayerBrushDescriptors { get; } public ObservableCollection<LayerBrushDescriptor> LayerBrushDescriptors { get; }
public ObservableCollection<string> GraphicsContexts { get; } public ObservableCollection<string> GraphicsContexts { get; }
@ -158,6 +159,7 @@ public class GeneralTabViewModel : RoutableScreen
public PluginSetting<bool> UIShowOnStartup => _settingsService.GetSetting("UI.ShowOnStartup", true); public PluginSetting<bool> UIShowOnStartup => _settingsService.GetSetting("UI.ShowOnStartup", true);
public PluginSetting<bool> EnableMica => _settingsService.GetSetting("UI.EnableMica", true); public PluginSetting<bool> EnableMica => _settingsService.GetSetting("UI.EnableMica", true);
public PluginSetting<bool> UICheckForUpdates => _settingsService.GetSetting("UI.Updating.AutoCheck", true); public PluginSetting<bool> UICheckForUpdates => _settingsService.GetSetting("UI.Updating.AutoCheck", true);
public PluginSetting<bool> WorkshopShowNotifications => _settingsService.GetSetting("Workshop.ShowNotifications", true);
public PluginSetting<bool> UIAutoUpdate => _settingsService.GetSetting("UI.Updating.AutoInstall", true); public PluginSetting<bool> UIAutoUpdate => _settingsService.GetSetting("UI.Updating.AutoInstall", true);
public PluginSetting<bool> ProfileEditorShowDataModelValues => _settingsService.GetSetting("ProfileEditor.ShowDataModelValues", false); public PluginSetting<bool> ProfileEditorShowDataModelValues => _settingsService.GetSetting("ProfileEditor.ShowDataModelValues", false);
public PluginSetting<LogEventLevel> CoreLoggingLevel => _settingsService.GetSetting("Core.LoggingLevel", LogEventLevel.Information); public PluginSetting<LogEventLevel> CoreLoggingLevel => _settingsService.GetSetting("Core.LoggingLevel", LogEventLevel.Information);

View File

@ -17,7 +17,7 @@
<TextBox Classes="clearButton" Text="{CompiledBinding SearchPluginInput}" Watermark="Search plugins" Margin="0 0 10 0" /> <TextBox Classes="clearButton" Text="{CompiledBinding SearchPluginInput}" Watermark="Search plugins" Margin="0 0 10 0" />
<StackPanel Spacing="5" Grid.Row="0" Grid.Column="1" HorizontalAlignment="Right" Orientation="Horizontal"> <StackPanel Spacing="5" Grid.Row="0" Grid.Column="1" HorizontalAlignment="Right" Orientation="Horizontal">
<controls:HyperlinkButton VerticalAlignment="Top" NavigateUri="https://wiki.artemis-rgb.com/en/guides/user/plugins?mtm_campaign=artemis&amp;mtm_kwd=plugins"> <controls:HyperlinkButton VerticalAlignment="Top" Command="{CompiledBinding GetMorePlugins}">
Get more plugins Get more plugins
</controls:HyperlinkButton> </controls:HyperlinkButton>
<Button Classes="accent" Command="{CompiledBinding ImportPlugin}">Import plugin</Button> <Button Classes="accent" Command="{CompiledBinding ImportPlugin}">Import plugin</Button>

View File

@ -1,3 +1,4 @@
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI; using Avalonia.ReactiveUI;
@ -9,5 +10,4 @@ public partial class PluginsTabView : ReactiveUserControl<PluginsTabViewModel>
{ {
InitializeComponent(); InitializeComponent();
} }
} }

View File

@ -23,12 +23,14 @@ namespace Artemis.UI.Screens.Settings;
public partial class PluginsTabViewModel : RoutableScreen public partial class PluginsTabViewModel : RoutableScreen
{ {
private readonly INotificationService _notificationService; private readonly INotificationService _notificationService;
private readonly IRouter _router;
private readonly IPluginManagementService _pluginManagementService; private readonly IPluginManagementService _pluginManagementService;
private readonly IWindowService _windowService; private readonly IWindowService _windowService;
[Notify] private string? _searchPluginInput; [Notify] private string? _searchPluginInput;
public PluginsTabViewModel(IPluginManagementService pluginManagementService, INotificationService notificationService, IWindowService windowService, ISettingsVmFactory settingsVmFactory) public PluginsTabViewModel(IRouter router, IPluginManagementService pluginManagementService, INotificationService notificationService, IWindowService windowService, ISettingsVmFactory settingsVmFactory)
{ {
_router = router;
_pluginManagementService = pluginManagementService; _pluginManagementService = pluginManagementService;
_notificationService = notificationService; _notificationService = notificationService;
_windowService = windowService; _windowService = windowService;
@ -113,4 +115,9 @@ public partial class PluginsTabViewModel : RoutableScreen
return data => data.Info.Name.Contains(text, StringComparison.InvariantCultureIgnoreCase) || return data => data.Info.Name.Contains(text, StringComparison.InvariantCultureIgnoreCase) ||
(data.Info.Description != null && data.Info.Description.Contains(text, StringComparison.InvariantCultureIgnoreCase)); (data.Info.Description != null && data.Info.Description.Contains(text, StringComparison.InvariantCultureIgnoreCase));
} }
public async Task GetMorePlugins()
{
await _router.Navigate("workshop/entries/plugins");
}
} }

View File

@ -59,8 +59,18 @@
Text="{CompiledBinding Entry.Name, FallbackValue=Title}" Text="{CompiledBinding Entry.Name, FallbackValue=Title}"
Margin="0 15" /> Margin="0 15" />
<TextBlock Classes="subtitle" TextTrimming="CharacterEllipsis" Text="{CompiledBinding Entry.Author, FallbackValue=Author}" /> <StackPanel Orientation="Horizontal">
<TextBlock Classes="subtitle" TextTrimming="CharacterEllipsis" Text="{CompiledBinding Entry.Author, FallbackValue=Author}" />
<avalonia:MaterialIcon IsVisible="{CompiledBinding Entry.IsOfficial}"
Kind="ShieldStar"
Foreground="{DynamicResource SystemAccentColorLight1}"
Margin="2 0 0 0"
Width="18"
Height="18"
HorizontalAlignment="Left"
ToolTip.Tip="Official entry by the Artemis team" />
</StackPanel>
<TextBlock Margin="0 8" TextWrapping="Wrap" Text="{CompiledBinding Entry.Summary, FallbackValue=Summary}" /> <TextBlock Margin="0 8" TextWrapping="Wrap" Text="{CompiledBinding Entry.Summary, FallbackValue=Summary}" />
<!-- Categories --> <!-- Categories -->

View File

@ -37,11 +37,23 @@
<!-- Body --> <!-- Body -->
<Grid Grid.Column="1" Grid.Row="0" Grid.RowSpan="2" VerticalAlignment="Stretch" RowDefinitions="Auto,*,Auto"> <Grid Grid.Column="1" Grid.Row="0" Grid.RowSpan="2" VerticalAlignment="Stretch" RowDefinitions="Auto,*,Auto">
<TextBlock Grid.Row="0" Margin="0 0 0 5" TextTrimming="CharacterEllipsis"> <StackPanel Grid.Row="0" Orientation="Horizontal">
<Run Classes="h5" Text="{CompiledBinding Entry.Name, FallbackValue=Title}" /> <TextBlock Margin="0 0 0 5" TextTrimming="CharacterEllipsis">
<Run Classes="subtitle">by</Run> <Run Classes="h5" Text="{CompiledBinding Entry.Name, FallbackValue=Title}" />
<Run Classes="subtitle" Text="{CompiledBinding Entry.Author, FallbackValue=Author}" /> <Run Classes="subtitle">by</Run>
</TextBlock> <Run Classes="subtitle" Text="{CompiledBinding Entry.Author, FallbackValue=Author}" />
</TextBlock>
<avalonia:MaterialIcon
IsVisible="{CompiledBinding Entry.IsOfficial}"
Kind="ShieldStar"
Foreground="{DynamicResource SystemAccentColorLight1}"
Margin="2 -2 0 0"
Width="18"
Height="18"
HorizontalAlignment="Left"
ToolTip.Tip="Official entry by the Artemis team" />
</StackPanel>
<TextBlock Grid.Row="1" <TextBlock Grid.Row="1"
Classes="subtitle" Classes="subtitle"
TextWrapping="Wrap" TextWrapping="Wrap"
@ -75,15 +87,15 @@
<Run>downloads</Run> <Run>downloads</Run>
</TextBlock> </TextBlock>
</StackPanel> </StackPanel>
<!-- Install state --> <!-- Install state -->
<StackPanel Grid.Column="2" Grid.Row="1" Margin="0 0 4 0" HorizontalAlignment="Right" VerticalAlignment="Bottom" IsVisible="{CompiledBinding IsInstalled}"> <StackPanel Grid.Column="2" Grid.Row="1" Margin="0 0 4 0" HorizontalAlignment="Right" VerticalAlignment="Bottom" IsVisible="{CompiledBinding IsInstalled}">
<TextBlock TextAlignment="Right" IsVisible="{CompiledBinding !UpdateAvailable}"> <TextBlock TextAlignment="Right" IsVisible="{CompiledBinding !UpdateAvailable}">
<avalonia:MaterialIcon Kind="CheckCircle" Foreground="{DynamicResource SystemAccentColorLight1}" Width="20" Height="20"/> <avalonia:MaterialIcon Kind="CheckCircle" Foreground="{DynamicResource SystemAccentColorLight1}" Width="20" Height="20" />
<Run>installed</Run> <Run>installed</Run>
</TextBlock> </TextBlock>
<TextBlock TextAlignment="Right" IsVisible="{CompiledBinding UpdateAvailable}"> <TextBlock TextAlignment="Right" IsVisible="{CompiledBinding UpdateAvailable}">
<avalonia:MaterialIcon Kind="Update" Foreground="{DynamicResource SystemAccentColorLight1}" Width="20" Height="20"/> <avalonia:MaterialIcon Kind="Update" Foreground="{DynamicResource SystemAccentColorLight1}" Width="20" Height="20" />
<Run>update available</Run> <Run>update available</Run>
</TextBlock> </TextBlock>
</StackPanel> </StackPanel>

View File

@ -15,8 +15,17 @@
</Styles> </Styles>
</UserControl.Styles> </UserControl.Styles>
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,*,Auto"> <Grid RowDefinitions="Auto,*,Auto" HorizontalAlignment="Stretch" MaxWidth="1330">
<StackPanel Grid.Column="0" Grid.RowSpan="3" Margin="0 0 10 0" VerticalAlignment="Top" Width="300" IsVisible="{CompiledBinding ShowCategoryFilter}"> <Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Grid.RowSpan="3"
Margin="0 0 10 0"
VerticalAlignment="Top"
HorizontalAlignment="Right"
Width="300"
IsVisible="{CompiledBinding ShowCategoryFilter}">
<Border Classes="card" VerticalAlignment="Stretch"> <Border Classes="card" VerticalAlignment="Stretch">
<StackPanel> <StackPanel>
<TextBlock Theme="{StaticResource SubtitleTextBlockStyle}">Categories</TextBlock> <TextBlock Theme="{StaticResource SubtitleTextBlockStyle}">Categories</TextBlock>
@ -27,7 +36,7 @@
</StackPanel> </StackPanel>
<ProgressBar Grid.Column="1" Grid.Row="0" VerticalAlignment="Top" Margin="0 0 20 0" IsVisible="{CompiledBinding FetchingMore}" IsIndeterminate="True" /> <ProgressBar Grid.Column="1" Grid.Row="0" VerticalAlignment="Top" Margin="0 0 20 0" IsVisible="{CompiledBinding FetchingMore}" IsIndeterminate="True" />
<ContentControl Grid.Column="1" Grid.Row="0" Margin="0 0 20 8" Content="{CompiledBinding InputViewModel}" /> <ContentControl Grid.Column="1" Grid.Row="0" Margin="0 0 20 8" Content="{CompiledBinding InputViewModel}"/>
<ScrollViewer Name="EntriesScrollViewer" Grid.Column="1" Grid.Row="1" ScrollChanged="ScrollViewer_OnScrollChanged" Offset="{CompiledBinding ScrollOffset}"> <ScrollViewer Name="EntriesScrollViewer" Grid.Column="1" Grid.Row="1" ScrollChanged="ScrollViewer_OnScrollChanged" Offset="{CompiledBinding ScrollOffset}">
<ItemsControl ItemsSource="{CompiledBinding Entries}" Margin="0 0 20 0"> <ItemsControl ItemsSource="{CompiledBinding Entries}" Margin="0 0 20 0">
@ -38,7 +47,7 @@
</ItemsControl.ItemsPanel> </ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate> <DataTemplate>
<ContentControl Content="{CompiledBinding}" Margin="0 0 0 5"></ContentControl> <ContentControl Content="{CompiledBinding}" Margin="0 0 0 8"></ContentControl>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>

View File

@ -101,20 +101,35 @@ public partial class EntryReleaseInfoViewModel : ActivatableViewModelBase
return; return;
} }
// If not the latest version, warn and offer to disable auto-updates
bool disableAutoUpdates = false;
if (Release.Id != Release.Entry.LatestReleaseId)
{
disableAutoUpdates = await _windowService.ShowConfirmContentDialog(
"You are installing an older version of this entry",
"Would you like to disable auto-updates for this entry?",
"Yes",
"No"
);
}
_cts = new CancellationTokenSource(); _cts = new CancellationTokenSource();
InstallProgress = 0; InstallProgress = 0;
InstallationInProgress = true; InstallationInProgress = true;
try try
{ {
EntryInstallResult result = await _workshopService.InstallEntry(Release.Entry, Release, _progress, _cts.Token); EntryInstallResult result = await _workshopService.InstallEntry(Release.Entry, Release, _progress, _cts.Token);
if (result.IsSuccess) if (result.IsSuccess && result.Entry != null)
{ {
_workshopService.SetAutoUpdate(result.Entry, !disableAutoUpdates);
_notificationService.CreateNotification().WithTitle("Installation succeeded").WithSeverity(NotificationSeverity.Success).Show(); _notificationService.CreateNotification().WithTitle("Installation succeeded").WithSeverity(NotificationSeverity.Success).Show();
InstallationInProgress = false; InstallationInProgress = false;
await Manage(); await Manage();
} }
else if (!_cts.IsCancellationRequested) else if (!_cts.IsCancellationRequested)
{
_notificationService.CreateNotification().WithTitle("Installation failed").WithMessage(result.Message).WithSeverity(NotificationSeverity.Error).Show(); _notificationService.CreateNotification().WithTitle("Installation failed").WithMessage(result.Message).WithSeverity(NotificationSeverity.Error).Show();
}
} }
catch (Exception e) catch (Exception e)
{ {
@ -156,7 +171,7 @@ public partial class EntryReleaseInfoViewModel : ActivatableViewModelBase
await UninstallPluginPrerequisites(installedEntry); await UninstallPluginPrerequisites(installedEntry);
await _workshopService.UninstallEntry(installedEntry, CancellationToken.None); await _workshopService.UninstallEntry(installedEntry, CancellationToken.None);
_notificationService.CreateNotification().WithTitle("Entry uninstalled").WithSeverity(NotificationSeverity.Success).Show(); _notificationService.CreateNotification().WithTitle("Entry uninstalled").WithSeverity(NotificationSeverity.Success).Show();
} }
finally finally

View File

@ -8,7 +8,7 @@
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="800" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="800"
x:Class="Artemis.UI.Screens.Workshop.Layout.LayoutDetailsView" x:Class="Artemis.UI.Screens.Workshop.Layout.LayoutDetailsView"
x:DataType="layout:LayoutDetailsViewModel"> x:DataType="layout:LayoutDetailsViewModel">
<Grid ColumnDefinitions="300,*, 300" RowDefinitions="Auto,*"> <Grid RowDefinitions="Auto,*" ColumnDefinitions="300,*,300" MaxWidth="1600" HorizontalAlignment="Stretch">
<StackPanel Grid.Row="1" Grid.Column="0" Spacing="10"> <StackPanel Grid.Row="1" Grid.Column="0" Spacing="10">
<Border Classes="card" VerticalAlignment="Top"> <Border Classes="card" VerticalAlignment="Top">
<ContentControl Content="{CompiledBinding EntryInfoViewModel}" /> <ContentControl Content="{CompiledBinding EntryInfoViewModel}" />

View File

@ -6,7 +6,7 @@
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Workshop.Layout.LayoutListDefaultView" x:Class="Artemis.UI.Screens.Workshop.Layout.LayoutListDefaultView"
x:DataType="layout:LayoutListDefaultViewModel"> x:DataType="layout:LayoutListDefaultViewModel">
<Grid ColumnDefinitions="400,*"> <Grid ColumnDefinitions="400,*" MaxWidth="1420">
<Border Grid.Column="0" Classes="card" Margin="0 0 10 0" VerticalAlignment="Top"> <Border Grid.Column="0" Classes="card" Margin="0 0 10 0" VerticalAlignment="Top">
<StackPanel> <StackPanel>
<DockPanel> <DockPanel>

View File

@ -1,4 +1,3 @@
using Artemis.UI.Screens.Workshop.Entries.List;
using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Routing;
using Artemis.WebClient.Workshop; using Artemis.WebClient.Workshop;
@ -6,7 +5,6 @@ namespace Artemis.UI.Screens.Workshop.Layout;
public class LayoutListViewModel : RoutableHostScreen<RoutableScreen> public class LayoutListViewModel : RoutableHostScreen<RoutableScreen>
{ {
private readonly EntryListViewModel _entryListViewModel;
public override RoutableScreen DefaultScreen { get; } public override RoutableScreen DefaultScreen { get; }
public LayoutListViewModel(LayoutListDefaultViewModel defaultViewModel) public LayoutListViewModel(LayoutListDefaultViewModel defaultViewModel)

View File

@ -19,13 +19,14 @@ namespace Artemis.UI.Screens.Workshop.LayoutFinder;
public partial class LayoutFinderViewModel : ActivatableViewModelBase public partial class LayoutFinderViewModel : ActivatableViewModelBase
{ {
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly SourceList<IRGBDeviceInfo> _devices;
[Notify] private ReadOnlyObservableCollection<LayoutFinderDeviceViewModel> _deviceViewModels; [Notify] private ReadOnlyObservableCollection<LayoutFinderDeviceViewModel> _deviceViewModels;
public LayoutFinderViewModel(ILogger logger, IDeviceService deviceService, Func<ArtemisDevice, LayoutFinderDeviceViewModel> getDeviceViewModel) public LayoutFinderViewModel(ILogger logger, IDeviceService deviceService, Func<ArtemisDevice, LayoutFinderDeviceViewModel> getDeviceViewModel)
{ {
_logger = logger; _logger = logger;
SearchAll = ReactiveCommand.CreateFromTask(ExecuteSearchAll); SearchAll = ReactiveCommand.CreateFromTask(ExecuteSearchAll);
DeviceViewModels = new ReadOnlyObservableCollection<LayoutFinderDeviceViewModel>([]);
this.WhenActivated((CompositeDisposable _) => this.WhenActivated((CompositeDisposable _) =>
{ {
IEnumerable<LayoutFinderDeviceViewModel> deviceGroups = deviceService.EnabledDevices.Select(getDeviceViewModel); IEnumerable<LayoutFinderDeviceViewModel> deviceGroups = deviceService.EnabledDevices.Select(getDeviceViewModel);

View File

@ -3,7 +3,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:tabs="clr-namespace:Artemis.UI.Screens.Workshop.Library.Tabs" xmlns:tabs="clr-namespace:Artemis.UI.Screens.Workshop.Library.Tabs"
xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia" xmlns:il="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia"
xmlns:converters="clr-namespace:Artemis.UI.Converters" xmlns:converters="clr-namespace:Artemis.UI.Converters"
xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
@ -13,47 +13,94 @@
<converters:EntryIconUriConverter x:Key="EntryIconUriConverter" /> <converters:EntryIconUriConverter x:Key="EntryIconUriConverter" />
<converters:DateTimeConverter x:Key="DateTimeConverter" /> <converters:DateTimeConverter x:Key="DateTimeConverter" />
</UserControl.Resources> </UserControl.Resources>
<Button MinHeight="65" <Button MinHeight="110"
MaxHeight="110" MaxHeight="140"
Padding="6" Padding="12"
Margin="0 0 0 5"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch" HorizontalContentAlignment="Stretch"
Command="{CompiledBinding ViewWorkshopPage}"> Command="{CompiledBinding ViewWorkshopPage}">
<Grid ColumnDefinitions="Auto,2*,*,*,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto,Auto" RowDefinitions="*, Auto">
<!-- Icon -->
<Border Grid.Column="0" <Border Grid.Column="0"
Grid.Row="0"
Grid.RowSpan="2"
CornerRadius="6" CornerRadius="6"
VerticalAlignment="Center" VerticalAlignment="Center"
Margin="0 0 10 0" Margin="0 0 10 0"
Width="50" Width="80"
Height="50" Height="80"
ClipToBounds="True"> ClipToBounds="True">
<Image Stretch="UniformToFill" asyncImageLoader:ImageLoader.Source="{CompiledBinding InstalledEntry.EntryId, Converter={StaticResource EntryIconUriConverter}, Mode=OneWay}" /> <Image Stretch="UniformToFill" il:ImageLoader.Source="{CompiledBinding Entry.Id, Converter={StaticResource EntryIconUriConverter}, Mode=OneWay}" />
</Border> </Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center"> <!-- Body -->
<TextBlock TextTrimming="CharacterEllipsis" <Grid Grid.Column="1" Grid.Row="0" Grid.RowSpan="2" VerticalAlignment="Stretch" RowDefinitions="Auto,*,Auto">
Text="{CompiledBinding InstalledEntry.Name, FallbackValue=Title}" /> <StackPanel Grid.Row="0" Orientation="Horizontal">
<TextBlock Classes="subtitle" <TextBlock Margin="0 0 0 5" TextTrimming="CharacterEllipsis">
<Run Classes="h5" Text="{CompiledBinding Entry.Name, FallbackValue=Title}" />
<Run Classes="subtitle">by</Run>
<Run Classes="subtitle" Text="{CompiledBinding Entry.Author, FallbackValue=Author}" />
</TextBlock>
<avalonia:MaterialIcon
IsVisible="{CompiledBinding Entry.IsOfficial}"
Kind="ShieldStar"
Foreground="{DynamicResource SystemAccentColorLight1}"
Margin="2 -2 0 0"
Width="18"
Height="18"
HorizontalAlignment="Left"
ToolTip.Tip="Official entry by the Artemis team" />
</StackPanel>
<TextBlock Grid.Row="1"
Classes="subtitle"
TextWrapping="Wrap" TextWrapping="Wrap"
TextTrimming="CharacterEllipsis" TextTrimming="CharacterEllipsis"
Text="{CompiledBinding InstalledEntry.Author, FallbackValue=Summary}"> Text="{CompiledBinding Entry.Summary, FallbackValue=Summary}">
</TextBlock>
<ItemsControl Grid.Row="2" ItemsSource="{CompiledBinding Entry.Categories}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="8"></StackPanel>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<avalonia:MaterialIcon Kind="{CompiledBinding Icon}" Margin="0 0 3 0"></avalonia:MaterialIcon>
<TextBlock Text="{CompiledBinding Name}" TextTrimming="CharacterEllipsis" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
<!-- Info -->
<StackPanel Grid.Column="2" Grid.Row="0" Margin="0 0 4 0" HorizontalAlignment="Right">
<TextBlock TextAlignment="Right">
<avalonia:MaterialIcon Kind="Harddisk" />
<Run Text="{CompiledBinding Entry.ReleaseVersion}" />
</TextBlock> </TextBlock>
</StackPanel> </StackPanel>
<TextBlock Grid.Column="2" VerticalAlignment="Center" Text="{CompiledBinding InstalledEntry.EntryType}"></TextBlock> <!-- Install state -->
<TextBlock Grid.Column="3" VerticalAlignment="Center" Text="{CompiledBinding InstalledEntry.ReleaseVersion}"></TextBlock> <StackPanel Grid.Column="2" Grid.Row="1" Margin="0 0 4 0" HorizontalAlignment="Right" VerticalAlignment="Bottom">
<TextBlock Grid.Column="4" VerticalAlignment="Center"> <TextBlock TextAlignment="Right" IsVisible="{CompiledBinding UpdateAvailable}">
<Run>Installed</Run> <avalonia:MaterialIcon Kind="Update" Foreground="{DynamicResource SystemAccentColorLight1}" Width="20" Height="20" />
<Run Text="{CompiledBinding InstalledEntry.InstalledAt, FallbackValue=01-01-1337, Mode=OneWay, Converter={StaticResource DateTimeConverter}}" /> <Run>update available</Run>
</TextBlock> </TextBlock>
<StackPanel Grid.Column="5" VerticalAlignment="Center" Orientation="Horizontal" Spacing="6">
<Button Command="{CompiledBinding ViewLocal}">Open</Button>
<Button Command="{CompiledBinding Uninstall}" Theme="{StaticResource TransparentButton}" Height="32">
<avalonia:MaterialIcon Kind="Trash"/>
</Button>
</StackPanel> </StackPanel>
<!-- Management -->
<Border Grid.Column="3" Grid.Row="0" Grid.RowSpan="2" BorderBrush="{DynamicResource ButtonBorderBrush}" BorderThickness="1 0 0 0" Margin="10 0 0 0" Padding="10 0 0 0">
<StackPanel VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="5">
<Button Command="{CompiledBinding ViewLocal}" HorizontalAlignment="Stretch" >Open</Button>
<Button Command="{CompiledBinding Uninstall}" HorizontalAlignment="Stretch">Uninstall</Button>
</StackPanel>
<CheckBox MinHeight="26" Margin="0 4 0 0" IsChecked="{CompiledBinding AutoUpdate}">Auto-update</CheckBox>
</StackPanel>
</Border>
</Grid> </Grid>
</Button> </Button>
</UserControl> </UserControl>

View File

@ -1,10 +1,8 @@
using Avalonia; using Avalonia.ReactiveUI;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace Artemis.UI.Screens.Workshop.Library.Tabs; namespace Artemis.UI.Screens.Workshop.Library.Tabs;
public partial class InstalledTabItemView : UserControl public partial class InstalledTabItemView : ReactiveUserControl<InstalledTabItemViewModel>
{ {
public InstalledTabItemView() public InstalledTabItemView()
{ {

View File

@ -1,12 +1,16 @@
using System; using System;
using System.Linq; using System.Linq;
using System.Reactive; using System.Reactive;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Artemis.Core; using Artemis.Core;
using Artemis.Core.Services; using Artemis.Core.Services;
using Artemis.UI.DryIoc.Factories; using Artemis.UI.DryIoc.Factories;
using Artemis.UI.Extensions;
using Artemis.UI.Screens.Plugins; using Artemis.UI.Screens.Plugins;
using Artemis.UI.Services.Interfaces;
using Artemis.UI.Shared; using Artemis.UI.Shared;
using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Routing;
using Artemis.UI.Shared.Services; using Artemis.UI.Shared.Services;
@ -15,70 +19,97 @@ using Artemis.WebClient.Workshop.Models;
using Artemis.WebClient.Workshop.Services; using Artemis.WebClient.Workshop.Services;
using PropertyChanged.SourceGenerator; using PropertyChanged.SourceGenerator;
using ReactiveUI; using ReactiveUI;
using StrawberryShake;
namespace Artemis.UI.Screens.Workshop.Library.Tabs; namespace Artemis.UI.Screens.Workshop.Library.Tabs;
public partial class InstalledTabItemViewModel : ViewModelBase public partial class InstalledTabItemViewModel : ActivatableViewModelBase
{ {
private readonly IWorkshopClient _client;
private readonly IWorkshopService _workshopService; private readonly IWorkshopService _workshopService;
private readonly IWorkshopUpdateService _workshopUpdateService;
private readonly IRouter _router; private readonly IRouter _router;
private readonly IWindowService _windowService; private readonly IWindowService _windowService;
private readonly IPluginManagementService _pluginManagementService; private readonly IPluginManagementService _pluginManagementService;
private readonly ISettingsVmFactory _settingsVmFactory; private readonly ISettingsVmFactory _settingsVmFactory;
public InstalledTabItemViewModel(InstalledEntry installedEntry, [Notify] private bool _updateAvailable;
[Notify] private bool _autoUpdate;
public InstalledTabItemViewModel(InstalledEntry entry,
IWorkshopClient client,
IWorkshopService workshopService, IWorkshopService workshopService,
IRouter router, IWorkshopUpdateService workshopUpdateService,
IRouter router,
IWindowService windowService, IWindowService windowService,
IPluginManagementService pluginManagementService, IPluginManagementService pluginManagementService,
ISettingsVmFactory settingsVmFactory) ISettingsVmFactory settingsVmFactory)
{ {
_client = client;
_workshopService = workshopService; _workshopService = workshopService;
_workshopUpdateService = workshopUpdateService;
_router = router; _router = router;
_windowService = windowService; _windowService = windowService;
_pluginManagementService = pluginManagementService; _pluginManagementService = pluginManagementService;
_settingsVmFactory = settingsVmFactory; _settingsVmFactory = settingsVmFactory;
InstalledEntry = installedEntry; _autoUpdate = entry.AutoUpdate;
Entry = entry;
ViewWorkshopPage = ReactiveCommand.CreateFromTask(ExecuteViewWorkshopPage); this.WhenActivatedAsync(async _ =>
ViewLocal = ReactiveCommand.CreateFromTask(ExecuteViewLocal);
Uninstall = ReactiveCommand.CreateFromTask(ExecuteUninstall);
}
public InstalledEntry InstalledEntry { get; }
public ReactiveCommand<Unit, Unit> ViewWorkshopPage { get; }
public ReactiveCommand<Unit,Unit> ViewLocal { get; }
public ReactiveCommand<Unit, Unit> Uninstall { get; }
private async Task ExecuteViewWorkshopPage()
{
await _workshopService.NavigateToEntry(InstalledEntry.EntryId, InstalledEntry.EntryType);
}
private async Task ExecuteViewLocal(CancellationToken cancellationToken)
{
if (InstalledEntry.EntryType == EntryType.Profile && InstalledEntry.TryGetMetadata("ProfileId", out Guid profileId))
{ {
await _router.Navigate($"profile-editor/{profileId}"); // Grab the latest entry summary from the workshop
} try
{
IOperationResult<IGetEntrySummaryByIdResult> entrySummary = await _client.GetEntrySummaryById.ExecuteAsync(Entry.Id);
if (entrySummary.Data?.Entry != null)
{
Entry.ApplyEntrySummary(entrySummary.Data.Entry);
_workshopService.SaveInstalledEntry(Entry);
}
}
finally
{
UpdateAvailable = Entry.ReleaseId != Entry.LatestReleaseId;
}
});
this.WhenAnyValue(vm => vm.AutoUpdate).Skip(1).Subscribe(_ => AutoUpdateToggled());
} }
private async Task ExecuteUninstall(CancellationToken cancellationToken) public InstalledEntry Entry { get; }
public async Task ViewWorkshopPage()
{
await _workshopService.NavigateToEntry(Entry.Id, Entry.EntryType);
}
public async Task ViewLocal()
{
if (Entry.EntryType == EntryType.Profile && Entry.TryGetMetadata("ProfileId", out Guid profileId))
await _router.Navigate($"profile-editor/{profileId}");
else if (Entry.EntryType == EntryType.Plugin)
await _router.Navigate($"workshop/entries/plugins/details/{Entry.Id}/manage");
else if (Entry.EntryType == EntryType.Layout)
await _router.Navigate($"workshop/entries/layouts/details/{Entry.Id}/manage");
}
public async Task Uninstall()
{ {
bool confirmed = await _windowService.ShowConfirmContentDialog("Do you want to uninstall this entry?", "Both the entry and its contents will be removed."); bool confirmed = await _windowService.ShowConfirmContentDialog("Do you want to uninstall this entry?", "Both the entry and its contents will be removed.");
if (!confirmed) if (!confirmed)
return; return;
// Ideally the installation handler does this but it doesn't have access to the required view models // Ideally the installation handler does this but it doesn't have access to the required view models
if (InstalledEntry.EntryType == EntryType.Plugin) if (Entry.EntryType == EntryType.Plugin)
await UninstallPluginPrerequisites(); await UninstallPluginPrerequisites();
await _workshopService.UninstallEntry(InstalledEntry, cancellationToken); await _workshopService.UninstallEntry(Entry, CancellationToken.None);
} }
private async Task UninstallPluginPrerequisites() private async Task UninstallPluginPrerequisites()
{ {
if (!InstalledEntry.TryGetMetadata("PluginId", out Guid pluginId)) if (!Entry.TryGetMetadata("PluginId", out Guid pluginId))
return; return;
Plugin? plugin = _pluginManagementService.GetAllPlugins().FirstOrDefault(p => p.Guid == pluginId); Plugin? plugin = _pluginManagementService.GetAllPlugins().FirstOrDefault(p => p.Guid == pluginId);
if (plugin == null) if (plugin == null)
@ -87,4 +118,18 @@ public partial class InstalledTabItemViewModel : ViewModelBase
PluginViewModel pluginViewModel = _settingsVmFactory.PluginViewModel(plugin, ReactiveCommand.Create(() => { })); PluginViewModel pluginViewModel = _settingsVmFactory.PluginViewModel(plugin, ReactiveCommand.Create(() => { }));
await pluginViewModel.ExecuteRemovePrerequisites(true); await pluginViewModel.ExecuteRemovePrerequisites(true);
} }
private void AutoUpdateToggled()
{
_workshopService.SetAutoUpdate(Entry, AutoUpdate);
if (!AutoUpdate)
return;
Task.Run(async () =>
{
await _workshopUpdateService.AutoUpdateEntry(Entry);
UpdateAvailable = Entry.ReleaseId != Entry.LatestReleaseId;
});
}
} }

View File

@ -15,8 +15,16 @@
</Styles> </Styles>
</UserControl.Styles> </UserControl.Styles>
<Panel> <Grid RowDefinitions="Auto,*">
<StackPanel IsVisible="{CompiledBinding Empty}" Margin="0 50 0 0" Classes="empty-state"> <Grid Grid.Row="0" Grid.Column="0" MaxWidth="1000" Margin="0 22 0 10">
<Grid.ColumnDefinitions>
<ColumnDefinition MinWidth="165" MaxWidth="400" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBox Classes="search-box" Text="{CompiledBinding SearchEntryInput}" Watermark="Search library" Margin="0 0 10 0" />
</Grid>
<StackPanel Grid.Row="1" Grid.Column="0" IsVisible="{CompiledBinding Empty}" Margin="0 50 0 0" Classes="empty-state">
<TextBlock Theme="{StaticResource TitleTextBlockStyle}">Not much here yet, huh!</TextBlock> <TextBlock Theme="{StaticResource TitleTextBlockStyle}">Not much here yet, huh!</TextBlock>
<TextBlock> <TextBlock>
<Run>Any entries you download from the workshop you can later manage here</Run> <Run>Any entries you download from the workshop you can later manage here</Run>
@ -24,21 +32,34 @@
<Lottie Path="/Assets/Animations/empty.json" RepeatCount="1" Width="350" Height="350"></Lottie> <Lottie Path="/Assets/Animations/empty.json" RepeatCount="1" Width="350" Height="350"></Lottie>
<Button HorizontalAlignment="Center" Command="{CompiledBinding OpenWorkshop}">Browse the Workshop</Button> <Button HorizontalAlignment="Center" Command="{CompiledBinding OpenWorkshop}">Browse the Workshop</Button>
</StackPanel> </StackPanel>
<ScrollViewer IsVisible="{CompiledBinding !Empty}"> <ScrollViewer Grid.Row="1" Grid.Column="0" HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto" VerticalAlignment="Top">
<ItemsControl ItemsSource="{CompiledBinding InstalledEntries}"> <ItemsControl ItemsSource="{CompiledBinding EntryGroups}" MaxWidth="1000">
<ItemsControl.ItemTemplate>
<DataTemplate>
<ContentControl Content="{CompiledBinding}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
<ItemsControl.ItemsPanel> <ItemsControl.ItemsPanel>
<ItemsPanelTemplate> <ItemsPanelTemplate>
<VirtualizingStackPanel /> <StackPanel Spacing="10"/>
</ItemsPanelTemplate> </ItemsPanelTemplate>
</ItemsControl.ItemsPanel> </ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel>
<TextBlock Theme="{StaticResource SubtitleTextBlockStyle}" Text="{Binding Key}" Margin="0 0 0 5"/>
<ItemsControl ItemsSource="{Binding Items}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<ContentControl Content="{Binding}" Margin="0 0 0 8" />
</DataTemplate>
</ItemsControl.ItemTemplate>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
</ScrollViewer> </ScrollViewer>
</Panel> </Grid>
</UserControl> </UserControl>

View File

@ -1,14 +1,16 @@
using System; using System;
using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Reactive; using System.Reactive;
using System.Reactive.Disposables; using System.Reactive.Disposables;
using System.Reactive.Linq; using System.Reactive.Linq;
using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Routing;
using Artemis.WebClient.Workshop;
using Artemis.WebClient.Workshop.Models; using Artemis.WebClient.Workshop.Models;
using Artemis.WebClient.Workshop.Services; using Artemis.WebClient.Workshop.Services;
using DynamicData; using DynamicData;
using DynamicData.Binding; using DynamicData.Binding;
using DynamicData.List;
using Humanizer;
using PropertyChanged.SourceGenerator; using PropertyChanged.SourceGenerator;
using ReactiveUI; using ReactiveUI;
@ -16,45 +18,45 @@ namespace Artemis.UI.Screens.Workshop.Library.Tabs;
public partial class InstalledTabViewModel : RoutableScreen public partial class InstalledTabViewModel : RoutableScreen
{ {
private SourceList<InstalledEntry> _installedEntries = new(); private SourceList<InstalledEntry> _entries = new();
[Notify] private string? _searchEntryInput; [Notify] private string? _searchEntryInput;
private readonly ObservableAsPropertyHelper<bool> _empty;
public InstalledTabViewModel(IWorkshopService workshopService, IRouter router, Func<InstalledEntry, InstalledTabItemViewModel> getInstalledTabItemViewModel) public InstalledTabViewModel(IWorkshopService workshopService, IRouter router, Func<InstalledEntry, InstalledTabItemViewModel> getInstalledTabItemViewModel)
{ {
IObservable<Func<InstalledEntry, bool>> pluginFilter = this.WhenAnyValue(vm => vm.SearchEntryInput).Throttle(TimeSpan.FromMilliseconds(100)).Select(CreatePredicate); IObservable<Func<InstalledEntry, bool>> searchFilter = this.WhenAnyValue(vm => vm.SearchEntryInput).Throttle(TimeSpan.FromMilliseconds(100)).Select(CreatePredicate);
_installedEntries.Connect() _entries.Connect()
.Filter(pluginFilter) .Filter(searchFilter)
.Sort(SortExpressionComparer<InstalledEntry>.Descending(p => p.InstalledAt)) .Sort(SortExpressionComparer<InstalledEntry>.Descending(p => p.InstalledAt))
.Transform(getInstalledTabItemViewModel) .Transform(getInstalledTabItemViewModel)
.Bind(out ReadOnlyObservableCollection<InstalledTabItemViewModel> installedEntryViewModels) .GroupWithImmutableState(vm => vm.Entry.EntryType.Humanize(LetterCasing.Title).Pluralize())
.Bind(out ReadOnlyObservableCollection<IGrouping<InstalledTabItemViewModel, string>> entryViewModels)
.Subscribe(); .Subscribe();
_empty = _entries.Connect().Count().Select(c => c == 0).ToProperty(this, vm => vm.Empty);
_entries.AddRange(workshopService.GetInstalledEntries());
List<InstalledEntry> entries = workshopService.GetInstalledEntries(); EntryGroups = entryViewModels;
_installedEntries.AddRange(entries);
Empty = entries.Count == 0;
InstalledEntries = installedEntryViewModels;
this.WhenActivated(d => this.WhenActivated(d =>
{ {
workshopService.OnEntryUninstalled += WorkshopServiceOnOnEntryUninstalled; workshopService.OnEntryUninstalled += WorkshopServiceOnOnEntryUninstalled;
Disposable.Create(() => workshopService.OnEntryUninstalled -= WorkshopServiceOnOnEntryUninstalled).DisposeWith(d); Disposable.Create(() => workshopService.OnEntryUninstalled -= WorkshopServiceOnOnEntryUninstalled).DisposeWith(d);
}); });
OpenWorkshop = ReactiveCommand.CreateFromTask(async () => await router.Navigate("workshop")); OpenWorkshop = ReactiveCommand.CreateFromTask(async () => await router.Navigate("workshop"));
} }
private void WorkshopServiceOnOnEntryUninstalled(object? sender, InstalledEntry e) private void WorkshopServiceOnOnEntryUninstalled(object? sender, InstalledEntry e)
{ {
_installedEntries.Remove(e); _entries.Remove(e);
} }
public bool Empty { get; } public bool Empty => _empty.Value;
public ReactiveCommand<Unit, Unit> OpenWorkshop { get; } public ReactiveCommand<Unit, Unit> OpenWorkshop { get; }
public ReadOnlyObservableCollection<InstalledTabItemViewModel> InstalledEntries { get; } public ReadOnlyObservableCollection<IGrouping<InstalledTabItemViewModel, string>> EntryGroups { get; }
private Func<InstalledEntry, bool> CreatePredicate(string? text) private Func<InstalledEntry, bool> CreatePredicate(string? text)
{ {
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))

View File

@ -3,41 +3,44 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:tabs="clr-namespace:Artemis.UI.Screens.Workshop.Library.Tabs" xmlns:tabs="clr-namespace:Artemis.UI.Screens.Workshop.Library.Tabs"
xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia" xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:avalonia1="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" xmlns:il="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia"
xmlns:converters="clr-namespace:Artemis.UI.Converters" xmlns:converters="clr-namespace:Artemis.UI.Converters"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Workshop.Library.Tabs.SubmissionsTabItemView" x:Class="Artemis.UI.Screens.Workshop.Library.Tabs.SubmissionsTabItemView"
x:DataType="tabs:SubmissionsTabItemViewModel"> x:DataType="tabs:SubmissionsTabItemViewModel">
<UserControl.Resources> <UserControl.Resources>
<converters:EntryIconUriConverter x:Key="EntryIconUriConverter" /> <converters:EntryIconUriConverter x:Key="EntryIconUriConverter" />
<converters:DateTimeConverter x:Key="DateTimeConverter" /> <converters:DateTimeConverter x:Key="DateTimeConverter" />
</UserControl.Resources> </UserControl.Resources>
<Button MinHeight="80" <Button MinHeight="110"
MaxHeight="110" MaxHeight="140"
Padding="12 6" Padding="12"
Margin="0 0 0 5"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch" HorizontalContentAlignment="Stretch"
Command="{CompiledBinding NavigateToEntry}"> Command="{CompiledBinding NavigateToEntry}"
<Grid ColumnDefinitions="Auto,*,Auto"> IsVisible="{CompiledBinding Entry, Converter={x:Static ObjectConverters.IsNotNull}}">
<Grid ColumnDefinitions="Auto,*,Auto" RowDefinitions="*, Auto">
<!-- Icon --> <!-- Icon -->
<Border Grid.Column="0" <Border Grid.Column="0"
Grid.Row="0"
Grid.RowSpan="2"
CornerRadius="6" CornerRadius="6"
VerticalAlignment="Center" VerticalAlignment="Center"
Margin="0 0 10 0" Margin="0 0 10 0"
Width="50" Width="80"
Height="50" Height="80"
ClipToBounds="True"> ClipToBounds="True">
<Image Stretch="UniformToFill" asyncImageLoader:ImageLoader.Source="{CompiledBinding Entry.Id, Converter={StaticResource EntryIconUriConverter}, Mode=OneWay}" /> <Image Stretch="UniformToFill" il:ImageLoader.Source="{CompiledBinding Entry.Id, Converter={StaticResource EntryIconUriConverter}, Mode=OneWay}" />
</Border> </Border>
<!-- Body --> <!-- Body -->
<Grid Grid.Column="1" VerticalAlignment="Stretch" RowDefinitions="Auto,*,Auto"> <Grid Grid.Column="1" Grid.Row="0" Grid.RowSpan="2" VerticalAlignment="Stretch" RowDefinitions="Auto,*,Auto">
<TextBlock Grid.Row="0" <TextBlock Grid.Row="0" Margin="0 0 0 5" TextTrimming="CharacterEllipsis">
Classes="h5 no-margin" <Run Classes="h5" Text="{CompiledBinding Entry.Name, FallbackValue=Title}" />
TextTrimming="CharacterEllipsis" <Run Classes="subtitle">by you</Run>
Text="{CompiledBinding Entry.Name, FallbackValue=Title}" /> <Run Text="{CompiledBinding Emoji}" />
</TextBlock>
<TextBlock Grid.Row="1" <TextBlock Grid.Row="1"
Classes="subtitle" Classes="subtitle"
TextWrapping="Wrap" TextWrapping="Wrap"
@ -54,7 +57,7 @@
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate> <DataTemplate>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<avalonia1:MaterialIcon Kind="{CompiledBinding Icon}" Margin="0 0 3 0"></avalonia1:MaterialIcon> <avalonia:MaterialIcon Kind="{CompiledBinding Icon}" Margin="0 0 3 0"></avalonia:MaterialIcon>
<TextBlock Text="{CompiledBinding Name}" TextTrimming="CharacterEllipsis" /> <TextBlock Text="{CompiledBinding Name}" TextTrimming="CharacterEllipsis" />
</StackPanel> </StackPanel>
</DataTemplate> </DataTemplate>
@ -63,10 +66,10 @@
</Grid> </Grid>
<!-- Info --> <!-- Info -->
<StackPanel Grid.Column="2" Margin="0 0 4 0"> <StackPanel Grid.Column="2" Grid.Row="0" Margin="0 0 4 0" HorizontalAlignment="Right">
<TextBlock TextAlignment="Right" Text="{CompiledBinding Entry.CreatedAt, FallbackValue=01-01-1337, Converter={StaticResource DateTimeConverter}}" /> <TextBlock TextAlignment="Right" Text="{CompiledBinding Entry.CreatedAt, FallbackValue=01-01-1337, Converter={StaticResource DateTimeConverter}}" />
<TextBlock TextAlignment="Right"> <TextBlock TextAlignment="Right">
<avalonia1:MaterialIcon Kind="Downloads" /> <avalonia:MaterialIcon Kind="Downloads" />
<Run Classes="h5" Text="{CompiledBinding Entry.Downloads, FallbackValue=0}" /> <Run Classes="h5" Text="{CompiledBinding Entry.Downloads, FallbackValue=0}" />
<Run>downloads</Run> <Run>downloads</Run>
</TextBlock> </TextBlock>

View File

@ -1,3 +1,4 @@
using System;
using System.Reactive; using System.Reactive;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -10,6 +11,7 @@ namespace Artemis.UI.Screens.Workshop.Library.Tabs;
public class SubmissionsTabItemViewModel : ViewModelBase public class SubmissionsTabItemViewModel : ViewModelBase
{ {
private static readonly string[] Emojis = ["❤️", "🧡", "💛", "💚", "💙", "💜", "💔", "❣️", "💕", "💞", "💓", "💗", "💖", "💘", "💝", "😍", "🥰"];
private readonly IRouter _router; private readonly IRouter _router;
public SubmissionsTabItemViewModel(IGetSubmittedEntries_SubmittedEntries entry, IRouter router) public SubmissionsTabItemViewModel(IGetSubmittedEntries_SubmittedEntries entry, IRouter router)
@ -18,9 +20,11 @@ public class SubmissionsTabItemViewModel : ViewModelBase
Entry = entry; Entry = entry;
NavigateToEntry = ReactiveCommand.CreateFromTask(ExecuteNavigateToEntry); NavigateToEntry = ReactiveCommand.CreateFromTask(ExecuteNavigateToEntry);
Emoji = Emojis[Random.Shared.Next(0, Emojis.Length)];
} }
public IGetSubmittedEntries_SubmittedEntries Entry { get; } public IGetSubmittedEntries_SubmittedEntries Entry { get; }
public string Emoji { get; }
public ReactiveCommand<Unit, Unit> NavigateToEntry { get; } public ReactiveCommand<Unit, Unit> NavigateToEntry { get; }
private async Task ExecuteNavigateToEntry(CancellationToken cancellationToken) private async Task ExecuteNavigateToEntry(CancellationToken cancellationToken)

View File

@ -6,7 +6,6 @@
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="650" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="650"
x:Class="Artemis.UI.Screens.Workshop.Library.Tabs.SubmissionsTabView" x:Class="Artemis.UI.Screens.Workshop.Library.Tabs.SubmissionsTabView"
x:DataType="tabs:SubmissionsTabViewModel"> x:DataType="tabs:SubmissionsTabViewModel">
<UserControl.Styles> <UserControl.Styles>
<Styles> <Styles>
<Style Selector="StackPanel.empty-state > TextBlock"> <Style Selector="StackPanel.empty-state > TextBlock">
@ -26,8 +25,16 @@
<Button HorizontalAlignment="Center" Command="{CompiledBinding Login}">Log in</Button> <Button HorizontalAlignment="Center" Command="{CompiledBinding Login}">Log in</Button>
</StackPanel> </StackPanel>
<Panel IsVisible="{CompiledBinding IsLoggedIn^}"> <Grid IsVisible="{CompiledBinding IsLoggedIn^}" RowDefinitions="Auto,*">
<StackPanel IsVisible="{CompiledBinding !Entries.Count}" Margin="0 50 0 0" Classes="empty-state"> <Grid Grid.Row="0" Grid.Column="0" MaxWidth="1000" Margin="0 22 0 10">
<Grid.ColumnDefinitions>
<ColumnDefinition MinWidth="165" MaxWidth="400" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBox Classes="search-box" Text="{CompiledBinding SearchEntryInput}" Watermark="Search submissions" Margin="0 0 10 0" />
</Grid>
<StackPanel Grid.Row="1" Grid.Column="0" IsVisible="{CompiledBinding Empty}" Margin="0 50 0 0" Classes="empty-state">
<TextBlock Theme="{StaticResource TitleTextBlockStyle}">Oh boy, it's empty here 🤔</TextBlock> <TextBlock Theme="{StaticResource TitleTextBlockStyle}">Oh boy, it's empty here 🤔</TextBlock>
<TextBlock> <TextBlock>
<Run>Any entries you submit to the workshop you can later manage here</Run> <Run>Any entries you submit to the workshop you can later manage here</Run>
@ -36,25 +43,35 @@
<Button HorizontalAlignment="Center" Command="{CompiledBinding AddSubmission}">Submit new entry</Button> <Button HorizontalAlignment="Center" Command="{CompiledBinding AddSubmission}">Submit new entry</Button>
</StackPanel> </StackPanel>
<Grid RowDefinitions="Auto,*"> <ScrollViewer Grid.Row="1" Grid.Column="0" HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto" VerticalAlignment="Top">
<Button Grid.Row="0" Margin="0 0 0 15" HorizontalAlignment="Right" Command="{CompiledBinding AddSubmission}">Submit new entry</Button> <ItemsControl ItemsSource="{CompiledBinding EntryGroups}" MaxWidth="1000">
<ScrollViewer Grid.Row="1" IsVisible="{CompiledBinding Entries.Count}"> <ItemsControl.ItemsPanel>
<ItemsControl ItemsSource="{CompiledBinding Entries}"> <ItemsPanelTemplate>
<ItemsControl.ItemTemplate> <StackPanel Spacing="10"/>
<DataTemplate> </ItemsPanelTemplate>
<ContentControl Content="{CompiledBinding}" /> </ItemsControl.ItemsPanel>
</DataTemplate> <ItemsControl.ItemTemplate>
</ItemsControl.ItemTemplate> <DataTemplate>
<ItemsControl.ItemsPanel> <StackPanel>
<ItemsPanelTemplate> <TextBlock Theme="{StaticResource SubtitleTextBlockStyle}" Text="{Binding Key}" Margin="0 0 0 5"/>
<VirtualizingStackPanel /> <ItemsControl ItemsSource="{Binding Items}">
</ItemsPanelTemplate> <ItemsControl.ItemTemplate>
</ItemsControl.ItemsPanel> <DataTemplate>
</ItemsControl> <ContentControl Content="{Binding}" Margin="0 0 0 8" />
</ScrollViewer> </DataTemplate>
</Grid> </ItemsControl.ItemTemplate>
<ItemsControl.ItemsPanel>
</Panel> <ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</Panel> </Panel>
</UserControl> </UserControl>

View File

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Reactive; using System.Reactive;
using System.Reactive.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Artemis.UI.Extensions; using Artemis.UI.Extensions;
@ -11,6 +12,9 @@ using Artemis.UI.Shared.Services;
using Artemis.WebClient.Workshop; using Artemis.WebClient.Workshop;
using Artemis.WebClient.Workshop.Services; using Artemis.WebClient.Workshop.Services;
using DynamicData; using DynamicData;
using DynamicData.Aggregation;
using DynamicData.Binding;
using Humanizer;
using PropertyChanged.SourceGenerator; using PropertyChanged.SourceGenerator;
using ReactiveUI; using ReactiveUI;
using StrawberryShake; using StrawberryShake;
@ -22,28 +26,37 @@ public partial class SubmissionsTabViewModel : RoutableScreen
private readonly IWorkshopClient _client; private readonly IWorkshopClient _client;
private readonly SourceCache<IGetSubmittedEntries_SubmittedEntries, long> _entries; private readonly SourceCache<IGetSubmittedEntries_SubmittedEntries, long> _entries;
private readonly IWindowService _windowService; private readonly IWindowService _windowService;
private readonly ObservableAsPropertyHelper<bool> _empty;
[Notify] private bool _isLoading = true; [Notify] private bool _isLoading = true;
[Notify] private bool _workshopReachable; [Notify] private bool _workshopReachable;
[Notify] private string? _searchEntryInput;
public SubmissionsTabViewModel(IWorkshopClient client, public SubmissionsTabViewModel(IWorkshopClient client,
IAuthenticationService authenticationService, IAuthenticationService authenticationService,
IWindowService windowService, IWindowService windowService,
IWorkshopService workshopService, IWorkshopService workshopService,
Func<IGetSubmittedEntries_SubmittedEntries, SubmissionsTabItemViewModel> getSubmissionsTabItemViewModel) Func<IGetSubmittedEntries_SubmittedEntries, SubmissionsTabItemViewModel> getSubmissionsTabItemViewModel)
{ {
IObservable<Func<IGetSubmittedEntries_SubmittedEntries, bool>> searchFilter = this.WhenAnyValue(vm => vm.SearchEntryInput).Throttle(TimeSpan.FromMilliseconds(100)).Select(CreatePredicate);
_client = client; _client = client;
_windowService = windowService; _windowService = windowService;
_entries = new SourceCache<IGetSubmittedEntries_SubmittedEntries, long>(e => e.Id); _entries = new SourceCache<IGetSubmittedEntries_SubmittedEntries, long>(e => e.Id);
_entries.Connect() _entries.Connect()
.Filter(searchFilter)
.Sort(SortExpressionComparer<IGetSubmittedEntries_SubmittedEntries>.Descending(p => p.CreatedAt))
.Transform(getSubmissionsTabItemViewModel) .Transform(getSubmissionsTabItemViewModel)
.Bind(out ReadOnlyObservableCollection<SubmissionsTabItemViewModel> entries) .GroupWithImmutableState(vm => vm.Entry.EntryType.Humanize(LetterCasing.Title).Pluralize())
.Bind(out ReadOnlyObservableCollection<IGrouping<SubmissionsTabItemViewModel, long, string>> entries)
.Subscribe(); .Subscribe();
_empty = _entries.Connect().Count().Select(c => c == 0).ToProperty(this, vm => vm.Empty);
AddSubmission = ReactiveCommand.CreateFromTask(ExecuteAddSubmission, this.WhenAnyValue(vm => vm.WorkshopReachable)); AddSubmission = ReactiveCommand.CreateFromTask(ExecuteAddSubmission, this.WhenAnyValue(vm => vm.WorkshopReachable));
Login = ReactiveCommand.CreateFromTask(ExecuteLogin, this.WhenAnyValue(vm => vm.WorkshopReachable)); Login = ReactiveCommand.CreateFromTask(ExecuteLogin, this.WhenAnyValue(vm => vm.WorkshopReachable));
IsLoggedIn = authenticationService.IsLoggedIn; IsLoggedIn = authenticationService.IsLoggedIn;
Entries = entries; EntryGroups = entries;
this.WhenActivatedAsync(async d => this.WhenActivatedAsync(async d =>
{ {
@ -53,10 +66,11 @@ public partial class SubmissionsTabViewModel : RoutableScreen
}); });
} }
public bool Empty => _empty.Value;
public ReactiveCommand<Unit, Unit> Login { get; } public ReactiveCommand<Unit, Unit> Login { get; }
public ReactiveCommand<Unit, Unit> AddSubmission { get; } public ReactiveCommand<Unit, Unit> AddSubmission { get; }
public IObservable<bool> IsLoggedIn { get; } public IObservable<bool> IsLoggedIn { get; }
public ReadOnlyObservableCollection<SubmissionsTabItemViewModel> Entries { get; } public ReadOnlyObservableCollection<IGrouping<SubmissionsTabItemViewModel, long, string>> EntryGroups { get; }
private async Task ExecuteLogin(CancellationToken ct) private async Task ExecuteLogin(CancellationToken ct)
{ {
@ -91,4 +105,12 @@ public partial class SubmissionsTabViewModel : RoutableScreen
IsLoading = false; IsLoading = false;
} }
} }
private Func<IGetSubmittedEntries_SubmittedEntries, bool> CreatePredicate(string? text)
{
if (string.IsNullOrWhiteSpace(text))
return _ => true;
return data => data.Name.Contains(text, StringComparison.InvariantCultureIgnoreCase);
}
} }

View File

@ -35,7 +35,7 @@
<ItemsControl ItemsSource="{CompiledBinding Dependants}"> <ItemsControl ItemsSource="{CompiledBinding Dependants}">
<ItemsControl.ItemsPanel> <ItemsControl.ItemsPanel>
<ItemsPanelTemplate> <ItemsPanelTemplate>
<StackPanel Spacing="5"></StackPanel> <StackPanel Spacing="8"></StackPanel>
</ItemsPanelTemplate> </ItemsPanelTemplate>
</ItemsControl.ItemsPanel> </ItemsControl.ItemsPanel>
</ItemsControl> </ItemsControl>

View File

@ -9,7 +9,7 @@
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Workshop.Plugins.PluginDetailsView" x:Class="Artemis.UI.Screens.Workshop.Plugins.PluginDetailsView"
x:DataType="plugins:PluginDetailsViewModel"> x:DataType="plugins:PluginDetailsViewModel">
<Grid ColumnDefinitions="300,*, 300" RowDefinitions="Auto,*"> <Grid ColumnDefinitions="300,*, 300" RowDefinitions="Auto,*" MaxWidth="1600" HorizontalAlignment="Stretch">
<StackPanel Grid.Row="1" Grid.Column="0" Spacing="10"> <StackPanel Grid.Row="1" Grid.Column="0" Spacing="10">
<Border Classes="card" VerticalAlignment="Top"> <Border Classes="card" VerticalAlignment="Top">
<ContentControl Content="{CompiledBinding EntryInfoViewModel}" /> <ContentControl Content="{CompiledBinding EntryInfoViewModel}" />

View File

@ -35,7 +35,7 @@
<ItemsControl ItemsSource="{CompiledBinding Dependencies}"> <ItemsControl ItemsSource="{CompiledBinding Dependencies}">
<ItemsControl.ItemsPanel> <ItemsControl.ItemsPanel>
<ItemsPanelTemplate> <ItemsPanelTemplate>
<StackPanel Spacing="5"></StackPanel> <StackPanel Spacing="8"></StackPanel>
</ItemsPanelTemplate> </ItemsPanelTemplate>
</ItemsControl.ItemsPanel> </ItemsControl.ItemsPanel>
</ItemsControl> </ItemsControl>

View File

@ -8,7 +8,7 @@
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="800" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="800"
x:Class="Artemis.UI.Screens.Workshop.Profile.ProfileDetailsView" x:Class="Artemis.UI.Screens.Workshop.Profile.ProfileDetailsView"
x:DataType="profile:ProfileDetailsViewModel"> x:DataType="profile:ProfileDetailsViewModel">
<Grid ColumnDefinitions="300,*, 300" RowDefinitions="Auto,*"> <Grid ColumnDefinitions="300,*, 300" RowDefinitions="Auto,*" MaxWidth="1600" HorizontalAlignment="Stretch">
<StackPanel Grid.Row="1" Grid.Column="0" Spacing="10"> <StackPanel Grid.Row="1" Grid.Column="0" Spacing="10">
<Border Classes="card" VerticalAlignment="Top"> <Border Classes="card" VerticalAlignment="Top">
<ContentControl Content="{CompiledBinding EntryInfoViewModel}" /> <ContentControl Content="{CompiledBinding EntryInfoViewModel}" />

View File

@ -0,0 +1,25 @@
using System.Threading.Tasks;
using Artemis.WebClient.Workshop.Models;
namespace Artemis.UI.Services.Interfaces;
public interface IWorkshopUpdateService : IArtemisUIService
{
/// <summary>
/// Automatically updates all installed entries that have auto-update enabled and have a new version available.
/// </summary>
/// <returns>A task that represents the asynchronous operation</returns>
Task AutoUpdateEntries();
/// <summary>
/// Automatically updates the provided entry if a new version is available.
/// </summary>
/// <param name="entry">The entry to update.</param>
/// <returns>A task of <see langword="true"/> if the entry was updated, <see langword="false"/> otherwise.</returns>
Task<bool> AutoUpdateEntry(InstalledEntry entry);
/// <summary>
/// Disable workshop update notifications.
/// </summary>
void DisableNotifications();
}

View File

@ -57,6 +57,18 @@ public class BasicUpdateNotificationProvider : IUpdateNotificationProvider
await _router.Navigate("settings/releases"); await _router.Navigate("settings/releases");
} }
/// <inheritdoc />
public void ShowWorkshopNotification(int updatedEntries)
{
_notificationService.CreateNotification()
.WithTitle(updatedEntries == 1 ? "Workshop update installed" : "Workshop updates installed")
.WithMessage(updatedEntries == 1 ? "A workshop update has been installed" : $"{updatedEntries} workshop updates have been installed")
.WithSeverity(NotificationSeverity.Success)
.WithTimeout(TimeSpan.FromSeconds(15))
.HavingButton(b => b.WithText("View library").WithAction(async () => await _router.Navigate("settings/workshop")))
.Show();
}
/// <inheritdoc /> /// <inheritdoc />
public void ShowNotification(Guid releaseId, string releaseVersion) public void ShowNotification(Guid releaseId, string releaseVersion)
{ {

View File

@ -4,6 +4,7 @@ namespace Artemis.UI.Services.Updating;
public interface IUpdateNotificationProvider public interface IUpdateNotificationProvider
{ {
void ShowWorkshopNotification(int updatedEntries);
void ShowNotification(Guid releaseId, string releaseVersion); void ShowNotification(Guid releaseId, string releaseVersion);
void ShowInstalledNotification(string installedVersion); void ShowInstalledNotification(string installedVersion);
} }

View File

@ -8,8 +8,10 @@ using Artemis.Core.Services;
using Artemis.Storage.Repositories; using Artemis.Storage.Repositories;
using Artemis.Storage.Repositories.Interfaces; using Artemis.Storage.Repositories.Interfaces;
using Artemis.UI.Exceptions; using Artemis.UI.Exceptions;
using Artemis.UI.Services.Interfaces;
using Artemis.UI.Shared.Services.MainWindow; using Artemis.UI.Shared.Services.MainWindow;
using Artemis.WebClient.Updating; using Artemis.WebClient.Updating;
using Artemis.WebClient.Workshop.Services;
using Serilog; using Serilog;
using StrawberryShake; using StrawberryShake;
using Timer = System.Timers.Timer; using Timer = System.Timers.Timer;
@ -26,6 +28,7 @@ public class UpdateService : IUpdateService
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly IMainWindowService _mainWindowService; private readonly IMainWindowService _mainWindowService;
private readonly IReleaseRepository _releaseRepository; private readonly IReleaseRepository _releaseRepository;
private readonly IWorkshopUpdateService _workshopUpdateService;
private readonly Lazy<IUpdateNotificationProvider> _updateNotificationProvider; private readonly Lazy<IUpdateNotificationProvider> _updateNotificationProvider;
private readonly Platform _updatePlatform; private readonly Platform _updatePlatform;
private readonly IUpdatingClient _updatingClient; private readonly IUpdatingClient _updatingClient;
@ -38,6 +41,7 @@ public class UpdateService : IUpdateService
IMainWindowService mainWindowService, IMainWindowService mainWindowService,
IUpdatingClient updatingClient, IUpdatingClient updatingClient,
IReleaseRepository releaseRepository, IReleaseRepository releaseRepository,
IWorkshopUpdateService workshopUpdateService,
Lazy<IUpdateNotificationProvider> updateNotificationProvider, Lazy<IUpdateNotificationProvider> updateNotificationProvider,
Func<Guid, ReleaseInstaller> getReleaseInstaller) Func<Guid, ReleaseInstaller> getReleaseInstaller)
{ {
@ -45,6 +49,7 @@ public class UpdateService : IUpdateService
_mainWindowService = mainWindowService; _mainWindowService = mainWindowService;
_updatingClient = updatingClient; _updatingClient = updatingClient;
_releaseRepository = releaseRepository; _releaseRepository = releaseRepository;
_workshopUpdateService = workshopUpdateService;
_updateNotificationProvider = updateNotificationProvider; _updateNotificationProvider = updateNotificationProvider;
_getReleaseInstaller = getReleaseInstaller; _getReleaseInstaller = getReleaseInstaller;
@ -65,72 +70,7 @@ public class UpdateService : IUpdateService
timer.Elapsed += HandleAutoUpdateEvent; timer.Elapsed += HandleAutoUpdateEvent;
timer.Start(); timer.Start();
} }
private void ProcessReleaseStatus()
{
string currentVersion = Constants.CurrentVersion;
bool updated = _releaseRepository.SaveVersionInstallDate(currentVersion);
PreviousVersion = _releaseRepository.GetPreviousInstalledVersion()?.Version;
if (!Directory.Exists(Constants.UpdatingFolder))
return;
// Clean up the update folder, leaving only the last ZIP
foreach (string file in Directory.GetFiles(Constants.UpdatingFolder))
{
if (Path.GetExtension(file) != ".zip" || Path.GetFileName(file) == $"{currentVersion}.zip")
continue;
try
{
_logger.Debug("Cleaning up old update file at {FilePath}", file);
File.Delete(file);
}
catch (Exception e)
{
_logger.Warning(e, "Failed to clean up old update file at {FilePath}", file);
}
}
if (updated)
_updateNotificationProvider.Value.ShowInstalledNotification(currentVersion);
}
private void ShowUpdateNotification(IGetNextRelease_NextPublishedRelease release)
{
_updateNotificationProvider.Value.ShowNotification(release.Id, release.Version);
}
private async Task AutoInstallUpdate(IGetNextRelease_NextPublishedRelease release)
{
ReleaseInstaller installer = _getReleaseInstaller(release.Id);
await installer.InstallAsync(CancellationToken.None);
RestartForUpdate("AutoInstallUpdate", true);
}
private async void HandleAutoUpdateEvent(object? sender, EventArgs e)
{
if (Constants.CurrentVersion == "local")
return;
// The event can trigger from multiple sources with a timer acting as a fallback, only actually perform an action once per max 59 minutes
if (DateTime.UtcNow - _lastAutoUpdateCheck < TimeSpan.FromMinutes(59))
return;
_lastAutoUpdateCheck = DateTime.UtcNow;
if (!_autoCheck.Value || _suspendAutoCheck)
return;
try
{
await CheckForUpdate();
}
catch (Exception ex)
{
_logger.Warning(ex, "Auto update-check failed");
}
}
/// <inheritdoc /> /// <inheritdoc />
public string Channel { get; private set; } = "master"; public string Channel { get; private set; } = "master";
@ -139,7 +79,7 @@ public class UpdateService : IUpdateService
/// <inheritdoc /> /// <inheritdoc />
public IGetNextRelease_NextPublishedRelease? CachedLatestRelease { get; private set; } public IGetNextRelease_NextPublishedRelease? CachedLatestRelease { get; private set; }
/// <inheritdoc /> /// <inheritdoc />
public async Task CacheLatestRelease() public async Task CacheLatestRelease()
{ {
@ -257,4 +197,86 @@ public class UpdateService : IUpdateService
_logger.Information("Update service initialized for {Channel} channel", Channel); _logger.Information("Update service initialized for {Channel} channel", Channel);
return false; return false;
} }
private async Task<bool> AutoCheckForUpdates()
{
// Don't perform auto-updates if the current version is local
if (Constants.CurrentVersion == "local")
return false;
// Don't perform auto-updates if the setting is disabled or an update was found but not yet installed
if (!_autoCheck.Value || _suspendAutoCheck)
return false;
try
{
return await CheckForUpdate() && _autoInstall.Value;
}
catch (Exception ex)
{
_logger.Warning(ex, "Auto update-check failed");
}
return false;
}
private void ProcessReleaseStatus()
{
string currentVersion = Constants.CurrentVersion;
bool updated = _releaseRepository.SaveVersionInstallDate(currentVersion);
PreviousVersion = _releaseRepository.GetPreviousInstalledVersion()?.Version;
if (!Directory.Exists(Constants.UpdatingFolder))
return;
// Clean up the update folder, leaving only the last ZIP
foreach (string file in Directory.GetFiles(Constants.UpdatingFolder))
{
if (Path.GetExtension(file) != ".zip" || Path.GetFileName(file) == $"{currentVersion}.zip")
continue;
try
{
_logger.Debug("Cleaning up old update file at {FilePath}", file);
File.Delete(file);
}
catch (Exception e)
{
_logger.Warning(e, "Failed to clean up old update file at {FilePath}", file);
}
}
if (updated)
_updateNotificationProvider.Value.ShowInstalledNotification(currentVersion);
}
private void ShowUpdateNotification(IGetNextRelease_NextPublishedRelease release)
{
_updateNotificationProvider.Value.ShowNotification(release.Id, release.Version);
}
private async Task AutoInstallUpdate(IGetNextRelease_NextPublishedRelease release)
{
ReleaseInstaller installer = _getReleaseInstaller(release.Id);
await installer.InstallAsync(CancellationToken.None);
RestartForUpdate("AutoInstallUpdate", true);
}
private async void HandleAutoUpdateEvent(object? sender, EventArgs e)
{
// The event can trigger from multiple sources with a timer acting as a fallback, only actually perform an action once per max 59 minutes
if (DateTime.UtcNow - _lastAutoUpdateCheck < TimeSpan.FromMinutes(59))
return;
_lastAutoUpdateCheck = DateTime.UtcNow;
if (await AutoCheckForUpdates())
{
_logger.Information("Auto-installing update, not performing workshop update check");
}
else
{
await _workshopUpdateService.AutoUpdateEntries();
}
}
} }

View File

@ -0,0 +1,98 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Artemis.Core;
using Artemis.Core.Services;
using Artemis.UI.Services.Interfaces;
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 Serilog;
using StrawberryShake;
namespace Artemis.UI.Services.Updating;
public class WorkshopUpdateService : IWorkshopUpdateService
{
private readonly ILogger _logger;
private readonly IWorkshopClient _client;
private readonly IWorkshopService _workshopService;
private readonly Lazy<IUpdateNotificationProvider> _updateNotificationProvider;
private readonly PluginSetting<bool> _showNotifications;
public WorkshopUpdateService(ILogger logger, IWorkshopClient client, IWorkshopService workshopService, ISettingsService settingsService, Lazy<IUpdateNotificationProvider> updateNotificationProvider)
{
_logger = logger;
_client = client;
_workshopService = workshopService;
_updateNotificationProvider = updateNotificationProvider;
_showNotifications = settingsService.GetSetting("Workshop.ShowNotifications", true);
}
public async Task AutoUpdateEntries()
{
_logger.Information("Checking for workshop updates");
int checkedEntries = 0;
int updatedEntries = 0;
foreach (InstalledEntry entry in _workshopService.GetInstalledEntries())
{
if (!entry.AutoUpdate)
continue;
checkedEntries++;
bool updated = await AutoUpdateEntry(entry);
if (updated)
updatedEntries++;
}
_logger.Information("Checked {CheckedEntries} entries, updated {UpdatedEntries}", checkedEntries, updatedEntries);
if (updatedEntries > 0 && _showNotifications.Value)
_updateNotificationProvider.Value.ShowWorkshopNotification(updatedEntries);
}
public async Task<bool> AutoUpdateEntry(InstalledEntry entry)
{
// Query the latest version
IOperationResult<IGetEntryLatestReleaseByIdResult> latestReleaseResult = await _client.GetEntryLatestReleaseById.ExecuteAsync(entry.Id);
if (latestReleaseResult.Data?.Entry?.LatestRelease is not IRelease latestRelease)
return false;
if (latestRelease.Id == entry.ReleaseId)
return false;
_logger.Information("Auto-updating entry {Entry} to version {Version}", entry, latestRelease.Version);
try
{
EntryInstallResult updateResult = await _workshopService.InstallEntry(entry, latestRelease, new Progress<StreamProgress>(), CancellationToken.None);
// This happens during installation too but not on our reference of the entry
if (updateResult.IsSuccess)
entry.ApplyRelease(latestRelease);
if (updateResult.IsSuccess)
_logger.Information("Auto-update successful for entry {Entry}", entry);
else
_logger.Warning("Auto-update failed for entry {Entry}: {Message}", entry, updateResult.Message);
return updateResult.IsSuccess;
}
catch (Exception e)
{
_logger.Warning(e, "Auto-update failed for entry {Entry}", entry);
}
return false;
}
/// <inheritdoc />
public void DisableNotifications()
{
_showNotifications.Value = false;
_showNotifications.Save();
}
}

View File

@ -26,4 +26,10 @@ public class EntryInstallResult
Entry = installedEntry Entry = installedEntry
}; };
} }
/// <inheritdoc />
public override string ToString()
{
return $"{nameof(IsSuccess)}: {IsSuccess}, {nameof(Message)}: {Message}";
}
} }

View File

@ -73,7 +73,7 @@ public class LayoutEntryInstallationHandler : IEntryInstallationHandler
// Remove the layout from any devices currently using it // Remove the layout from any devices currently using it
foreach (ArtemisDevice device in _deviceService.Devices) foreach (ArtemisDevice device in _deviceService.Devices)
{ {
if (device.LayoutSelection.Type == WorkshopLayoutProvider.LayoutType && device.LayoutSelection.Parameter == installedEntry.EntryId.ToString()) if (device.LayoutSelection.Type == WorkshopLayoutProvider.LayoutType && device.LayoutSelection.Parameter == installedEntry.Id.ToString())
{ {
_defaultLayoutProvider.ConfigureDevice(device); _defaultLayoutProvider.ConfigureDevice(device);
_deviceService.SaveDevice(device); _deviceService.SaveDevice(device);

View File

@ -30,7 +30,7 @@ public class PluginEntryInstallationHandler : IEntryInstallationHandler
{ {
// If the folder already exists, we're not going to reinstall the plugin since files may be in use, consider our job done // If the folder already exists, we're not going to reinstall the plugin since files may be in use, consider our job done
if (installedEntry.GetReleaseDirectory(release).Exists) if (installedEntry.GetReleaseDirectory(release).Exists)
return EntryInstallResult.FromSuccess(installedEntry); return ApplyAndSave(installedEntry, release);
} }
else else
{ {
@ -102,10 +102,7 @@ public class PluginEntryInstallationHandler : IEntryInstallationHandler
return EntryInstallResult.FromFailure(e.Message); return EntryInstallResult.FromFailure(e.Message);
} }
installedEntry.ApplyRelease(release); return ApplyAndSave(installedEntry, release);
_workshopService.SaveInstalledEntry(installedEntry);
return EntryInstallResult.FromSuccess(installedEntry);
} }
public Task<EntryUninstallResult> UninstallAsync(InstalledEntry installedEntry, CancellationToken cancellationToken) public Task<EntryUninstallResult> UninstallAsync(InstalledEntry installedEntry, CancellationToken cancellationToken)
@ -135,4 +132,11 @@ public class PluginEntryInstallationHandler : IEntryInstallationHandler
_workshopService.RemoveInstalledEntry(installedEntry); _workshopService.RemoveInstalledEntry(installedEntry);
return Task.FromResult(EntryUninstallResult.FromSuccess(message)); return Task.FromResult(EntryUninstallResult.FromSuccess(message));
} }
private EntryInstallResult ApplyAndSave(InstalledEntry installedEntry, IRelease release)
{
installedEntry.ApplyRelease(release);
_workshopService.SaveInstalledEntry(installedEntry);
return EntryInstallResult.FromSuccess(installedEntry);
}
} }

View File

@ -6,9 +6,22 @@ using Artemis.Storage.Entities.Workshop;
namespace Artemis.WebClient.Workshop.Models; namespace Artemis.WebClient.Workshop.Models;
public class InstalledEntry public class InstalledEntry : CorePropertyChanged, IEntrySummary
{ {
private Dictionary<string, JsonNode> _metadata = new(); private Dictionary<string, JsonNode> _metadata = new();
private long _id;
private string _author;
private bool _isOfficial;
private string _name;
private string _summary;
private EntryType _entryType;
private long _downloads;
private DateTimeOffset _createdAt;
private long? _latestReleaseId;
private IReadOnlyList<IGetDependantEntries_Entries_Items_Categories> _categories;
private long _releaseId;
private string _releaseVersion = string.Empty;
private bool _autoUpdate;
internal InstalledEntry(EntryEntity entity) internal InstalledEntry(EntryEntity entity)
{ {
@ -20,55 +33,74 @@ public class InstalledEntry
{ {
Entity = new EntryEntity(); Entity = new EntryEntity();
EntryId = entry.Id; ApplyEntrySummary(entry);
EntryType = entry.EntryType;
Author = entry.Author;
Name = entry.Name;
InstalledAt = DateTimeOffset.Now; InstalledAt = DateTimeOffset.Now;
ReleaseId = release.Id; ReleaseId = release.Id;
ReleaseVersion = release.Version; ReleaseVersion = release.Version;
AutoUpdate = true;
} }
public long EntryId { get; set; } internal EntryEntity Entity { get; }
public EntryType EntryType { get; set; }
public string Author { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public long ReleaseId { get; set; }
public string ReleaseVersion { get; set; } = string.Empty;
public DateTimeOffset InstalledAt { get; set; } public DateTimeOffset InstalledAt { get; set; }
internal EntryEntity Entity { get; } public long ReleaseId
{
get => _releaseId;
set => SetAndNotify(ref _releaseId, value);
}
public string ReleaseVersion
{
get => _releaseVersion;
set => SetAndNotify(ref _releaseVersion, value);
}
public bool AutoUpdate
{
get => _autoUpdate;
set => SetAndNotify(ref _autoUpdate, value);
}
internal void Load() internal void Load()
{ {
EntryId = Entity.EntryId; Id = Entity.EntryId;
EntryType = (EntryType) Entity.EntryType;
Author = Entity.Author; Author = Entity.Author;
IsOfficial = Entity.IsOfficial;
Name = Entity.Name; Name = Entity.Name;
Summary = Entity.Summary;
EntryType = (EntryType) Entity.EntryType;
Downloads = Entity.Downloads;
CreatedAt = Entity.CreatedAt;
LatestReleaseId = Entity.LatestReleaseId;
Categories = Entity.Categories?.Select(c => new GetDependantEntries_Entries_Items_Categories_Category(c.Name, c.Icon)).ToList() ?? [];
ReleaseId = Entity.ReleaseId; ReleaseId = Entity.ReleaseId;
ReleaseVersion = Entity.ReleaseVersion; ReleaseVersion = Entity.ReleaseVersion;
InstalledAt = Entity.InstalledAt; InstalledAt = Entity.InstalledAt;
AutoUpdate = Entity.AutoUpdate;
_metadata = Entity.Metadata != null ? new Dictionary<string, JsonNode>(Entity.Metadata) : new Dictionary<string, JsonNode>(); _metadata = Entity.Metadata != null ? new Dictionary<string, JsonNode>(Entity.Metadata) : [];
} }
internal void Save() internal void Save()
{ {
Entity.EntryId = EntryId; Entity.EntryId = Id;
Entity.EntryType = (int) EntryType; Entity.EntryType = (int) EntryType;
Entity.Author = Author; Entity.Author = Author;
Entity.IsOfficial = IsOfficial;
Entity.Name = Name; Entity.Name = Name;
Entity.Summary = Summary;
Entity.Downloads = Downloads;
Entity.CreatedAt = CreatedAt;
Entity.LatestReleaseId = LatestReleaseId;
Entity.Categories = Categories.Select(c => new EntryCategoryEntity(c.Name, c.Icon)).ToList();
Entity.ReleaseId = ReleaseId; Entity.ReleaseId = ReleaseId;
Entity.ReleaseVersion = ReleaseVersion; Entity.ReleaseVersion = ReleaseVersion;
Entity.InstalledAt = InstalledAt; Entity.InstalledAt = InstalledAt;
Entity.AutoUpdate = AutoUpdate;
Entity.Metadata = new Dictionary<string, JsonNode>(_metadata); Entity.Metadata = new Dictionary<string, JsonNode>(_metadata);
} }
@ -118,7 +150,7 @@ public class InstalledEntry
/// <returns>The directory info of the directory.</returns> /// <returns>The directory info of the directory.</returns>
public DirectoryInfo GetDirectory() public DirectoryInfo GetDirectory()
{ {
return new DirectoryInfo(Path.Combine(Constants.WorkshopFolder, $"{EntryId}-{StringUtilities.UrlFriendly(Name)}")); return new DirectoryInfo(Path.Combine(Constants.WorkshopFolder, $"{Id}-{StringUtilities.UrlFriendly(Name)}"));
} }
/// <summary> /// <summary>
@ -141,4 +173,98 @@ public class InstalledEntry
ReleaseVersion = release.Version; ReleaseVersion = release.Version;
InstalledAt = DateTimeOffset.UtcNow; InstalledAt = DateTimeOffset.UtcNow;
} }
public void ApplyEntrySummary(IEntrySummary entry)
{
Id = entry.Id;
Author = entry.Author;
IsOfficial = entry.IsOfficial;
Name = entry.Name;
Summary = entry.Summary;
EntryType = entry.EntryType;
Downloads = entry.Downloads;
CreatedAt = entry.CreatedAt;
LatestReleaseId = entry.LatestReleaseId;
Categories = entry.Categories;
}
#region Implementation of IEntrySummary
/// <inheritdoc />
public long Id
{
get => _id;
private set => SetAndNotify(ref _id, value);
}
/// <inheritdoc />
public string Author
{
get => _author;
private set => SetAndNotify(ref _author, value);
}
/// <inheritdoc />
public bool IsOfficial
{
get => _isOfficial;
private set => SetAndNotify(ref _isOfficial, value);
}
/// <inheritdoc />
public string Name
{
get => _name;
private set => SetAndNotify(ref _name, value);
}
/// <inheritdoc />
public string Summary
{
get => _summary;
private set => SetAndNotify(ref _summary, value);
}
/// <inheritdoc />
public EntryType EntryType
{
get => _entryType;
private set => SetAndNotify(ref _entryType, value);
}
/// <inheritdoc />
public long Downloads
{
get => _downloads;
private set => SetAndNotify(ref _downloads, value);
}
/// <inheritdoc />
public DateTimeOffset CreatedAt
{
get => _createdAt;
private set => SetAndNotify(ref _createdAt, value);
}
/// <inheritdoc />
public long? LatestReleaseId
{
get => _latestReleaseId;
private set => SetAndNotify(ref _latestReleaseId, value);
}
/// <inheritdoc />
public IReadOnlyList<IGetDependantEntries_Entries_Items_Categories> Categories
{
get => _categories;
private set => SetAndNotify(ref _categories, value);
}
#endregion
/// <inheritdoc />
public override string ToString()
{
return $"[{EntryType}] {Id} - {Name}";
}
} }

View File

@ -55,6 +55,6 @@ public class WorkshopLayoutProvider : ILayoutProvider
throw new InvalidOperationException($"Cannot use a workshop entry of type {entry.EntryType} as a layout"); throw new InvalidOperationException($"Cannot use a workshop entry of type {entry.EntryType} as a layout");
device.LayoutSelection.Type = LayoutType; device.LayoutSelection.Type = LayoutType;
device.LayoutSelection.Parameter = entry?.EntryId.ToString(); device.LayoutSelection.Parameter = entry?.Id.ToString();
} }
} }

View File

@ -31,6 +31,7 @@ fragment submittedEntry on Entry {
fragment entrySummary on Entry { fragment entrySummary on Entry {
id id
author author
isOfficial
name name
summary summary
entryType entryType
@ -45,6 +46,7 @@ fragment entrySummary on Entry {
fragment entryDetails on Entry { fragment entryDetails on Entry {
id id
author author
isOfficial
name name
summary summary
entryType entryType

View File

@ -26,4 +26,18 @@ query GetLayoutEntryById($id: Long!) {
...layoutInfo ...layoutInfo
} }
} }
}
query GetEntrySummaryById($id: Long!) {
entry(id: $id) {
...entrySummary
}
}
query GetEntryLatestReleaseById($id: Long!) {
entry(id: $id) {
latestRelease {
...releaseDetails
}
}
} }

View File

@ -124,7 +124,7 @@ public interface IWorkshopService
/// Initializes the workshop service. /// Initializes the workshop service.
/// </summary> /// </summary>
void Initialize(); void Initialize();
/// <summary> /// <summary>
/// Represents the status of the workshop. /// Represents the status of the workshop.
/// </summary> /// </summary>
@ -133,4 +133,6 @@ public interface IWorkshopService
public event EventHandler<InstalledEntry>? OnInstalledEntrySaved; public event EventHandler<InstalledEntry>? OnInstalledEntrySaved;
public event EventHandler<InstalledEntry>? OnEntryUninstalled; public event EventHandler<InstalledEntry>? OnEntryUninstalled;
public event EventHandler<InstalledEntry>? OnEntryInstalled; public event EventHandler<InstalledEntry>? OnEntryInstalled;
void SetAutoUpdate(InstalledEntry installedEntry, bool autoUpdate);
} }

View File

@ -165,7 +165,7 @@ public class WorkshopService : IWorkshopService
if (result.IsSuccess && result.Entry != null) if (result.IsSuccess && result.Entry != null)
OnEntryInstalled?.Invoke(this, result.Entry); OnEntryInstalled?.Invoke(this, result.Entry);
else else
_logger.Warning("Failed to install entry {EntryId}: {Message}", entry.Id, result.Message); _logger.Warning("Failed to install entry {Entry}: {Message}", entry, result.Message);
return result; return result;
} }
@ -178,7 +178,7 @@ public class WorkshopService : IWorkshopService
if (result.IsSuccess) if (result.IsSuccess)
OnEntryUninstalled?.Invoke(this, installedEntry); OnEntryUninstalled?.Invoke(this, installedEntry);
else else
_logger.Warning("Failed to uninstall entry {EntryId}: {Message}", installedEntry.EntryId, result.Message); _logger.Warning("Failed to uninstall entry {EntryId}: {Message}", installedEntry.Id, result.Message);
return result; return result;
} }
@ -254,6 +254,16 @@ public class WorkshopService : IWorkshopService
} }
} }
/// <inheritdoc />
public void SetAutoUpdate(InstalledEntry installedEntry, bool autoUpdate)
{
if (installedEntry.AutoUpdate == autoUpdate)
return;
installedEntry.AutoUpdate = autoUpdate;
SaveInstalledEntry(installedEntry);
}
private void RemoveOrphanedFiles() private void RemoveOrphanedFiles()
{ {
List<InstalledEntry> entries = GetInstalledEntries(); List<InstalledEntry> entries = GetInstalledEntries();
@ -308,6 +318,8 @@ public class WorkshopService : IWorkshopService
} }
public event EventHandler<InstalledEntry>? OnInstalledEntrySaved; public event EventHandler<InstalledEntry>? OnInstalledEntrySaved;
public event EventHandler<InstalledEntry>? OnEntryUninstalled; public event EventHandler<InstalledEntry>? OnEntryUninstalled;
public event EventHandler<InstalledEntry>? OnEntryInstalled; public event EventHandler<InstalledEntry>? OnEntryInstalled;
} }

View File

@ -2,7 +2,7 @@ schema: schema.graphql
extensions: extensions:
endpoints: endpoints:
Default GraphQL Endpoint: Default GraphQL Endpoint:
url: https://localhost:7281/graphql url: https://workshop.artemis-rgb.com/graphql
headers: headers:
user-agent: JS GraphQL user-agent: JS GraphQL
introspect: true introspect: true

View File

@ -61,6 +61,7 @@ type Entry {
iconId: UUID iconId: UUID
id: Long! id: Long!
images: [Image!]! images: [Image!]!
isOfficial: Boolean!
latestRelease: Release latestRelease: Release
latestReleaseId: Long latestReleaseId: Long
layoutInfo: [LayoutInfo!]! layoutInfo: [LayoutInfo!]!
@ -124,6 +125,7 @@ type PluginInfo {
entry: Entry! entry: Entry!
entryId: Long! entryId: Long!
helpPage: String helpPage: String
minmumVersion: String
pluginGuid: UUID! pluginGuid: UUID!
repository: String repository: String
requiresAdmin: Boolean! requiresAdmin: Boolean!
@ -310,6 +312,7 @@ input EntryFilterInput {
iconId: UuidOperationFilterInput iconId: UuidOperationFilterInput
id: LongOperationFilterInput id: LongOperationFilterInput
images: ListFilterInputTypeOfImageFilterInput images: ListFilterInputTypeOfImageFilterInput
isOfficial: BooleanOperationFilterInput
latestRelease: ReleaseFilterInput latestRelease: ReleaseFilterInput
latestReleaseId: LongOperationFilterInput latestReleaseId: LongOperationFilterInput
layoutInfo: ListFilterInputTypeOfLayoutInfoFilterInput layoutInfo: ListFilterInputTypeOfLayoutInfoFilterInput
@ -331,6 +334,7 @@ input EntrySortInput {
icon: ImageSortInput icon: ImageSortInput
iconId: SortEnumType iconId: SortEnumType
id: SortEnumType id: SortEnumType
isOfficial: SortEnumType
latestRelease: ReleaseSortInput latestRelease: ReleaseSortInput
latestReleaseId: SortEnumType latestReleaseId: SortEnumType
name: SortEnumType name: SortEnumType
@ -479,6 +483,7 @@ input PluginInfoFilterInput {
entry: EntryFilterInput entry: EntryFilterInput
entryId: LongOperationFilterInput entryId: LongOperationFilterInput
helpPage: StringOperationFilterInput helpPage: StringOperationFilterInput
minmumVersion: StringOperationFilterInput
or: [PluginInfoFilterInput!] or: [PluginInfoFilterInput!]
pluginGuid: UuidOperationFilterInput pluginGuid: UuidOperationFilterInput
repository: StringOperationFilterInput repository: StringOperationFilterInput
@ -494,6 +499,7 @@ input PluginInfoSortInput {
entry: EntrySortInput entry: EntrySortInput
entryId: SortEnumType entryId: SortEnumType
helpPage: SortEnumType helpPage: SortEnumType
minmumVersion: SortEnumType
pluginGuid: SortEnumType pluginGuid: SortEnumType
repository: SortEnumType repository: SortEnumType
requiresAdmin: SortEnumType requiresAdmin: SortEnumType

View File

@ -53,7 +53,7 @@
<PackageVersion Include="Serilog" Version="4.0.0" /> <PackageVersion Include="Serilog" Version="4.0.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" /> <PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageVersion Include="Serilog.Sinks.Debug" Version="3.0.0" /> <PackageVersion Include="Serilog.Sinks.Debug" Version="3.0.0" />
<PackageVersion Include="Serilog.Sinks.File" Version="5.0.0" /> <PackageVersion Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageVersion Include="SkiaSharp" Version="2.88.8" /> <PackageVersion Include="SkiaSharp" Version="2.88.8" />
<PackageVersion Include="SkiaSharp.Vulkan.SharpVk" Version="2.88.8" /> <PackageVersion Include="SkiaSharp.Vulkan.SharpVk" Version="2.88.8" />
<PackageVersion Include="Splat.DryIoc" Version="15.1.1" /> <PackageVersion Include="Splat.DryIoc" Version="15.1.1" />