Browse Source

Identity providers

Krzysztof Krysiński 3 months ago
parent
commit
bb5bf2b9a5
39 changed files with 918 additions and 501 deletions
  1. 8 65
      src/PixiEditor.Extensions.Runtime/ExtensionLoader.cs
  2. 1 1
      src/PixiEditor.IdentityProvider.PixiAuth/Gravatar.cs
  3. 321 0
      src/PixiEditor.IdentityProvider.PixiAuth/PixiAuthIdentityProvider.cs
  4. 15 0
      src/PixiEditor.IdentityProvider.PixiAuth/PixiEditor.IdentityProvider.PixiAuth.csproj
  5. 19 0
      src/PixiEditor.IdentityProvider.PixiAuth/PixiUser.cs
  6. 1 1
      src/PixiEditor.IdentityProvider.PixiAuth/UsernameGenerator.cs
  7. 11 0
      src/PixiEditor.IdentityProvider/IIdentityProvider.cs
  8. 9 0
      src/PixiEditor.IdentityProvider/IUser.cs
  9. 9 0
      src/PixiEditor.IdentityProvider/PixiEditor.IdentityProvider.csproj
  10. 25 0
      src/PixiEditor.OperatingSystem/SecureStorage.cs
  11. 16 0
      src/PixiEditor.PixiAuth/Models/Product.cs
  12. 11 15
      src/PixiEditor.PixiAuth/PixiAuthClient.cs
  13. 0 18
      src/PixiEditor.PixiAuth/User.cs
  14. 12 0
      src/PixiEditor.PixiAuth/Utils/EmailUtility.cs
  15. 74 12
      src/PixiEditor.Platform.MSStore/MSAdditionalContentProvider.cs
  16. 13 2
      src/PixiEditor.Platform.MSStore/MicrosoftStorePlatform.cs
  17. 1 0
      src/PixiEditor.Platform.MSStore/PixiEditor.Platform.MSStore.csproj
  18. 2 0
      src/PixiEditor.Platform.Standalone/PixiEditor.Platform.Standalone.csproj
  19. 73 9
      src/PixiEditor.Platform.Standalone/StandaloneAdditionalContentProvider.cs
  20. 15 4
      src/PixiEditor.Platform.Standalone/StandalonePlatform.cs
  21. 20 4
      src/PixiEditor.Platform.Steam/SteamAdditionalContentProvider.cs
  22. 12 0
      src/PixiEditor.Platform.Steam/SteamIdentityProvider.cs
  23. 3 1
      src/PixiEditor.Platform.Steam/SteamPlatform.cs
  24. 5 7
      src/PixiEditor.Platform/IAdditionalContentProvider.cs
  25. 4 1
      src/PixiEditor.Platform/IPlatform.cs
  26. 4 0
      src/PixiEditor.Platform/PixiEditor.Platform.csproj
  27. 65 0
      src/PixiEditor.sln
  28. 5 3
      src/PixiEditor/Data/Localization/Languages/en.json
  29. 20 6
      src/PixiEditor/Initialization/ClassicDesktopEntry.cs
  30. 3 0
      src/PixiEditor/PixiEditor.csproj
  31. 2 2
      src/PixiEditor/ViewModels/SubViewModels/AdditionalContent/AdditionalContentViewModel.cs
  32. 5 5
      src/PixiEditor/ViewModels/SubViewModels/ExtensionsViewModel.cs
  33. 117 332
      src/PixiEditor/ViewModels/SubViewModels/UserViewModel.cs
  34. 2 2
      src/PixiEditor/ViewModels/SubViewModels/WindowViewModel.cs
  35. 1 1
      src/PixiEditor/Views/Auth/LoginForm.axaml
  36. 1 1
      src/PixiEditor/Views/Auth/LoginPopup.axaml
  37. 10 8
      src/PixiEditor/Views/Auth/UserAvatarToggle.axaml
  38. 1 1
      src/PixiEditor/Views/Main/MainTitleBar.axaml
  39. 2 0
      src/PixiEditor/Views/MainWindow.axaml.cs

+ 8 - 65
src/PixiEditor.Extensions.Runtime/ExtensionLoader.cs

@@ -2,6 +2,7 @@
 using System.Reflection;
 using System.Reflection;
 using System.Runtime.InteropServices;
 using System.Runtime.InteropServices;
 using Newtonsoft.Json;
 using Newtonsoft.Json;
+using PixiEditor.Extensions.Common.Localization;
 using PixiEditor.Extensions.Metadata;
 using PixiEditor.Extensions.Metadata;
 using PixiEditor.Extensions.WasmRuntime;
 using PixiEditor.Extensions.WasmRuntime;
 using PixiEditor.Platform;
 using PixiEditor.Platform;
@@ -10,14 +11,13 @@ namespace PixiEditor.Extensions.Runtime;
 
 
 public class ExtensionLoader
 public class ExtensionLoader
 {
 {
-    private readonly Dictionary<string, OfficialExtensionData> _officialExtensionsKeys =
-        new Dictionary<string, OfficialExtensionData>();
-
     public List<Extension> LoadedExtensions { get; } = new();
     public List<Extension> LoadedExtensions { get; } = new();
 
 
     public string PackagesPath { get; }
     public string PackagesPath { get; }
     public string UnpackedExtensionsPath { get; }
     public string UnpackedExtensionsPath { get; }
 
 
+    public ExtensionServices Services { get; set; }
+
     private WasmRuntime.WasmRuntime _wasmRuntime = new WasmRuntime.WasmRuntime();
     private WasmRuntime.WasmRuntime _wasmRuntime = new WasmRuntime.WasmRuntime();
 
 
     public ExtensionLoader(string packagesPath, string unpackedExtensionsPath)
     public ExtensionLoader(string packagesPath, string unpackedExtensionsPath)
@@ -27,11 +27,6 @@ public class ExtensionLoader
         ValidateExtensionFolder();
         ValidateExtensionFolder();
     }
     }
 
 
-    public void AddOfficialExtension(string uniqueName, OfficialExtensionData data)
-    {
-        _officialExtensionsKeys.Add(uniqueName, data);
-    }
-
     public void LoadExtensions()
     public void LoadExtensions()
     {
     {
         foreach (var file in Directory.GetFiles(PackagesPath))
         foreach (var file in Directory.GetFiles(PackagesPath))
@@ -262,12 +257,12 @@ public class ExtensionLoader
                 throw new ForbiddenUniqueNameExtension();
                 throw new ForbiddenUniqueNameExtension();
             }
             }
 
 
-            if (!IsAdditionalContentInstalled(fixedUniqueName))
+            if (!IsAdditionalContentOwned(fixedUniqueName))
             {
             {
                 return false;
                 return false;
             }
             }
         }
         }
-        // TODO: Validate if unique name is unique
+        // TODO: Validate if unique name is in fact, unique
 
 
         if (string.IsNullOrEmpty(metadata.DisplayName))
         if (string.IsNullOrEmpty(metadata.DisplayName))
         {
         {
@@ -282,68 +277,29 @@ public class ExtensionLoader
         return true;
         return true;
     }
     }
 
 
-    private bool IsAdditionalContentInstalled(string fixedUniqueName)
+    private bool IsAdditionalContentOwned(string fixedUniqueName)
     {
     {
-        if (!_officialExtensionsKeys.ContainsKey(fixedUniqueName)) return false;
-        AdditionalContentProduct? product = _officialExtensionsKeys[fixedUniqueName].Product;
-
-        if (product == null) return true;
-
-        return IPlatform.Current.AdditionalContentProvider?.IsContentInstalled(product.Value) ?? false;
+        return IPlatform.Current.AdditionalContentProvider?.IsContentOwned(fixedUniqueName) ?? false;
     }
     }
 
 
     private bool IsOfficialAssemblyLegit(string metadataUniqueName, ExtensionEntry entry)
     private bool IsOfficialAssemblyLegit(string metadataUniqueName, ExtensionEntry entry)
     {
     {
         if (entry == null) return false; // All official extensions must have a valid assembly
         if (entry == null) return false; // All official extensions must have a valid assembly
-        if (!_officialExtensionsKeys.ContainsKey(metadataUniqueName)) return false;
 
 
         if (entry is DllExtensionEntry dllExtensionEntry)
         if (entry is DllExtensionEntry dllExtensionEntry)
         {
         {
-            return VerifyAssemblySignature(metadataUniqueName, dllExtensionEntry.Assembly);
+            return false;
         }
         }
 
 
         if (entry is WasmExtensionEntry wasmExtensionEntry)
         if (entry is WasmExtensionEntry wasmExtensionEntry)
         {
         {
             return true;
             return true;
             //TODO: Verify wasm signature somehow
             //TODO: Verify wasm signature somehow
-            //return VerifyAssemblySignature(metadataUniqueName, wasmExtensionEntry.Instance);
         }
         }
 
 
         return false;
         return false;
     }
     }
 
 
-    private bool VerifyAssemblySignature(string metadataUniqueName, Assembly assembly)
-    {
-        bool wasVerified = false;
-        bool verified = StrongNameSignatureVerificationEx(assembly.Location, true, ref wasVerified);
-        if (!verified || !wasVerified) return false;
-
-        byte[]? assemblyPublicKey = assembly.GetName().GetPublicKey();
-        if (assemblyPublicKey == null) return false;
-
-        return PublicKeysMatch(assemblyPublicKey, _officialExtensionsKeys[metadataUniqueName].PublicKeyName);
-    }
-
-    private bool PublicKeysMatch(byte[] assemblyPublicKey, string pathToPublicKey)
-    {
-        Assembly currentAssembly = Assembly.GetExecutingAssembly();
-        using Stream? stream =
-            currentAssembly.GetManifestResourceStream(
-                $"{currentAssembly.GetName().Name}.OfficialExtensions.{pathToPublicKey}");
-        if (stream == null) return false;
-
-        using MemoryStream memoryStream = new MemoryStream();
-        stream.CopyTo(memoryStream);
-        byte[] publicKey = memoryStream.ToArray();
-
-        return assemblyPublicKey.SequenceEqual(publicKey);
-    }
-
-    //TODO: uhh, other platforms dumbass?
-    [DllImport("mscoree.dll", CharSet = CharSet.Unicode)]
-    static extern bool StrongNameSignatureVerificationEx(string wszFilePath, bool fForceVerification,
-        ref bool pfWasVerified);
-
     private Extension LoadExtensionEntry(ExtensionEntry entry, ExtensionMetadata metadata)
     private Extension LoadExtensionEntry(ExtensionEntry entry, ExtensionMetadata metadata)
     {
     {
         Extension extension = entry.CreateExtension();
         Extension extension = entry.CreateExtension();
@@ -426,16 +382,3 @@ public class ExtensionLoader
         return null;
         return null;
     }
     }
 }
 }
-
-public struct OfficialExtensionData
-{
-    public string PublicKeyName { get; }
-    public AdditionalContentProduct? Product { get; }
-    public string? PurchaseLink { get; }
-
-    public OfficialExtensionData(string publicKeyName, AdditionalContentProduct product, string? purchaseLink = null)
-    {
-        PublicKeyName = publicKeyName;
-        Product = product;
-    }
-}

+ 1 - 1
src/PixiEditor/Models/User/Gravatar.cs → src/PixiEditor.IdentityProvider.PixiAuth/Gravatar.cs

@@ -1,6 +1,6 @@
 using System.Net.Http.Headers;
 using System.Net.Http.Headers;
 
 
-namespace PixiEditor.Models.User;
+namespace PixiEditor.IdentityProvider.PixiAuth;
 
 
 public static class Gravatar
 public static class Gravatar
 {
 {

+ 321 - 0
src/PixiEditor.IdentityProvider.PixiAuth/PixiAuthIdentityProvider.cs

@@ -0,0 +1,321 @@
+using System.Collections.ObjectModel;
+using PixiEditor.OperatingSystem;
+using PixiEditor.PixiAuth;
+using PixiEditor.PixiAuth.Exceptions;
+using PixiEditor.PixiAuth.Utils;
+
+namespace PixiEditor.IdentityProvider.PixiAuth;
+
+public class PixiAuthIdentityProvider : IIdentityProvider
+{
+    public bool ApiValid => apiValid;
+    private bool apiValid = true;
+    public PixiAuthClient PixiAuthClient { get; }
+    public PixiUser User { get; private set; }
+    public bool IsLoggedIn => User?.IsLoggedIn ?? false;
+
+    public event Action<string, object>? OnError;
+    public event Action<List<string>>? OwnedProductsUpdated;
+    public event Action<string>? UsernameUpdated;
+    public event Action<PixiUser>? LoginRequestSuccessful;
+    public event Action<double>? LoginTimeout;
+    public event Action? LoggedOut;
+
+    IUser IIdentityProvider.User => User;
+
+    public PixiAuthIdentityProvider(string pixiEditorApiUrl)
+    {
+        try
+        {
+            PixiAuthClient = new PixiAuthClient(pixiEditorApiUrl);
+        }
+        catch (UriFormatException e)
+        {
+            Console.WriteLine($"Invalid api URL format: {e.Message}");
+            apiValid = false;
+        }
+
+        User = SecureStorage.GetValue<PixiUser>("UserData", null);
+        Task.Run(async () =>
+        {
+            await LoadUserData();
+            await TryRefreshToken();
+            await LogoutIfTokenExpired();
+        });
+    }
+
+    public async Task RequestLogin(string email)
+    {
+        if (!apiValid) return;
+
+        try
+        {
+            Guid? session = await PixiAuthClient.GenerateSession(email);
+            string hash = EmailUtility.GetEmailHash(email);
+            if (session != null)
+            {
+                User = new PixiUser()
+                {
+                    SessionId = session.Value, EmailHash = hash, Username = GenerateUsername(hash)
+                };
+
+                LoginRequestSuccessful?.Invoke(User);
+
+                SaveUserInfo();
+            }
+        }
+        catch (PixiAuthException authException)
+        {
+            Error(authException.Message);
+        }
+        catch (HttpRequestException httpRequestException)
+        {
+            Error("CONNECTION_ERROR");
+        }
+        catch (TaskCanceledException timeoutException)
+        {
+            Error("CONNECTION_TIMEOUT");
+        }
+    }
+
+    public async Task ResendActivation(string email)
+    {
+        if (!apiValid) return;
+
+        string emailHash = EmailUtility.GetEmailHash(email);
+        if (User?.EmailHash != emailHash)
+        {
+            await RequestLogin(email);
+            return;
+        }
+
+        if (User?.SessionId == null)
+        {
+            return;
+        }
+
+        try
+        {
+            await PixiAuthClient.ResendActivation(User.SessionId.Value);
+            LoginTimeout?.Invoke(60);
+        }
+        catch (TooManyRequestsException e)
+        {
+            Error(e.Message, e.TimeLeft);
+            LoginTimeout?.Invoke(e.TimeLeft);
+        }
+        catch (PixiAuthException authException)
+        {
+            Error(authException.Message);
+        }
+        catch (HttpRequestException httpRequestException)
+        {
+            Error("CONNECTION_ERROR");
+        }
+        catch (TaskCanceledException timeoutException)
+        {
+            Error("CONNECTION_TIMEOUT");
+        }
+    }
+
+    public async Task<bool> TryRefreshToken()
+    {
+        if (!apiValid) return false;
+
+        if (!IsLoggedIn)
+        {
+            return false;
+        }
+
+        try
+        {
+            (string? token, DateTime? expirationDate) = await PixiAuthClient.RefreshToken(User.SessionToken);
+
+            if (token != null)
+            {
+                User.SessionToken = token;
+                User.SessionExpirationDate = expirationDate;
+                SaveUserInfo();
+                return true;
+            }
+        }
+        catch (ForbiddenException e)
+        {
+            User = null;
+            LoggedOut?.Invoke();
+            SaveUserInfo();
+            Error(e.Message);
+        }
+        catch (PixiAuthException authException)
+        {
+            Error(authException.Message);
+        }
+        catch (HttpRequestException httpRequestException)
+        {
+            Error("CONNECTION_ERROR");
+        }
+        catch (TaskCanceledException timeoutException)
+        {
+            Error("CONNECTION_TIMEOUT");
+        }
+
+        return false;
+    }
+
+    public async Task Logout()
+    {
+        if (!IsLoggedIn)
+        {
+            return;
+        }
+
+        string? sessionToken = User?.SessionToken;
+
+        User = null;
+        LoggedOut?.Invoke();
+        SaveUserInfo();
+
+        if (!apiValid) return;
+
+        try
+        {
+            await PixiAuthClient.Logout(sessionToken);
+        }
+        catch (PixiAuthException authException)
+        {
+        }
+        catch (HttpRequestException httpRequestException)
+        {
+        }
+        catch (TaskCanceledException timeoutException)
+        {
+        }
+    }
+
+    public async Task SaveUserInfo()
+    {
+        await SecureStorage.SetValueAsync("UserData", User);
+    }
+
+    public async Task LoadUserData()
+    {
+        try
+        {
+            User.Username = await TryFetchUserName(User.EmailHash);
+            UsernameUpdated?.Invoke(User.Username);
+            var products = await PixiAuthClient.GetOwnedProducts(User.SessionToken);
+            if (products != null)
+            {
+                User.OwnedProducts = products.Where(x => x.IsDlc && x.Target == "PixiEditor")
+                    .Select(x => x.ProductId).ToList();
+                OwnedProductsUpdated?.Invoke(new List<string>(User.OwnedProducts));
+            }
+        }
+        catch (Exception e)
+        {
+            Error("FAIL_LOAD_USER_DATA");
+        }
+    }
+
+    public async Task LogoutIfTokenExpired()
+    {
+        if (User?.SessionExpirationDate != null && User.SessionExpirationDate < DateTime.Now)
+        {
+            await Logout();
+            Error("SESSION_EXPIRED");
+        }
+    }
+
+    public async Task<bool> TryValidateSession()
+    {
+        if (!apiValid) return false;
+
+        if (User?.SessionId == null)
+        {
+            return false;
+        }
+
+        try
+        {
+            (string? token, DateTime? expirationDate) =
+                await PixiAuthClient.TryClaimSessionToken(User.SessionId.Value);
+            if (token != null)
+            {
+                User.SessionToken = token;
+                User.SessionExpirationDate = expirationDate;
+                var products = await PixiAuthClient.GetOwnedProducts(User.SessionToken);
+                if (products != null)
+                {
+                    User.OwnedProducts = products.Where(x => x.IsDlc && x.Target == "PixiEditor")
+                        .Select(x => x.ProductId).ToList();
+                    OwnedProductsUpdated?.Invoke(new List<string>(User.OwnedProducts));
+                }
+
+                Task.Run(async () =>
+                {
+                    string username = User.Username;
+                    User.Username = await TryFetchUserName(User.EmailHash);
+                    if (username != User.Username)
+                    {
+                        UsernameUpdated?.Invoke(User.Username);
+                        SaveUserInfo();
+                    }
+                });
+
+                SaveUserInfo();
+                return true;
+            }
+        }
+        catch (BadRequestException ex)
+        {
+            Error(ex.Message);
+        }
+        catch (PixiAuthException authException)
+        {
+            Error(authException.Message);
+        }
+        catch (HttpRequestException httpRequestException)
+        {
+            Error("CONNECTION_ERROR");
+        }
+        catch (TaskCanceledException timeoutException)
+        {
+            Error("CONNECTION_TIMEOUT");
+        }
+
+        return false;
+    }
+
+    private async Task<string> TryFetchUserName(string emailHash)
+    {
+        try
+        {
+            string? username = await Gravatar.GetUsername(emailHash);
+            if (username != null)
+            {
+                return username;
+            }
+        }
+        catch (PixiAuthException authException)
+        {
+            Error(authException.Message);
+        }
+        catch (HttpRequestException httpRequestException)
+        {
+            Error("CONNECTION_ERROR");
+        }
+        catch (TaskCanceledException timeoutException)
+        {
+            Error("CONNECTION_TIMEOUT");
+        }
+
+        return GenerateUsername(emailHash);
+    }
+
+    private string GenerateUsername(string emailHash)
+    {
+        return UsernameGenerator.GenerateUsername(emailHash);
+    }
+
+    private void Error(string exception, object? arg = null) => OnError?.Invoke(exception, arg);
+}

+ 15 - 0
src/PixiEditor.IdentityProvider.PixiAuth/PixiEditor.IdentityProvider.PixiAuth.csproj

@@ -0,0 +1,15 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+    <PropertyGroup>
+        <TargetFramework>net8.0</TargetFramework>
+        <ImplicitUsings>enable</ImplicitUsings>
+        <Nullable>enable</Nullable>
+    </PropertyGroup>
+
+    <ItemGroup>
+      <ProjectReference Include="..\PixiEditor.IdentityProvider\PixiEditor.IdentityProvider.csproj" />
+      <ProjectReference Include="..\PixiEditor.OperatingSystem\PixiEditor.OperatingSystem.csproj" />
+      <ProjectReference Include="..\PixiEditor.PixiAuth\PixiEditor.PixiAuth.csproj" />
+    </ItemGroup>
+
+</Project>

+ 19 - 0
src/PixiEditor.IdentityProvider.PixiAuth/PixiUser.cs

@@ -0,0 +1,19 @@
+namespace PixiEditor.IdentityProvider.PixiAuth;
+
+[Serializable]
+public class PixiUser : IUser
+{
+    public string Username { get; set; }
+
+    public string? AvatarUrl =>
+        EmailHash != null ? $"https://www.gravatar.com/avatar/{EmailHash}?s=100&d=initials" : null;
+
+    public string EmailHash { get; set; } = string.Empty;
+    public List<string> OwnedProducts { get; set; }
+    public Guid? SessionId { get; set; }
+    public string? SessionToken { get; set; } = string.Empty;
+    public DateTime? SessionExpirationDate { get; set; }
+
+    public bool IsLoggedIn => this is { SessionId: not null } && !string.IsNullOrEmpty(SessionToken);
+    public bool IsWaitingForActivation => this is { SessionId: not null } && string.IsNullOrEmpty(SessionToken);
+}

+ 1 - 1
src/PixiEditor/Models/User/UsernameGenerator.cs → src/PixiEditor.IdentityProvider.PixiAuth/UsernameGenerator.cs

@@ -1,6 +1,6 @@
 using System.Text;
 using System.Text;
 
 
-namespace PixiEditor.Models.User;
+namespace PixiEditor.IdentityProvider.PixiAuth;
 
 
 public static class UsernameGenerator
 public static class UsernameGenerator
 {
 {

+ 11 - 0
src/PixiEditor.IdentityProvider/IIdentityProvider.cs

@@ -0,0 +1,11 @@
+namespace PixiEditor.IdentityProvider;
+
+public interface IIdentityProvider
+{
+    public IUser User { get; }
+    public bool IsLoggedIn { get; }
+
+    public event Action<string, object> OnError;
+    public event Action<List<string>> OwnedProductsUpdated;
+    public event Action<string> UsernameUpdated;
+}

+ 9 - 0
src/PixiEditor.IdentityProvider/IUser.cs

@@ -0,0 +1,9 @@
+namespace PixiEditor.IdentityProvider;
+
+public interface IUser
+{
+    public string Username { get; }
+    public string? AvatarUrl { get; }
+    public List<string> OwnedProducts { get; }
+    public bool IsLoggedIn { get; }
+}

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

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

+ 25 - 0
src/PixiEditor.OperatingSystem/SecureStorage.cs

@@ -77,4 +77,29 @@ public static class SecureStorage
         stream.Close();
         stream.Close();
         return existingData;
         return existingData;
     }
     }
+
+    public static T GetValue<T>(string key, T? defaultValue = default)
+    {
+        byte[] current = ReadExistingData();
+
+        if (current is { Length: > 0 })
+        {
+            byte[] decryptedData = IOperatingSystem.Current.Encryptor.Decrypt(current);
+
+            string existingValue = Encoding.UTF8.GetString(decryptedData);
+            Dictionary<string, object>? data = JsonSerializer.Deserialize<Dictionary<string, object>>(existingValue);
+            if (data != null && data.TryGetValue(key, out object value))
+            {
+                if (value is JsonElement jsonElement)
+                {
+                    string jsonString = jsonElement.GetRawText();
+                    return JsonSerializer.Deserialize<T>(jsonString);
+                }
+
+                return (T)value;
+            }
+        }
+
+        return defaultValue;
+    }
 }
 }

+ 16 - 0
src/PixiEditor.PixiAuth/Models/Product.cs

@@ -0,0 +1,16 @@
+using System.Text.Json.Serialization;
+
+namespace PixiEditor.PixiAuth.Models;
+
+[Serializable]
+public class Product
+{
+    [JsonPropertyName("productId")]
+    public string ProductId { get; set; } = string.Empty;
+
+    [JsonPropertyName("isDlc")]
+    public bool IsDlc { get; set; }
+
+    [JsonPropertyName("target")]
+    public string Target { get; set; }
+}

+ 11 - 15
src/PixiEditor.PixiAuth/PixiAuthClient.cs

@@ -3,6 +3,7 @@ using System.Net.Http.Headers;
 using System.Net.Http.Json;
 using System.Net.Http.Json;
 using System.Text.Json;
 using System.Text.Json;
 using PixiEditor.PixiAuth.Exceptions;
 using PixiEditor.PixiAuth.Exceptions;
+using PixiEditor.PixiAuth.Models;
 
 
 namespace PixiEditor.PixiAuth;
 namespace PixiEditor.PixiAuth;
 
 
@@ -242,7 +243,7 @@ public class PixiAuthClient
         return false;
         return false;
     }
     }
 
 
-    public async Task<List<string>> GetOwnedProducts(string token)
+    public async Task<List<Product>> GetOwnedProducts(string token)
     {
     {
         HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, "/content/getOwnedProducts");
         HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, "/content/getOwnedProducts");
         request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
         request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
@@ -262,25 +263,20 @@ public class PixiAuthClient
         if (response.StatusCode == HttpStatusCode.OK)
         if (response.StatusCode == HttpStatusCode.OK)
         {
         {
             string result = await response.Content.ReadAsStringAsync();
             string result = await response.Content.ReadAsStringAsync();
-            List<Dictionary<string, string>>? ownedProducts =
-                System.Text.Json.JsonSerializer.Deserialize<List<Dictionary<string, string>>>(result);
-
-            if (ownedProducts != null)
+            try
             {
             {
-                List<string> productIds = new List<string>();
-                foreach (var ownedProduct in ownedProducts)
-                {
-                    if (ownedProduct.TryGetValue("productId", out string? productId))
-                    {
-                        productIds.Add(productId);
-                    }
-                }
+                List<Product>? ownedProducts = JsonSerializer.Deserialize<List<Product>>(result);
 
 
-                return productIds;
+                return ownedProducts ?? new List<Product>();
+            }
+            catch (JsonException)
+            {
+                // Handle JSON parsing error
+                throw new BadRequestException("PARSING_FAILED");
             }
             }
         }
         }
 
 
-        return new List<string>();
+        return [];
     }
     }
 
 
     public async Task<Stream> DownloadProduct(string token, string productId)
     public async Task<Stream> DownloadProduct(string token, string productId)

+ 0 - 18
src/PixiEditor.PixiAuth/User.cs

@@ -1,18 +0,0 @@
-namespace PixiEditor.PixiAuth;
-
-[Serializable]
-public class User
-{
-    public string EmailHash { get; set; } = string.Empty;
-    public string Username { get; set; } = string.Empty;
-    public Guid? SessionId { get; set; }
-    public string? SessionToken { get; set; } = string.Empty;
-    public DateTime? SessionExpirationDate { get; set; }
-
-    public List<string> OwnedProducts { get; set; } = new();
-
-    public User()
-    {
-
-    }
-}

+ 12 - 0
src/PixiEditor.PixiAuth/Utils/EmailUtility.cs

@@ -0,0 +1,12 @@
+namespace PixiEditor.PixiAuth.Utils;
+
+public static class EmailUtility
+{
+    public static string GetEmailHash(string email)
+    {
+        using var sha256 = System.Security.Cryptography.SHA256.Create();
+        byte[] inputBytes = System.Text.Encoding.UTF8.GetBytes(email.ToLower());
+        byte[] hashBytes = sha256.ComputeHash(inputBytes);
+        return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
+    }
+}

+ 74 - 12
src/PixiEditor.Platform.MSStore/MSAdditionalContentProvider.cs

@@ -1,24 +1,86 @@
-namespace PixiEditor.Platform.MSStore;
+using PixiEditor.IdentityProvider.PixiAuth;
+using PixiEditor.PixiAuth.Exceptions;
+
+namespace PixiEditor.Platform.MSStore;
 
 
 public sealed class MSAdditionalContentProvider : IAdditionalContentProvider
 public sealed class MSAdditionalContentProvider : IAdditionalContentProvider
 {
 {
-    public bool IsContentInstalled(AdditionalContentProduct product)
+    public string ExtensionsPath { get; }
+    public PixiAuthIdentityProvider IdentityProvider { get; }
+
+    public event Action<string, object>? OnError;
+
+    public MSAdditionalContentProvider(string extensionsPath, PixiAuthIdentityProvider identityProvider)
+    {
+        IdentityProvider = identityProvider;
+        ExtensionsPath = extensionsPath;
+    }
+
+    public async Task<string?> InstallContent(string productId)
     {
     {
-        if(!PlatformHasContent(product)) return false;
+        if (!IdentityProvider.ApiValid) return null;
+
+        if (IdentityProvider.User is not { IsLoggedIn: true })
+        {
+            return null;
+        }
+
+        try
+        {
+            var stream =
+                await IdentityProvider.PixiAuthClient.DownloadProduct(IdentityProvider.User.SessionToken, productId);
+            if (stream != null)
+            {
+                var filePath = Path.Combine(ExtensionsPath, $"{productId}.pixiext");
+                await using (var fileStream = File.Create(filePath))
+                {
+                    await stream.CopyToAsync(fileStream);
+                }
+
+                await stream.DisposeAsync();
 
 
-        return product switch
+                return filePath;
+            }
+        }
+        catch (PixiAuthException authException)
         {
         {
-            AdditionalContentProduct.SupporterPack => false,
-            _ => false
-        };
+            Error(authException.Message);
+        }
+        catch (HttpRequestException httpRequestException)
+        {
+            Error("CONNECTION_ERROR");
+        }
+        catch (TaskCanceledException timeoutException)
+        {
+            Error("CONNECTION_TIMEOUT");
+        }
+
+        return null;
     }
     }
 
 
-    public bool PlatformHasContent(AdditionalContentProduct product)
+    public bool IsContentOwned(string product)
     {
     {
-        return product switch
+        if (!PlatformHasContent(product)) return false;
+
+        if (IdentityProvider.User is not { IsLoggedIn: true })
         {
         {
-            AdditionalContentProduct.SupporterPack => false,
-            _ => false
-        };
+            return false;
+        }
+
+        return IdentityProvider.User.OwnedProducts.Contains(product);
+    }
+
+    public bool PlatformHasContent(string product)
+    {
+#if DEBUG
+        return true;
+#else
+        return false;
+#endif
+    }
+
+    public void Error(string error)
+    {
+        OnError?.Invoke(error, null);
     }
     }
 }
 }

+ 13 - 2
src/PixiEditor.Platform.MSStore/MicrosoftStorePlatform.cs

@@ -1,7 +1,17 @@
-namespace PixiEditor.Platform.MSStore;
+using PixiEditor.IdentityProvider;
+using PixiEditor.IdentityProvider.PixiAuth;
+
+namespace PixiEditor.Platform.MSStore;
 
 
 public sealed class MicrosoftStorePlatform : IPlatform
 public sealed class MicrosoftStorePlatform : IPlatform
 {
 {
+    public MicrosoftStorePlatform(string extensionsPath, string apiUrl)
+    {
+        var provider = new PixiAuthIdentityProvider(apiUrl);
+        IdentityProvider = provider;
+        AdditionalContentProvider = new MSAdditionalContentProvider(extensionsPath, provider);
+    }
+
     public string Id { get; } = "ms-store";
     public string Id { get; } = "ms-store";
     public string Name => "Microsoft Store";
     public string Name => "Microsoft Store";
 
 
@@ -15,5 +25,6 @@ public sealed class MicrosoftStorePlatform : IPlatform
 
 
     }
     }
 
 
-    public IAdditionalContentProvider? AdditionalContentProvider { get; } = new MSAdditionalContentProvider();
+    public IAdditionalContentProvider? AdditionalContentProvider { get; }
+    public IIdentityProvider? IdentityProvider { get; }
 }
 }

+ 1 - 0
src/PixiEditor.Platform.MSStore/PixiEditor.Platform.MSStore.csproj

@@ -7,6 +7,7 @@
     </PropertyGroup>
     </PropertyGroup>
 
 
     <ItemGroup>
     <ItemGroup>
+      <ProjectReference Include="..\PixiEditor.IdentityProvider.PixiAuth\PixiEditor.IdentityProvider.PixiAuth.csproj" />
       <ProjectReference Include="..\PixiEditor.Platform\PixiEditor.Platform.csproj" />
       <ProjectReference Include="..\PixiEditor.Platform\PixiEditor.Platform.csproj" />
     </ItemGroup>
     </ItemGroup>
 
 

+ 2 - 0
src/PixiEditor.Platform.Standalone/PixiEditor.Platform.Standalone.csproj

@@ -7,6 +7,8 @@
     </PropertyGroup>
     </PropertyGroup>
 
 
     <ItemGroup>
     <ItemGroup>
+      <ProjectReference Include="..\PixiEditor.IdentityProvider.PixiAuth\PixiEditor.IdentityProvider.PixiAuth.csproj" />
+      <ProjectReference Include="..\PixiEditor.PixiAuth\PixiEditor.PixiAuth.csproj" />
       <ProjectReference Include="..\PixiEditor.Platform\PixiEditor.Platform.csproj" />
       <ProjectReference Include="..\PixiEditor.Platform\PixiEditor.Platform.csproj" />
     </ItemGroup>
     </ItemGroup>
 
 

+ 73 - 9
src/PixiEditor.Platform.Standalone/StandaloneAdditionalContentProvider.cs

@@ -1,18 +1,77 @@
-namespace PixiEditor.Platform.Standalone;
+using System.Security.Principal;
+using PixiEditor.IdentityProvider;
+using PixiEditor.IdentityProvider.PixiAuth;
+using PixiEditor.PixiAuth;
+using PixiEditor.PixiAuth.Exceptions;
+
+namespace PixiEditor.Platform.Standalone;
 
 
 public sealed class StandaloneAdditionalContentProvider : IAdditionalContentProvider
 public sealed class StandaloneAdditionalContentProvider : IAdditionalContentProvider
 {
 {
-    public bool IsContentInstalled(AdditionalContentProduct product)
+    public string ExtensionsPath { get; }
+    public PixiAuthIdentityProvider IdentityProvider { get; }
+
+    public event Action<string, object>? OnError;
+    public StandaloneAdditionalContentProvider(string extensionsPath, PixiAuthIdentityProvider identityProvider)
     {
     {
-        if(!PlatformHasContent(product)) return false;
-#if DEBUG
-        return true;
-#else
-        return false;
-#endif
+        IdentityProvider = identityProvider;
+        ExtensionsPath = extensionsPath;
     }
     }
 
 
-    public bool PlatformHasContent(AdditionalContentProduct product)
+    public async Task<string?> InstallContent(string productId)
+    {
+        if (!IdentityProvider.ApiValid) return null;
+
+        if (IdentityProvider.User is not { IsLoggedIn: true })
+        {
+            return null;
+        }
+
+        try
+        {
+            var stream = await IdentityProvider.PixiAuthClient.DownloadProduct(IdentityProvider.User.SessionToken, productId);
+            if (stream != null)
+            {
+                var filePath = Path.Combine(ExtensionsPath, $"{productId}.pixiext");
+                await using (var fileStream = File.Create(filePath))
+                {
+                    await stream.CopyToAsync(fileStream);
+                }
+
+                await stream.DisposeAsync();
+
+                return filePath;
+            }
+        }
+        catch (PixiAuthException authException)
+        {
+            Error(authException.Message);
+        }
+        catch (HttpRequestException httpRequestException)
+        {
+            Error("CONNECTION_ERROR");
+        }
+        catch (TaskCanceledException timeoutException)
+        {
+            Error("CONNECTION_TIMEOUT");
+        }
+
+        return null;
+    }
+
+    public bool IsContentOwned(string product)
+    {
+        if (!PlatformHasContent(product)) return false;
+
+        if (IdentityProvider.User is not { IsLoggedIn: true })
+        {
+            return false;
+        }
+
+        return IdentityProvider.User.OwnedProducts.Contains(product, StringComparer.OrdinalIgnoreCase);
+    }
+
+    public bool PlatformHasContent(string product)
     {
     {
 #if DEBUG
 #if DEBUG
         return true;
         return true;
@@ -20,4 +79,9 @@ public sealed class StandaloneAdditionalContentProvider : IAdditionalContentProv
         return false;
         return false;
 #endif
 #endif
     }
     }
+
+    public void Error(string error)
+    {
+        OnError?.Invoke(error, null);
+    }
 }
 }

+ 15 - 4
src/PixiEditor.Platform.Standalone/StandalonePlatform.cs

@@ -1,13 +1,26 @@
-namespace PixiEditor.Platform.Standalone;
+using PixiEditor.IdentityProvider;
+using PixiEditor.IdentityProvider.PixiAuth;
+using PixiEditor.PixiAuth;
+
+namespace PixiEditor.Platform.Standalone;
 
 
 public sealed class StandalonePlatform : IPlatform
 public sealed class StandalonePlatform : IPlatform
 {
 {
     public string Id { get; } = "standalone";
     public string Id { get; } = "standalone";
     public string Name => "Standalone";
     public string Name => "Standalone";
 
 
-    public bool PerformHandshake()
+    public IIdentityProvider? IdentityProvider { get; }
+    public IAdditionalContentProvider? AdditionalContentProvider { get; }
+
+    public StandalonePlatform(string extensionsPath, string apiUrl)
     {
     {
+        PixiAuthIdentityProvider authProvider = new PixiAuthIdentityProvider(apiUrl);
+        IdentityProvider = authProvider;
+        AdditionalContentProvider = new StandaloneAdditionalContentProvider(extensionsPath, authProvider);
+    }
 
 
+    public bool PerformHandshake()
+    {
         return true;
         return true;
     }
     }
 
 
@@ -15,6 +28,4 @@ public sealed class StandalonePlatform : IPlatform
     {
     {
 
 
     }
     }
-
-    public IAdditionalContentProvider? AdditionalContentProvider { get; } = new StandaloneAdditionalContentProvider();
 }
 }

+ 20 - 4
src/PixiEditor.Platform.Steam/SteamAdditionalContentProvider.cs

@@ -4,12 +4,12 @@ namespace PixiEditor.Platform.Steam;
 
 
 public sealed class SteamAdditionalContentProvider : IAdditionalContentProvider
 public sealed class SteamAdditionalContentProvider : IAdditionalContentProvider
 {
 {
-    private Dictionary<AdditionalContentProduct, AppId_t> productIds = new()
+    private Dictionary<string, AppId_t> productIds = new()
     {
     {
-        { AdditionalContentProduct.SupporterPack, new AppId_t(2435860) }
+        { "PixiEditor.FoundersPack", new AppId_t(2435860) }
     };
     };
 
 
-    public bool IsContentInstalled(AdditionalContentProduct product)
+    public bool IsContentOwned(string product)
     {
     {
         if(!SteamAPI.IsSteamRunning()) return false;
         if(!SteamAPI.IsSteamRunning()) return false;
         if(!PlatformHasContent(product)) return false;
         if(!PlatformHasContent(product)) return false;
@@ -19,8 +19,24 @@ public sealed class SteamAdditionalContentProvider : IAdditionalContentProvider
         return installed;
         return installed;
     }
     }
 
 
-    public bool PlatformHasContent(AdditionalContentProduct product)
+    public async Task<string?> InstallContent(string productId)
+    {
+        if (!SteamAPI.IsSteamRunning()) return null;
+        if (!PlatformHasContent(productId)) return null;
+
+        AppId_t appId = productIds[productId];
+        SteamApps.InstallDLC(appId);
+
+        // Steam does not provide a way to check if the installation was successful
+        // so we will just return the product ID
+        // TODO: Implement properly
+        return productId;
+    }
+
+    public bool PlatformHasContent(string product)
     {
     {
         return productIds.ContainsKey(product);
         return productIds.ContainsKey(product);
     }
     }
+
+    public event Action<string, object>? OnError;
 }
 }

+ 12 - 0
src/PixiEditor.Platform.Steam/SteamIdentityProvider.cs

@@ -0,0 +1,12 @@
+using PixiEditor.IdentityProvider;
+
+namespace PixiEditor.Platform.Steam;
+
+public class SteamIdentityProvider : IIdentityProvider
+{
+    public IUser User { get; } // TODO: Implement
+    public bool IsLoggedIn { get; }
+    public event Action<string, object>? OnError;
+    public event Action<List<string>>? OwnedProductsUpdated;
+    public event Action<string>? UsernameUpdated;
+}

+ 3 - 1
src/PixiEditor.Platform.Steam/SteamPlatform.cs

@@ -1,4 +1,5 @@
-using Steamworks;
+using PixiEditor.IdentityProvider;
+using Steamworks;
 using Timer = System.Timers.Timer;
 using Timer = System.Timers.Timer;
 
 
 namespace PixiEditor.Platform.Steam;
 namespace PixiEditor.Platform.Steam;
@@ -27,4 +28,5 @@ public class SteamPlatform : IPlatform
     }
     }
 
 
     public IAdditionalContentProvider? AdditionalContentProvider { get; } = new SteamAdditionalContentProvider();
     public IAdditionalContentProvider? AdditionalContentProvider { get; } = new SteamAdditionalContentProvider();
+    public IIdentityProvider? IdentityProvider { get; } = new SteamIdentityProvider();
 }
 }

+ 5 - 7
src/PixiEditor.Platform/IAdditionalContentProvider.cs

@@ -1,12 +1,10 @@
 namespace PixiEditor.Platform;
 namespace PixiEditor.Platform;
 
 
-public enum AdditionalContentProduct
-{
-    SupporterPack
-}
-
 public interface IAdditionalContentProvider
 public interface IAdditionalContentProvider
 {
 {
-    public bool IsContentInstalled(AdditionalContentProduct product);
-    public bool PlatformHasContent(AdditionalContentProduct product);
+    public Task<string?> InstallContent(string productId);
+    public bool IsContentOwned(string productId);
+    public bool PlatformHasContent(string productId);
+
+    public event Action<string, object> OnError;
 }
 }

+ 4 - 1
src/PixiEditor.Platform/IPlatform.cs

@@ -1,4 +1,6 @@
-namespace PixiEditor.Platform;
+using PixiEditor.IdentityProvider;
+
+namespace PixiEditor.Platform;
 
 
 public interface IPlatform
 public interface IPlatform
 {
 {
@@ -8,6 +10,7 @@ public interface IPlatform
     public bool PerformHandshake();
     public bool PerformHandshake();
     public void Update();
     public void Update();
     public IAdditionalContentProvider? AdditionalContentProvider { get; }
     public IAdditionalContentProvider? AdditionalContentProvider { get; }
+    public IIdentityProvider? IdentityProvider { get; }
 
 
     public static void RegisterPlatform(IPlatform platform)
     public static void RegisterPlatform(IPlatform platform)
     {
     {

+ 4 - 0
src/PixiEditor.Platform/PixiEditor.Platform.csproj

@@ -10,4 +10,8 @@
       <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
       <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
     </ItemGroup>
     </ItemGroup>
 
 
+    <ItemGroup>
+      <ProjectReference Include="..\PixiEditor.IdentityProvider\PixiEditor.IdentityProvider.csproj" />
+    </ItemGroup>
+
 </Project>
 </Project>

+ 65 - 0
src/PixiEditor.sln

@@ -138,6 +138,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColorPicker.Models", "Color
 EndProject
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PixiEditor.PixiAuth", "PixiEditor.PixiAuth\PixiEditor.PixiAuth.csproj", "{7EE8ED1A-000A-4583-BDBF-EA8B90314CFE}"
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PixiEditor.PixiAuth", "PixiEditor.PixiAuth\PixiEditor.PixiAuth.csproj", "{7EE8ED1A-000A-4583-BDBF-EA8B90314CFE}"
 EndProject
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PixiEditor.IdentityProvider", "PixiEditor.IdentityProvider\PixiEditor.IdentityProvider.csproj", "{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Identity", "Identity", "{E29E0A2B-6775-4804-97F3-79246F60949B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PixiEditor.IdentityProvider.PixiAuth", "PixiEditor.IdentityProvider.PixiAuth\PixiEditor.IdentityProvider.PixiAuth.csproj", "{E93941F3-0476-44DA-80BE-CE8F4776297C}"
+EndProject
 Global
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|x64 = Debug|x64
 		Debug|x64 = Debug|x64
@@ -1346,6 +1352,62 @@ Global
 		{7EE8ED1A-000A-4583-BDBF-EA8B90314CFE}.Steam|x64.Build.0 = Debug|Any CPU
 		{7EE8ED1A-000A-4583-BDBF-EA8B90314CFE}.Steam|x64.Build.0 = Debug|Any CPU
 		{7EE8ED1A-000A-4583-BDBF-EA8B90314CFE}.Steam|ARM64.ActiveCfg = Debug|Any CPU
 		{7EE8ED1A-000A-4583-BDBF-EA8B90314CFE}.Steam|ARM64.ActiveCfg = Debug|Any CPU
 		{7EE8ED1A-000A-4583-BDBF-EA8B90314CFE}.Steam|ARM64.Build.0 = Debug|Any CPU
 		{7EE8ED1A-000A-4583-BDBF-EA8B90314CFE}.Steam|ARM64.Build.0 = Debug|Any CPU
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}.Debug|x64.Build.0 = Debug|Any CPU
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}.Debug|ARM64.ActiveCfg = Debug|Any CPU
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}.Debug|ARM64.Build.0 = Debug|Any CPU
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}.DevRelease|x64.ActiveCfg = Debug|Any CPU
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}.DevRelease|x64.Build.0 = Debug|Any CPU
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}.DevRelease|ARM64.ActiveCfg = Debug|Any CPU
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}.DevRelease|ARM64.Build.0 = Debug|Any CPU
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}.DevSteam|x64.ActiveCfg = Debug|Any CPU
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}.DevSteam|x64.Build.0 = Debug|Any CPU
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}.DevSteam|ARM64.ActiveCfg = Debug|Any CPU
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}.DevSteam|ARM64.Build.0 = Debug|Any CPU
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}.MSIX Debug|x64.ActiveCfg = Debug|Any CPU
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}.MSIX Debug|x64.Build.0 = Debug|Any CPU
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}.MSIX Debug|ARM64.ActiveCfg = Debug|Any CPU
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}.MSIX Debug|ARM64.Build.0 = Debug|Any CPU
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}.MSIX|x64.ActiveCfg = Debug|Any CPU
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}.MSIX|x64.Build.0 = Debug|Any CPU
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}.MSIX|ARM64.ActiveCfg = Debug|Any CPU
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}.MSIX|ARM64.Build.0 = Debug|Any CPU
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}.Release|x64.ActiveCfg = Release|Any CPU
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}.Release|x64.Build.0 = Release|Any CPU
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}.Release|ARM64.ActiveCfg = Release|Any CPU
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}.Release|ARM64.Build.0 = Release|Any CPU
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}.Steam|x64.ActiveCfg = Debug|Any CPU
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}.Steam|x64.Build.0 = Debug|Any CPU
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}.Steam|ARM64.ActiveCfg = Debug|Any CPU
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1}.Steam|ARM64.Build.0 = Debug|Any CPU
+		{E93941F3-0476-44DA-80BE-CE8F4776297C}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{E93941F3-0476-44DA-80BE-CE8F4776297C}.Debug|x64.Build.0 = Debug|Any CPU
+		{E93941F3-0476-44DA-80BE-CE8F4776297C}.Debug|ARM64.ActiveCfg = Debug|Any CPU
+		{E93941F3-0476-44DA-80BE-CE8F4776297C}.Debug|ARM64.Build.0 = Debug|Any CPU
+		{E93941F3-0476-44DA-80BE-CE8F4776297C}.DevRelease|x64.ActiveCfg = Debug|Any CPU
+		{E93941F3-0476-44DA-80BE-CE8F4776297C}.DevRelease|x64.Build.0 = Debug|Any CPU
+		{E93941F3-0476-44DA-80BE-CE8F4776297C}.DevRelease|ARM64.ActiveCfg = Debug|Any CPU
+		{E93941F3-0476-44DA-80BE-CE8F4776297C}.DevRelease|ARM64.Build.0 = Debug|Any CPU
+		{E93941F3-0476-44DA-80BE-CE8F4776297C}.DevSteam|x64.ActiveCfg = Debug|Any CPU
+		{E93941F3-0476-44DA-80BE-CE8F4776297C}.DevSteam|x64.Build.0 = Debug|Any CPU
+		{E93941F3-0476-44DA-80BE-CE8F4776297C}.DevSteam|ARM64.ActiveCfg = Debug|Any CPU
+		{E93941F3-0476-44DA-80BE-CE8F4776297C}.DevSteam|ARM64.Build.0 = Debug|Any CPU
+		{E93941F3-0476-44DA-80BE-CE8F4776297C}.MSIX Debug|x64.ActiveCfg = Debug|Any CPU
+		{E93941F3-0476-44DA-80BE-CE8F4776297C}.MSIX Debug|x64.Build.0 = Debug|Any CPU
+		{E93941F3-0476-44DA-80BE-CE8F4776297C}.MSIX Debug|ARM64.ActiveCfg = Debug|Any CPU
+		{E93941F3-0476-44DA-80BE-CE8F4776297C}.MSIX Debug|ARM64.Build.0 = Debug|Any CPU
+		{E93941F3-0476-44DA-80BE-CE8F4776297C}.MSIX|x64.ActiveCfg = Debug|Any CPU
+		{E93941F3-0476-44DA-80BE-CE8F4776297C}.MSIX|x64.Build.0 = Debug|Any CPU
+		{E93941F3-0476-44DA-80BE-CE8F4776297C}.MSIX|ARM64.ActiveCfg = Debug|Any CPU
+		{E93941F3-0476-44DA-80BE-CE8F4776297C}.MSIX|ARM64.Build.0 = Debug|Any CPU
+		{E93941F3-0476-44DA-80BE-CE8F4776297C}.Release|x64.ActiveCfg = Release|Any CPU
+		{E93941F3-0476-44DA-80BE-CE8F4776297C}.Release|x64.Build.0 = Release|Any CPU
+		{E93941F3-0476-44DA-80BE-CE8F4776297C}.Release|ARM64.ActiveCfg = Release|Any CPU
+		{E93941F3-0476-44DA-80BE-CE8F4776297C}.Release|ARM64.Build.0 = Release|Any CPU
+		{E93941F3-0476-44DA-80BE-CE8F4776297C}.Steam|x64.ActiveCfg = Debug|Any CPU
+		{E93941F3-0476-44DA-80BE-CE8F4776297C}.Steam|x64.Build.0 = Debug|Any CPU
+		{E93941F3-0476-44DA-80BE-CE8F4776297C}.Steam|ARM64.ActiveCfg = Debug|Any CPU
+		{E93941F3-0476-44DA-80BE-CE8F4776297C}.Steam|ARM64.Build.0 = Debug|Any CPU
 	EndGlobalSection
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
 		HideSolutionNode = FALSE
@@ -1409,6 +1471,9 @@ Global
 		{885A99AB-86F0-4D8E-A989-FB0000C1662D} = {E8A74431-F76F-43B1-BC66-CA05E249E6F4}
 		{885A99AB-86F0-4D8E-A989-FB0000C1662D} = {E8A74431-F76F-43B1-BC66-CA05E249E6F4}
 		{ED673353-3433-4FCB-9199-0F8DE0968F52} = {E8A74431-F76F-43B1-BC66-CA05E249E6F4}
 		{ED673353-3433-4FCB-9199-0F8DE0968F52} = {E8A74431-F76F-43B1-BC66-CA05E249E6F4}
 		{7EE8ED1A-000A-4583-BDBF-EA8B90314CFE} = {1E816135-76C1-4255-BE3C-BF17895A65AA}
 		{7EE8ED1A-000A-4583-BDBF-EA8B90314CFE} = {1E816135-76C1-4255-BE3C-BF17895A65AA}
+		{E29E0A2B-6775-4804-97F3-79246F60949B} = {1E816135-76C1-4255-BE3C-BF17895A65AA}
+		{9AC42CC4-F6A3-4652-951B-17CE4DFC5CC1} = {E29E0A2B-6775-4804-97F3-79246F60949B}
+		{E93941F3-0476-44DA-80BE-CE8F4776297C} = {E29E0A2B-6775-4804-97F3-79246F60949B}
 	EndGlobalSection
 	EndGlobalSection
 	GlobalSection(ExtensibilityGlobals) = postSolution
 	GlobalSection(ExtensibilityGlobals) = postSolution
 		SolutionGuid = {D04B4AB0-CA33-42FD-A909-79966F9255C5}
 		SolutionGuid = {D04B4AB0-CA33-42FD-A909-79966F9255C5}

+ 5 - 3
src/PixiEditor/Data/Localization/Languages/en.json

@@ -1043,9 +1043,9 @@
   "ENTER_EMAIL": "Enter your email",
   "ENTER_EMAIL": "Enter your email",
   "LOGIN_LINK": "Send Login Link",
   "LOGIN_LINK": "Send Login Link",
   "LOGIN_LINK_INFO": "We'll email you a secure link to log in. No password needed.",
   "LOGIN_LINK_INFO": "We'll email you a secure link to log in. No password needed.",
-  "LOGIN_WINDOW_TITLE": "Founder's Account",
+  "ACCOUNT_WINDOW_TITLE": "Founder's Account",
   "CONNECTION_TIMEOUT": "Connection timed out. Please try again.",
   "CONNECTION_TIMEOUT": "Connection timed out. Please try again.",
-  "OPEN_LOGIN_WINDOW": "Open login window",
+  "OPEN_ACCOUNT_WINDOW": "Manage Account",
   "AUTO_SCALE_BACKGROUND": "Auto scale background",
   "AUTO_SCALE_BACKGROUND": "Auto scale background",
   "UPDATES": "Updates",
   "UPDATES": "Updates",
   "SCENE": "Scene",
   "SCENE": "Scene",
@@ -1053,5 +1053,7 @@
   "PRIMARY_BG_COLOR": "Primary background color",
   "PRIMARY_BG_COLOR": "Primary background color",
   "SECONDARY_BG_COLOR": "Secondary background color",
   "SECONDARY_BG_COLOR": "Secondary background color",
   "RESET": "Reset",
   "RESET": "Reset",
-  "INSTALL": "Install"
+  "INSTALL": "Install",
+  "MANAGE_ACCOUNT": "Manage",
+  "OWNED_PRODUCTS": "Owned Content"
 }
 }

+ 20 - 6
src/PixiEditor/Initialization/ClassicDesktopEntry.cs

@@ -16,6 +16,7 @@ using PixiEditor.Models.Dialogs;
 using PixiEditor.Models.ExceptionHandling;
 using PixiEditor.Models.ExceptionHandling;
 using PixiEditor.Models.IO;
 using PixiEditor.Models.IO;
 using PixiEditor.OperatingSystem;
 using PixiEditor.OperatingSystem;
+using PixiEditor.PixiAuth;
 using PixiEditor.Platform;
 using PixiEditor.Platform;
 using PixiEditor.Views;
 using PixiEditor.Views;
 using PixiEditor.Views.Dialogs;
 using PixiEditor.Views.Dialogs;
@@ -84,7 +85,7 @@ internal class ClassicDesktopEntry
 
 
             return;
             return;
         }
         }
-        
+
 #if !STEAM && !DEBUG
 #if !STEAM && !DEBUG
         if (!HandleNewInstance(Dispatcher.UIThread))
         if (!HandleNewInstance(Dispatcher.UIThread))
         {
         {
@@ -112,10 +113,6 @@ internal class ClassicDesktopEntry
         InitPlatform();
         InitPlatform();
 
 
         ExtensionLoader extensionLoader = new ExtensionLoader(Paths.ExtensionPackagesPath, Paths.UserExtensionsPath);
         ExtensionLoader extensionLoader = new ExtensionLoader(Paths.ExtensionPackagesPath, Paths.UserExtensionsPath);
-        //TODO: fetch from extension store
-        extensionLoader.AddOfficialExtension("pixieditor.supporterpack",
-            new OfficialExtensionData("supporter-pack.snk", AdditionalContentProduct.SupporterPack));
-        extensionLoader.AddOfficialExtension("pixieditor.beta", new OfficialExtensionData());
         extensionLoader.LoadExtensions();
         extensionLoader.LoadExtensions();
 
 
         return extensionLoader;
         return extensionLoader;
@@ -128,7 +125,7 @@ internal class ClassicDesktopEntry
 #elif MSIX || MSIX_DEBUG
 #elif MSIX || MSIX_DEBUG
         return new PixiEditor.Platform.MSStore.MicrosoftStorePlatform();
         return new PixiEditor.Platform.MSStore.MicrosoftStorePlatform();
 #else
 #else
-        return new PixiEditor.Platform.Standalone.StandalonePlatform();
+        return new PixiEditor.Platform.Standalone.StandalonePlatform(Paths.ExtensionPackagesPath, GetApiUrl());
 #endif
 #endif
     }
     }
 
 
@@ -219,4 +216,21 @@ internal class ClassicDesktopEntry
             });
             });
         }
         }
     }
     }
+
+    private string GetApiUrl()
+    {
+        string baseUrl = BuildConstants.PixiEditorApiUrl;
+#if DEBUG
+        if (baseUrl.Contains('{') && baseUrl.Contains('}'))
+        {
+            string? envUrl = Environment.GetEnvironmentVariable("PIXIEDITOR_API_URL");
+            if (envUrl != null)
+            {
+                baseUrl = envUrl;
+            }
+        }
+#endif
+
+        return baseUrl;
+    }
 }
 }

+ 3 - 0
src/PixiEditor/PixiEditor.csproj

@@ -107,6 +107,8 @@
     <ProjectReference Include="..\Drawie\src\Drawie.Interop.Avalonia\Drawie.Interop.Avalonia.csproj"/>
     <ProjectReference Include="..\Drawie\src\Drawie.Interop.Avalonia\Drawie.Interop.Avalonia.csproj"/>
     <ProjectReference Include="..\Drawie\src\Drawie.Interop.Avalonia.Core\Drawie.Interop.Avalonia.Core.csproj"/>
     <ProjectReference Include="..\Drawie\src\Drawie.Interop.Avalonia.Core\Drawie.Interop.Avalonia.Core.csproj"/>
     <ProjectReference Include="..\PixiDocks\src\PixiDocks.Avalonia\PixiDocks.Avalonia.csproj"/>
     <ProjectReference Include="..\PixiDocks\src\PixiDocks.Avalonia\PixiDocks.Avalonia.csproj"/>
+    <ProjectReference Include="..\PixiEditor.IdentityProvider.PixiAuth\PixiEditor.IdentityProvider.PixiAuth.csproj" />
+    <ProjectReference Include="..\PixiEditor.IdentityProvider\PixiEditor.IdentityProvider.csproj" />
     <ProjectReference Include="..\PixiEditor.PixiAuth\PixiEditor.PixiAuth.csproj" />
     <ProjectReference Include="..\PixiEditor.PixiAuth\PixiEditor.PixiAuth.csproj" />
     <ProjectReference Include="..\PixiEditor.SVG\PixiEditor.SVG.csproj"/>
     <ProjectReference Include="..\PixiEditor.SVG\PixiEditor.SVG.csproj"/>
     <ProjectReference Include="..\PixiParser\src\PixiParser.Skia\PixiParser.Skia.csproj"/>
     <ProjectReference Include="..\PixiParser\src\PixiParser.Skia\PixiParser.Skia.csproj"/>
@@ -156,6 +158,7 @@
 
 
   <ItemGroup>
   <ItemGroup>
     <Folder Include="Extensions\"/>
     <Folder Include="Extensions\"/>
+    <Folder Include="Models\User\" />
   </ItemGroup>
   </ItemGroup>
 
 
 </Project>
 </Project>

+ 2 - 2
src/PixiEditor/ViewModels/SubViewModels/AdditionalContent/AdditionalContentViewModel.cs

@@ -10,7 +10,7 @@ internal class AdditionalContentViewModel : ViewModelBase
         AdditionalContentProvider = additionalContentProvider;
         AdditionalContentProvider = additionalContentProvider;
     }
     }
 
 
-    public bool IsSupporterPackAvailable => AdditionalContentProvider != null 
-                                            && AdditionalContentProvider.IsContentInstalled(AdditionalContentProduct.SupporterPack)
+    public bool IsFoundersPackAvailable => AdditionalContentProvider != null
+                                            && AdditionalContentProvider.IsContentOwned("PixiEditor.FoundersPack")
                                             && ViewModelMain.Current.ExtensionsSubViewModel.ExtensionLoader.LoadedExtensions.Any(x => x.Metadata.UniqueName == "PixiEditor.SupporterPack");
                                             && ViewModelMain.Current.ExtensionsSubViewModel.ExtensionLoader.LoadedExtensions.Any(x => x.Metadata.UniqueName == "PixiEditor.SupporterPack");
 }
 }

+ 5 - 5
src/PixiEditor/ViewModels/SubViewModels/ExtensionsViewModel.cs

@@ -21,11 +21,6 @@ internal class ExtensionsViewModel : SubViewModel<ViewModelMain>
         Owner.OnStartupEvent += Owner_OnStartupEvent;
         Owner.OnStartupEvent += Owner_OnStartupEvent;
     }
     }
 
 
-    private void RegisterCoreWindows(WindowProvider? windowProvider)
-    {
-        windowProvider?.RegisterWindow<PalettesBrowser>();
-    }
-
     public void LoadExtensionAdHoc(string extension)
     public void LoadExtensionAdHoc(string extension)
     {
     {
         if (extension.EndsWith(".pixiext"))
         if (extension.EndsWith(".pixiext"))
@@ -41,6 +36,11 @@ internal class ExtensionsViewModel : SubViewModel<ViewModelMain>
         }
         }
     }
     }
 
 
+    private void RegisterCoreWindows(WindowProvider? windowProvider)
+    {
+        windowProvider?.RegisterWindow<PalettesBrowser>();
+    }
+
     private void Owner_OnStartupEvent()
     private void Owner_OnStartupEvent()
     {
     {
         ExtensionLoader.InitializeExtensions(new ExtensionServices(Owner.Services));
         ExtensionLoader.InitializeExtensions(new ExtensionServices(Owner.Services));

+ 117 - 332
src/PixiEditor/ViewModels/SubViewModels/UserViewModel.cs

@@ -1,26 +1,40 @@
 using System.Collections.ObjectModel;
 using System.Collections.ObjectModel;
 using Avalonia.Threading;
 using Avalonia.Threading;
 using CommunityToolkit.Mvvm.Input;
 using CommunityToolkit.Mvvm.Input;
+using DiscordRPC;
 using PixiEditor.Extensions.Common.Localization;
 using PixiEditor.Extensions.Common.Localization;
 using PixiEditor.Helpers;
 using PixiEditor.Helpers;
+using PixiEditor.IdentityProvider;
+using PixiEditor.IdentityProvider.PixiAuth;
 using PixiEditor.Models.Commands.Attributes.Commands;
 using PixiEditor.Models.Commands.Attributes.Commands;
-using PixiEditor.Models.User;
 using PixiEditor.OperatingSystem;
 using PixiEditor.OperatingSystem;
 using PixiEditor.PixiAuth;
 using PixiEditor.PixiAuth;
 using PixiEditor.PixiAuth.Exceptions;
 using PixiEditor.PixiAuth.Exceptions;
+using PixiEditor.PixiAuth.Utils;
+using PixiEditor.Platform;
 
 
 namespace PixiEditor.ViewModels.SubViewModels;
 namespace PixiEditor.ViewModels.SubViewModels;
 
 
 internal class UserViewModel : SubViewModel<ViewModelMain>
 internal class UserViewModel : SubViewModel<ViewModelMain>
 {
 {
     private LocalizedString? lastError = null;
     private LocalizedString? lastError = null;
-    public PixiAuthClient PixiAuthClient { get; }
-    public User? User { get; private set; }
 
 
-    public bool NotLoggedIn => User?.SessionId is null || User.SessionId == Guid.Empty;
-    public bool WaitingForActivation => User is { SessionId: not null } && string.IsNullOrEmpty(User.SessionToken);
-    public bool IsLoggedIn => User is { SessionId: not null } && !string.IsNullOrEmpty(User.SessionToken);
-    public bool EmailEqualsLastSentMail => (CurrentEmail != null ? GetEmailHash(CurrentEmail) : "") == lastSentHash;
+    public IIdentityProvider IdentityProvider { get; }
+    public IAdditionalContentProvider AdditionalContentProvider { get; }
+
+    public bool NotLoggedIn => !IsLoggedIn && !WaitingForActivation;
+
+    public bool WaitingForActivation => IdentityProvider is PixiAuthIdentityProvider
+    {
+        User: { IsWaitingForActivation: true }
+    };
+
+    public bool IsLoggedIn => IdentityProvider.IsLoggedIn;
+
+    public IUser User => IdentityProvider.User;
+
+    public bool EmailEqualsLastSentMail =>
+        (CurrentEmail != null ? EmailUtility.GetEmailHash(CurrentEmail) : "") == lastSentHash;
 
 
     public AsyncRelayCommand<string> RequestLoginCommand { get; }
     public AsyncRelayCommand<string> RequestLoginCommand { get; }
     public AsyncRelayCommand TryValidateSessionCommand { get; }
     public AsyncRelayCommand TryValidateSessionCommand { get; }
@@ -36,7 +50,6 @@ internal class UserViewModel : SubViewModel<ViewModelMain>
         set => SetProperty(ref lastError, value);
         set => SetProperty(ref lastError, value);
     }
     }
 
 
-    private bool apiValid = true;
 
 
     public DateTime? TimeToEndTimeout { get; private set; } = null;
     public DateTime? TimeToEndTimeout { get; private set; } = null;
 
 
@@ -54,13 +67,9 @@ internal class UserViewModel : SubViewModel<ViewModelMain>
         }
         }
     }
     }
 
 
-    public string? UserGravatarUrl =>
-        User?.EmailHash != null ? $"https://www.gravatar.com/avatar/{User.EmailHash}?s=100&d=initials" : null;
-
-    public ObservableCollection<string> OwnedProducts { get; private set; } = new ObservableCollection<string>();
+    public ObservableCollection<string> OwnedProducts => new(IdentityProvider?.User?.OwnedProducts ?? new List<string>());
 
 
     private string currentEmail = string.Empty;
     private string currentEmail = string.Empty;
-    private string username;
 
 
     public string CurrentEmail
     public string CurrentEmail
     {
     {
@@ -74,131 +83,95 @@ internal class UserViewModel : SubViewModel<ViewModelMain>
         }
         }
     }
     }
 
 
-    public string Username
-    {
-        get => username;
-        set
-        {
-            SetProperty(ref username, value);
-        }
-    }
+    public string Username => IdentityProvider?.User?.Username;
+
+    public string? AvatarUrl => IdentityProvider.User?.AvatarUrl;
 
 
     public UserViewModel(ViewModelMain owner) : base(owner)
     public UserViewModel(ViewModelMain owner) : base(owner)
     {
     {
+        IdentityProvider = IPlatform.Current.IdentityProvider;
+        AdditionalContentProvider = IPlatform.Current.AdditionalContentProvider;
         RequestLoginCommand = new AsyncRelayCommand<string>(RequestLogin, CanRequestLogin);
         RequestLoginCommand = new AsyncRelayCommand<string>(RequestLogin, CanRequestLogin);
         TryValidateSessionCommand = new AsyncRelayCommand(TryValidateSession);
         TryValidateSessionCommand = new AsyncRelayCommand(TryValidateSession);
         ResendActivationCommand = new AsyncRelayCommand<string>(ResendActivation, CanResendActivation);
         ResendActivationCommand = new AsyncRelayCommand<string>(ResendActivation, CanResendActivation);
         InstallContentCommand = new AsyncRelayCommand<string>(InstallContent);
         InstallContentCommand = new AsyncRelayCommand<string>(InstallContent);
         LogoutCommand = new AsyncRelayCommand(Logout);
         LogoutCommand = new AsyncRelayCommand(Logout);
 
 
-        string baseUrl = BuildConstants.PixiEditorApiUrl;
-#if DEBUG
-        if (baseUrl.Contains('{') && baseUrl.Contains('}'))
-        {
-            string? envUrl = Environment.GetEnvironmentVariable("PIXIEDITOR_API_URL");
-            if (envUrl != null)
-            {
-                baseUrl = envUrl;
-            }
-        }
-#endif
-        try
-        {
-            PixiAuthClient = new PixiAuthClient(baseUrl);
-        }
-        catch (UriFormatException e)
+        IdentityProvider.OnError += OnError;
+        IdentityProvider.OwnedProductsUpdated += IdentityProviderOnOwnedProductsUpdated;
+        IdentityProvider.UsernameUpdated += IdentityProviderOnUsernameUpdated;
+
+        if (IdentityProvider is PixiAuthIdentityProvider pixiAuth)
         {
         {
-            Console.WriteLine($"Invalid api URL format: {e.Message}");
-            apiValid = false;
+            pixiAuth.LoginRequestSuccessful += PixiAuthOnLoginRequestSuccessful;
+            pixiAuth.LoginTimeout += PixiAuthOnLoginTimeout;
+            pixiAuth.LoggedOut += PixiAuthOnLoggedOut;
         }
         }
+    }
 
 
-        Task.Run(async () =>
-        {
-            await LoadUserData();
-            await TryRefreshToken();
-            await LogoutIfTokenExpired();
-        });
+    private void IdentityProviderOnUsernameUpdated(string newUsername)
+    {
+        NotifyProperties();
     }
     }
 
 
-    public async Task RequestLogin(string email)
+    private void IdentityProviderOnOwnedProductsUpdated(List<string> products)
     {
     {
-        if (!apiValid) return;
+        NotifyProperties();
+    }
 
 
-        try
+    private void PixiAuthOnLoggedOut()
+    {
+        OwnedProducts.Clear();
+        NotifyProperties();
+    }
+
+    private void PixiAuthOnLoginTimeout(double seconds)
+    {
+        TimeToEndTimeout = DateTime.Now.AddSeconds(seconds);
+        RunTimeoutTimers(seconds);
+        NotifyProperties();
+    }
+
+    private void PixiAuthOnLoginRequestSuccessful(PixiUser user)
+    {
+        lastSentHash = user.EmailHash;
+        NotifyProperties();
+    }
+
+    public async Task RequestLogin(string email)
+    {
+        if (IdentityProvider is PixiAuthIdentityProvider pixiAuthIdentityProvider)
         {
         {
-            Guid? session = await PixiAuthClient.GenerateSession(email);
-            string hash = GetEmailHash(email);
-            if (session != null)
+            LastError = null;
+            try
             {
             {
-                LastError = null;
-                Username = null;
-                User = new User { SessionId = session.Value, EmailHash = hash, Username = GenerateUsername(hash) };
-                lastSentHash = User.EmailHash;
-                NotifyProperties();
-                SaveUserInfo();
+                await pixiAuthIdentityProvider.RequestLogin(email);
+            }
+            catch (Exception ex)
+            {
+                CrashHelper.SendExceptionInfo(ex);
             }
             }
-        }
-        catch (PixiAuthException authException)
-        {
-            LastError = new LocalizedString(authException.Message);
-        }
-        catch (HttpRequestException httpRequestException)
-        {
-            LastError = new LocalizedString("CONNECTION_ERROR");
-        }
-        catch (TaskCanceledException timeoutException)
-        {
-            LastError = new LocalizedString("CONNECTION_TIMEOUT");
         }
         }
     }
     }
 
 
     public bool CanRequestLogin(string email)
     public bool CanRequestLogin(string email)
     {
     {
-        return !string.IsNullOrEmpty(email) && email.Contains('@');
+        return IdentityProvider is PixiAuthIdentityProvider && !string.IsNullOrEmpty(email) && email.Contains('@');
     }
     }
 
 
     public async Task ResendActivation(string email)
     public async Task ResendActivation(string email)
     {
     {
-        if (!apiValid) return;
-
-        string emailHash = GetEmailHash(email);
-        if (User?.EmailHash != emailHash)
+        if (IdentityProvider is PixiAuthIdentityProvider pixiAuthIdentityProvider)
         {
         {
-            await RequestLogin(email);
-            return;
-        }
-
-        if (User?.SessionId == null)
-        {
-            return;
-        }
-
-        try
-        {
-            await PixiAuthClient.ResendActivation(User.SessionId.Value);
-            TimeToEndTimeout = DateTime.Now.Add(TimeSpan.FromSeconds(60));
-            RunTimeoutTimers(60);
-            NotifyProperties();
             LastError = null;
             LastError = null;
-        }
-        catch (TooManyRequestsException e)
-        {
-            LastError = new LocalizedString(e.Message, e.TimeLeft);
-            TimeToEndTimeout = DateTime.Now.Add(TimeSpan.FromSeconds(e.TimeLeft));
-            RunTimeoutTimers(e.TimeLeft);
-            NotifyProperties();
-        }
-        catch (PixiAuthException authException)
-        {
-            LastError = new LocalizedString(authException.Message);
-        }
-        catch (HttpRequestException httpRequestException)
-        {
-            LastError = new LocalizedString("CONNECTION_ERROR");
-        }
-        catch (TaskCanceledException timeoutException)
-        {
-            LastError = new LocalizedString("CONNECTION_TIMEOUT");
+            try
+            {
+                await pixiAuthIdentityProvider.ResendActivation(email);
+            }
+            catch (Exception ex)
+            {
+                CrashHelper.SendExceptionInfo(ex);
+            }
         }
         }
     }
     }
 
 
@@ -221,295 +194,107 @@ internal class UserViewModel : SubViewModel<ViewModelMain>
 
 
     public bool CanResendActivation(string email)
     public bool CanResendActivation(string email)
     {
     {
-        if (email == null || User?.EmailHash == null)
+        if (IdentityProvider is not PixiAuthIdentityProvider pixiAuthIdentityProvider)
         {
         {
             return false;
             return false;
         }
         }
 
 
-        if (User?.EmailHash != GetEmailHash(email)) return true;
-
-        return WaitingForActivation && TimeToEndTimeout == null;
-    }
-
-    public async Task<bool> TryRefreshToken()
-    {
-        if (!apiValid) return false;
-
-        if (!IsLoggedIn)
+        if (email == null || pixiAuthIdentityProvider?.User?.EmailHash == null)
         {
         {
             return false;
             return false;
         }
         }
 
 
-        try
-        {
-            (string? token, DateTime? expirationDate) = await PixiAuthClient.RefreshToken(User.SessionToken);
-
-            if (token != null)
-            {
-                User.SessionToken = token;
-                User.SessionExpirationDate = expirationDate;
-                NotifyProperties();
-                SaveUserInfo();
-                return true;
-            }
-        }
-        catch (ForbiddenException e)
-        {
-            User = null;
-            NotifyProperties();
-            SaveUserInfo();
-            LastError = new LocalizedString(e.Message);
-        }
-        catch (PixiAuthException authException)
-        {
-            LastError = new LocalizedString(authException.Message);
-        }
-        catch (HttpRequestException httpRequestException)
-        {
-            LastError = new LocalizedString("CONNECTION_ERROR");
-        }
-        catch (TaskCanceledException timeoutException)
-        {
-            LastError = new LocalizedString("CONNECTION_TIMEOUT");
-        }
+        if (pixiAuthIdentityProvider.User?.EmailHash != EmailUtility.GetEmailHash(email)) return true;
 
 
-        return false;
+        return WaitingForActivation && TimeToEndTimeout == null;
     }
     }
 
 
     public async Task<bool> TryValidateSession()
     public async Task<bool> TryValidateSession()
     {
     {
-        if (!apiValid) return false;
-
-        if (User?.SessionId == null)
+        if (IdentityProvider is not PixiAuthIdentityProvider pixiAuthIdentityProvider)
         {
         {
             return false;
             return false;
         }
         }
 
 
+        LastError = null;
         try
         try
         {
         {
-            (string? token, DateTime? expirationDate) =
-                await PixiAuthClient.TryClaimSessionToken(User.SessionId.Value);
-            if (token != null)
+            bool validated = await pixiAuthIdentityProvider.TryValidateSession();
+            if (validated)
             {
             {
-                LastError = null;
-                User.SessionToken = token;
-                User.SessionExpirationDate = expirationDate;
-                var products = await PixiAuthClient.GetOwnedProducts(User.SessionToken);
-                if (products != null)
-                {
-                    User.OwnedProducts = products;
-                    OwnedProducts = new ObservableCollection<string>(User.OwnedProducts);
-                    NotifyProperties();
-                }
-
-                Task.Run(async () =>
-                {
-                    string username = User.Username;
-                    User.Username = await TryFetchUserName(User.EmailHash);
-                    Username = User.Username;
-                    if (username != User.Username)
-                    {
-                        Dispatcher.UIThread.Invoke(() =>
-                        {
-                            NotifyProperties();
-                            SaveUserInfo();
-                        });
-                    }
-                });
-
                 CurrentEmail = null;
                 CurrentEmail = null;
                 NotifyProperties();
                 NotifyProperties();
-                SaveUserInfo();
-                return true;
             }
             }
+
+            return validated;
         }
         }
-        catch (BadRequestException ex)
-        {
-            if (ex.Message == "SESSION_NOT_VALIDATED")
-            {
-                LastError = null;
-            }
-        }
-        catch (PixiAuthException authException)
-        {
-            LastError = new LocalizedString(authException.Message);
-        }
-        catch (HttpRequestException httpRequestException)
-        {
-            LastError = new LocalizedString("CONNECTION_ERROR");
-        }
-        catch (TaskCanceledException timeoutException)
+        catch (Exception ex)
         {
         {
-            LastError = new LocalizedString("CONNECTION_TIMEOUT");
+            CrashHelper.SendExceptionInfo(ex);
+            return false;
         }
         }
-
-        return false;
     }
     }
 
 
     public async Task Logout()
     public async Task Logout()
     {
     {
-        if (!IsLoggedIn)
+        if (IdentityProvider is not PixiAuthIdentityProvider pixiAuthIdentityProvider)
         {
         {
             return;
             return;
         }
         }
 
 
-        string? sessionToken = User?.SessionToken;
+        if (!IsLoggedIn)
+        {
+            return;
+        }
 
 
-        User = null;
         LastError = null;
         LastError = null;
-        OwnedProducts.Clear();
-        Username = string.Empty;
-        NotifyProperties();
-        SaveUserInfo();
-
-        if (!apiValid) return;
-
         try
         try
         {
         {
-            await PixiAuthClient.Logout(sessionToken);
+            await pixiAuthIdentityProvider.Logout();
         }
         }
-        catch (PixiAuthException authException)
-        {
-        }
-        catch (HttpRequestException httpRequestException)
-        {
-        }
-        catch (TaskCanceledException timeoutException)
+        catch (Exception ex)
         {
         {
+            CrashHelper.SendExceptionInfo(ex);
         }
         }
     }
     }
 
 
     public async Task InstallContent(string productId)
     public async Task InstallContent(string productId)
     {
     {
-        if (!apiValid) return;
-
-        if (User?.SessionToken == null)
+        LastError = null;
+        if (IdentityProvider is not PixiAuthIdentityProvider pixiAuthIdentityProvider)
         {
         {
-            LastError = new LocalizedString("NOT_LOGGED_IN");
             return;
             return;
         }
         }
 
 
-        try
-        {
-            var stream = await PixiAuthClient.DownloadProduct(User.SessionToken, productId);
-            if (stream != null)
-            {
-                var packagesPath = Owner.ExtensionsSubViewModel.ExtensionLoader.PackagesPath;
-                var filePath = Path.Combine(packagesPath, $"{productId}.pixiext");
-                await using (var fileStream = File.Create(filePath))
-                {
-                    await stream.CopyToAsync(fileStream);
-                }
-
-                await stream.DisposeAsync();
-
-                Owner.ExtensionsSubViewModel.LoadExtensionAdHoc(filePath);
-                LastError = null;
-            }
-        }
-        catch (PixiAuthException authException)
-        {
-            LastError = new LocalizedString(authException.Message);
-        }
-        catch (HttpRequestException httpRequestException)
-        {
-            LastError = new LocalizedString("CONNECTION_ERROR");
-        }
-        catch (TaskCanceledException timeoutException)
-        {
-            LastError = new LocalizedString("CONNECTION_TIMEOUT");
-        }
-    }
-
-    public async Task SaveUserInfo()
-    {
-        try
+        if (string.IsNullOrEmpty(productId))
         {
         {
-            await SecureStorage.SetValueAsync("UserData", User);
-        }
-        catch (Exception e)
-        {
-            CrashHelper.SendExceptionInfo(e);
+            return;
         }
         }
-    }
 
 
-    public async Task LoadUserData()
-    {
         try
         try
         {
         {
-            User = await SecureStorage.GetValueAsync<User>("UserData", null);
-            try
-            {
-                User.Username = await TryFetchUserName(User.EmailHash);
-                var products = await PixiAuthClient.GetOwnedProducts(User.SessionToken);
-                if (products != null)
-                {
-                    User.OwnedProducts = products;
-                    OwnedProducts = new ObservableCollection<string>(User.OwnedProducts);
-                }
-
-                Username = User.Username;
-                NotifyProperties();
-            }
-            catch
+            string? extensionPath = await AdditionalContentProvider.InstallContent(productId);
+            if (extensionPath != null)
             {
             {
+                Owner.ExtensionsSubViewModel.LoadExtensionAdHoc(extensionPath);
             }
             }
         }
         }
-        catch (Exception e)
+        catch (Exception ex)
         {
         {
-            CrashHelper.SendExceptionInfo(e);
-            User = null;
-            NotifyProperties();
-            LastError = "FAIL_LOAD_USER_DATA";
+            CrashHelper.SendExceptionInfo(ex);
         }
         }
     }
     }
 
 
-    public async Task LogoutIfTokenExpired()
+    private void OnError(string error, object? arg = null)
     {
     {
-        if (User?.SessionExpirationDate != null && User.SessionExpirationDate < DateTime.Now)
-        {
-            await Logout();
-            LastError = new LocalizedString("SESSION_EXPIRED");
-        }
-    }
-
-    private async Task<string> TryFetchUserName(string emailHash)
-    {
-        try
-        {
-            string? username = await Gravatar.GetUsername(emailHash);
-            if (username != null)
-            {
-                return username;
-            }
-        }
-        catch (PixiAuthException authException)
-        {
-            LastError = new LocalizedString(authException.Message);
-        }
-        catch (HttpRequestException httpRequestException)
+        if (error == "SESSION_NOT_VALIDATED")
         {
         {
-            LastError = new LocalizedString("CONNECTION_ERROR");
+            LastError = null;
         }
         }
-        catch (TaskCanceledException timeoutException)
+        else
         {
         {
-            LastError = new LocalizedString("CONNECTION_TIMEOUT");
+            LastError = arg != null ? new LocalizedString(error, arg) : new LocalizedString(error);
         }
         }
-
-        return GenerateUsername(emailHash);
-    }
-
-    private string GetEmailHash(string email)
-    {
-        using var sha256 = System.Security.Cryptography.SHA256.Create();
-        byte[] inputBytes = System.Text.Encoding.UTF8.GetBytes(email.ToLower());
-        byte[] hashBytes = sha256.ComputeHash(inputBytes);
-        return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
-    }
-
-    private string GenerateUsername(string emailHash)
-    {
-        return UsernameGenerator.GenerateUsername(emailHash);
     }
     }
 
 
     private void NotifyProperties()
     private void NotifyProperties()
@@ -521,7 +306,7 @@ internal class UserViewModel : SubViewModel<ViewModelMain>
         OnPropertyChanged(nameof(LastError));
         OnPropertyChanged(nameof(LastError));
         OnPropertyChanged(nameof(TimeToEndTimeout));
         OnPropertyChanged(nameof(TimeToEndTimeout));
         OnPropertyChanged(nameof(TimeToEndTimeoutString));
         OnPropertyChanged(nameof(TimeToEndTimeoutString));
-        OnPropertyChanged(nameof(UserGravatarUrl));
+        OnPropertyChanged(nameof(AvatarUrl));
         OnPropertyChanged(nameof(EmailEqualsLastSentMail));
         OnPropertyChanged(nameof(EmailEqualsLastSentMail));
         OnPropertyChanged(nameof(OwnedProducts));
         OnPropertyChanged(nameof(OwnedProducts));
         ResendActivationCommand.NotifyCanExecuteChanged();
         ResendActivationCommand.NotifyCanExecuteChanged();

+ 2 - 2
src/PixiEditor/ViewModels/SubViewModels/WindowViewModel.cs

@@ -271,9 +271,9 @@ internal class WindowViewModel : SubViewModel<ViewModelMain>, IWindowHandler
         Owner.LayoutSubViewModel.LayoutManager.ShowDockable(id);
         Owner.LayoutSubViewModel.LayoutManager.ShowDockable(id);
     }
     }
 
 
-    [Commands_Command.Basic("PixiEditor.Window.OpenLoginWindow", "OPEN_LOGIN_WINDOW", "OPEN_LOGIN_WINDOW",
+    [Commands_Command.Basic("PixiEditor.Window.OpenAccountWindow", "OPEN_ACCOUNT_WINDOW", "OPEN_ACCOUNT_WINDOW",
         MenuItemOrder = 6, AnalyticsTrack = true)]
         MenuItemOrder = 6, AnalyticsTrack = true)]
-    public void OpenLoginWindow()
+    public void OpenAccountWindow()
     {
     {
         LoginPopup popup = new LoginPopup() { DataContext = Owner.UserViewModel };
         LoginPopup popup = new LoginPopup() { DataContext = Owner.UserViewModel };
         popup.Show();
         popup.Show();

+ 1 - 1
src/PixiEditor/Views/Auth/LoginForm.axaml

@@ -73,7 +73,7 @@
             <Border ClipToBounds="True" Width="100" Height="100" CornerRadius="100">
             <Border ClipToBounds="True" Width="100" Height="100" CornerRadius="100">
                 <HyperlinkButton NavigateUri="https://gravatar.com/connect" Cursor="Hand"
                 <HyperlinkButton NavigateUri="https://gravatar.com/connect" Cursor="Hand"
                                  ui:Translator.TooltipKey="AVATAR_INFO">
                                  ui:Translator.TooltipKey="AVATAR_INFO">
-                    <Image asyncImageLoader:ImageLoader.Source="{Binding UserGravatarUrl}" />
+                    <Image asyncImageLoader:ImageLoader.Source="{Binding AvatarUrl}" />
                 </HyperlinkButton>
                 </HyperlinkButton>
             </Border>
             </Border>
             <TextBlock HorizontalAlignment="Center" FontSize="{DynamicResource FontSizeNormal}"
             <TextBlock HorizontalAlignment="Center" FontSize="{DynamicResource FontSizeNormal}"

+ 1 - 1
src/PixiEditor/Views/Auth/LoginPopup.axaml

@@ -12,7 +12,7 @@
                          CanMinimize="False"
                          CanMinimize="False"
                          CanResize="True"
                          CanResize="True"
                          Width="320" MinHeight="190"
                          Width="320" MinHeight="190"
-                         ui:Translator.Key="LOGIN_WINDOW_TITLE">
+                         ui:Translator.Key="ACCOUNT_WINDOW_TITLE">
     <Design.DataContext>
     <Design.DataContext>
         <subViewModels:UserViewModel />
         <subViewModels:UserViewModel />
     </Design.DataContext>
     </Design.DataContext>

+ 10 - 8
src/PixiEditor/Views/Auth/UserAvatarToggle.axaml

@@ -19,7 +19,7 @@
             <Button Name="UserAvatarButton" Padding="0"
             <Button Name="UserAvatarButton" Padding="0"
                     BorderThickness="0" Classes="pixi-icon">
                     BorderThickness="0" Classes="pixi-icon">
                 <Button.Content>
                 <Button.Content>
-                    <Image asyncImageLoader:ImageLoader.Source="{Binding UserGravatarUrl}" />
+                    <Image asyncImageLoader:ImageLoader.Source="{Binding AvatarUrl}" />
                 </Button.Content>
                 </Button.Content>
                 <Button.Styles>
                 <Button.Styles>
                     <Style Selector="FlyoutPresenter">
                     <Style Selector="FlyoutPresenter">
@@ -33,17 +33,19 @@
                     <Flyout>
                     <Flyout>
                         <StackPanel IsVisible="{Binding IsLoggedIn}" Margin="5" Spacing="12" Orientation="Vertical">
                         <StackPanel IsVisible="{Binding IsLoggedIn}" Margin="5" Spacing="12" Orientation="Vertical">
                             <Border ClipToBounds="True" Width="100" Height="100" CornerRadius="100">
                             <Border ClipToBounds="True" Width="100" Height="100" CornerRadius="100">
-                                <HyperlinkButton NavigateUri="https://gravatar.com/connect" Cursor="Hand" ui:Translator.TooltipKey="AVATAR_INFO">
-                                    <Image asyncImageLoader:ImageLoader.Source="{Binding UserGravatarUrl}" />
+                                <HyperlinkButton NavigateUri="https://gravatar.com/connect" Cursor="Hand"
+                                                 ui:Translator.TooltipKey="AVATAR_INFO">
+                                    <Image asyncImageLoader:ImageLoader.Source="{Binding AvatarUrl}" />
                                 </HyperlinkButton>
                                 </HyperlinkButton>
                             </Border>
                             </Border>
-                            <TextBlock HorizontalAlignment="Center" FontSize="{DynamicResource FontSizeNormal}" ui:Translator.Key="LOGGED_IN_AS">
+                            <TextBlock HorizontalAlignment="Center" FontSize="{DynamicResource FontSizeNormal}"
+                                       ui:Translator.Key="LOGGED_IN_AS">
                                 <Run Text="" />
                                 <Run Text="" />
-                                <Run Text="{Binding User.Username}" />
+                                <Run Text="{Binding Username}" />
                             </TextBlock>
                             </TextBlock>
                             <Button
                             <Button
-                                    Content="{ui:Translate Key=LOGOUT}"
-                                    Command="{Binding LogoutCommand}" />
+                                Content="{ui:Translate Key=MANAGE_ACCOUNT}"
+                                Command="{xaml:Command Name=PixiEditor.Window.OpenAccountWindow}" />
                         </StackPanel>
                         </StackPanel>
                     </Flyout>
                     </Flyout>
                 </Button.Flyout>
                 </Button.Flyout>
@@ -52,6 +54,6 @@
         <Button IsVisible="{Binding !IsLoggedIn}"
         <Button IsVisible="{Binding !IsLoggedIn}"
                 Classes="pixi-icon"
                 Classes="pixi-icon"
                 Content="{DynamicResource icon-user}"
                 Content="{DynamicResource icon-user}"
-                Command="{xaml:Command Name=PixiEditor.Window.OpenLoginWindow}" />
+                Command="{xaml:Command Name=PixiEditor.Window.OpenAccountWindow}" />
     </Grid>
     </Grid>
 </UserControl>
 </UserControl>

+ 1 - 1
src/PixiEditor/Views/Main/MainTitleBar.axaml

@@ -25,7 +25,7 @@
                 <StackPanel Margin="5 0" Spacing="5" Orientation="Horizontal" DataContext="{Binding Path=DataContext, RelativeSource={RelativeSource FindAncestor, AncestorType=main:MainTitleBar}}">
                 <StackPanel Margin="5 0" Spacing="5" Orientation="Horizontal" DataContext="{Binding Path=DataContext, RelativeSource={RelativeSource FindAncestor, AncestorType=main:MainTitleBar}}">
                     <Border BorderThickness="1" BorderBrush="{DynamicResource AccentColor}"
                     <Border BorderThickness="1" BorderBrush="{DynamicResource AccentColor}"
                             Padding="5 0" CornerRadius="5" Height="25"
                             Padding="5 0" CornerRadius="5" Height="25"
-                            IsVisible="{Binding Path=AdditionalContentSubViewModel.IsSupporterPackAvailable, FallbackValue=False}">
+                            IsVisible="{Binding Path=AdditionalContentSubViewModel.IsFoundersPackAvailable, FallbackValue=False}">
                         <TextBlock VerticalAlignment="Center"
                         <TextBlock VerticalAlignment="Center"
                                    ui:Translator.Key="PixiEditor.SupporterPack:AWESOME_SUPPORTER" />
                                    ui:Translator.Key="PixiEditor.SupporterPack:AWESOME_SUPPORTER" />
                     </Border>
                     </Border>

+ 2 - 0
src/PixiEditor/Views/MainWindow.axaml.cs

@@ -14,6 +14,7 @@ using Avalonia.VisualTree;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using Drawie.Backend.Core.Bridge;
 using Drawie.Backend.Core.Bridge;
 using PixiDocks.Avalonia.Helpers;
 using PixiDocks.Avalonia.Helpers;
+using PixiEditor.Extensions;
 using PixiEditor.Extensions.CommonApi.UserPreferences;
 using PixiEditor.Extensions.CommonApi.UserPreferences;
 using PixiEditor.Extensions.Runtime;
 using PixiEditor.Extensions.Runtime;
 using PixiEditor.Helpers;
 using PixiEditor.Helpers;
@@ -78,6 +79,7 @@ internal partial class MainWindow : Window
         platform = services.GetRequiredService<IPlatform>();
         platform = services.GetRequiredService<IPlatform>();
         DataContext = services.GetRequiredService<ViewModels_ViewModelMain>();
         DataContext = services.GetRequiredService<ViewModels_ViewModelMain>();
         DataContext.Setup(services);
         DataContext.Setup(services);
+        extensionLoader.Services = new ExtensionServices(services);
         StartupPerformance.ReportToMainViewModel();
         StartupPerformance.ReportToMainViewModel();
 
 
         var analytics = services.GetService<AnalyticsPeriodicReporter>();
         var analytics = services.GetService<AnalyticsPeriodicReporter>();