Browse Source

Add Custom URI scheme handlers for oauth

pull/324/head
Ionite 1 year ago
parent
commit
ebc0aafad0
No known key found for this signature in database
  1. 20
      StabilityMatrix.Avalonia/App.axaml.cs
  2. 4
      StabilityMatrix.Avalonia/Assets.cs
  3. 103
      StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml
  4. 47
      StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml.cs
  5. 11
      StabilityMatrix.Avalonia/DesignData/DesignData.cs
  6. 102
      StabilityMatrix.Avalonia/Helpers/UriHandler.cs
  7. 19
      StabilityMatrix.Avalonia/Program.cs
  8. 30
      StabilityMatrix.Avalonia/Services/AccountsService.cs
  9. 4
      StabilityMatrix.Avalonia/Services/IAccountsService.cs
  10. 3
      StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj
  11. 3
      StabilityMatrix.Avalonia/Styles/HyperlinkIconButtonThemes.axaml
  12. 7
      StabilityMatrix.Avalonia/ViewModels/Dialogs/OAuthConnectViewModel.cs
  13. 63
      StabilityMatrix.Avalonia/ViewModels/Settings/AccountSettingsViewModel.cs
  14. 39
      StabilityMatrix.Avalonia/Views/Settings/AccountSettingsPage.axaml
  15. 34
      StabilityMatrix.Core/Api/ILykosAuthApi.cs
  16. 25
      StabilityMatrix.Core/Extensions/UriExtensions.cs
  17. 2
      StabilityMatrix.Core/Models/Api/Lykos/LykosAccountStatusUpdateEventArgs.cs

20
StabilityMatrix.Avalonia/App.axaml.cs

@ -26,6 +26,7 @@ using Avalonia.Platform.Storage;
using Avalonia.Styling;
using Avalonia.Threading;
using FluentAvalonia.UI.Controls;
using MessagePipe;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@ -138,6 +139,8 @@ public sealed class App : Application
{
DesktopLifetime.ShutdownMode = ShutdownMode.OnExplicitShutdown;
Setup();
// First time setup if needed
var settingsManager = Services.GetRequiredService<ISettingsManager>();
if (!settingsManager.IsEulaAccepted())
@ -176,6 +179,17 @@ public sealed class App : Application
}
}
/// <summary>
/// Setup tasks to be run shortly before any window is shown
/// </summary>
private void Setup()
{
using var _ = new CodeTimer();
// Setup uri handler for `stabilitymatrix://` protocol
Program.UriHandler.RegisterUriScheme();
}
private void ShowMainWindow()
{
if (DesktopLifetime is null)
@ -327,6 +341,9 @@ public sealed class App : Application
services.AddMemoryCache();
services.AddLazyInstance();
services.AddMessagePipe();
services.AddMessagePipeNamedPipeInterprocess("StabilityMatrix");
var exportedTypes = AppDomain.CurrentDomain
.GetAssemblies()
.Where(a => a.FullName?.StartsWith("StabilityMatrix") == true)
@ -536,6 +553,9 @@ public sealed class App : Application
c.BaseAddress = new Uri("https://stableauthentication.azurewebsites.net");
c.Timeout = TimeSpan.FromSeconds(15);
})
.ConfigurePrimaryHttpMessageHandler(
() => new HttpClientHandler { AllowAutoRedirect = false }
)
.AddPolicyHandler(retryPolicy)
.AddHttpMessageHandler(
serviceProvider =>

4
StabilityMatrix.Avalonia/Assets.cs

@ -151,8 +151,12 @@ internal static class Assets
public static Uri DiscordServerUrl { get; } = new("https://discord.com/invite/TUrgfECxHz");
public static Uri LykosUrl { get; } = new("https://lykos.ai");
public static Uri PatreonUrl { get; } = new("https://patreon.com/StabilityMatrix");
public static Uri CivitAIUrl { get; } = new("https://civitai.com");
public static Uri LykosForgotPasswordUrl { get; } = new("https://lykos.ai/forgot-password");
/// <summary>

103
StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml

@ -16,6 +16,13 @@
IconSource="CloudFilled"
IsConnected="True"
OffDescription="Manage account services like A, B, and C" />
<controls:SettingsAccountLinkExpander
Header="Service 3"
IconSource="CloudFilled"
IsConnected="True"
OffDescription="Manage account services like A, B, and C">
</controls:SettingsAccountLinkExpander>
</StackPanel>
</Design.PreviewWith>
@ -23,10 +30,10 @@
<!-- Set Defaults -->
<Setter Property="Template">
<ControlTemplate>
<ui:SettingsExpander IconSource="{TemplateBinding IconSource}">
<ui:SettingsExpander x:Name="PART_SettingsExpander" IconSource="{TemplateBinding IconSource}">
<ui:SettingsExpander.Header>
<StackPanel>
<TextBlock Text="{TemplateBinding Header}" />
<TextBlock x:Name="PART_HeaderTextBlock" Text="{TemplateBinding Header}" />
<TextBlock
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
@ -58,32 +65,38 @@
</ui:SettingsExpander.Header>
<ui:SettingsExpander.Footer>
<StackPanel Margin="0,0,12,0">
<StackPanel Margin="0,0,12,0" Orientation="Horizontal">
<!-- for some reason direct bind to IsConnected doesn't work here -->
<!--<controls:ProgressRing
Margin="0,0,24,0"
BorderThickness="3"
IsEnabled="{TemplateBinding IsLoading}"
IsIndeterminate="{Binding $self.IsEnabled}"/>-->
<!-- Connect button -->
<Button
x:Name="PART_ConnectButton"
Command="{TemplateBinding ConnectCommand}"
Padding="32,6"
Command="{TemplateBinding ConnectCommand}"
Content="{x:Static lang:Resources.Action_Connect}"
IsVisible="{Binding !IsVisible, ElementName=PART_OnDescriptionPanel}" />
<!-- Disconnect button -->
<Button
x:Name="PART_DisconnectButton"
HorizontalAlignment="Right"
IsVisible="{Binding IsVisible, ElementName=PART_OnDescriptionPanel}"
Padding="6,8"
HorizontalAlignment="Right"
BorderThickness="0"
Classes="transparent"
BorderThickness="0">
IsVisible="{Binding IsVisible, ElementName=PART_OnDescriptionPanel}">
<ui:SymbolIcon FontSize="20" Symbol="More" />
<Button.Flyout>
<ui:FAMenuFlyout Placement="BottomEdgeAlignedLeft">
<ui:MenuFlyoutItem
x:Name="PART_DisconnectMenuItem"
Command="{TemplateBinding DisconnectCommand}"
Text="{x:Static lang:Resources.Action_Disconnect}"/>
Text="{x:Static lang:Resources.Action_Disconnect}" />
</ui:FAMenuFlyout>
</Button.Flyout>
</Button>
@ -95,4 +108,78 @@
</ControlTemplate>
</Setter>
</Style>
<Style Selector="controls|SettingsAccountLinkExpander.item">
<!-- Set Defaults -->
<Setter Property="Template">
<ControlTemplate>
<ui:SettingsExpanderItem IconSource="{TemplateBinding IconSource}">
<StackPanel>
<TextBlock x:Name="PART_HeaderTextBlock" Text="{TemplateBinding Header}" />
<TextBlock
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
IsVisible="{TemplateBinding IsConnected,
Mode=OneWay,
Converter={x:Static BoolConverters.Not}}"
Text="{TemplateBinding OffDescription}"
TextWrapping="Wrap"
Theme="{DynamicResource CaptionTextBlockStyle}" />
<StackPanel
x:Name="PART_OnDescriptionPanel"
IsVisible="{TemplateBinding IsConnected,
Mode=OneWay}"
Orientation="Horizontal"
Spacing="4">
<Ellipse
Width="5"
Height="5"
Fill="{StaticResource ThemeMediumSeaGreenColor}" />
<TextBlock
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Text="{TemplateBinding OnDescription}"
TextWrapping="Wrap"
Theme="{DynamicResource CaptionTextBlockStyle}" />
</StackPanel>
</StackPanel>
<ui:SettingsExpanderItem.Footer>
<StackPanel Margin="0,0,12,0">
<!-- for some reason direct bind to IsConnected doesn't work here -->
<!-- Connect button -->
<Button
x:Name="PART_ConnectButton"
Padding="32,6"
Command="{TemplateBinding ConnectCommand}"
Content="{x:Static lang:Resources.Action_Connect}"
IsVisible="{Binding !IsVisible, ElementName=PART_OnDescriptionPanel}" />
<!-- Disconnect button -->
<Button
x:Name="PART_DisconnectButton"
Padding="6,8"
HorizontalAlignment="Right"
BorderThickness="0"
Classes="transparent"
IsVisible="{Binding IsVisible, ElementName=PART_OnDescriptionPanel}">
<ui:SymbolIcon FontSize="20" Symbol="More" />
<Button.Flyout>
<ui:FAMenuFlyout Placement="BottomEdgeAlignedLeft">
<ui:MenuFlyoutItem
x:Name="PART_DisconnectMenuItem"
Command="{TemplateBinding DisconnectCommand}"
Text="{x:Static lang:Resources.Action_Disconnect}" />
</ui:FAMenuFlyout>
</Button.Flyout>
</Button>
</StackPanel>
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
</ControlTemplate>
</Setter>
</Style>
</Styles>

47
StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml.cs

@ -1,15 +1,23 @@
using System;
using System.Collections.Generic;
using System.Windows.Input;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Metadata;
using Avalonia.VisualTree;
using FluentAvalonia.UI.Controls;
using StabilityMatrix.Core.Processes;
namespace StabilityMatrix.Avalonia.Controls;
public class SettingsAccountLinkExpander : TemplatedControl
{
private readonly List<object?> _items = new();
[Content]
public List<object?> Items => _items;
// ReSharper disable MemberCanBePrivate.Global
public static readonly StyledProperty<object?> HeaderProperty =
HeaderedItemsControl.HeaderProperty.AddOwner<SettingsAccountLinkExpander>();
@ -20,6 +28,17 @@ public class SettingsAccountLinkExpander : TemplatedControl
set => SetValue(HeaderProperty, value);
}
public static readonly StyledProperty<Uri?> HeaderTargetUriProperty = AvaloniaProperty.Register<
SettingsAccountLinkExpander,
Uri?
>("HeaderTargetUri");
public Uri? HeaderTargetUri
{
get => GetValue(HeaderTargetUriProperty);
set => SetValue(HeaderTargetUriProperty, value);
}
public static readonly StyledProperty<IconSource?> IconSourceProperty =
SettingsExpander.IconSourceProperty.AddOwner<SettingsAccountLinkExpander>();
@ -67,6 +86,17 @@ public class SettingsAccountLinkExpander : TemplatedControl
enableDataValidation: true
);
public static readonly StyledProperty<bool> IsLoadingProperty = AvaloniaProperty.Register<
SettingsAccountLinkExpander,
bool
>(nameof(IsLoading));
public bool IsLoading
{
get => GetValue(IsLoadingProperty);
set => SetValue(IsLoadingProperty, value);
}
public ICommand? ConnectCommand
{
get => GetValue(ConnectCommandProperty);
@ -92,6 +122,23 @@ public class SettingsAccountLinkExpander : TemplatedControl
{
base.OnApplyTemplate(e);
// Bind tapped event on header
if (
HeaderTargetUri is { } headerTargetUri
&& e.NameScope.Find<TextBlock>("PART_HeaderTextBlock") is { } headerTextBlock
)
{
headerTextBlock.Tapped += (_, _) =>
{
ProcessRunner.OpenUrl(headerTargetUri.ToString());
};
}
if (e.NameScope.Find<SettingsExpander>("PART_SettingsExpander") is { } expander)
{
expander.ItemsSource = Items;
}
if (ConnectCommand is { } command)
{
var connectButton = e.NameScope.Get<Button>("PART_ConnectButton");

11
StabilityMatrix.Avalonia/DesignData/DesignData.cs

@ -550,6 +550,17 @@ The gallery images are often inpainted, but you will get something very similar
public static LykosLoginViewModel LykosLoginViewModel =>
DialogFactory.Get<LykosLoginViewModel>();
public static OAuthConnectViewModel OAuthConnectViewModel =>
DialogFactory.Get<OAuthConnectViewModel>(vm =>
{
vm.Url =
"https://www.example.org/oauth2/authorize?"
+ "client_id=66ad566552679cb6e650be01ed6f8d2ae9a0f803c0369850a5c9ee82a2396062&"
+ "scope=identity%20identity.memberships&"
+ "response_type=code&state=test%40example.org&"
+ "redirect_uri=http://localhost:5022/api/oauth/patreon/callback";
});
public static InferenceTextToImageViewModel InferenceTextToImageViewModel =>
DialogFactory.Get<InferenceTextToImageViewModel>(vm =>
{

102
StabilityMatrix.Avalonia/Helpers/UriHandler.cs

@ -0,0 +1,102 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Runtime.Versioning;
using System.Text.Json;
using System.Threading.Tasks;
using MessagePipe;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Win32;
using StabilityMatrix.Core.Helper;
using URIScheme;
namespace StabilityMatrix.Avalonia.Helpers;
/// <summary>
/// Custom URI scheme handler for the stabilitymatrix:// protocol
/// </summary>
/// <remarks>Need to call <see cref="RegisterUriScheme"/> on App startup</remarks>
public class UriHandler
{
public const string IpcKeySend = "uri_handler_send";
public string Scheme { get; }
public string Description { get; }
public UriHandler(string scheme, string description)
{
Scheme = scheme;
Description = description;
}
/// <summary>
/// Send a received Uri over MessagePipe and exits
/// </summary>
/// <param name="uri"></param>
[DoesNotReturn]
public void SendAndExit(Uri uri)
{
var services = new ServiceCollection();
services.AddMessagePipe();
services.AddMessagePipeNamedPipeInterprocess("StabilityMatrix");
var provider = services.BuildServiceProvider();
var publisher = provider.GetRequiredService<IDistributedPublisher<string, Uri>>();
var sendTask = Task.Run(async () => await publisher.PublishAsync(IpcKeySend, uri));
sendTask.Wait();
var info = JsonSerializer.Serialize(new Dictionary<string, Uri> { [IpcKeySend] = uri });
Debug.WriteLine(info);
Console.WriteLine(info);
Environment.Exit(0);
}
public void Callback() { }
public void RegisterUriScheme()
{
if (Compat.IsWindows)
{
RegisterUriSchemeWin();
}
else
{
RegisterUriSchemeUnix();
}
}
[SupportedOSPlatform("windows")]
private void RegisterUriSchemeWin()
{
using var key = Registry.CurrentUser.CreateSubKey(@$"SOFTWARE\Classes\{Scheme}");
key.SetValue("", "URL:" + Description);
key.SetValue(null, Description);
key.SetValue("URL Protocol", "");
using (var defaultIcon = key.CreateSubKey("DefaultIcon"))
{
defaultIcon.SetValue("", Compat.AppCurrentPath.FullPath + ",1");
}
using (var commandKey = key.CreateSubKey(@"shell\open\command"))
{
commandKey.SetValue("", "\"" + Compat.AppCurrentPath.FullPath + "\" --uri \"%1\"");
}
}
private void RegisterUriSchemeUnix()
{
var service = URISchemeServiceFactory.GetURISchemeSerivce(
Scheme,
Description,
Compat.AppCurrentPath.FullPath
);
service.Set();
}
}

19
StabilityMatrix.Avalonia/Program.cs

@ -13,12 +13,14 @@ using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using FluentAvalonia.Core;
using NLog;
using Polly.Contrib.WaitAndRetry;
using Projektanker.Icons.Avalonia;
using Projektanker.Icons.Avalonia.FontAwesome;
using Semver;
using Sentry;
using StabilityMatrix.Avalonia.Helpers;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Avalonia.Views.Dialogs;
@ -40,6 +42,10 @@ public class Program
public static Stopwatch StartupTimer { get; } = new();
public static UriHandler UriHandler { get; } = new("stabilitymatrix", "StabilityMatrix");
public static Uri MessagePipeUri { get; } = new("stabilitymatrix://app");
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
@ -57,6 +63,19 @@ public class Program
Args.ResetWindowPosition = args.Contains("--reset-window-position");
Args.DisableGpuRendering = args.Contains("--disable-gpu");
// Launched for custom URI scheme, send to main process
if (args.Contains("--uri"))
{
var uriArg = args.ElementAtOrDefault(args.IndexOf("--uri") + 1);
if (
Uri.TryCreate(uriArg, UriKind.Absolute, out var uri)
&& string.Equals(uri.Scheme, UriHandler.Scheme, StringComparison.OrdinalIgnoreCase)
)
{
UriHandler.SendAndExit(uri);
}
}
SetDebugBuild();
HandleUpdateReplacement();

30
StabilityMatrix.Avalonia/Services/AccountsService.cs

@ -75,6 +75,36 @@ public class AccountsService : IAccountsService
OnLykosAccountStatusUpdate(LykosAccountStatusUpdateEventArgs.Disconnected);
}
/// <inheritdoc />
public async Task LykosPatreonOAuthLoginAsync()
{
var secrets = await secretsManager.SafeLoadAsync();
if (secrets.LykosAccount is null)
{
throw new InvalidOperationException(
"Lykos account must be connected in to manage OAuth connections"
);
}
// TODO
}
/// <inheritdoc />
public async Task LykosPatreonOAuthLogoutAsync()
{
var secrets = await secretsManager.SafeLoadAsync();
if (secrets.LykosAccount is null)
{
throw new InvalidOperationException(
"Lykos account must be connected in to manage OAuth connections"
);
}
await lykosAuthApi.DeletePatreonOAuth();
await RefreshLykosAsync(secrets);
}
public async Task RefreshAsync()
{
var secrets = await secretsManager.SafeLoadAsync();

4
StabilityMatrix.Avalonia/Services/IAccountsService.cs

@ -16,5 +16,9 @@ public interface IAccountsService
Task LykosLogoutAsync();
Task LykosPatreonOAuthLoginAsync();
Task LykosPatreonOAuthLogoutAsync();
Task RefreshAsync();
}

3
StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj

@ -45,6 +45,8 @@
<PackageReference Include="FuzzySharp" Version="2.0.2" />
<PackageReference Include="JetBrains.Annotations" Version="2023.3.0" />
<PackageReference Include="Markdown.Avalonia" Version="11.0.2" />
<PackageReference Include="MessagePipe" Version="1.7.4" />
<PackageReference Include="MessagePipe.Interprocess" Version="1.7.4" />
<PackageReference Include="MetadataExtractor" Version="2.8.1" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="7.0.0" />
@ -68,6 +70,7 @@
<PackageReference Include="Sylvan.Data" Version="0.2.12" />
<PackageReference Include="Sylvan.Data.Csv" Version="1.3.3" />
<PackageReference Include="TextMateSharp.Grammars" Version="1.0.56" />
<PackageReference Include="URISchemeTools" Version="1.0.2" />
</ItemGroup>

3
StabilityMatrix.Avalonia/Styles/HyperlinkIconButtonThemes.axaml

@ -17,7 +17,8 @@
FontSize="15"
Foreground="{DynamicResource HyperlinkButtonForeground}"
Symbol="Link" />
<TextBlock Foreground="{DynamicResource HyperlinkButtonForeground}" Text="{Binding}" />
<TextBlock
Foreground="{DynamicResource HyperlinkButtonForeground}" Text="{Binding}" />
</StackPanel>
</DataTemplate>
</Setter>

7
StabilityMatrix.Avalonia/ViewModels/Dialogs/OAuthConnectViewModel.cs

@ -53,8 +53,11 @@ public partial class OAuthConnectViewModel : ContentDialogViewModelBase
UriHandler.IpcKeySend,
receivedUri =>
{
logger.LogDebug("UriHandler Received URI: {Uri}", receivedUri);
OnPrimaryButtonClick();
logger.LogDebug("UriHandler Received URI: {Uri}", receivedUri.PathAndQuery);
if (receivedUri.PathAndQuery.StartsWith("/oauth/patreon/callback"))
{
OnPrimaryButtonClick();
}
}
);
}

63
StabilityMatrix.Avalonia/ViewModels/Settings/AccountSettingsViewModel.cs

@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using Avalonia.Controls;
@ -8,12 +10,17 @@ using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Avalonia.Views.Settings;
using StabilityMatrix.Core.Api;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Models.Api.Lykos;
using StabilityMatrix.Core.Processes;
using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
@ -25,6 +32,8 @@ public partial class AccountSettingsViewModel : PageViewModelBase
{
private readonly IAccountsService accountsService;
private readonly ServiceManager<ViewModelBase> vmFactory;
private readonly INotificationService notificationService;
private readonly ILykosAuthApi lykosAuthApi;
/// <inheritdoc />
public override string Title => "Accounts";
@ -33,12 +42,6 @@ public partial class AccountSettingsViewModel : PageViewModelBase
public override IconSource IconSource =>
new SymbolIconSource { Symbol = Symbol.Person, IsFilled = true };
[ObservableProperty]
private string? civitStatus;
[ObservableProperty]
private bool isCivitConnected;
[ObservableProperty]
private bool isLykosConnected;
@ -49,6 +52,12 @@ public partial class AccountSettingsViewModel : PageViewModelBase
[ObservableProperty]
private string? lykosProfileImageUrl;
[ObservableProperty]
private bool isPatreonConnected;
[ObservableProperty]
private bool isCivitConnected;
partial void OnLykosUserChanged(GetUserResponse? value)
{
if (value?.Id is { } userEmail)
@ -68,11 +77,15 @@ public partial class AccountSettingsViewModel : PageViewModelBase
public AccountSettingsViewModel(
IAccountsService accountsService,
ServiceManager<ViewModelBase> vmFactory
ServiceManager<ViewModelBase> vmFactory,
INotificationService notificationService,
ILykosAuthApi lykosAuthApi
)
{
this.accountsService = accountsService;
this.vmFactory = vmFactory;
this.notificationService = notificationService;
this.lykosAuthApi = lykosAuthApi;
accountsService.LykosAccountStatusUpdate += (_, args) =>
{
@ -80,6 +93,7 @@ public partial class AccountSettingsViewModel : PageViewModelBase
{
IsLykosConnected = args.IsConnected;
LykosUser = args.User;
IsPatreonConnected = args.IsPatreonConnected;
});
};
}
@ -143,6 +157,41 @@ public partial class AccountSettingsViewModel : PageViewModelBase
await accountsService.LykosLogoutAsync();
}
[RelayCommand]
private async Task ConnectPatreon()
{
if (LykosUser?.Id is null)
return;
var urlResult = await notificationService.TryAsync(
lykosAuthApi.GetPatreonOAuthUrl(
Program.MessagePipeUri.Append("/oauth/patreon/callback").ToString()
)
);
if (!urlResult.IsSuccessful || urlResult.Result is not { } url)
{
return;
}
ProcessRunner.OpenUrl(urlResult.Result);
var dialogVm = vmFactory.Get<OAuthConnectViewModel>();
dialogVm.Title = "Connect Patreon Account";
dialogVm.Url = url;
if (await dialogVm.GetDialog().ShowAsync() == ContentDialogResult.Primary)
{
await accountsService.RefreshAsync();
}
}
[RelayCommand]
private async Task DisconnectPatreon()
{
await notificationService.TryAsync(accountsService.LykosPatreonOAuthLogoutAsync());
}
/*[RelayCommand]
private async Task ConnectCivitAccountOld()
{

39
StabilityMatrix.Avalonia/Views/Settings/AccountSettingsPage.axaml

@ -13,6 +13,7 @@
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:vmSettings="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Settings"
xmlns:sm="clr-namespace:StabilityMatrix.Avalonia"
d:DataContext="{x:Static mocks:DesignData.AccountSettingsViewModel}"
d:DesignHeight="450"
d:DesignWidth="800"
@ -60,10 +61,17 @@
</DrawingGroup>
</DrawingGroup>
</DrawingImage>
<SolidColorBrush x:Key="BrushB0" Color="#FFFFFFFF" />
<DrawingImage x:Key="BrandsPatreonSymbolWhite">
<DrawingGroup>
<GeometryDrawing Brush="{DynamicResource BrushB0}" Geometry="F1 M1033.05 324.45C1032.86 186.55 925.46 73.53 799.45 32.75C642.97 -17.89 436.59 -10.55 287.17 59.95C106.07 145.41 49.18 332.61 47.06 519.31C45.32 672.81 60.64 1077.1 288.68 1079.98C458.12 1082.13 483.35 863.8 561.75 758.65C617.53 683.84 689.35 662.71 777.76 640.83C929.71 603.22 1033.27 483.3 1033.05 324.45Z" />
</DrawingGroup>
</DrawingImage>
</controls:UserControlBase.Resources>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="16,16" Spacing="6">
<StackPanel Margin="16,16" Spacing="2">
<sg:SpacedGrid Margin="0,4,0,16" ColumnDefinitions="*,*">
<StackPanel Orientation="Horizontal" Spacing="16">
@ -95,26 +103,49 @@
<controls:SettingsAccountLinkExpander
ConnectCommand="{Binding ConnectLykosCommand}"
DisconnectCommand="{Binding DisconnectLykosCommand}"
IsLoading="{Binding ConnectLykosCommand.IsRunning}"
Header="Lykos"
HeaderTargetUri="{x:Static sm:Assets.LykosUrl}"
IsConnected="{Binding IsLykosConnected}"
OffDescription="Manage additional integrations with Stability Matrix">
OffDescription="Manage connected features in Stability Matrix">
<controls:SettingsAccountLinkExpander.IconSource>
<ui:ImageIconSource Source="{StaticResource BrandsLykos}" />
</controls:SettingsAccountLinkExpander.IconSource>
</controls:SettingsAccountLinkExpander>
<controls:SettingsAccountLinkExpander
ConnectCommand="{Binding ConnectPatreonCommand}"
DisconnectCommand="{Binding DisconnectPatreonCommand}"
IsLoading="{Binding ConnectPatreonCommand.IsRunning}"
Header="Patreon"
IsEnabled="{Binding IsLykosConnected}"
HeaderTargetUri="{x:Static sm:Assets.PatreonUrl}"
IsConnected="{Binding IsPatreonConnected}"
OffDescription="Access Preview and Dev release channels for auto-updates">
<controls:SettingsAccountLinkExpander.IconSource>
<ui:ImageIconSource Source="{StaticResource BrandsPatreonSymbolWhite}" />
</controls:SettingsAccountLinkExpander.IconSource>
</controls:SettingsAccountLinkExpander>
<sg:SpacedGrid RowDefinitions="Auto,*,*" RowSpacing="4">
<sg:SpacedGrid RowDefinitions="Auto,*,*" RowSpacing="4" Margin="0,6,0,0">
<!--<sg:SpacedGrid.Styles>
<Style Selector="sg|SpacedGrid > controls|SettingsAccountLinkExpander">
<Setter Property="IsEnabled" Value="{Binding IsLykosConnected}"/>
</Style>
</sg:SpacedGrid.Styles>-->
<TextBlock
Margin="0,0,0,4"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_Integrations}" />
<controls:SettingsAccountLinkExpander
Grid.Row="1"
Grid.Row="2"
ConnectCommand="{Binding ConnectCivitCommand}"
DisconnectCommand="{Binding DisconnectCivitCommand}"
IsLoading="{Binding ConnectCivitCommand.IsRunning}"
Header="CivitAI"
HeaderTargetUri="{x:Static sm:Assets.CivitAIUrl}"
IsConnected="{Binding IsCivitConnected}"
OffDescription="Connect to Download Models that require login">
<controls:SettingsAccountLinkExpander.IconSource>

34
StabilityMatrix.Core/Api/ILykosAuthApi.cs

@ -1,4 +1,5 @@
using Refit;
using System.Net;
using Refit;
using StabilityMatrix.Core.Models.Api.Lykos;
namespace StabilityMatrix.Core.Api;
@ -31,4 +32,35 @@ public interface ILykosAuthApi
[Body] PostLoginRefreshRequest request,
CancellationToken cancellationToken = default
);
[Headers("Authorization: Bearer")]
[Get("/api/oauth/patreon/redirect")]
Task<HttpResponseMessage> GetPatreonOAuthRedirect(
string redirectUrl,
CancellationToken cancellationToken = default
);
public async Task<string> GetPatreonOAuthUrl(
string redirectUrl,
CancellationToken cancellationToken = default
)
{
var result = await GetPatreonOAuthRedirect(redirectUrl, cancellationToken)
.ConfigureAwait(false);
if (result.StatusCode != HttpStatusCode.Redirect)
{
result.EnsureSuccessStatusCode();
throw new InvalidOperationException(
$"Expected a redirect 302 response, got {result.StatusCode}"
);
}
return result.Headers.Location?.ToString()
?? throw new InvalidOperationException("Expected a redirect URL, but got none");
}
[Headers("Authorization: Bearer")]
[Delete("/api/oauth/patreon")]
Task DeletePatreonOAuth(CancellationToken cancellationToken = default);
}

25
StabilityMatrix.Core/Extensions/UriExtensions.cs

@ -0,0 +1,25 @@
using System.Web;
namespace StabilityMatrix.Core.Extensions;
public static class UriExtensions
{
public static Uri WithQuery(this Uri uri, string key, string value)
{
var builder = new UriBuilder(uri);
var query = HttpUtility.ParseQueryString(builder.Query);
query[key] = value;
builder.Query = query.ToString() ?? string.Empty;
return builder.Uri;
}
public static Uri Append(this Uri uri, params string[] paths)
{
return new Uri(
paths.Aggregate(
uri.AbsoluteUri,
(current, path) => $"{current.TrimEnd('/')}/{path.TrimStart('/')}"
)
);
}
}

2
StabilityMatrix.Core/Models/Api/Lykos/LykosAccountStatusUpdateEventArgs.cs

@ -7,4 +7,6 @@ public class LykosAccountStatusUpdateEventArgs : EventArgs
public bool IsConnected { get; init; }
public GetUserResponse? User { get; init; }
public bool IsPatreonConnected => User?.PatreonId != null;
}

Loading…
Cancel
Save