Browse Source

Add Civit account connection and model downloads

pull/324/head
Ionite 1 year ago
parent
commit
0b16f3efb0
No known key found for this signature in database
  1. BIN
      StabilityMatrix.Avalonia/Assets/guide-civitai-api.webp
  2. 99
      StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml
  3. 44
      StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml.cs
  4. 79
      StabilityMatrix.Avalonia/DialogHelper.cs
  5. 9
      StabilityMatrix.Avalonia/Languages/Resources.Designer.cs
  6. 3
      StabilityMatrix.Avalonia/Languages/Resources.resx
  7. 107
      StabilityMatrix.Avalonia/Services/AccountsService.cs
  8. 9
      StabilityMatrix.Avalonia/Services/IAccountsService.cs
  9. 1
      StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj
  10. 194
      StabilityMatrix.Avalonia/ViewModels/Settings/AccountSettingsViewModel.cs
  11. 23
      StabilityMatrix.Avalonia/Views/Settings/AccountSettingsPage.axaml
  12. 36
      StabilityMatrix.Core/Api/ICivitTRPCApi.cs
  13. 15
      StabilityMatrix.Core/Models/Api/CivitAccountStatusUpdateEventArgs.cs
  14. 3
      StabilityMatrix.Core/Models/Api/CivitTRPC/CivitApiTokens.cs
  15. 16
      StabilityMatrix.Core/Models/Api/CivitTRPC/CivitGetUserByIdRequest.cs
  16. 3
      StabilityMatrix.Core/Models/Api/CivitTRPC/CivitGetUserByIdResponse.cs
  17. 23
      StabilityMatrix.Core/Models/Api/CivitTRPC/CivitUserAccountResponse.cs
  18. 5
      StabilityMatrix.Core/Models/Secrets.cs
  19. 2
      StabilityMatrix.Core/Models/Settings/Settings.cs
  20. 20
      StabilityMatrix.Core/Models/Settings/TeachingTip.cs
  21. 16
      StabilityMatrix.Core/Models/StringValue.cs
  22. 43
      StabilityMatrix.Core/Services/DownloadService.cs

BIN
StabilityMatrix.Avalonia/Assets/guide-civitai-api.webp

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

99
StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml

@ -11,6 +11,12 @@
IconSource="OtherUser"
OffDescription="Manage account services like A, B, and C" />
<controls:SettingsAccountLinkExpander
Header="Service 1 (Loading)"
IconSource="OtherUser"
IsLoading="True"
OffDescription="Manage account services like A, B, and C" />
<controls:SettingsAccountLinkExpander
Header="Service 2"
IconSource="CloudFilled"
@ -21,8 +27,8 @@
Header="Service 3"
IconSource="CloudFilled"
IsConnected="True"
OffDescription="Manage account services like A, B, and C">
</controls:SettingsAccountLinkExpander>
OnDescriptionExtra="(account)"
OffDescription="Manage account services like A, B, and C" />
</StackPanel>
</Design.PreviewWith>
@ -44,6 +50,7 @@
TextWrapping="Wrap"
Theme="{DynamicResource CaptionTextBlockStyle}" />
<StackPanel
x:Name="PART_OnDescriptionPanel"
IsVisible="{TemplateBinding IsConnected,
@ -59,6 +66,11 @@
Text="{TemplateBinding OnDescription}"
TextWrapping="Wrap"
Theme="{DynamicResource CaptionTextBlockStyle}" />
<TextBlock
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Text="{TemplateBinding OnDescriptionExtra}"
TextWrapping="Wrap"
Theme="{DynamicResource CaptionTextBlockStyle}" />
</StackPanel>
</StackPanel>
@ -68,85 +80,12 @@
<StackPanel Margin="0,0,12,0" Orientation="Horizontal">
<!-- for some reason direct bind to IsConnected doesn't work here -->
<!--<controls:ProgressRing
<controls:ProgressRing
Margin="0,0,24,0"
BorderThickness="3"
IsEnabled="{TemplateBinding IsLoading}"
IsIndeterminate="{Binding $self.IsEnabled}"/>-->
<!-- 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}" />
IsIndeterminate="{Binding $parent[controls:SettingsAccountLinkExpander].IsLoading}"
IsVisible="{Binding $parent[controls:SettingsAccountLinkExpander].IsLoading}" />
<!-- 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:SettingsExpander.Footer>
</ui:SettingsExpander>
</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
@ -176,9 +115,9 @@
</Button>
</StackPanel>
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpander.Footer>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
</ControlTemplate>
</Setter>
</Style>

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

@ -71,6 +71,15 @@ public class SettingsAccountLinkExpander : TemplatedControl
set => SetValue(OnDescriptionProperty, value);
}
public static readonly StyledProperty<object?> OnDescriptionExtraProperty =
AvaloniaProperty.Register<SettingsAccountLinkExpander, object?>("OnDescriptionExtra");
public object? OnDescriptionExtra
{
get => GetValue(OnDescriptionExtraProperty);
set => SetValue(OnDescriptionExtraProperty, value);
}
public static readonly StyledProperty<object?> OffDescriptionProperty =
AvaloniaProperty.Register<SettingsAccountLinkExpander, object?>("OffDescription");
@ -86,17 +95,6 @@ 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);
@ -115,6 +113,30 @@ public class SettingsAccountLinkExpander : TemplatedControl
set => SetValue(DisconnectCommandProperty, value);
}
/*public static readonly StyledProperty<bool> IsLoading2Property = AvaloniaProperty.Register<SettingsAccountLinkExpander, bool>(
nameof(IsLoading2));
public bool IsLoading2
{
get => GetValue(IsLoading2Property);
set => SetValue(IsLoading2Property, value);
}*/
private bool _isLoading;
public static readonly DirectProperty<SettingsAccountLinkExpander, bool> IsLoadingProperty =
AvaloniaProperty.RegisterDirect<SettingsAccountLinkExpander, bool>(
"IsLoading",
o => o.IsLoading,
(o, v) => o.IsLoading = v
);
public bool IsLoading
{
get => _isLoading;
set => SetAndRaise(IsLoadingProperty, ref _isLoading, value);
}
// ReSharper restore MemberCanBePrivate.Global
/// <inheritdoc />

79
StabilityMatrix.Avalonia/DialogHelper.cs

@ -13,6 +13,7 @@ using Avalonia.Layout;
using Avalonia.LogicalTree;
using Avalonia.Media;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AvaloniaEdit;
using AvaloniaEdit.TextMate;
using CommunityToolkit.Mvvm.Input;
@ -46,10 +47,66 @@ public static class DialogHelper
string description,
IReadOnlyList<TextBoxField> textFields
)
{
return CreateTextEntryDialog(
title,
new MarkdownScrollViewer { Markdown = description },
textFields
);
}
/// <summary>
/// Create a generic textbox entry content dialog.
/// </summary>
public static BetterContentDialog CreateTextEntryDialog(
string title,
string description,
string imageSource,
IReadOnlyList<TextBoxField> textFields
)
{
var markdown = new MarkdownScrollViewer { Markdown = description };
var image = new BetterAdvancedImage((Uri?)null)
{
Source = imageSource,
Stretch = Stretch.UniformToFill,
StretchDirection = StretchDirection.Both,
HorizontalAlignment = HorizontalAlignment.Center,
MaxWidth = 400,
};
Grid.SetRow(markdown, 0);
Grid.SetRow(image, 1);
var grid = new Grid
{
RowDefinitions =
{
new RowDefinition(GridLength.Star),
new RowDefinition(GridLength.Auto)
},
Children = { markdown, image }
};
return CreateTextEntryDialog(title, grid, textFields);
}
/// <summary>
/// Create a generic textbox entry content dialog.
/// </summary>
public static BetterContentDialog CreateTextEntryDialog(
string title,
Control content,
IReadOnlyList<TextBoxField> textFields
)
{
Dispatcher.UIThread.VerifyAccess();
var stackPanel = new StackPanel();
var stackPanel = new StackPanel { Spacing = 4 };
Grid.SetRow(content, 0);
Grid.SetRow(stackPanel, 1);
var grid = new Grid
{
RowDefinitions =
@ -57,17 +114,16 @@ public static class DialogHelper
new RowDefinition(GridLength.Auto),
new RowDefinition(GridLength.Star)
},
Children =
{
new TextBlock { Text = description },
stackPanel
}
Children = { content, stackPanel }
};
grid.Loaded += (_, _) =>
{
// Focus first textbox
var firstTextBox = stackPanel.Children.OfType<TextBox>().First();
firstTextBox.Focus();
// Focus first TextBox
var firstTextBox = stackPanel.Children
.OfType<StackPanel>()
.FirstOrDefault()
.FindDescendantOfType<TextBox>();
firstTextBox!.Focus();
firstTextBox.CaretIndex = firstTextBox.Text?.LastIndexOf('.') ?? 0;
};
@ -85,8 +141,7 @@ public static class DialogHelper
// Create textboxes
foreach (var field in textFields)
{
var label = new TextBlock { Text = field.Label };
stackPanel.Children.Add(label);
var label = new TextBlock { Text = field.Label, Margin = new Thickness(0, 0, 0, 4) };
var textBox = new TextBox
{
@ -106,7 +161,7 @@ public static class DialogHelper
};
}
stackPanel.Children.Add(textBox);
stackPanel.Children.Add(new StackPanel { Spacing = 4, Children = { label, textBox } });
// When IsValid property changes, update invalid count and primary button
field.PropertyChanged += (_, args) =>

9
StabilityMatrix.Avalonia/Languages/Resources.Designer.cs generated

@ -617,6 +617,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to API Key.
/// </summary>
public static string Label_ApiKey {
get {
return ResourceManager.GetString("Label_ApiKey", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Appearance.
/// </summary>

3
StabilityMatrix.Avalonia/Languages/Resources.resx

@ -774,4 +774,7 @@
<data name="Label_ConfirmPassword" xml:space="preserve">
<value>Confirm Password</value>
</data>
<data name="Label_ApiKey" xml:space="preserve">
<value>API Key</value>
</data>
</root>

107
StabilityMatrix.Avalonia/Services/AccountsService.cs

@ -6,6 +6,8 @@ using Octokit;
using StabilityMatrix.Core.Api;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Api;
using StabilityMatrix.Core.Models.Api.CivitTRPC;
using StabilityMatrix.Core.Models.Api.Lykos;
using StabilityMatrix.Core.Services;
using ApiException = Refit.ApiException;
@ -23,8 +25,13 @@ public class AccountsService : IAccountsService
/// <inheritdoc />
public event EventHandler<LykosAccountStatusUpdateEventArgs>? LykosAccountStatusUpdate;
/// <inheritdoc />
public event EventHandler<CivitAccountStatusUpdateEventArgs>? CivitAccountStatusUpdate;
public LykosAccountStatusUpdateEventArgs? LykosStatus { get; private set; }
public CivitAccountStatusUpdateEventArgs? CivitStatus { get; private set; }
public AccountsService(
ILogger<AccountsService> logger,
ISecretsManager secretsManager,
@ -47,9 +54,11 @@ public class AccountsService : IAccountsService
var tokens = await lykosAuthApi.PostLogin(new PostLoginRequest(email, password));
await secretsManager.SaveAsync(secrets with { LykosAccount = tokens });
secrets = secrets with { LykosAccount = tokens };
await RefreshAsync();
await secretsManager.SaveAsync(secrets);
await RefreshLykosAsync(secrets);
}
public async Task LykosSignupAsync(string email, string password, string username)
@ -76,7 +85,7 @@ public class AccountsService : IAccountsService
}
/// <inheritdoc />
public async Task LykosPatreonOAuthLoginAsync()
public async Task LykosPatreonOAuthLogoutAsync()
{
var secrets = await secretsManager.SafeLoadAsync();
if (secrets.LykosAccount is null)
@ -86,23 +95,40 @@ public class AccountsService : IAccountsService
);
}
// TODO
await lykosAuthApi.DeletePatreonOAuth();
await RefreshLykosAsync(secrets);
}
/// <inheritdoc />
public async Task LykosPatreonOAuthLogoutAsync()
public async Task CivitLoginAsync(string apiToken)
{
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();
// Get id first using the api token
var userAccount = await civitTRPCApi.GetUserAccountDefault(apiToken);
var id = userAccount.Result.Data.Json.Id;
await RefreshLykosAsync(secrets);
// Then get the username using the id
var account = await civitTRPCApi.GetUserById(
new CivitGetUserByIdRequest { Id = id },
apiToken
);
var username = account.Result.Data.Json.Username;
secrets = secrets with { CivitApi = new CivitApiTokens(apiToken, username) };
await secretsManager.SaveAsync(secrets);
await RefreshCivitAsync(secrets);
}
/// <inheritdoc />
public async Task CivitLogoutAsync()
{
var secrets = await secretsManager.SafeLoadAsync();
await secretsManager.SaveAsync(secrets with { CivitApi = null });
OnCivitAccountStatusUpdate(CivitAccountStatusUpdateEventArgs.Disconnected);
}
public async Task RefreshAsync()
@ -110,6 +136,7 @@ public class AccountsService : IAccountsService
var secrets = await secretsManager.SafeLoadAsync();
await RefreshLykosAsync(secrets);
await RefreshCivitAsync(secrets);
}
private async Task RefreshLykosAsync(Secrets secrets)
@ -143,6 +170,40 @@ public class AccountsService : IAccountsService
OnLykosAccountStatusUpdate(LykosAccountStatusUpdateEventArgs.Disconnected);
}
private async Task RefreshCivitAsync(Secrets secrets)
{
if (secrets.CivitApi is not null)
{
try
{
var user = await civitTRPCApi.GetUserProfile(
new CivitUserProfileRequest { Username = secrets.CivitApi.Username },
secrets.CivitApi.ApiToken
);
OnCivitAccountStatusUpdate(
new CivitAccountStatusUpdateEventArgs { IsConnected = true, UserProfile = user }
);
return;
}
catch (OperationCanceledException)
{
logger.LogWarning("Timed out while fetching Civit Auth user info");
}
catch (ApiException e)
{
if (e.StatusCode is HttpStatusCode.Unauthorized) { }
else
{
logger.LogWarning(e, "Failed to get user info from Civit");
}
}
}
OnCivitAccountStatusUpdate(CivitAccountStatusUpdateEventArgs.Disconnected);
}
private void OnLykosAccountStatusUpdate(LykosAccountStatusUpdateEventArgs e)
{
if (!e.IsConnected && LykosStatus?.IsConnected == true)
@ -160,4 +221,22 @@ public class AccountsService : IAccountsService
LykosAccountStatusUpdate?.Invoke(this, e);
}
private void OnCivitAccountStatusUpdate(CivitAccountStatusUpdateEventArgs e)
{
if (!e.IsConnected && CivitStatus?.IsConnected == true)
{
logger.LogInformation("Civit account disconnected");
}
else if (e.IsConnected && CivitStatus?.IsConnected == false)
{
logger.LogInformation(
"Civit account connected: {Id} ({Username})",
e.UserProfile?.UserId,
e.UserProfile?.Username
);
}
CivitAccountStatusUpdate?.Invoke(this, e);
}
}

9
StabilityMatrix.Avalonia/Services/IAccountsService.cs

@ -1,5 +1,6 @@
using System;
using System.Threading.Tasks;
using StabilityMatrix.Core.Models.Api;
using StabilityMatrix.Core.Models.Api.Lykos;
namespace StabilityMatrix.Avalonia.Services;
@ -8,6 +9,8 @@ public interface IAccountsService
{
event EventHandler<LykosAccountStatusUpdateEventArgs>? LykosAccountStatusUpdate;
event EventHandler<CivitAccountStatusUpdateEventArgs>? CivitAccountStatusUpdate;
LykosAccountStatusUpdateEventArgs? LykosStatus { get; }
Task LykosSignupAsync(string email, string password, string username);
@ -16,9 +19,11 @@ public interface IAccountsService
Task LykosLogoutAsync();
Task LykosPatreonOAuthLoginAsync();
Task LykosPatreonOAuthLogoutAsync();
Task CivitLoginAsync(string apiToken);
Task CivitLogoutAsync();
Task RefreshAsync();
}

1
StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj

@ -99,6 +99,7 @@
<ItemGroup>
<AvaloniaResource Include="Assets\brands-*.png" />
<AvaloniaResource Include="Assets\brands-*.svg" />
<AvaloniaResource Include="Assets\guide-*.webp"/>
</ItemGroup>
<ItemGroup>

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

@ -1,8 +1,7 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using Avalonia.Controls;
@ -10,19 +9,22 @@ using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls;
using StabilityMatrix.Avalonia.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;
using StabilityMatrix.Core.Models.Api.Lykos;
using StabilityMatrix.Core.Processes;
using StabilityMatrix.Core.Services;
using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
using TeachingTip = StabilityMatrix.Core.Models.Settings.TeachingTip;
namespace StabilityMatrix.Avalonia.ViewModels.Settings;
@ -31,6 +33,7 @@ namespace StabilityMatrix.Avalonia.ViewModels.Settings;
public partial class AccountSettingsViewModel : PageViewModelBase
{
private readonly IAccountsService accountsService;
private readonly ISettingsManager settingsManager;
private readonly ServiceManager<ViewModelBase> vmFactory;
private readonly INotificationService notificationService;
private readonly ILykosAuthApi lykosAuthApi;
@ -42,13 +45,6 @@ public partial class AccountSettingsViewModel : PageViewModelBase
public override IconSource IconSource =>
new SymbolIconSource { Symbol = Symbol.Person, IsFilled = true };
[ObservableProperty]
private bool isLykosConnected;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(LykosProfileImageUrl))]
private GetUserResponse? lykosUser;
[ObservableProperty]
private string? lykosProfileImageUrl;
@ -56,33 +52,24 @@ public partial class AccountSettingsViewModel : PageViewModelBase
private bool isPatreonConnected;
[ObservableProperty]
private bool isCivitConnected;
partial void OnLykosUserChanged(GetUserResponse? value)
{
if (value?.Id is { } userEmail)
{
userEmail = userEmail.Trim().ToLowerInvariant();
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(userEmail));
var hash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
[NotifyPropertyChangedFor(nameof(LykosProfileImageUrl))]
private LykosAccountStatusUpdateEventArgs lykosStatus =
LykosAccountStatusUpdateEventArgs.Disconnected;
LykosProfileImageUrl = $"https://gravatar.com/avatar/{hash}?s=512&d=retro";
}
else
{
LykosProfileImageUrl = null;
}
}
[ObservableProperty]
private CivitAccountStatusUpdateEventArgs civitStatus =
CivitAccountStatusUpdateEventArgs.Disconnected;
public AccountSettingsViewModel(
IAccountsService accountsService,
ISettingsManager settingsManager,
ServiceManager<ViewModelBase> vmFactory,
INotificationService notificationService,
ILykosAuthApi lykosAuthApi
)
{
this.accountsService = accountsService;
this.settingsManager = settingsManager;
this.vmFactory = vmFactory;
this.notificationService = notificationService;
this.lykosAuthApi = lykosAuthApi;
@ -91,11 +78,18 @@ public partial class AccountSettingsViewModel : PageViewModelBase
{
Dispatcher.UIThread.Post(() =>
{
IsLykosConnected = args.IsConnected;
LykosUser = args.User;
LykosStatus = args;
IsPatreonConnected = args.IsPatreonConnected;
});
};
accountsService.CivitAccountStatusUpdate += (_, args) =>
{
Dispatcher.UIThread.Post(() =>
{
CivitStatus = args;
});
};
}
/// <inheritdoc />
@ -111,56 +105,67 @@ public partial class AccountSettingsViewModel : PageViewModelBase
accountsService.RefreshAsync().SafeFireAndForget();
}
[RelayCommand]
private async Task ConnectCivit()
private async Task<bool> BeforeConnectCheck()
{
var textFields = new TextBoxField[] { new() { Label = "API Key" } };
var dialog = DialogHelper.CreateTextEntryDialog("Connect CivitAI Account", "", textFields);
// Show credentials storage notice if not seen
if (
await dialog.ShowAsync() != ContentDialogResult.Primary
|| textFields[0].Text is not { } json
!settingsManager.Settings.SeenTeachingTips.Contains(
TeachingTip.AccountsCredentialsStorageNotice
)
)
{
return;
}
// TODO
await Task.Delay(200);
IsCivitConnected = true;
}
var dialog = new BetterContentDialog
{
Title = "About Account Credentials",
Content = """
Account credentials and tokens are stored locally on your computer, with at-rest AES encryption.
If you make changes to your computer hardware, you may need to re-login to your accounts.
Account tokens will not be viewable after saving, please make a note of them if you need to use them elsewhere.
""",
PrimaryButtonText = Resources.Action_Continue,
CloseButtonText = Resources.Action_Cancel,
DefaultButton = ContentDialogButton.Primary,
MaxDialogWidth = 400
};
if (await dialog.ShowAsync() != ContentDialogResult.Primary)
{
return false;
}
[RelayCommand]
private async Task DisconnectCivit()
{
await Task.Yield();
settingsManager.Transaction(
s => s.SeenTeachingTips.Add(TeachingTip.AccountsCredentialsStorageNotice)
);
}
IsCivitConnected = false;
return true;
}
[RelayCommand]
private async Task ConnectLykos()
{
if (!await BeforeConnectCheck())
return;
var vm = vmFactory.Get<LykosLoginViewModel>();
if (await vm.ShowDialogAsync() == TaskDialogStandardResult.OK)
{
IsLykosConnected = true;
await accountsService.RefreshAsync();
}
await vm.ShowDialogAsync();
}
[RelayCommand]
private async Task DisconnectLykos()
private Task DisconnectLykos()
{
await accountsService.LykosLogoutAsync();
return accountsService.LykosLogoutAsync();
}
[RelayCommand]
private async Task ConnectPatreon()
{
if (LykosUser?.Id is null)
if (!await BeforeConnectCheck())
return;
if (LykosStatus.User?.Id is null)
return;
var urlResult = await notificationService.TryAsync(
@ -196,25 +201,78 @@ public partial class AccountSettingsViewModel : PageViewModelBase
await notificationService.TryAsync(accountsService.LykosPatreonOAuthLogoutAsync());
}
/*[RelayCommand]
private async Task ConnectCivitAccountOld()
[RelayCommand]
private async Task ConnectCivit()
{
var textFields = new TextBoxField[] { new() { Label = "API Key" } };
if (!await BeforeConnectCheck())
return;
var textFields = new TextBoxField[]
{
new()
{
Label = Resources.Label_ApiKey,
Validator = s =>
{
if (string.IsNullOrWhiteSpace(s))
{
throw new ValidationException("API key is required");
}
}
}
};
var dialog = DialogHelper.CreateTextEntryDialog("Connect CivitAI Account", "", textFields);
var dialog = DialogHelper.CreateTextEntryDialog(
"Connect CivitAI Account",
"""
Login to [CivitAI](https://civitai.com/) and head to your [Account](https://civitai.com/user/account) page
Add a new API key and paste it below
""",
"avares://StabilityMatrix.Avalonia/Assets/guide-civitai-api.webp",
textFields
);
dialog.PrimaryButtonText = Resources.Action_Connect;
if (
await dialog.ShowAsync() != ContentDialogResult.Primary
|| textFields[0].Text is not { } json
|| textFields[0].Text is not { } apiToken
)
{
return;
}
var secrets = GlobalUserSecrets.LoadFromFile()!;
secrets.CivitApiToken = json;
secrets.SaveToFile();
var result = await notificationService.TryAsync(accountsService.CivitLoginAsync(apiToken));
if (result.IsSuccessful)
{
await accountsService.RefreshAsync();
}
}
[RelayCommand]
private Task DisconnectCivit()
{
return accountsService.CivitLogoutAsync();
}
/// <summary>
/// Update the Lykos profile image URL when the user changes.
/// </summary>
partial void OnLykosStatusChanged(LykosAccountStatusUpdateEventArgs? value)
{
if (value?.User?.Id is { } userEmail)
{
userEmail = userEmail.Trim().ToLowerInvariant();
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(userEmail));
var hash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
RefreshCivitAccount().SafeFireAndForget();
}*/
LykosProfileImageUrl = $"https://gravatar.com/avatar/{hash}?s=512&d=retro";
}
else
{
LykosProfileImageUrl = null;
}
}
}

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

@ -93,9 +93,9 @@
<StackPanel>
<TextBlock
Margin="-1,0,0,0"
Text="{Binding LykosUser.Account.Name, FallbackValue=''}"
Text="{Binding LykosStatus.User.Account.Name, FallbackValue=''}"
Theme="{DynamicResource SubtitleTextBlockStyle}" />
<TextBlock Text="{Binding LykosUser.Id, FallbackValue=''}" />
<TextBlock Text="{Binding LykosStatus.User.Id, FallbackValue=''}" />
</StackPanel>
</StackPanel>
</sg:SpacedGrid>
@ -106,7 +106,7 @@
IsLoading="{Binding ConnectLykosCommand.IsRunning}"
Header="Lykos"
HeaderTargetUri="{x:Static sm:Assets.LykosUrl}"
IsConnected="{Binding IsLykosConnected}"
IsConnected="{Binding LykosStatus.IsConnected}"
OffDescription="Manage connected features in Stability Matrix">
<controls:SettingsAccountLinkExpander.IconSource>
<ui:ImageIconSource Source="{StaticResource BrandsLykos}" />
@ -118,7 +118,7 @@
DisconnectCommand="{Binding DisconnectPatreonCommand}"
IsLoading="{Binding ConnectPatreonCommand.IsRunning}"
Header="Patreon"
IsEnabled="{Binding IsLykosConnected}"
IsEnabled="{Binding LykosStatus.IsConnected}"
HeaderTargetUri="{x:Static sm:Assets.PatreonUrl}"
IsConnected="{Binding IsPatreonConnected}"
OffDescription="Access Preview and Dev release channels for auto-updates">
@ -128,12 +128,6 @@
</controls:SettingsAccountLinkExpander>
<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"
@ -143,14 +137,19 @@
Grid.Row="2"
ConnectCommand="{Binding ConnectCivitCommand}"
DisconnectCommand="{Binding DisconnectCivitCommand}"
IsLoading="{Binding ConnectCivitCommand.IsRunning}"
Header="CivitAI"
HeaderTargetUri="{x:Static sm:Assets.CivitAIUrl}"
IsConnected="{Binding IsCivitConnected}"
IsConnected="{Binding CivitStatus.IsConnected}"
OnDescriptionExtra="{Binding CivitStatus.UsernameWithParentheses}"
OffDescription="Connect to Download Models that require login">
<controls:SettingsAccountLinkExpander.IconSource>
<ui:BitmapIconSource UriSource="avares://StabilityMatrix.Avalonia/Assets/brands-civitai.png" />
</controls:SettingsAccountLinkExpander.IconSource>
<controls:SettingsAccountLinkExpander.IsLoading>
<MultiBinding Converter="{x:Static BoolConverters.Or}">
<Binding Path="ConnectCivitCommand.IsRunning" />
</MultiBinding>
</controls:SettingsAccountLinkExpander.IsLoading>
</controls:SettingsAccountLinkExpander>
</sg:SpacedGrid>

36
StabilityMatrix.Core/Api/ICivitTRPCApi.cs

@ -3,14 +3,46 @@ using StabilityMatrix.Core.Models.Api.CivitTRPC;
namespace StabilityMatrix.Core.Api;
[Headers("Referer: https://civitai.com")]
[Headers(
"Content-Type: application/x-www-form-urlencoded",
"Referer: https://civitai.com",
"Origin: https://civitai.com"
)]
public interface ICivitTRPCApi
{
[Headers("Content-Type: application/x-www-form-urlencoded")]
[QueryUriFormat(UriFormat.UriEscaped)]
[Get("/api/trpc/userProfile.get")]
Task<CivitUserProfileResponse> GetUserProfile(
[Query] CivitUserProfileRequest input,
[Authorize] string bearerToken,
CancellationToken cancellationToken = default
);
[QueryUriFormat(UriFormat.UriEscaped)]
[Get("/api/trpc/buzz.getUserAccount")]
Task<CivitTrpcResponse<CivitUserAccountResponse>> GetUserAccount(
[Query] string input,
[Authorize] string bearerToken,
CancellationToken cancellationToken = default
);
Task<CivitTrpcResponse<CivitUserAccountResponse>> GetUserAccountDefault(
string bearerToken,
CancellationToken cancellationToken = default
)
{
return GetUserAccount(
"{\"json\":null,\"meta\":{\"values\":[\"undefined\"]}}",
bearerToken,
cancellationToken
);
}
[QueryUriFormat(UriFormat.UriEscaped)]
[Get("/api/trpc/user.getById")]
Task<CivitTrpcResponse<CivitGetUserByIdResponse>> GetUserById(
[Query] CivitGetUserByIdRequest input,
[Authorize] string bearerToken,
CancellationToken cancellationToken = default
);
}

15
StabilityMatrix.Core/Models/Api/CivitAccountStatusUpdateEventArgs.cs

@ -0,0 +1,15 @@
using StabilityMatrix.Core.Models.Api.CivitTRPC;
namespace StabilityMatrix.Core.Models.Api;
public class CivitAccountStatusUpdateEventArgs : EventArgs
{
public static CivitAccountStatusUpdateEventArgs Disconnected { get; } = new();
public bool IsConnected { get; init; }
public CivitUserProfileResponse? UserProfile { get; init; }
public string? UsernameWithParentheses =>
string.IsNullOrEmpty(UserProfile?.Username) ? null : $"({UserProfile.Username})";
}

3
StabilityMatrix.Core/Models/Api/CivitTRPC/CivitApiTokens.cs

@ -0,0 +1,3 @@
namespace StabilityMatrix.Core.Models.Api.CivitTRPC;
public record CivitApiTokens(string ApiToken, string Username);

16
StabilityMatrix.Core/Models/Api/CivitTRPC/CivitGetUserByIdRequest.cs

@ -0,0 +1,16 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StabilityMatrix.Core.Models.Api.CivitTRPC;
public record CivitGetUserByIdRequest : IFormattable
{
[JsonPropertyName("id")]
public required int Id { get; set; }
/// <inheritdoc />
public string ToString(string? format, IFormatProvider? formatProvider)
{
return JsonSerializer.Serialize(new { json = this });
}
}

3
StabilityMatrix.Core/Models/Api/CivitTRPC/CivitGetUserByIdResponse.cs

@ -0,0 +1,3 @@
namespace StabilityMatrix.Core.Models.Api.CivitTRPC;
public record CivitGetUserByIdResponse(int Id, string Username, string? Image);

23
StabilityMatrix.Core/Models/Api/CivitTRPC/CivitUserAccountResponse.cs

@ -0,0 +1,23 @@
using System.Text.Json.Serialization;
namespace StabilityMatrix.Core.Models.Api.CivitTRPC;
public record CivitUserAccountResponse(int Id, int Balance, int LifetimeBalance);
public record CivitTrpcResponse<T>
{
[JsonPropertyName("result")]
public required CivitTrpcResponseData<T> Result { get; set; }
public record CivitTrpcResponseData<TData>
{
[JsonPropertyName("data")]
public required CivitTrpcResponseDataJson<TData> Data { get; set; }
}
public record CivitTrpcResponseDataJson<TJson>
{
[JsonPropertyName("Json")]
public required TJson Json { get; set; }
}
}

5
StabilityMatrix.Core/Models/Secrets.cs

@ -1,8 +1,11 @@
using StabilityMatrix.Core.Models.Api.Lykos;
using StabilityMatrix.Core.Models.Api.CivitTRPC;
using StabilityMatrix.Core.Models.Api.Lykos;
namespace StabilityMatrix.Core.Models;
public readonly record struct Secrets
{
public LykosAccountTokens? LykosAccount { get; init; }
public CivitApiTokens? CivitApi { get; init; }
}

2
StabilityMatrix.Core/Models/Settings/Settings.cs

@ -94,6 +94,8 @@ public class Settings
public HashSet<int> FavoriteModels { get; set; } = new();
public HashSet<TeachingTip> SeenTeachingTips { get; set; } = new();
public Size InferenceImageSize { get; set; } = new(150, 190);
public Size OutputsImageSize { get; set; } = new(300, 300);

20
StabilityMatrix.Core/Models/Settings/TeachingTip.cs

@ -0,0 +1,20 @@
using System.Text.Json.Serialization;
using StabilityMatrix.Core.Converters.Json;
namespace StabilityMatrix.Core.Models.Settings;
/// <summary>
/// Teaching tip names
/// </summary>
[JsonConverter(typeof(StringJsonConverter<TeachingTip>))]
public record TeachingTip(string Value) : StringValue(Value)
{
public static TeachingTip AccountsCredentialsStorageNotice =>
new("AccountsCredentialsStorageNotice");
/// <inheritdoc />
public override string ToString()
{
return base.ToString();
}
}

16
StabilityMatrix.Core/Models/StringValue.cs

@ -0,0 +1,16 @@
namespace StabilityMatrix.Core.Models;
public abstract record StringValue(string Value) : IFormattable
{
/// <inheritdoc />
public override string ToString()
{
return Value;
}
/// <inheritdoc />
public string ToString(string? format, IFormatProvider? formatProvider)
{
return Value;
}
}

43
StabilityMatrix.Core/Services/DownloadService.cs

@ -2,6 +2,7 @@
using Microsoft.Extensions.Logging;
using Polly.Contrib.WaitAndRetry;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models.Progress;
namespace StabilityMatrix.Core.Services;
@ -11,12 +12,18 @@ public class DownloadService : IDownloadService
{
private readonly ILogger<DownloadService> logger;
private readonly IHttpClientFactory httpClientFactory;
private readonly ISecretsManager secretsManager;
private const int BufferSize = ushort.MaxValue;
public DownloadService(ILogger<DownloadService> logger, IHttpClientFactory httpClientFactory)
public DownloadService(
ILogger<DownloadService> logger,
IHttpClientFactory httpClientFactory,
ISecretsManager secretsManager
)
{
this.logger = logger;
this.httpClientFactory = httpClientFactory;
this.secretsManager = secretsManager;
}
public async Task DownloadToFileAsync(
@ -35,6 +42,9 @@ public class DownloadService : IDownloadService
client.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue("StabilityMatrix", "2.0")
);
await AddConditionalHeaders(client, new Uri(downloadUrl)).ConfigureAwait(false);
await using var file = new FileStream(
downloadPath,
FileMode.Create,
@ -122,6 +132,8 @@ public class DownloadService : IDownloadService
new ProductInfoHeaderValue("StabilityMatrix", "2.0")
);
await AddConditionalHeaders(client, new Uri(downloadUrl)).ConfigureAwait(false);
// Create file if it doesn't exist
if (!File.Exists(downloadPath))
{
@ -234,6 +246,8 @@ public class DownloadService : IDownloadService
new ProductInfoHeaderValue("StabilityMatrix", "2.0")
);
await AddConditionalHeaders(client, new Uri(downloadUrl)).ConfigureAwait(false);
var contentLength = 0L;
foreach (
@ -265,6 +279,7 @@ public class DownloadService : IDownloadService
client.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue("StabilityMatrix", "2.0")
);
await AddConditionalHeaders(client, new Uri(url)).ConfigureAwait(false);
try
{
var response = await client.GetAsync(url).ConfigureAwait(false);
@ -276,4 +291,30 @@ public class DownloadService : IDownloadService
return null;
}
}
/// <summary>
/// Adds conditional headers to the HttpClient for the given URL
/// </summary>
private async Task AddConditionalHeaders(HttpClient client, Uri url)
{
// Check if civit download
if (url.Host.Equals("civitai.com", StringComparison.OrdinalIgnoreCase))
{
// Add auth if we have it
if (
await secretsManager.LoadAsync().ConfigureAwait(false) is { CivitApi: { } civitApi }
)
{
logger.LogTrace(
"Adding Civit auth header {Signature} for download {Url}",
ObjectHash.GetStringSignature(civitApi.ApiToken),
url
);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer",
civitApi.ApiToken
);
}
}
}
}

Loading…
Cancel
Save