Bladeren bron

Extensions core initial wip

Krzysztof Krysiński 2 jaren geleden
bovenliggende
commit
601d6bbbd0

+ 52 - 0
src/PixiEditor.Extensions/Extension.cs

@@ -0,0 +1,52 @@
+using System.Reflection;
+using PixiEditor.Extensions.Metadata;
+
+namespace PixiEditor.Extensions;
+
+/// <summary>
+///     This class is used to extend the functionality of the PixiEditor.
+/// </summary>
+public abstract class Extension
+{
+    public ExtensionMetadata Metadata { get; private set; }
+
+    public Action<string, string> NoticeDialogImpl { get; set; }
+    public void NoticeDialog(string message, string title)
+    {
+        NoticeDialogImpl?.Invoke(message, title);
+    }
+
+    public void ProvideMetadata(ExtensionMetadata metadata)
+    {
+        if (Metadata != null)
+        {
+            return;
+        }
+
+        Metadata = metadata;
+    }
+
+    public void Load()
+    {
+        OnLoaded();
+    }
+
+    public void Initialize()
+    {
+        OnInitialized();
+    }
+
+    /// <summary>
+    ///     Called right after the extension is loaded. Not all extensions are initialized at this point.
+    /// </summary>
+    protected virtual void OnLoaded()
+    {
+    }
+
+    /// <summary>
+    ///     Called after all extensions and PixiEditor is loaded. All extensions are initialized at this point.
+    /// </summary>
+    protected virtual void OnInitialized()
+    {
+    }
+}

+ 8 - 0
src/PixiEditor.Extensions/Metadata/Author.cs

@@ -0,0 +1,8 @@
+namespace PixiEditor.Extensions.Metadata;
+
+public class Author
+{
+    public string Name { get; init; }
+    public string Email { get; init; }
+    public string Website { get; init; }
+}

+ 14 - 0
src/PixiEditor.Extensions/Metadata/ExtensionMetadata.cs

@@ -0,0 +1,14 @@
+namespace PixiEditor.Extensions.Metadata;
+
+public class ExtensionMetadata
+{
+    public string UniqueName { get; init; }
+    public string DisplayName { get; init; }
+    public string Description { get; init; }
+    public Author Author { get; init; }
+    public Author Publisher { get; init; }
+    public Author[] Contributors { get; init; }
+    public string Version { get; init; }
+    public string License { get; init; }
+    public string[] Categories { get; init; }
+}

+ 9 - 0
src/PixiEditor.Extensions/PixiEditor.Extensions.csproj

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

+ 80 - 0
src/PixiEditor.sln

@@ -56,6 +56,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Misc", "Misc", "{5AFBF881-C
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PixiEditor.Platform.Standalone", "PixiEditor.Platform.Standalone\PixiEditor.Platform.Standalone.csproj", "{7A12C96B-8B5C-45E1-9EF6-0B1DA7F270DE}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PixiEditor.Extensions", "PixiEditor.Extensions\PixiEditor.Extensions.csproj", "{1249EE2B-EB0D-411C-B311-53A7A22B7743}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Samples", "Samples", "{E4FF4CE6-5831-450D-8006-0539353C030B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SampleExtension", "SampleExtension\SampleExtension.csproj", "{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -780,6 +786,78 @@ Global
 		{7A12C96B-8B5C-45E1-9EF6-0B1DA7F270DE}.Steam|x64.Build.0 = Debug|Any CPU
 		{7A12C96B-8B5C-45E1-9EF6-0B1DA7F270DE}.Steam|x86.ActiveCfg = Debug|Any CPU
 		{7A12C96B-8B5C-45E1-9EF6-0B1DA7F270DE}.Steam|x86.Build.0 = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.Debug|x64.Build.0 = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.Debug|x86.Build.0 = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.DevRelease|Any CPU.ActiveCfg = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.DevRelease|Any CPU.Build.0 = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.DevRelease|x64.ActiveCfg = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.DevRelease|x64.Build.0 = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.DevRelease|x86.ActiveCfg = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.DevRelease|x86.Build.0 = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.MSIX Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.MSIX Debug|Any CPU.Build.0 = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.MSIX Debug|x64.ActiveCfg = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.MSIX Debug|x64.Build.0 = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.MSIX Debug|x86.ActiveCfg = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.MSIX Debug|x86.Build.0 = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.MSIX|Any CPU.ActiveCfg = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.MSIX|Any CPU.Build.0 = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.MSIX|x64.ActiveCfg = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.MSIX|x64.Build.0 = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.MSIX|x86.ActiveCfg = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.MSIX|x86.Build.0 = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.Release|Any CPU.Build.0 = Release|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.Release|x64.ActiveCfg = Release|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.Release|x64.Build.0 = Release|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.Release|x86.ActiveCfg = Release|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.Release|x86.Build.0 = Release|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.Steam|Any CPU.ActiveCfg = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.Steam|Any CPU.Build.0 = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.Steam|x64.ActiveCfg = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.Steam|x64.Build.0 = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.Steam|x86.ActiveCfg = Debug|Any CPU
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743}.Steam|x86.Build.0 = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.Debug|x64.Build.0 = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.Debug|x86.Build.0 = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.DevRelease|Any CPU.ActiveCfg = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.DevRelease|Any CPU.Build.0 = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.DevRelease|x64.ActiveCfg = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.DevRelease|x64.Build.0 = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.DevRelease|x86.ActiveCfg = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.DevRelease|x86.Build.0 = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.MSIX Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.MSIX Debug|Any CPU.Build.0 = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.MSIX Debug|x64.ActiveCfg = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.MSIX Debug|x64.Build.0 = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.MSIX Debug|x86.ActiveCfg = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.MSIX Debug|x86.Build.0 = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.MSIX|Any CPU.ActiveCfg = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.MSIX|Any CPU.Build.0 = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.MSIX|x64.ActiveCfg = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.MSIX|x64.Build.0 = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.MSIX|x86.ActiveCfg = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.MSIX|x86.Build.0 = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.Release|Any CPU.Build.0 = Release|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.Release|x64.ActiveCfg = Release|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.Release|x64.Build.0 = Release|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.Release|x86.ActiveCfg = Release|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.Release|x86.Build.0 = Release|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.Steam|Any CPU.ActiveCfg = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.Steam|Any CPU.Build.0 = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.Steam|x64.ActiveCfg = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.Steam|x64.Build.0 = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.Steam|x86.ActiveCfg = Debug|Any CPU
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814}.Steam|x86.Build.0 = Debug|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
@@ -807,5 +885,7 @@ Global
 		{510ED47C-2455-4DCE-A561-1074725E1236} = {5AFBF881-C054-4CE4-8159-8D4017FFD27A}
 		{5193C1C1-8362-40FD-802B-E097E8C88082} = {5AFBF881-C054-4CE4-8159-8D4017FFD27A}
 		{7A12C96B-8B5C-45E1-9EF6-0B1DA7F270DE} = {9A81B795-66AB-4743-9284-90565941343D}
+		{1249EE2B-EB0D-411C-B311-53A7A22B7743} = {1E816135-76C1-4255-BE3C-BF17895A65AA}
+		{CE1C8DC9-E26B-4BBB-AB87-34054DE34814} = {E4FF4CE6-5831-450D-8006-0539353C030B}
 	EndGlobalSection
 EndGlobal

+ 7 - 1
src/PixiEditor/App.xaml.cs

@@ -2,6 +2,7 @@
 using System.Text.RegularExpressions;
 using System.Windows;
 using System.Windows.Media;
+using PixiEditor.Models.AppExtensions;
 using PixiEditor.Models.Controllers;
 using PixiEditor.Models.DataHolders;
 using PixiEditor.Models.Dialogs;
@@ -47,9 +48,14 @@ internal partial class App : Application
         }
 
         AddNativeAssets();
-        
+
+        ExtensionLoader loader = new ExtensionLoader();
+        loader.LoadExtensions();
+
         MainWindow = new MainWindow();
         MainWindow.Show();
+
+        loader.InitializeExtensions();
     }
 
     private void AddNativeAssets()

+ 2 - 0
src/PixiEditor/Helpers/Extensions/ServiceCollectionHelpers.cs

@@ -8,6 +8,7 @@ using PixiEditor.Models.IO.PaletteParsers.JascPalFile;
 using PixiEditor.Models.Localization;
 using PixiEditor.Models.UserPreferences;
 using PixiEditor.ViewModels;
+using PixiEditor.ViewModels.SubViewModels.AdditionalContent;
 using PixiEditor.ViewModels.SubViewModels.Document;
 using PixiEditor.ViewModels.SubViewModels.Main;
 using PixiEditor.ViewModels.SubViewModels.Tools;
@@ -41,6 +42,7 @@ internal static class ServiceCollectionHelpers
         .AddSingleton(static x => new DiscordViewModel(x.GetService<ViewModelMain>(), "764168193685979138"))
         .AddSingleton<DebugViewModel>()
         .AddSingleton<SearchViewModel>()
+        .AddSingleton<AdditionalContentViewModel>()
         // Controllers
         .AddSingleton<ShortcutController>()
         .AddSingleton<CommandController>()

+ 33 - 0
src/PixiEditor/Models/AppExtensions/ExtensionException.cs

@@ -0,0 +1,33 @@
+using PixiEditor.Exceptions;
+using PixiEditor.Models.Localization;
+
+namespace PixiEditor.Models.AppExtensions;
+
+public class ExtensionException : RecoverableException
+{
+    public ExtensionException(LocalizedString messageKey) : base(messageKey)
+    {
+    }
+}
+
+public class NoEntryAssemblyException : ExtensionException
+{
+    public NoEntryAssemblyException(string containingFolder) : base(new LocalizedString("ERROR_NO_ENTRY_ASSEMBLY", containingFolder))
+    {
+    }
+}
+
+public class NoClassEntryException : ExtensionException
+{
+    public NoClassEntryException(string assemblyPath) : base(new LocalizedString("ERROR_NO_CLASS_ENTRY", assemblyPath))
+    {
+    }
+}
+
+public class MissingMetadataException : ExtensionException
+{
+    public MissingMetadataException(string missingMetadataKey) : base(new LocalizedString("ERROR_MISSING_METADATA", missingMetadataKey))
+    {
+    }
+}
+

+ 137 - 0
src/PixiEditor/Models/AppExtensions/ExtensionLoader.cs

@@ -0,0 +1,137 @@
+using System.IO;
+using System.Reflection;
+using System.Windows;
+using Newtonsoft.Json;
+using PixiEditor.Extensions;
+using PixiEditor.Extensions.Metadata;
+using PixiEditor.Models.Dialogs;
+using PixiEditor.Models.IO;
+using PixiEditor.Models.Localization;
+
+namespace PixiEditor.Models.AppExtensions;
+
+internal class ExtensionLoader
+{
+    private List<Extension> LoadedExtensions { get; } = new();
+    public ExtensionLoader()
+    {
+        ValidateExtensionFolder();
+    }
+
+    public void LoadExtensions()
+    {
+        var directories = Directory.GetDirectories(Paths.ExtensionsFullPath);
+        foreach (var directory in directories)
+        {
+            string packageJsonPath = Path.Combine(directory, "extension.json");
+            bool isExtension = File.Exists(packageJsonPath);
+            if (isExtension)
+            {
+                LoadExtension(packageJsonPath);
+            }
+        }
+    }
+
+    public void InitializeExtensions()
+    {
+        foreach (var extension in LoadedExtensions)
+        {
+            extension.Initialize();
+        }
+    }
+
+    private void LoadExtension(string packageJsonPath)
+    {
+        string json = File.ReadAllText(packageJsonPath);
+        try
+        {
+            var metadata = JsonConvert.DeserializeObject<ExtensionMetadata>(json);
+            ValidateMetadata(metadata);
+            var extension = LoadExtensionEntry(Path.GetDirectoryName(packageJsonPath), metadata);
+            extension.NoticeDialogImpl = (string message, string title) => NoticeDialog.Show(message, title);
+            extension.Load(/*TODO: Inject api*/);
+            LoadedExtensions.Add(extension);
+        }
+        catch (JsonException)
+        {
+            MessageBox.Show(new LocalizedString("ERROR_INVALID_PACKAGE", packageJsonPath), "ERROR");
+        }
+        catch (ExtensionException ex)
+        {
+            MessageBox.Show(ex.DisplayMessage, "ERROR");
+        }
+        catch (Exception ex)
+        {
+            MessageBox.Show(new LocalizedString("ERROR_LOADING_PACKAGE", packageJsonPath), "ERROR");
+        }
+    }
+
+    private void ValidateMetadata(ExtensionMetadata metadata)
+    {
+        if (string.IsNullOrEmpty(metadata.UniqueName))
+        {
+            throw new MissingMetadataException("Description");
+        }
+        // TODO: Validate if unique name is unique
+
+        if (string.IsNullOrEmpty(metadata.DisplayName))
+        {
+            throw new MissingMetadataException("DisplayName");
+        }
+
+        if (string.IsNullOrEmpty(metadata.Version))
+        {
+            throw new MissingMetadataException("Version");
+        }
+    }
+
+    private Extension LoadExtensionEntry(string assemblyFolder, ExtensionMetadata metadata)
+    {
+        string[] dlls = Directory.GetFiles(assemblyFolder, "*.dll");
+        Assembly? entryAssembly = GetEntryAssembly(dlls, out Type extensionType);
+        if (entryAssembly is null)
+        {
+            throw new NoEntryAssemblyException(assemblyFolder);
+        }
+
+        var extension = (Extension)Activator.CreateInstance(extensionType);
+        if (extension is null)
+        {
+            throw new NoClassEntryException(entryAssembly.Location);
+        }
+
+        extension.ProvideMetadata(metadata);
+        return extension;
+    }
+
+    private Assembly? GetEntryAssembly(string[] dlls, out Type extensionType)
+    {
+        foreach (var dll in dlls)
+        {
+            try
+            {
+                var assembly = Assembly.LoadFrom(dll);
+                extensionType = assembly.GetTypes().FirstOrDefault(x => x.IsSubclassOf(typeof(Extension)));
+                if (extensionType is not null)
+                {
+                    return assembly;
+                }
+            }
+            catch
+            {
+                // ignored
+            }
+        }
+
+        extensionType = null;
+        return null;
+    }
+
+    private void ValidateExtensionFolder()
+    {
+        if (!Directory.Exists(Paths.ExtensionsFullPath))
+        {
+            Directory.CreateDirectory(Paths.ExtensionsFullPath);
+        }
+    }
+}

+ 1 - 0
src/PixiEditor/Models/IO/Paths.cs

@@ -5,4 +5,5 @@ namespace PixiEditor.Models.IO;
 public static class Paths
 {
     public static string DataFullPath = Path.Combine(Path.GetDirectoryName(Assembly.GetEntryAssembly().Location), "Data");
+    public static string ExtensionsFullPath = Path.Combine(Path.GetDirectoryName(Assembly.GetEntryAssembly().Location), "Extensions");
 }

+ 1 - 0
src/PixiEditor/PixiEditor.csproj

@@ -442,6 +442,7 @@
 	<ItemGroup>
 		<ProjectReference Include="..\PixiEditor.ChangeableDocument\PixiEditor.ChangeableDocument.csproj" />
 		<ProjectReference Include="..\PixiEditor.DrawingApi.Skia\PixiEditor.DrawingApi.Skia.csproj" />
+		<ProjectReference Include="..\PixiEditor.Extensions\PixiEditor.Extensions.csproj" />
 		<ProjectReference Include="..\PixiEditor.Platform\PixiEditor.Platform.csproj" />
 		<ProjectReference Include="..\PixiEditor.UpdateModule\PixiEditor.UpdateModule.csproj" />
 		<ProjectReference Include="..\PixiEditor.Zoombox\PixiEditor.Zoombox.csproj" />

+ 16 - 0
src/PixiEditor/ViewModels/SubViewModels/AdditionalContent/AdditionalContentViewModel.cs

@@ -0,0 +1,16 @@
+using PixiEditor.Models.Commands.Attributes.Commands;
+using PixiEditor.Platform;
+
+namespace PixiEditor.ViewModels.SubViewModels.AdditionalContent;
+
+internal class AdditionalContentViewModel : ViewModelBase
+{
+    public IAdditionalContentProvider AdditionalContentProvider { get; }
+    public AdditionalContentViewModel(IAdditionalContentProvider additionalContentProvider)
+    {
+        AdditionalContentProvider = additionalContentProvider;
+    }
+
+    public bool IsSupporterPackAvailable =>
+        AdditionalContentProvider != null && AdditionalContentProvider.IsContentAvailable(AdditionalContentProduct.SupporterPack);
+}

+ 5 - 0
src/PixiEditor/ViewModels/ViewModelMain.cs

@@ -13,6 +13,7 @@ using PixiEditor.Models.Enums;
 using PixiEditor.Models.Events;
 using PixiEditor.Models.Localization;
 using PixiEditor.Models.UserPreferences;
+using PixiEditor.ViewModels.SubViewModels.AdditionalContent;
 using PixiEditor.ViewModels.SubViewModels.Document;
 using PixiEditor.ViewModels.SubViewModels.Tools;
 
@@ -72,6 +73,8 @@ internal class ViewModelMain : ViewModelBase
 
     public RegistryViewModel RegistrySubViewModel { get; set; }
 
+    public AdditionalContentViewModel AdditionalContentSubViewModel { get; set; }
+
     public IPreferences Preferences { get; set; }
     public ILocalizationProvider LocalizationProvider { get; set; }
 
@@ -140,6 +143,8 @@ internal class ViewModelMain : ViewModelBase
         StylusSubViewModel = services.GetService<StylusViewModel>();
         RegistrySubViewModel = services.GetService<RegistryViewModel>();
 
+        AdditionalContentSubViewModel = services.GetService<AdditionalContentViewModel>();
+
         MiscSubViewModel = services.GetService<MiscViewModel>();
 
         CommandController = services.GetService<CommandController>();

+ 3 - 0
src/PixiEditor/Views/MainWindow.xaml

@@ -468,6 +468,9 @@
                     Margin="0,-5,-5,0"
                     HorizontalAlignment="Right"
                     WindowChrome.IsHitTestVisibleInChrome="True">
+                    <Image Source="../Images/Star-filled.png" Width="24"
+                           Visibility="{Binding Path=AdditionalContentSubViewModel.IsSupporterPackAvailable,
+                           Converter={converters:BoolToVisibilityConverter}}"/>
                     <Button
                         Style="{StaticResource MinimizeButtonStyle}"
                         WindowChrome.IsHitTestVisibleInChrome="True"

+ 15 - 0
src/SampleExtension/SampleExtension.cs

@@ -0,0 +1,15 @@
+using PixiEditor.Extensions;
+
+namespace SampleExtension;
+
+public class SampleExtension : Extension
+{
+    protected override void OnLoaded()
+    {
+    }
+
+    protected override void OnInitialized()
+    {
+        NoticeDialog($"Hello from {Metadata.DisplayName}", "SampleExtension");
+    }
+}

+ 20 - 0
src/SampleExtension/SampleExtension.csproj

@@ -0,0 +1,20 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+    <PropertyGroup>
+        <TargetFramework>net7.0</TargetFramework>
+        <ImplicitUsings>enable</ImplicitUsings>
+        <Nullable>enable</Nullable>
+    </PropertyGroup>
+
+    <ItemGroup>
+      <ProjectReference Include="..\PixiEditor.Extensions\PixiEditor.Extensions.csproj" />
+    </ItemGroup>
+
+    <ItemGroup>
+      <None Remove="package.json" />
+      <Content Include="extension.json">
+        <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+      </Content>
+    </ItemGroup>
+
+</Project>

+ 30 - 0
src/SampleExtension/extension.json

@@ -0,0 +1,30 @@
+{
+  "displayName": "Sample Extension 1",
+  "uniqueName": "PixiEditor.Samples.SampleExtension",
+  "description": "Sample extension for PixiEditor",
+  "version": "1.0.0",
+  "author": {
+    "name": "PixiEditor",
+    "email": "[email protected]",
+    "website": "https://pixieditor.net"
+  },
+  "publisher": {
+    "name": "PixiEditor",
+    "email": "[email protected]",
+    "website": "https://pixieditor.net"
+  },
+  "contributors": [
+    {
+      "name": "flabbet",
+      "email": "[email protected]",
+      "website": "https://github.com/flabbet"
+    },
+    {
+      "name": "CPK"
+    }
+  ],
+  "license": "MIT",
+  "categories": [
+    "Extension"
+  ]
+}