Browse Source

Merge branch 'dev' into merge-main-to-dev-e5b8ceb

pull/324/head
Ionite 1 year ago committed by GitHub
parent
commit
612afa9f81
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      .github/workflows/build.yml
  2. 2
      .github/workflows/release.yml
  3. 2
      .github/workflows/test-ui.yml
  4. 2
      .github/workflows/version-bump.yml
  5. 2
      StabilityMatrix.Avalonia.Diagnostics/StabilityMatrix.Avalonia.Diagnostics.csproj
  6. 49
      StabilityMatrix.Avalonia/Animations/BetterSlideNavigationTransition.cs
  7. 6
      StabilityMatrix.Avalonia/App.axaml
  8. 84
      StabilityMatrix.Avalonia/App.axaml.cs
  9. 6
      StabilityMatrix.Avalonia/Assets.cs
  10. BIN
      StabilityMatrix.Avalonia/Assets/brands-civitai.png
  11. BIN
      StabilityMatrix.Avalonia/Assets/guide-civitai-api.webp
  12. 9
      StabilityMatrix.Avalonia/Controls/BetterContentDialog.cs
  13. 13
      StabilityMatrix.Avalonia/Controls/HyperlinkIconButton.cs
  14. 124
      StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml
  15. 199
      StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml.cs
  16. 59
      StabilityMatrix.Avalonia/Controls/StarsRating.axaml
  17. 168
      StabilityMatrix.Avalonia/Controls/StarsRating.axaml.cs
  18. 30
      StabilityMatrix.Avalonia/DesignData/DesignData.cs
  19. 84
      StabilityMatrix.Avalonia/DialogHelper.cs
  20. 102
      StabilityMatrix.Avalonia/Helpers/UriHandler.cs
  21. 108
      StabilityMatrix.Avalonia/Languages/Resources.Designer.cs
  22. 36
      StabilityMatrix.Avalonia/Languages/Resources.resx
  23. 5
      StabilityMatrix.Avalonia/Models/AppArgs.cs
  24. 10
      StabilityMatrix.Avalonia/Models/TypedNavigationEventArgs.cs
  25. 20
      StabilityMatrix.Avalonia/Program.cs
  26. 242
      StabilityMatrix.Avalonia/Services/AccountsService.cs
  27. 29
      StabilityMatrix.Avalonia/Services/IAccountsService.cs
  28. 18
      StabilityMatrix.Avalonia/Services/INavigationService.cs
  29. 81
      StabilityMatrix.Avalonia/Services/NavigationService.cs
  30. 63
      StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj
  31. 44
      StabilityMatrix.Avalonia/Styles/ControlThemes/HyperlinkIconButtonStyles.axaml
  32. 2
      StabilityMatrix.Avalonia/Styles/ThemeColors.axaml
  33. 2
      StabilityMatrix.Avalonia/ViewModels/Base/InferenceTabViewModelBase.cs
  34. 77
      StabilityMatrix.Avalonia/ViewModels/Base/TaskDialogViewModelBase.cs
  35. 4
      StabilityMatrix.Avalonia/ViewModels/Dialogs/InferenceConnectionHelpViewModel.cs
  36. 133
      StabilityMatrix.Avalonia/ViewModels/Dialogs/LykosLoginViewModel.cs
  37. 84
      StabilityMatrix.Avalonia/ViewModels/Dialogs/OAuthConnectViewModel.cs
  38. 4
      StabilityMatrix.Avalonia/ViewModels/Dialogs/OneClickInstallViewModel.cs
  39. 4
      StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs
  40. 4
      StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs
  41. 52
      StabilityMatrix.Avalonia/ViewModels/Progress/ProgressManagerViewModel.cs
  42. 283
      StabilityMatrix.Avalonia/ViewModels/Settings/AccountSettingsViewModel.cs
  43. 17
      StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs
  44. 961
      StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs
  45. 907
      StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs
  46. 250
      StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml
  47. 110
      StabilityMatrix.Avalonia/Views/Dialogs/LykosLoginDialog.axaml
  48. 13
      StabilityMatrix.Avalonia/Views/Dialogs/LykosLoginDialog.axaml.cs
  49. 40
      StabilityMatrix.Avalonia/Views/Dialogs/OAuthConnectDialog.axaml
  50. 13
      StabilityMatrix.Avalonia/Views/Dialogs/OAuthConnectDialog.axaml.cs
  51. 17
      StabilityMatrix.Avalonia/Views/MainWindow.axaml.cs
  52. 16
      StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml
  53. 171
      StabilityMatrix.Avalonia/Views/Settings/AccountSettingsPage.axaml
  54. 13
      StabilityMatrix.Avalonia/Views/Settings/AccountSettingsPage.axaml.cs
  55. 67
      StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml
  56. 10
      StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml.cs
  57. 550
      StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml
  58. 13
      StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml.cs
  59. 529
      StabilityMatrix.Avalonia/Views/SettingsPage.axaml
  60. 87
      StabilityMatrix.Avalonia/Views/SettingsPage.axaml.cs
  61. 48
      StabilityMatrix.Core/Api/ICivitTRPCApi.cs
  62. 66
      StabilityMatrix.Core/Api/ILykosAuthApi.cs
  63. 7
      StabilityMatrix.Core/Api/ITokenProvider.cs
  64. 52
      StabilityMatrix.Core/Api/LykosAuthTokenProvider.cs
  65. 69
      StabilityMatrix.Core/Api/TokenAuthHeaderHandler.cs
  66. 11
      StabilityMatrix.Core/Attributes/SingletonAttribute.cs
  67. 12
      StabilityMatrix.Core/Exceptions/AppException.cs
  68. 25
      StabilityMatrix.Core/Extensions/UriExtensions.cs
  69. 5
      StabilityMatrix.Core/Helper/CodeTimer.cs
  70. 25
      StabilityMatrix.Core/Helper/LazyInstance.cs
  71. 26
      StabilityMatrix.Core/Helper/ObjectHash.cs
  72. 15
      StabilityMatrix.Core/Models/Api/CivitAccountStatusUpdateEventArgs.cs
  73. 3
      StabilityMatrix.Core/Models/Api/CivitTRPC/CivitApiTokens.cs
  74. 16
      StabilityMatrix.Core/Models/Api/CivitTRPC/CivitGetUserByIdRequest.cs
  75. 3
      StabilityMatrix.Core/Models/Api/CivitTRPC/CivitGetUserByIdResponse.cs
  76. 23
      StabilityMatrix.Core/Models/Api/CivitTRPC/CivitUserAccountResponse.cs
  77. 20
      StabilityMatrix.Core/Models/Api/CivitTRPC/CivitUserProfileRequest.cs
  78. 91
      StabilityMatrix.Core/Models/Api/CivitTRPC/CivitUserProfileResponse.cs
  79. 9
      StabilityMatrix.Core/Models/Api/Lykos/GetUserResponse.cs
  80. 3
      StabilityMatrix.Core/Models/Api/Lykos/LykosAccount.cs
  81. 12
      StabilityMatrix.Core/Models/Api/Lykos/LykosAccountStatusUpdateEventArgs.cs
  82. 3
      StabilityMatrix.Core/Models/Api/Lykos/LykosAccountTokens.cs
  83. 8
      StabilityMatrix.Core/Models/Api/Lykos/PostAccountRequest.cs
  84. 3
      StabilityMatrix.Core/Models/Api/Lykos/PostLoginRefreshRequest.cs
  85. 3
      StabilityMatrix.Core/Models/Api/Lykos/PostLoginRequest.cs
  86. 93
      StabilityMatrix.Core/Models/GlobalEncryptedSerializer.cs
  87. 11
      StabilityMatrix.Core/Models/Secrets.cs
  88. 2
      StabilityMatrix.Core/Models/Settings/Settings.cs
  89. 20
      StabilityMatrix.Core/Models/Settings/TeachingTip.cs
  90. 16
      StabilityMatrix.Core/Models/StringValue.cs
  91. 85
      StabilityMatrix.Core/Services/DownloadService.cs
  92. 21
      StabilityMatrix.Core/Services/ISecretsManager.cs
  93. 76
      StabilityMatrix.Core/Services/SecretsManager.cs
  94. 25
      StabilityMatrix.Core/StabilityMatrix.Core.csproj
  95. 54
      StabilityMatrix.Core/Validators/RequiresMatchAttribute.cs
  96. 12
      StabilityMatrix.Tests/StabilityMatrix.Tests.csproj
  97. 2
      StabilityMatrix.UITests/MainWindowTests.cs
  98. 1
      StabilityMatrix.UITests/Snapshots/MainWindowTests.MainWindowViewModel_ShouldOk.verified.txt
  99. 8
      StabilityMatrix.UITests/StabilityMatrix.UITests.csproj

2
.github/workflows/build.yml

@ -21,7 +21,7 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: '7.0.x'
dotnet-version: '8.0.x'
- name: Install dependencies
run: dotnet restore

2
.github/workflows/release.yml

@ -146,7 +146,7 @@ jobs:
- name: Set up .NET 7
uses: actions/setup-dotnet@v3
with:
dotnet-version: '7.0.x'
dotnet-version: '8.0.x'
- name: Install dependencies
run: dotnet restore

2
.github/workflows/test-ui.yml

@ -18,7 +18,7 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: '7.0.x'
dotnet-version: '8.0.x'
- name: Install dependencies
run: dotnet restore

2
.github/workflows/version-bump.yml

@ -25,7 +25,7 @@ jobs:
- name: Setup .NET Core
uses: actions/setup-dotnet@v2
with:
dotnet-version: 6.0.x
dotnet-version: '8.0.x'
- name: Bump versions
uses: SiqiLu/dotnet-bump-version@2.0.0

2
StabilityMatrix.Avalonia.Diagnostics/StabilityMatrix.Avalonia.Diagnostics.csproj

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifiers>win-x64;linux-x64;osx-x64;osx-arm64</RuntimeIdentifiers>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

49
StabilityMatrix.Avalonia/Animations/BetterSlideNavigationTransition.cs

@ -1,11 +1,13 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Animation;
using Avalonia.Animation.Easings;
using Avalonia.Media;
using Avalonia.Styling;
using FluentAvalonia.UI.Media.Animation;
using Projektanker.Icons.Avalonia;
namespace StabilityMatrix.Avalonia.Animations;
@ -16,7 +18,8 @@ public class BetterSlideNavigationTransition : BaseTransitionInfo
/// <summary>
/// Gets or sets the type of animation effect to play during the slide transition.
/// </summary>
public SlideNavigationTransitionEffect Effect { get; set; } = SlideNavigationTransitionEffect.FromRight;
public SlideNavigationTransitionEffect Effect { get; set; } =
SlideNavigationTransitionEffect.FromRight;
/// <summary>
/// Gets or sets the HorizontalOffset used when animating from the Left or Right
@ -28,6 +31,11 @@ public class BetterSlideNavigationTransition : BaseTransitionInfo
/// </summary>
public double FromVerticalOffset { get; set; } = 56;
/// <summary>
/// Gets or sets the easing function applied to the slide transition.
/// </summary>
public Easing Easing { get; set; } = new SplineEasing(0.1, 0.9, 0.2, 1.0);
public override async void RunAnimation(Animatable ctrl, CancellationToken cancellationToken)
{
double length = 0;
@ -52,24 +60,26 @@ public class BetterSlideNavigationTransition : BaseTransitionInfo
var animation = new Animation
{
Easing = new SplineEasing(0.1, 0.9, 0.2, 1.0),
Easing = Easing,
Children =
{
new KeyFrame
{
Setters =
{
new Setter(isVertical ? TranslateTransform.YProperty : TranslateTransform.XProperty, length),
new Setter(
isVertical
? TranslateTransform.YProperty
: TranslateTransform.XProperty,
length
),
new Setter(Visual.OpacityProperty, 0d)
},
Cue = new Cue(0d)
},
new KeyFrame
{
Setters=
{
new Setter(Visual.OpacityProperty, 1d)
},
Setters = { new Setter(Visual.OpacityProperty, 1d) },
Cue = new Cue(0.05d)
},
new KeyFrame
@ -77,7 +87,12 @@ public class BetterSlideNavigationTransition : BaseTransitionInfo
Setters =
{
new Setter(Visual.OpacityProperty, 1d),
new Setter(isVertical ? TranslateTransform.YProperty : TranslateTransform.XProperty, 0.0)
new Setter(
isVertical
? TranslateTransform.YProperty
: TranslateTransform.XProperty,
0.0
)
},
Cue = new Cue(1d)
}
@ -93,4 +108,22 @@ public class BetterSlideNavigationTransition : BaseTransitionInfo
visual.Opacity = 1;
}
}
public static BetterSlideNavigationTransition PageSlideFromLeft =>
new()
{
Duration = TimeSpan.FromMilliseconds(300),
Effect = SlideNavigationTransitionEffect.FromLeft,
FromHorizontalOffset = 150,
Easing = new SplineEasing(0.6, 0.4, 0.1, 0.1)
};
public static BetterSlideNavigationTransition PageSlideFromRight =>
new()
{
Duration = TimeSpan.FromMilliseconds(300),
Effect = SlideNavigationTransitionEffect.FromRight,
FromHorizontalOffset = 150,
Easing = new SplineEasing(0.6, 0.4, 0.1, 0.1)
};
}

6
StabilityMatrix.Avalonia/App.axaml

@ -22,11 +22,14 @@
<ResourceInclude Source="Controls/EditorFlyouts.axaml"/>
<ResourceInclude Source="Controls/Scroll/BetterScrollViewer.axaml"/>
<ResourceInclude Source="Controls/ImageFolderCard.axaml"/>
<ResourceInclude Source="Styles/ControlThemes/HyperlinkIconButtonStyles.axaml"/>
</ResourceDictionary.MergedDictionaries>
<idcr:ControlRecycling x:Key="ControlRecyclingKey" />
<x:Double x:Key="ContentDialogMaxWidth">700</x:Double>
<x:Double x:Key="BreadcrumbBarItemThemeFontSize">32</x:Double>
<SolidColorBrush x:Key="ToolTipBackground" Color="#1E1F22"/>
<SolidColorBrush x:Key="ToolTipForeground" Color="#9FBDC3"/>
<FontFamily x:Key="NotoSansJP">avares://StabilityMatrix.Avalonia/Assets/Fonts/NotoSansJP#Noto Sans JP</FontFamily>
@ -39,6 +42,7 @@
<StyleInclude Source="avares://Dock.Avalonia/Themes/DockFluentTheme.axaml" />
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml"/>
<StyleInclude Source="avares://AsyncImageLoader.Avalonia/AdvancedImage.axaml" />
<StyleInclude Source="avares://FluentAvalonia.BreadcrumbBar/Styling/Styles.axaml" />
<StyleInclude Source="Styles/ProgressRing.axaml"/>
<StyleInclude Source="Styles/ButtonStyles.axaml"/>
<StyleInclude Source="Styles/SplitButtonStyles.axaml"/>
@ -62,6 +66,8 @@
<StyleInclude Source="Controls/FreeUCard.axaml"/>
<StyleInclude Source="Controls/Paginator.axaml"/>
<StyleInclude Source="Controls/SelectableImageCard/SelectableImageButton.axaml"/>
<StyleInclude Source="Controls/SettingsAccountLinkExpander.axaml"/>
<StyleInclude Source="Controls/StarsRating.axaml"/>
<Style Selector="DockControl">
<Setter Property="(DockProperties.ControlRecycling)" Value="{StaticResource ControlRecyclingKey}" />

84
StabilityMatrix.Avalonia/App.axaml.cs

@ -17,6 +17,7 @@ using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core.Plugins;
using Avalonia.Input.Platform;
using Avalonia.Markup.Xaml;
using Avalonia.Media.Imaging;
@ -25,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;
@ -53,6 +55,7 @@ using StabilityMatrix.Core.Converters.Json;
using StabilityMatrix.Core.Database;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Api;
using StabilityMatrix.Core.Models.Configs;
using StabilityMatrix.Core.Models.FileInterfaces;
@ -110,6 +113,16 @@ public sealed class App : Application
public override void OnFrameworkInitializationCompleted()
{
// Remove DataAnnotations validation plugin since we're using INotifyDataErrorInfo from MvvmToolkit
var dataValidationPluginsToRemove = BindingPlugins.DataValidators
.OfType<DataAnnotationsValidationPlugin>()
.ToArray();
foreach (var plugin in dataValidationPluginsToRemove)
{
BindingPlugins.DataValidators.Remove(plugin);
}
base.OnFrameworkInitializationCompleted();
if (Design.IsDesignMode)
@ -126,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())
@ -164,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)
@ -313,6 +339,10 @@ public sealed class App : Application
{
var services = new ServiceCollection();
services.AddMemoryCache();
services.AddLazyInstance();
services.AddMessagePipe();
services.AddMessagePipeNamedPipeInterprocess("StabilityMatrix");
var exportedTypes = AppDomain.CurrentDomain
.GetAssemblies()
@ -352,17 +382,31 @@ public sealed class App : Application
t1.attributes is { Length: > 0 }
&& !t1.t.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase)
)
.Select(t1 => new { Type = t1.t, Attribute = (SingletonAttribute)t1.attributes[0] });
.Select(
t1 =>
new
{
Type = t1.t,
Attributes = t1.attributes.Cast<SingletonAttribute>().ToArray()
}
);
foreach (var typePair in singletonTypes)
{
if (typePair.Attribute.InterfaceType is null)
foreach (var attribute in typePair.Attributes)
{
if (attribute.InterfaceType is null)
{
services.AddSingleton(typePair.Type);
}
else if (attribute.ImplType is not null)
{
services.AddSingleton(attribute.InterfaceType, attribute.ImplType);
}
else
{
services.AddSingleton(typePair.Attribute.InterfaceType, typePair.Type);
services.AddSingleton(attribute.InterfaceType, typePair.Type);
}
}
}
@ -493,11 +537,45 @@ public sealed class App : Application
})
.AddPolicyHandler(retryPolicy);
services
.AddRefitClient<ICivitTRPCApi>(defaultRefitSettings)
.ConfigureHttpClient(c =>
{
c.BaseAddress = new Uri("https://civitai.com");
c.Timeout = TimeSpan.FromSeconds(15);
})
.AddPolicyHandler(retryPolicy);
services
.AddRefitClient<ILykosAuthApi>(defaultRefitSettings)
.ConfigureHttpClient(c =>
{
c.BaseAddress = new Uri("https://stableauthentication.azurewebsites.net");
c.Timeout = TimeSpan.FromSeconds(15);
})
.ConfigurePrimaryHttpMessageHandler(
() => new HttpClientHandler { AllowAutoRedirect = false }
)
.AddPolicyHandler(retryPolicy)
.AddHttpMessageHandler(
serviceProvider =>
new TokenAuthHeaderHandler(
serviceProvider.GetRequiredService<LykosAuthTokenProvider>()
)
);
// Add Refit client managers
services
.AddHttpClient("A3Client")
.AddPolicyHandler(localTimeout.WrapAsync(localRetryPolicy));
services
.AddHttpClient("DontFollowRedirects")
.ConfigurePrimaryHttpMessageHandler(
() => new HttpClientHandler { AllowAutoRedirect = false }
)
.AddPolicyHandler(retryPolicy);
/*services.AddHttpClient("IComfyApi")
.AddPolicyHandler(localTimeout.WrapAsync(localRetryPolicy));*/

6
StabilityMatrix.Avalonia/Assets.cs

@ -151,8 +151,14 @@ 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>
/// Yield AvaloniaResources given a relative directory path within the 'Assets' folder.
/// </summary>

BIN
StabilityMatrix.Avalonia/Assets/brands-civitai.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

9
StabilityMatrix.Avalonia/Controls/BetterContentDialog.cs

@ -205,6 +205,12 @@ public class BetterContentDialog : ContentDialog
viewModel.SecondaryButtonClick += OnDialogButtonClick;
viewModel.CloseButtonClick += OnDialogButtonClick;
}
else if (Content is ContentDialogViewModelBase viewModelDirect)
{
viewModelDirect.PrimaryButtonClick += OnDialogButtonClick;
viewModelDirect.SecondaryButtonClick += OnDialogButtonClick;
viewModelDirect.CloseButtonClick += OnDialogButtonClick;
}
else if (
(Content as Control)?.DataContext
is ContentDialogProgressViewModelBase progressViewModel
@ -262,9 +268,12 @@ public class BetterContentDialog : ContentDialog
}
protected void OnDialogButtonClick(object? sender, ContentDialogResult e)
{
Dispatcher.UIThread.Post(() =>
{
Result = e;
HideCore();
});
}
protected override void OnDataContextChanged(EventArgs e)

13
StabilityMatrix.Avalonia/Controls/HyperlinkIconButton.cs

@ -0,0 +1,13 @@
using System;
using FluentAvalonia.UI.Controls;
namespace StabilityMatrix.Avalonia.Controls;
/// <summary>
/// Like <see cref="HyperlinkButton"/>, but with a link icon left of the text content.
/// </summary>
public class HyperlinkIconButton : HyperlinkButton
{
/// <inheritdoc />
protected override Type StyleKeyOverride => typeof(HyperlinkIconButton);
}

124
StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml

@ -0,0 +1,124 @@
<Styles
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:StabilityMatrix.Avalonia.Controls"
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia">
<Design.PreviewWith>
<StackPanel Width="600" Height="400">
<controls:SettingsAccountLinkExpander
Header="Service 1"
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"
IsConnected="True"
OffDescription="Manage account services like A, B, and C" />
<controls:SettingsAccountLinkExpander
Header="Service 3"
IconSource="CloudFilled"
IsConnected="True"
OnDescriptionExtra="(account)"
OffDescription="Manage account services like A, B, and C" />
</StackPanel>
</Design.PreviewWith>
<Style Selector="controls|SettingsAccountLinkExpander">
<!-- Set Defaults -->
<Setter Property="Template">
<ControlTemplate>
<ui:SettingsExpander x:Name="PART_SettingsExpander" IconSource="{TemplateBinding IconSource}">
<ui:SettingsExpander.Header>
<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}" />
<TextBlock
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Text="{TemplateBinding OnDescriptionExtra}"
TextWrapping="Wrap"
Theme="{DynamicResource CaptionTextBlockStyle}" />
</StackPanel>
</StackPanel>
</ui:SettingsExpander.Header>
<ui:SettingsExpander.Footer>
<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"
IsIndeterminate="{Binding $parent[controls:SettingsAccountLinkExpander].IsLoading}"
IsVisible="{Binding $parent[controls:SettingsAccountLinkExpander].IsLoading}" />
<!-- 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:SettingsExpander.Footer>
</ui:SettingsExpander>
</ControlTemplate>
</Setter>
</Style>
</Styles>

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

@ -0,0 +1,199 @@
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>();
public object? Header
{
get => GetValue(HeaderProperty);
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>();
public IconSource? IconSource
{
get => GetValue(IconSourceProperty);
set => SetValue(IconSourceProperty, value);
}
public static readonly StyledProperty<bool> IsConnectedProperty = AvaloniaProperty.Register<
SettingsAccountLinkExpander,
bool
>("IsConnected");
public bool IsConnected
{
get => GetValue(IsConnectedProperty);
set => SetValue(IsConnectedProperty, value);
}
public static readonly StyledProperty<object?> OnDescriptionProperty =
AvaloniaProperty.Register<SettingsAccountLinkExpander, object?>(
"OnDescription",
Languages.Resources.Label_Connected
);
public object? OnDescription
{
get => GetValue(OnDescriptionProperty);
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");
public object? OffDescription
{
get => GetValue(OffDescriptionProperty);
set => SetValue(OffDescriptionProperty, value);
}
public static readonly StyledProperty<ICommand?> ConnectCommandProperty =
AvaloniaProperty.Register<SettingsAccountLinkExpander, ICommand?>(
nameof(ConnectCommand),
enableDataValidation: true
);
public ICommand? ConnectCommand
{
get => GetValue(ConnectCommandProperty);
set => SetValue(ConnectCommandProperty, value);
}
public static readonly StyledProperty<ICommand?> DisconnectCommandProperty =
AvaloniaProperty.Register<SettingsAccountLinkExpander, ICommand?>(
nameof(DisconnectCommand),
enableDataValidation: true
);
public ICommand? DisconnectCommand
{
get => GetValue(DisconnectCommandProperty);
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 />
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
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");
connectButton.Command = command;
}
if (DisconnectCommand is { } disconnectCommand)
{
var disconnectMenuItem = e.NameScope.Get<MenuFlyoutItem>("PART_DisconnectMenuItem");
disconnectMenuItem.Command = disconnectCommand;
}
}
/// <inheritdoc />
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (!this.IsAttachedToVisualTree())
{
return;
}
if (change.Property == ConnectCommandProperty)
{
var button = this.GetControl<Button>("PART_ConnectButton");
button.Command = ConnectCommand;
}
if (change.Property == DisconnectCommandProperty)
{
var button = this.GetControl<Button>("PART_DisconnectButton");
button.Command = DisconnectCommand;
}
}
}

59
StabilityMatrix.Avalonia/Controls/StarsRating.axaml

@ -0,0 +1,59 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:StabilityMatrix.Avalonia.Controls"
xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia"
xmlns:icons="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia">
<Design.PreviewWith>
<StackPanel Width="400" Height="400" Spacing="4">
<StackPanel.Styles>
<Style Selector="controls|Card">
<Setter Property="Padding" Value="6,4"/>
<Setter Property="HorizontalAlignment" Value="Left"/>
</Style>
</StackPanel.Styles>
<controls:Card>
<controls:StarsRating Value="1" />
</controls:Card>
<controls:Card Classes="transparent">
<controls:StarsRating Value="2" />
</controls:Card>
<controls:Card Classes="transparent">
<controls:StarsRating Value="2.5" />
</controls:Card>
</StackPanel>
</Design.PreviewWith>
<!--<Styles.Resources>
<icons:SymbolIcon
x:Key="StarFilledIcon"
FontSize="15"
Margin="8,0"
VerticalAlignment="Center"
Symbol="Star"
IsFilled="True"/>
</Styles.Resources>-->
<Style Selector="controls|StarsRating">
<!-- Set Defaults -->
<Setter Property="Template">
<ControlTemplate>
<ItemsControl
x:Name="PART_StarsItemsControl">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="2" Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<!--<sg:SpacedGrid
x:Name="PART_Grid">
~1~ Stars filled dynamically @1@
</sg:SpacedGrid>-->
</ControlTemplate>
</Setter>
</Style>
</Styles>

168
StabilityMatrix.Avalonia/Controls/StarsRating.axaml.cs

@ -0,0 +1,168 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Controls.Primitives;
using Avalonia.Data;
using Avalonia.Layout;
using Avalonia.Markup.Xaml.MarkupExtensions;
using Avalonia.Media;
using Avalonia.VisualTree;
using FluentIcons.Common;
using FluentIcons.FluentAvalonia;
using SpacedGridControl.Avalonia;
using StabilityMatrix.Avalonia.Styles;
namespace StabilityMatrix.Avalonia.Controls;
public class StarsRating : TemplatedControl
{
private SymbolIcon? StarFilledIcon => Resources["StarFilledIcon"] as SymbolIcon;
private ItemsControl? itemsControl;
private IEnumerable<SymbolIcon> StarItems => itemsControl!.ItemsSource!.Cast<SymbolIcon>();
public static readonly StyledProperty<bool> IsEditableProperty = AvaloniaProperty.Register<
StarsRating,
bool
>("IsEditable");
public bool IsEditable
{
get => GetValue(IsEditableProperty);
set => SetValue(IsEditableProperty, value);
}
public static readonly StyledProperty<int> MaximumProperty = AvaloniaProperty.Register<
StarsRating,
int
>(nameof(Maximum), 5);
public int Maximum
{
get => GetValue(MaximumProperty);
set => SetValue(MaximumProperty, value);
}
public static readonly StyledProperty<double> ValueProperty = AvaloniaProperty.Register<
StarsRating,
double
>(nameof(Value));
public double Value
{
get => GetValue(ValueProperty);
set => SetValue(ValueProperty, value);
}
/// <inheritdoc />
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
itemsControl = e.NameScope.Find<ItemsControl>("PART_StarsItemsControl")!;
CreateStars();
}
/// <inheritdoc />
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (!this.IsAttachedToVisualTree())
{
return;
}
if (change.Property == ValueProperty || change.Property == MaximumProperty)
{
SyncStarState();
}
}
private void CreateStars()
{
if (itemsControl is null)
{
return;
}
// Fill stars
var stars = new List<Control>();
for (var i = 0; i < Maximum; i++)
{
var star = new SymbolIcon
{
FontSize = FontSize,
Margin = new Thickness(0, 0),
Symbol = Symbol.Star,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Tag = i
};
stars.Add(star);
OnStarAdded(star);
}
itemsControl.ItemsSource = stars;
SyncStarState();
}
private void OnStarAdded(SymbolIcon item)
{
if (IsEditable)
{
item.Tapped += (sender, args) =>
{
var star = (SymbolIcon)sender!;
Value = (int)star.Tag! + 1;
};
}
}
/// <summary>
/// Round a number to the nearest 0.5
/// </summary>
private static double RoundToHalf(double value)
{
return Math.Round(value * 2, MidpointRounding.AwayFromZero) / 2;
}
private void SyncStarState()
{
// Set star to filled when Value is greater than or equal to the star index
foreach (var star in StarItems)
{
// Add 1 to tag since its index is 0-based
var tag = (int)star.Tag! + 1;
// Fill if current is equal or lower than floor of Value
if (tag <= Math.Floor(RoundToHalf(Value)))
{
star.Symbol = Symbol.Star;
star.IsFilled = true;
star.Foreground = Foreground;
}
// If current is between floor and ceil of value, use half-star
else if (tag <= Math.Ceiling(RoundToHalf(Value)))
{
star.Symbol = Symbol.StarHalf;
star.IsFilled = true;
star.Foreground = Foreground;
}
// Otherwise no fill and gray disabled color
else
{
star.Symbol = Symbol.Star;
star.IsFilled = false;
star.Foreground = new SolidColorBrush(Colors.DarkSlateGray);
}
}
}
}

30
StabilityMatrix.Avalonia/DesignData/DesignData.cs

@ -103,7 +103,6 @@ public static class DesignData
// General services
services
.AddLogging()
.AddSingleton<INavigationService, NavigationService>()
.AddSingleton<IPackageFactory, PackageFactory>()
.AddSingleton<IUpdateHelper, UpdateHelper>()
.AddSingleton<ModelFinder>()
@ -119,6 +118,7 @@ public static class DesignData
.AddSingleton(Substitute.For<IDiscordRichPresenceService>())
.AddSingleton(Substitute.For<ITrackedDownloadService>())
.AddSingleton(Substitute.For<ILiteDbContext>())
.AddSingleton(Substitute.For<IAccountsService>())
.AddSingleton<IInferenceClientManager, MockInferenceClientManager>()
.AddSingleton<ICompletionProvider, MockCompletionProvider>()
.AddSingleton<IModelIndexService, MockModelIndexService>()
@ -304,6 +304,10 @@ public static class DesignData
{
Name = "BB95 Furry Mix",
Description = "A furry mix of BB95",
Stats = new CivitModelStats { Rating = 3.5, RatingCount = 24 },
ModelVersions = [
new() { Name = "v1.2.2-Inpainting" }
]
};
})
};
@ -344,7 +348,9 @@ public static class DesignData
new ProgressReport(0.5f, "Downloading...")
)
),
new MockDownloadProgressItemViewModel("Test File 2.exe"),
new MockDownloadProgressItemViewModel(
"Very Long Test File Name Need Even More Longness Thanks That's pRobably good 2.exe"
),
new PackageInstallProgressItemViewModel(
new PackageModificationRunner
{
@ -437,6 +443,12 @@ public static class DesignData
public static InferenceSettingsViewModel InferenceSettingsViewModel =>
Services.GetRequiredService<InferenceSettingsViewModel>();
public static MainSettingsViewModel MainSettingsViewModel =>
Services.GetRequiredService<MainSettingsViewModel>();
public static AccountSettingsViewModel AccountSettingsViewModel =>
Services.GetRequiredService<AccountSettingsViewModel>();
public static CheckpointBrowserViewModel CheckpointBrowserViewModel =>
Services.GetRequiredService<CheckpointBrowserViewModel>();
@ -542,6 +554,20 @@ 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 =>
{

84
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) =>
@ -512,8 +567,9 @@ public static class DialogHelper
{
new TextBlock
{
Margin = new Thickness(0, 0, 0, 8),
FontSize = 16,
Margin = new Thickness(0, 2, 0, 8),
FontSize = 20,
FontWeight = FontWeight.DemiBold,
Text = title,
TextWrapping = TextWrapping.WrapWithOverflow,
},

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();
}
}

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

@ -176,6 +176,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Disconnect.
/// </summary>
public static string Action_Disconnect {
get {
return ResourceManager.GetString("Action_Disconnect", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Downgrade.
/// </summary>
@ -239,6 +248,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Login.
/// </summary>
public static string Action_Login {
get {
return ResourceManager.GetString("Action_Login", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to New.
/// </summary>
@ -500,6 +518,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Signup.
/// </summary>
public static string Action_Signup {
get {
return ResourceManager.GetString("Action_Signup", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Stop.
/// </summary>
@ -554,6 +581,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Accounts.
/// </summary>
public static string Label_Accounts {
get {
return ResourceManager.GetString("Label_Accounts", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Add Stability Matrix to the Start Menu.
/// </summary>
@ -590,6 +626,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>
@ -698,6 +743,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to You must be logged in to download this checkpoint. Please enter a CivitAI API Key in the settings..
/// </summary>
public static string Label_CivitAiLoginRequired {
get {
return ResourceManager.GetString("Label_CivitAiLoginRequired", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Close dialog when finished.
/// </summary>
@ -734,6 +788,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Confirm Password.
/// </summary>
public static string Label_ConfirmPassword {
get {
return ResourceManager.GetString("Label_ConfirmPassword", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Confirm?.
/// </summary>
@ -743,6 +806,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Connected.
/// </summary>
public static string Label_Connected {
get {
return ResourceManager.GetString("Label_Connected", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Connected Model.
/// </summary>
@ -860,6 +932,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Download Failed.
/// </summary>
public static string Label_DownloadFailed {
get {
return ResourceManager.GetString("Label_DownloadFailed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Downloads.
/// </summary>
@ -887,6 +968,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Email.
/// </summary>
public static string Label_Email {
get {
return ResourceManager.GetString("Label_Email", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Embedded Python.
/// </summary>
@ -1364,6 +1454,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Password.
/// </summary>
public static string Label_Password {
get {
return ResourceManager.GetString("Label_Password", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Please choose a different name or select a different install location..
/// </summary>
@ -1724,6 +1823,15 @@ namespace StabilityMatrix.Avalonia.Languages {
}
}
/// <summary>
/// Looks up a localized string similar to Username.
/// </summary>
public static string Label_Username {
get {
return ResourceManager.GetString("Label_Username", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Output Sharing.
/// </summary>

36
StabilityMatrix.Avalonia/Languages/Resources.resx

@ -750,4 +750,40 @@
<data name="Action_OpenGithub" xml:space="preserve">
<value>Open on GitHub</value>
</data>
<data name="Label_Connected" xml:space="preserve">
<value>Connected</value>
</data>
<data name="Action_Disconnect" xml:space="preserve">
<value>Disconnect</value>
</data>
<data name="Label_Email" xml:space="preserve">
<value>Email</value>
</data>
<data name="Label_Username" xml:space="preserve">
<value>Username</value>
</data>
<data name="Label_Password" xml:space="preserve">
<value>Password</value>
</data>
<data name="Action_Login" xml:space="preserve">
<value>Login</value>
</data>
<data name="Action_Signup" xml:space="preserve">
<value>Signup</value>
</data>
<data name="Label_ConfirmPassword" xml:space="preserve">
<value>Confirm Password</value>
</data>
<data name="Label_ApiKey" xml:space="preserve">
<value>API Key</value>
</data>
<data name="Label_Accounts" xml:space="preserve">
<value>Accounts</value>
</data>
<data name="Label_CivitAiLoginRequired" xml:space="preserve">
<value>You must be logged in to download this checkpoint. Please enter a CivitAI API Key in the settings.</value>
</data>
<data name="Label_DownloadFailed" xml:space="preserve">
<value>Download Failed</value>
</data>
</root>

5
StabilityMatrix.Avalonia/Models/AppArgs.cs

@ -5,6 +5,11 @@
/// </summary>
public class AppArgs
{
/// <summary>
/// Whether to enable debug mode
/// </summary>
public bool DebugMode { get; set; }
/// <summary>
/// Whether to use the exception dialog while debugger is attached.
/// When no debugger is attached, the exception dialog is always used.

10
StabilityMatrix.Avalonia/Models/TypedNavigationEventArgs.cs

@ -0,0 +1,10 @@
using System;
namespace StabilityMatrix.Avalonia.Models;
public class TypedNavigationEventArgs : EventArgs
{
public required Type ViewModelType { get; init; }
public object? ViewModel { get; init; }
}

20
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.
@ -48,6 +54,7 @@ public class Program
{
StartupTimer.Start();
Args.DebugMode = args.Contains("--debug");
Args.DebugExceptionDialog = args.Contains("--debug-exception-dialog");
Args.DebugSentry = args.Contains("--debug-sentry");
Args.DebugOneClickInstall = args.Contains("--debug-one-click-install");
@ -56,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();

242
StabilityMatrix.Avalonia/Services/AccountsService.cs

@ -0,0 +1,242 @@
using System;
using System.Net;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
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;
namespace StabilityMatrix.Avalonia.Services;
[Singleton(typeof(IAccountsService))]
public class AccountsService : IAccountsService
{
private readonly ILogger<AccountsService> logger;
private readonly ISecretsManager secretsManager;
private readonly ILykosAuthApi lykosAuthApi;
private readonly ICivitTRPCApi civitTRPCApi;
/// <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,
ILykosAuthApi lykosAuthApi,
ICivitTRPCApi civitTRPCApi
)
{
this.logger = logger;
this.secretsManager = secretsManager;
this.lykosAuthApi = lykosAuthApi;
this.civitTRPCApi = civitTRPCApi;
// Update our own status when the Lykos account status changes
LykosAccountStatusUpdate += (_, args) => LykosStatus = args;
}
public async Task LykosLoginAsync(string email, string password)
{
var secrets = await secretsManager.SafeLoadAsync();
var tokens = await lykosAuthApi.PostLogin(new PostLoginRequest(email, password));
secrets = secrets with { LykosAccount = tokens };
await secretsManager.SaveAsync(secrets);
await RefreshLykosAsync(secrets);
}
public async Task LykosSignupAsync(string email, string password, string username)
{
var secrets = await secretsManager.SafeLoadAsync();
var tokens = await lykosAuthApi.PostAccount(
new PostAccountRequest(email, password, password, username)
);
secrets = secrets with { LykosAccount = tokens };
await secretsManager.SaveAsync(secrets);
await RefreshLykosAsync(secrets);
}
public async Task LykosLogoutAsync()
{
var secrets = await secretsManager.SafeLoadAsync();
await secretsManager.SaveAsync(secrets with { LykosAccount = null });
OnLykosAccountStatusUpdate(LykosAccountStatusUpdateEventArgs.Disconnected);
}
/// <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 CivitLoginAsync(string apiToken)
{
var secrets = await secretsManager.SafeLoadAsync();
// Get id first using the api token
var userAccount = await civitTRPCApi.GetUserAccountDefault(apiToken);
var id = userAccount.Result.Data.Json.Id;
// 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()
{
var secrets = await secretsManager.SafeLoadAsync();
await RefreshLykosAsync(secrets);
await RefreshCivitAsync(secrets);
}
private async Task RefreshLykosAsync(Secrets secrets)
{
if (secrets.LykosAccount is not null)
{
try
{
var user = await lykosAuthApi.GetUserSelf();
OnLykosAccountStatusUpdate(
new LykosAccountStatusUpdateEventArgs { IsConnected = true, User = user }
);
return;
}
catch (OperationCanceledException)
{
logger.LogWarning("Timed out while fetching Lykos Auth user info");
}
catch (ApiException e)
{
if (e.StatusCode is HttpStatusCode.Unauthorized) { }
else
{
logger.LogWarning(e, "Failed to get user info from Lykos");
}
}
}
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)
{
logger.LogInformation("Lykos account disconnected");
}
else if (e.IsConnected && LykosStatus?.IsConnected == false)
{
logger.LogInformation(
"Lykos account connected: {Id} ({Username})",
e.User?.Id,
e.User?.Account.Name
);
}
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);
}
}

29
StabilityMatrix.Avalonia/Services/IAccountsService.cs

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

18
StabilityMatrix.Avalonia/Services/INavigationService.cs

@ -1,11 +1,16 @@
using FluentAvalonia.UI.Controls;
using System;
using System.Diagnostics.CodeAnalysis;
using FluentAvalonia.UI.Controls;
using FluentAvalonia.UI.Media.Animation;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.ViewModels.Base;
namespace StabilityMatrix.Avalonia.Services;
public interface INavigationService
public interface INavigationService<[SuppressMessage("ReSharper", "UnusedTypeParameter")] T>
{
event EventHandler<TypedNavigationEventArgs>? TypedNavigation;
/// <summary>
/// Set the frame to use for navigation.
/// </summary>
@ -20,6 +25,15 @@ public interface INavigationService
)
where TViewModel : ViewModelBase;
/// <summary>
/// Navigate to the view of the given view model type.
/// </summary>
void NavigateTo(
Type viewModelType,
NavigationTransitionInfo? transitionInfo = null,
object? param = null
);
/// <summary>
/// Navigate to the view of the given view model.
/// </summary>

81
StabilityMatrix.Avalonia/Services/NavigationService.cs

@ -4,6 +4,7 @@ using FluentAvalonia.UI.Controls;
using FluentAvalonia.UI.Media.Animation;
using FluentAvalonia.UI.Navigation;
using StabilityMatrix.Avalonia.Animations;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.ViewModels;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Core.Attributes;
@ -11,11 +12,20 @@ using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.Services;
[Singleton(typeof(INavigationService))]
public class NavigationService : INavigationService
[Singleton(
ImplType = typeof(NavigationService<MainWindowViewModel>),
InterfaceType = typeof(INavigationService<MainWindowViewModel>)
)]
[Singleton(
ImplType = typeof(NavigationService<SettingsViewModel>),
InterfaceType = typeof(INavigationService<SettingsViewModel>)
)]
public class NavigationService<T> : INavigationService<T>
{
private Frame? _frame;
public event EventHandler<TypedNavigationEventArgs>? TypedNavigation;
/// <inheritdoc />
public void SetFrame(Frame frame)
{
@ -60,18 +70,60 @@ public class NavigationService : INavigationService
}
);
if (!typeof(TViewModel).IsAssignableTo(typeof(PageViewModelBase)))
return;
TypedNavigation?.Invoke(
this,
new TypedNavigationEventArgs { ViewModelType = typeof(TViewModel) }
);
}
if (
App.Services.GetService(typeof(MainWindowViewModel))
is MainWindowViewModel mainViewModel
/// <inheritdoc />
public void NavigateTo(
Type viewModelType,
NavigationTransitionInfo? transitionInfo = null,
object? param = null
)
{
mainViewModel.SelectedCategory = mainViewModel.Pages.FirstOrDefault(
x => x.GetType() == typeof(TViewModel)
);
if (!viewModelType.IsAssignableTo(typeof(ViewModelBase)))
{
// ReSharper disable once LocalizableElement
throw new ArgumentException("Type must be a ViewModelBase.", nameof(viewModelType));
}
if (_frame is null)
{
throw new InvalidOperationException("SetFrame was not called before NavigateTo.");
}
if (App.Services.GetService(typeof(ISettingsManager)) is ISettingsManager settingsManager)
{
// Handle animation scale
switch (transitionInfo)
{
// If the transition info is null or animation scale is 0, suppress the transition
case null:
case BaseTransitionInfo when settingsManager.Settings.AnimationScale == 0f:
transitionInfo = new SuppressNavigationTransitionInfo();
break;
case BaseTransitionInfo baseTransitionInfo:
baseTransitionInfo.Duration *= settingsManager.Settings.AnimationScale;
break;
}
}
_frame.NavigateToType(
viewModelType,
param,
new FrameNavigationOptions
{
IsNavigationStackEnabled = true,
TransitionInfoOverride = transitionInfo ?? new SuppressNavigationTransitionInfo()
}
);
TypedNavigation?.Invoke(
this,
new TypedNavigationEventArgs { ViewModelType = viewModelType }
);
}
/// <inheritdoc />
@ -106,5 +158,14 @@ public class NavigationService : INavigationService
TransitionInfoOverride = transitionInfo ?? new SuppressNavigationTransitionInfo()
}
);
TypedNavigation?.Invoke(
this,
new TypedNavigationEventArgs
{
ViewModelType = viewModel.GetType(),
ViewModel = viewModel
}
);
}
}

63
StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj

@ -1,14 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifiers>win-x64;linux-x64;osx-x64;osx-arm64</RuntimeIdentifiers>
<Nullable>enable</Nullable>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<ApplicationIcon>./Assets/Icon.ico</ApplicationIcon>
<Version>2.6.0-dev.1</Version>
<Version>2.7.0-dev.1</Version>
<InformationalVersion>$(Version)</InformationalVersion>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
@ -22,50 +22,55 @@
<ItemGroup>
<PackageReference Include="AsyncImageLoader.Avalonia" Version="3.2.1" />
<PackageReference Include="AutoComplete.Net" Version="1.2211.2014.42"/>
<PackageReference Include="Avalonia.AvaloniaEdit" Version="11.0.1" />
<PackageReference Include="Avalonia.AvaloniaEdit" Version="11.0.5" />
<PackageReference Include="Avalonia.Controls.PanAndZoom" Version="11.0.0" />
<PackageReference Include="Avalonia" Version="11.0.5" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.5" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.5" />
<PackageReference Include="Avalonia" Version="11.0.999" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.999" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.999" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.5" />
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.999" />
<PackageReference Include="Avalonia.HtmlRenderer" Version="11.0.0" />
<PackageReference Include="Avalonia.Xaml.Behaviors" Version="11.0.2" />
<PackageReference Include="AvaloniaEdit.TextMate" Version="11.0.1" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
<PackageReference Include="Avalonia.Xaml.Behaviors" Version="11.0.5" />
<PackageReference Include="AvaloniaEdit.TextMate" Version="11.0.5" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
<PackageReference Include="DiscordRichPresence" Version="1.2.1.24" />
<PackageReference Include="Dock.Avalonia" Version="11.0.0.2" />
<PackageReference Include="Dock.Model.Avalonia" Version="11.0.0.2" />
<PackageReference Include="Dock.Serializer" Version="11.0.0.2" />
<PackageReference Include="Dock.Avalonia" Version="11.0.0.3" />
<PackageReference Include="Dock.Model.Avalonia" Version="11.0.0.3" />
<PackageReference Include="Dock.Serializer" Version="11.0.0.3" />
<PackageReference Include="DynamicData" Version="8.1.1" />
<PackageReference Include="FluentAvalonia.BreadcrumbBar" Version="2.0.2" />
<PackageReference Include="FluentAvaloniaUI" Version="2.0.4" />
<PackageReference Include="FluentIcons.Avalonia" Version="1.1.220" />
<PackageReference Include="FluentIcons.FluentAvalonia" Version="1.1.220" />
<PackageReference Include="FluentIcons.Avalonia" Version="1.1.222" />
<PackageReference Include="FluentIcons.FluentAvalonia" Version="1.1.222" />
<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" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="7.0.12" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
<PackageReference Include="Nito.AsyncEx" Version="5.1.2" />
<PackageReference Include="NLog" Version="5.2.5" />
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.5" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="Polly" Version="8.0.0" />
<PackageReference Include="Polly" Version="8.2.0" />
<PackageReference Include="Polly.Contrib.WaitAndRetry" Version="1.1.1" />
<PackageReference Include="Polly.Extensions.Http" Version="3.0.0" />
<PackageReference Include="Projektanker.Icons.Avalonia.FontAwesome" Version="8.3.0" />
<PackageReference Include="RockLib.Reflection.Optimized" Version="2.0.0" />
<PackageReference Include="Sentry" Version="3.40.1" />
<PackageReference Include="Sentry.NLog" Version="3.40.1" />
<PackageReference Include="Sentry" Version="3.41.0" />
<PackageReference Include="Sentry.NLog" Version="3.41.0" />
<PackageReference Include="SpacedGrid-Avalonia" Version="11.0.0" />
<PackageReference Include="Sylvan.Common" Version="0.4.2" />
<PackageReference Include="Sylvan.Common" Version="0.4.3" />
<PackageReference Include="Sylvan.Data" Version="0.2.12" />
<PackageReference Include="Sylvan.Data.Csv" Version="1.3.3" />
<PackageReference Include="Sylvan.Data.Csv" Version="1.3.5" />
<PackageReference Include="TextMateSharp.Grammars" Version="1.0.56" />
<PackageReference Include="URISchemeTools" Version="1.0.2" />
</ItemGroup>
@ -91,6 +96,12 @@
<AvaloniaResource Include="Assets\noimage.png" />
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\brands-*.png" />
<AvaloniaResource Include="Assets\brands-*.svg" />
<AvaloniaResource Include="Assets\guide-*.webp"/>
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\licenses.json" />
</ItemGroup>

44
StabilityMatrix.Avalonia/Styles/ControlThemes/HyperlinkIconButtonStyles.axaml

@ -0,0 +1,44 @@
<ResourceDictionary
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:ui="using:FluentAvalonia.UI.Controls">
<!-- ReSharper disable once Xaml.StaticResourceNotResolved -->
<ControlTheme
x:Key="{x:Type controls:HyperlinkIconButton}"
BasedOn="{StaticResource {x:Type ui:HyperlinkButton}}"
TargetType="controls:HyperlinkIconButton">
<Setter Property="ContentTemplate">
<DataTemplate DataType="x:String">
<StackPanel Orientation="Horizontal">
<ui:SymbolIcon
Margin="0,1,4,0"
FontSize="15"
Foreground="{DynamicResource HyperlinkButtonForeground}"
Symbol="Link" />
<TextBlock
Foreground="{DynamicResource HyperlinkButtonForeground}" Text="{Binding}" />
</StackPanel>
</DataTemplate>
</Setter>
<!-- Override template to not set Underline TextDecoration -->
<Setter Property="Template">
<ControlTemplate>
<ContentPresenter Name="ContentPresenter"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
Padding="{TemplateBinding Padding}"
CornerRadius="{TemplateBinding CornerRadius}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
Foreground="{TemplateBinding Foreground}">
</ContentPresenter>
</ControlTemplate>
</Setter>
</ControlTheme>
</ResourceDictionary>

2
StabilityMatrix.Avalonia/Styles/ThemeColors.axaml

@ -22,12 +22,14 @@
<Color x:Key="ThemeDarkGreenColor">#3A783C</Color>
<Color x:Key="ThemeGreenColor">#4BA04F</Color>
<Color x:Key="ThemeGreenColorTransparent">#AA4BA04F</Color>
<Color x:Key="ThemeMediumSeaGreenColor">#6CCB5F</Color>
<Color x:Key="ThemeLightGreenColor">#8BC34A</Color>
<Color x:Key="ThemeLimeColor">#CDDC39</Color>
<Color x:Key="ThemeYellowColor">#FFEB3B</Color>
<Color x:Key="ThemeAmberColor">#FFC107</Color>
<Color x:Key="ThemeOrangeColor">#FF9800</Color>
<Color x:Key="ThemeDeepOrangeColor">#FF5722</Color>
<Color x:Key="ThemeEldenRingOrangeColor">#FF4F00</Color>
<Color x:Key="ThemeBrownColor">#795548</Color>
<Color x:Key="ThemeGreyColor">#9E9E9E</Color>
<Color x:Key="ThemeBlueGreyColor">#607D8B</Color>

2
StabilityMatrix.Avalonia/ViewModels/Base/InferenceTabViewModelBase.cs

@ -127,7 +127,7 @@ public abstract partial class InferenceTabViewModelBase
// ResetViewState();
// TODO: Dock reset not working, using this hack for now to get a new view
var navService = App.Services.GetRequiredService<INavigationService>();
var navService = App.Services.GetRequiredService<INavigationService<MainWindowViewModel>>();
navService.NavigateTo<LaunchPageViewModel>(new SuppressNavigationTransitionInfo());
((IPersistentViewProvider)this).AttachedPersistentView = null;
navService.NavigateTo<InferenceViewModel>(new BetterEntranceNavigationTransition());

77
StabilityMatrix.Avalonia/ViewModels/Base/TaskDialogViewModelBase.cs

@ -0,0 +1,77 @@
using System.Threading.Tasks;
using System.Windows.Input;
using Avalonia.Threading;
using FluentAvalonia.UI.Controls;
using StabilityMatrix.Avalonia.Languages;
namespace StabilityMatrix.Avalonia.ViewModels.Base;
/// <summary>
/// Base class for view models that are used in <see cref="FluentAvalonia.UI.Controls.TaskDialog"/>
/// </summary>
public abstract class TaskDialogViewModelBase : ViewModelBase
{
private TaskDialog? dialog;
protected static TaskDialogCommand GetCommandButton(string text, ICommand command)
{
return new TaskDialogCommand
{
Text = text,
DialogResult = TaskDialogStandardResult.None,
Command = command,
IsDefault = true,
ClosesOnInvoked = false
};
}
protected static TaskDialogButton GetCloseButton()
{
return new TaskDialogButton
{
Text = Resources.Action_Close,
DialogResult = TaskDialogStandardResult.Close
};
}
/// <summary>
/// Return a <see cref="TaskDialog"/> that uses this view model as its content
/// </summary>
public virtual TaskDialog GetDialog()
{
Dispatcher.UIThread.VerifyAccess();
dialog = new TaskDialog
{
Content = this,
XamlRoot = App.VisualRoot,
Buttons = { GetCloseButton() }
};
dialog.AttachedToVisualTree += (s, _) =>
{
((TaskDialog)s!).Closing += OnDialogClosing;
};
dialog.DetachedFromVisualTree += (s, _) =>
{
((TaskDialog)s!).Closing -= OnDialogClosing;
};
return dialog;
}
/// <summary>
/// Show the dialog from <see cref="GetDialog"/> and return the result
/// </summary>
public async Task<TaskDialogStandardResult> ShowDialogAsync()
{
return (TaskDialogStandardResult)await GetDialog().ShowAsync(true);
}
protected void CloseDialog(TaskDialogStandardResult result)
{
dialog?.Hide(result);
}
protected virtual async void OnDialogClosing(object? sender, TaskDialogClosingEventArgs e) { }
}

4
StabilityMatrix.Avalonia/ViewModels/Dialogs/InferenceConnectionHelpViewModel.cs

@ -27,7 +27,7 @@ namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
public partial class InferenceConnectionHelpViewModel : ContentDialogViewModelBase
{
private readonly ISettingsManager settingsManager;
private readonly INavigationService navigationService;
private readonly INavigationService<MainWindowViewModel> navigationService;
private readonly IPackageFactory packageFactory;
[ObservableProperty]
@ -56,7 +56,7 @@ public partial class InferenceConnectionHelpViewModel : ContentDialogViewModelBa
public InferenceConnectionHelpViewModel(
ISettingsManager settingsManager,
INavigationService navigationService,
INavigationService<MainWindowViewModel> navigationService,
IPackageFactory packageFactory
)
{

133
StabilityMatrix.Avalonia/ViewModels/Dialogs/LykosLoginViewModel.cs

@ -0,0 +1,133 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls;
using Refit;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Exceptions;
using StabilityMatrix.Core.Validators;
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
[View(typeof(LykosLoginDialog))]
[Transient, ManagedService]
public partial class LykosLoginViewModel : TaskDialogViewModelBase
{
private readonly IAccountsService accountsService;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ContinueButtonClickCommand))]
private bool isSignupMode;
[ObservableProperty]
[NotifyDataErrorInfo, NotifyCanExecuteChangedFor(nameof(ContinueButtonClickCommand))]
[EmailAddress(ErrorMessage = "Email is not valid")]
private string? email;
[ObservableProperty]
[NotifyDataErrorInfo, NotifyCanExecuteChangedFor(nameof(ContinueButtonClickCommand))]
[Required]
private string? username;
[ObservableProperty]
[NotifyDataErrorInfo, NotifyCanExecuteChangedFor(nameof(ContinueButtonClickCommand))]
[Required]
private string? password;
[ObservableProperty]
[NotifyDataErrorInfo, NotifyCanExecuteChangedFor(nameof(ContinueButtonClickCommand))]
[Required, RequiresMatch<string>(nameof(Password))]
private string? confirmPassword;
[ObservableProperty]
private AppException? loginError;
[ObservableProperty]
private AppException? signupError;
public LykosLoginViewModel(IAccountsService accountsService)
{
this.accountsService = accountsService;
}
private bool CanExecuteContinueButtonClick()
{
return !HasErrors && IsValid();
}
[RelayCommand(CanExecute = nameof(CanExecuteContinueButtonClick))]
private Task OnContinueButtonClick()
{
return IsSignupMode ? SignupAsync() : LoginAsync();
}
private async Task LoginAsync()
{
try
{
await accountsService.LykosLoginAsync(Email!, Password!);
CloseDialog(TaskDialogStandardResult.OK);
}
catch (OperationCanceledException)
{
LoginError = new AppException("Request timed out", "Please try again later");
}
catch (ApiException e)
{
LoginError = new AppException("Failed to login", $"{e.StatusCode} - {e.Message}");
}
}
private async Task SignupAsync()
{
try
{
await accountsService.LykosSignupAsync(Email!, Password!, Username!);
CloseDialog(TaskDialogStandardResult.OK);
}
catch (OperationCanceledException)
{
SignupError = new AppException("Request timed out", "Please try again later");
}
catch (ApiException e)
{
SignupError = new AppException("Failed to signup", $"{e.StatusCode} - {e.Message}");
}
}
/// <inheritdoc />
public override TaskDialog GetDialog()
{
var dialog = base.GetDialog();
dialog.Buttons = new List<TaskDialogButton>
{
GetCommandButton(Resources.Action_Continue, ContinueButtonClickCommand),
GetCloseButton()
};
return dialog;
}
private bool IsValid()
{
if (IsSignupMode)
{
return !(
string.IsNullOrEmpty(Email)
|| string.IsNullOrEmpty(Username)
|| string.IsNullOrEmpty(Password)
|| string.IsNullOrEmpty(ConfirmPassword)
);
}
return !(string.IsNullOrEmpty(Email) || string.IsNullOrEmpty(Password));
}
}

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

@ -0,0 +1,84 @@
using System;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using MessagePipe;
using Microsoft.Extensions.Logging;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Helpers;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Core.Attributes;
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
[View(typeof(OAuthConnectDialog))]
[Transient, ManagedService]
public partial class OAuthConnectViewModel : ContentDialogViewModelBase
{
private readonly ILogger<OAuthConnectViewModel> logger;
private readonly IDistributedSubscriber<string, Uri> uriHandlerSubscriber;
private IAsyncDisposable? uriHandlerSubscription;
[ObservableProperty]
private string? title = "Connect OAuth";
[ObservableProperty]
private string? url;
[ObservableProperty]
private string? description =
"Please login and click 'Allow' in the opened browser window to connect with StabilityMatrix.\n\n"
+ "Once you have done so, close this prompt to complete the connection.";
[ObservableProperty]
private string? footer = "Once you have done so, close this prompt to complete the connection.";
public OAuthConnectViewModel(
ILogger<OAuthConnectViewModel> logger,
IDistributedSubscriber<string, Uri> uriHandlerSubscriber
)
{
this.logger = logger;
this.uriHandlerSubscriber = uriHandlerSubscriber;
}
/// <inheritdoc />
public override async Task OnLoadedAsync()
{
await base.OnLoadedAsync();
uriHandlerSubscription = await uriHandlerSubscriber.SubscribeAsync(
UriHandler.IpcKeySend,
receivedUri =>
{
logger.LogDebug("UriHandler Received URI: {Uri}", receivedUri.PathAndQuery);
if (receivedUri.PathAndQuery.StartsWith("/oauth/patreon/callback"))
{
OnPrimaryButtonClick();
}
}
);
}
/// <inheritdoc />
public override async Task OnUnloadedAsync()
{
if (uriHandlerSubscription is not null)
{
await uriHandlerSubscription.DisposeAsync();
uriHandlerSubscription = null;
}
}
public BetterContentDialog GetDialog()
{
return new BetterContentDialog
{
Title = Title,
Content = this,
CloseButtonText = Resources.Action_Close
};
}
}

4
StabilityMatrix.Avalonia/ViewModels/Dialogs/OneClickInstallViewModel.cs

@ -30,7 +30,7 @@ public partial class OneClickInstallViewModel : ContentDialogViewModelBase
private readonly IPrerequisiteHelper prerequisiteHelper;
private readonly ILogger<OneClickInstallViewModel> logger;
private readonly IPyRunner pyRunner;
private readonly INavigationService navigationService;
private readonly INavigationService<MainWindowViewModel> navigationService;
private const string DefaultPackageName = "stable-diffusion-webui";
[ObservableProperty]
@ -71,7 +71,7 @@ public partial class OneClickInstallViewModel : ContentDialogViewModelBase
IPrerequisiteHelper prerequisiteHelper,
ILogger<OneClickInstallViewModel> logger,
IPyRunner pyRunner,
INavigationService navigationService
INavigationService<MainWindowViewModel> navigationService
)
{
this.settingsManager = settingsManager;

4
StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs

@ -49,7 +49,7 @@ public partial class OutputsPageViewModel : PageViewModelBase
private readonly ISettingsManager settingsManager;
private readonly IPackageFactory packageFactory;
private readonly INotificationService notificationService;
private readonly INavigationService navigationService;
private readonly INavigationService<MainWindowViewModel> navigationService;
private readonly ILogger<OutputsPageViewModel> logger;
public override string Title => Resources.Label_OutputsPageTitle;
@ -99,7 +99,7 @@ public partial class OutputsPageViewModel : PageViewModelBase
ISettingsManager settingsManager,
IPackageFactory packageFactory,
INotificationService notificationService,
INavigationService navigationService,
INavigationService<MainWindowViewModel> navigationService,
ILogger<OutputsPageViewModel> logger
)
{

4
StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs

@ -36,7 +36,7 @@ public partial class PackageCardViewModel : ProgressViewModel
private readonly IPackageFactory packageFactory;
private readonly INotificationService notificationService;
private readonly ISettingsManager settingsManager;
private readonly INavigationService navigationService;
private readonly INavigationService<MainWindowViewModel> navigationService;
private readonly ServiceManager<ViewModelBase> vmFactory;
[ObservableProperty]
@ -80,7 +80,7 @@ public partial class PackageCardViewModel : ProgressViewModel
IPackageFactory packageFactory,
INotificationService notificationService,
ISettingsManager settingsManager,
INavigationService navigationService,
INavigationService<MainWindowViewModel> navigationService,
ServiceManager<ViewModelBase> vmFactory
)
{

52
StabilityMatrix.Avalonia/ViewModels/Progress/ProgressManagerViewModel.cs

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
@ -6,9 +7,14 @@ using Avalonia.Collections;
using Avalonia.Controls.Notifications;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls;
using FluentAvalonia.UI.Media.Animation;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.ViewModels.Settings;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper;
@ -27,6 +33,8 @@ namespace StabilityMatrix.Avalonia.ViewModels.Progress;
public partial class ProgressManagerViewModel : PageViewModelBase
{
private readonly INotificationService notificationService;
private readonly INavigationService<MainWindowViewModel> navigationService;
private readonly INavigationService<SettingsViewModel> settingsNavService;
public override string Title => "Download Manager";
public override IconSource IconSource =>
@ -38,10 +46,14 @@ public partial class ProgressManagerViewModel : PageViewModelBase
public ProgressManagerViewModel(
ITrackedDownloadService trackedDownloadService,
INotificationService notificationService
INotificationService notificationService,
INavigationService<MainWindowViewModel> navigationService,
INavigationService<SettingsViewModel> settingsNavService
)
{
this.notificationService = notificationService;
this.navigationService = navigationService;
this.settingsNavService = settingsNavService;
// Attach to the event
trackedDownloadService.DownloadAdded += TrackedDownloadService_OnDownloadAdded;
@ -82,8 +94,42 @@ public partial class ProgressManagerViewModel : PageViewModelBase
var msg = "";
if (download?.Exception is { } exception)
{
msg = $"({exception.GetType().Name}) {exception.Message}";
if (
exception is UnauthorizedAccessException
|| exception.InnerException is UnauthorizedAccessException
)
{
Dispatcher.UIThread.InvokeAsync(async () =>
{
var errorDialog = new BetterContentDialog
{
Title = Resources.Label_DownloadFailed,
Content = Resources.Label_CivitAiLoginRequired,
PrimaryButtonText = "Go to Settings",
SecondaryButtonText = "Close",
DefaultButton = ContentDialogButton.Primary,
};
var result = await errorDialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
navigationService.NavigateTo<SettingsViewModel>(
new SuppressNavigationTransitionInfo()
);
await Task.Delay(100);
settingsNavService.NavigateTo<AccountSettingsViewModel>(
new SuppressNavigationTransitionInfo()
);
}
});
return;
}
msg =
$"({exception.GetType().Name}) {exception.InnerException?.Message ?? exception.Message}";
}
Dispatcher.UIThread.Post(() =>
{
notificationService.ShowPersistent(

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

@ -0,0 +1,283 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using Avalonia.Controls;
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.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;
[View(typeof(AccountSettingsPage))]
[Singleton, ManagedService]
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;
/// <inheritdoc />
public override string Title => "Accounts";
/// <inheritdoc />
public override IconSource IconSource =>
new SymbolIconSource { Symbol = Symbol.Person, IsFilled = true };
[ObservableProperty]
private bool isInitialUpdateFinished;
[ObservableProperty]
private string? lykosProfileImageUrl;
[ObservableProperty]
private bool isPatreonConnected;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(LykosProfileImageUrl))]
private LykosAccountStatusUpdateEventArgs lykosStatus =
LykosAccountStatusUpdateEventArgs.Disconnected;
[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;
accountsService.LykosAccountStatusUpdate += (_, args) =>
{
Dispatcher.UIThread.Post(() =>
{
IsInitialUpdateFinished = true;
LykosStatus = args;
IsPatreonConnected = args.IsPatreonConnected;
});
};
accountsService.CivitAccountStatusUpdate += (_, args) =>
{
Dispatcher.UIThread.Post(() =>
{
IsInitialUpdateFinished = true;
CivitStatus = args;
});
};
}
/// <inheritdoc />
public override void OnLoaded()
{
base.OnLoaded();
if (Design.IsDesignMode)
{
return;
}
accountsService.RefreshAsync().SafeFireAndForget();
}
private async Task<bool> BeforeConnectCheck()
{
// Show credentials storage notice if not seen
if (
!settingsManager.Settings.SeenTeachingTips.Contains(
TeachingTip.AccountsCredentialsStorageNotice
)
)
{
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;
}
settingsManager.Transaction(
s => s.SeenTeachingTips.Add(TeachingTip.AccountsCredentialsStorageNotice)
);
}
return true;
}
[RelayCommand(CanExecute = nameof(IsInitialUpdateFinished))]
private async Task ConnectLykos()
{
if (!await BeforeConnectCheck())
return;
var vm = vmFactory.Get<LykosLoginViewModel>();
await vm.ShowDialogAsync();
}
[RelayCommand]
private Task DisconnectLykos()
{
return accountsService.LykosLogoutAsync();
}
[RelayCommand(CanExecute = nameof(IsInitialUpdateFinished))]
private async Task ConnectPatreon()
{
if (!await BeforeConnectCheck())
return;
if (LykosStatus.User?.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();
// Bring main window to front since browser is probably covering
var main = App.TopLevel as Window;
main?.Activate();
}
}
[RelayCommand]
private async Task DisconnectPatreon()
{
await notificationService.TryAsync(accountsService.LykosPatreonOAuthLogoutAsync());
}
[RelayCommand(CanExecute = nameof(IsInitialUpdateFinished))]
private async Task ConnectCivit()
{
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",
"""
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 { } apiToken
)
{
return;
}
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();
LykosProfileImageUrl = $"https://gravatar.com/avatar/{hash}?s=512&d=retro";
}
else
{
LykosProfileImageUrl = null;
}
}
}

17
StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs

@ -1,9 +1,20 @@
using StabilityMatrix.Avalonia.ViewModels.Base;
using FluentAvalonia.UI.Controls;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.Views.Settings;
using StabilityMatrix.Core.Attributes;
using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
namespace StabilityMatrix.Avalonia.ViewModels.Settings;
[View(typeof(InferenceSettingsPage))]
[Singleton]
public partial class InferenceSettingsViewModel : ViewModelBase { }
[Singleton, ManagedService]
public class InferenceSettingsViewModel : PageViewModelBase
{
/// <inheritdoc />
public override string Title => "Inference";
/// <inheritdoc />
public override IconSource IconSource =>
new SymbolIconSource { Symbol = Symbol.Settings, IsFilled = true };
}

961
StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs

@ -0,0 +1,961 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reactive.Linq;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using Avalonia;
using Avalonia.Controls.Notifications;
using Avalonia.Controls.Primitives;
using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage;
using Avalonia.Styling;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DynamicData.Binding;
using FluentAvalonia.UI.Controls;
using FluentAvalonia.UI.Media.Animation;
using NLog;
using SkiaSharp;
using StabilityMatrix.Avalonia.Animations;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Extensions;
using StabilityMatrix.Avalonia.Helpers;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Models.Inference;
using StabilityMatrix.Avalonia.Models.TagCompletion;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Avalonia.ViewModels.Inference;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Avalonia.Views.Settings;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Python;
using StabilityMatrix.Core.Services;
using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
namespace StabilityMatrix.Avalonia.ViewModels.Settings;
[View(typeof(MainSettingsPage))]
[Singleton, ManagedService]
public partial class MainSettingsViewModel : PageViewModelBase
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private readonly INotificationService notificationService;
private readonly ISettingsManager settingsManager;
private readonly IPrerequisiteHelper prerequisiteHelper;
private readonly IPyRunner pyRunner;
private readonly ServiceManager<ViewModelBase> dialogFactory;
private readonly ICompletionProvider completionProvider;
private readonly ITrackedDownloadService trackedDownloadService;
private readonly IModelIndexService modelIndexService;
private readonly INavigationService<SettingsViewModel> settingsNavigationService;
private readonly IAccountsService accountsService;
public SharedState SharedState { get; }
public override string Title => "Settings";
public override IconSource IconSource =>
new SymbolIconSource { Symbol = Symbol.Settings, IsFilled = true };
// ReSharper disable once MemberCanBeMadeStatic.Global
public string AppVersion =>
$"Version {Compat.AppVersion}" + (Program.IsDebugBuild ? " (Debug)" : "");
// Theme section
[ObservableProperty]
private string? selectedTheme;
public IReadOnlyList<string> AvailableThemes { get; } = new[] { "Light", "Dark", "System", };
[ObservableProperty]
private CultureInfo selectedLanguage;
// ReSharper disable once MemberCanBeMadeStatic.Global
public IReadOnlyList<CultureInfo> AvailableLanguages => Cultures.SupportedCultures;
public IReadOnlyList<float> AnimationScaleOptions { get; } =
new[] { 0f, 0.25f, 0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f, };
[ObservableProperty]
private float selectedAnimationScale;
// Shared folder options
[ObservableProperty]
private bool removeSymlinksOnShutdown;
// Inference UI section
[ObservableProperty]
private bool isPromptCompletionEnabled = true;
[ObservableProperty]
private IReadOnlyList<string> availableTagCompletionCsvs = Array.Empty<string>();
[ObservableProperty]
private string? selectedTagCompletionCsv;
[ObservableProperty]
private bool isCompletionRemoveUnderscoresEnabled = true;
[ObservableProperty]
[CustomValidation(typeof(MainSettingsViewModel), nameof(ValidateOutputImageFileNameFormat))]
private string? outputImageFileNameFormat;
[ObservableProperty]
private string? outputImageFileNameFormatSample;
public IEnumerable<FileNameFormatVar> OutputImageFileNameFormatVars =>
FileNameFormatProvider
.GetSample()
.Substitutions.Select(
kv =>
new FileNameFormatVar
{
Variable = $"{{{kv.Key}}}",
Example = kv.Value.Invoke()
}
);
[ObservableProperty]
private bool isImageViewerPixelGridEnabled = true;
// Integrations section
[ObservableProperty]
private bool isDiscordRichPresenceEnabled;
// Debug section
[ObservableProperty]
private string? debugPaths;
[ObservableProperty]
private string? debugCompatInfo;
[ObservableProperty]
private string? debugGpuInfo;
// Info section
private const int VersionTapCountThreshold = 7;
[ObservableProperty, NotifyPropertyChangedFor(nameof(VersionFlyoutText))]
private int versionTapCount;
[ObservableProperty]
private bool isVersionTapTeachingTipOpen;
public string VersionFlyoutText =>
$"You are {VersionTapCountThreshold - VersionTapCount} clicks away from enabling Debug options.";
public string DataDirectory =>
settingsManager.IsLibraryDirSet ? settingsManager.LibraryDir : "Not set";
public MainSettingsViewModel(
INotificationService notificationService,
ISettingsManager settingsManager,
IPrerequisiteHelper prerequisiteHelper,
IPyRunner pyRunner,
ServiceManager<ViewModelBase> dialogFactory,
ITrackedDownloadService trackedDownloadService,
SharedState sharedState,
ICompletionProvider completionProvider,
IModelIndexService modelIndexService,
INavigationService<SettingsViewModel> settingsNavigationService,
IAccountsService accountsService
)
{
this.notificationService = notificationService;
this.settingsManager = settingsManager;
this.prerequisiteHelper = prerequisiteHelper;
this.pyRunner = pyRunner;
this.dialogFactory = dialogFactory;
this.trackedDownloadService = trackedDownloadService;
this.completionProvider = completionProvider;
this.modelIndexService = modelIndexService;
this.settingsNavigationService = settingsNavigationService;
this.accountsService = accountsService;
SharedState = sharedState;
if (Program.Args.DebugMode)
{
SharedState.IsDebugMode = true;
}
SelectedTheme = settingsManager.Settings.Theme ?? AvailableThemes[1];
SelectedLanguage = Cultures.GetSupportedCultureOrDefault(settingsManager.Settings.Language);
RemoveSymlinksOnShutdown = settingsManager.Settings.RemoveFolderLinksOnShutdown;
SelectedAnimationScale = settingsManager.Settings.AnimationScale;
settingsManager.RelayPropertyFor(this, vm => vm.SelectedTheme, settings => settings.Theme);
settingsManager.RelayPropertyFor(
this,
vm => vm.IsDiscordRichPresenceEnabled,
settings => settings.IsDiscordRichPresenceEnabled,
true
);
settingsManager.RelayPropertyFor(
this,
vm => vm.SelectedAnimationScale,
settings => settings.AnimationScale
);
settingsManager.RelayPropertyFor(
this,
vm => vm.SelectedTagCompletionCsv,
settings => settings.TagCompletionCsv
);
settingsManager.RelayPropertyFor(
this,
vm => vm.IsPromptCompletionEnabled,
settings => settings.IsPromptCompletionEnabled,
true
);
settingsManager.RelayPropertyFor(
this,
vm => vm.IsCompletionRemoveUnderscoresEnabled,
settings => settings.IsCompletionRemoveUnderscoresEnabled,
true
);
this.WhenPropertyChanged(vm => vm.OutputImageFileNameFormat)
.Throttle(TimeSpan.FromMilliseconds(50))
.Subscribe(formatProperty =>
{
var provider = FileNameFormatProvider.GetSample();
var template = formatProperty.Value ?? string.Empty;
if (
!string.IsNullOrEmpty(template)
&& provider.Validate(template) == ValidationResult.Success
)
{
var format = FileNameFormat.Parse(template, provider);
OutputImageFileNameFormatSample = format.GetFileName() + ".png";
}
else
{
// Use default format if empty
var defaultFormat = FileNameFormat.Parse(
FileNameFormat.DefaultTemplate,
provider
);
OutputImageFileNameFormatSample = defaultFormat.GetFileName() + ".png";
}
});
settingsManager.RelayPropertyFor(
this,
vm => vm.OutputImageFileNameFormat,
settings => settings.InferenceOutputImageFileNameFormat,
true
);
settingsManager.RelayPropertyFor(
this,
vm => vm.IsImageViewerPixelGridEnabled,
settings => settings.IsImageViewerPixelGridEnabled,
true
);
DebugThrowAsyncExceptionCommand.WithNotificationErrorHandler(
notificationService,
LogLevel.Warn
);
ImportTagCsvCommand.WithNotificationErrorHandler(notificationService, LogLevel.Warn);
}
/// <inheritdoc />
public override async Task OnLoadedAsync()
{
await base.OnLoadedAsync();
await notificationService.TryAsync(completionProvider.Setup());
UpdateAvailableTagCompletionCsvs();
// Start accounts update
accountsService.RefreshAsync().SafeFireAndForget();
}
public static ValidationResult ValidateOutputImageFileNameFormat(
string? format,
ValidationContext context
)
{
return FileNameFormatProvider.GetSample().Validate(format ?? string.Empty);
}
partial void OnSelectedThemeChanged(string? value)
{
// In case design / tests
if (Application.Current is null)
return;
// Change theme
Application.Current.RequestedThemeVariant = value switch
{
"Dark" => ThemeVariant.Dark,
"Light" => ThemeVariant.Light,
_ => ThemeVariant.Default
};
}
partial void OnSelectedLanguageChanged(CultureInfo? oldValue, CultureInfo newValue)
{
if (oldValue is null || newValue.Name == Cultures.Current?.Name)
return;
// Set locale
if (AvailableLanguages.Contains(newValue))
{
Logger.Info("Changing language from {Old} to {New}", oldValue, newValue);
Cultures.TrySetSupportedCulture(newValue);
settingsManager.Transaction(s => s.Language = newValue.Name);
var dialog = new BetterContentDialog
{
Title = Resources.Label_RelaunchRequired,
Content = Resources.Text_RelaunchRequiredToApplyLanguage,
DefaultButton = ContentDialogButton.Primary,
PrimaryButtonText = Resources.Action_Relaunch,
CloseButtonText = Resources.Action_RelaunchLater
};
Dispatcher.UIThread.InvokeAsync(async () =>
{
if (await dialog.ShowAsync() == ContentDialogResult.Primary)
{
Process.Start(Compat.AppCurrentPath);
App.Shutdown();
}
});
}
else
{
Logger.Info(
"Requested invalid language change from {Old} to {New}",
oldValue,
newValue
);
}
}
partial void OnRemoveSymlinksOnShutdownChanged(bool value)
{
settingsManager.Transaction(s => s.RemoveFolderLinksOnShutdown = value);
}
public async Task ResetCheckpointCache()
{
settingsManager.Transaction(s => s.InstalledModelHashes = new HashSet<string>());
await Task.Run(() => settingsManager.IndexCheckpoints());
notificationService.Show(
"Checkpoint cache reset",
"The checkpoint cache has been reset.",
NotificationType.Success
);
}
[RelayCommand]
private void NavigateToSubPage(Type viewModelType)
{
Dispatcher.UIThread.Post(
() =>
settingsNavigationService.NavigateTo(
viewModelType,
BetterSlideNavigationTransition.PageSlideFromRight
),
DispatcherPriority.Send
);
}
#region Package Environment
[RelayCommand]
private async Task OpenEnvVarsDialog()
{
var viewModel = dialogFactory.Get<EnvVarsViewModel>();
// Load current settings
var current =
settingsManager.Settings.EnvironmentVariables ?? new Dictionary<string, string>();
viewModel.EnvVars = new ObservableCollection<EnvVarKeyPair>(
current.Select(kvp => new EnvVarKeyPair(kvp.Key, kvp.Value))
);
var dialog = new BetterContentDialog
{
Content = new EnvVarsDialog { DataContext = viewModel },
PrimaryButtonText = Resources.Action_Save,
IsPrimaryButtonEnabled = true,
CloseButtonText = Resources.Action_Cancel,
};
if (await dialog.ShowAsync() == ContentDialogResult.Primary)
{
// Save settings
var newEnvVars = viewModel.EnvVars
.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key))
.GroupBy(kvp => kvp.Key, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().Value, StringComparer.Ordinal);
settingsManager.Transaction(s => s.EnvironmentVariables = newEnvVars);
}
}
[RelayCommand]
private async Task CheckPythonVersion()
{
var isInstalled = prerequisiteHelper.IsPythonInstalled;
Logger.Debug($"Check python installed: {isInstalled}");
// Ensure python installed
if (!prerequisiteHelper.IsPythonInstalled)
{
// Need 7z as well for site packages repack
Logger.Debug("Python not installed, unpacking resources...");
await prerequisiteHelper.UnpackResourcesIfNecessary();
Logger.Debug("Unpacked resources, installing python...");
await prerequisiteHelper.InstallPythonIfNecessary();
}
// Get python version
await pyRunner.Initialize();
var result = await pyRunner.GetVersionInfo();
// Show dialog box
var dialog = new ContentDialog
{
Title = Resources.Label_PythonVersionInfo,
Content = result,
PrimaryButtonText = Resources.Action_OK,
IsPrimaryButtonEnabled = true
};
await dialog.ShowAsync();
}
#endregion
#region Inference UI
private void UpdateAvailableTagCompletionCsvs()
{
if (!settingsManager.IsLibraryDirSet)
return;
var tagsDir = settingsManager.TagsDirectory;
if (!tagsDir.Exists)
return;
var csvFiles = tagsDir.Info.EnumerateFiles("*.csv");
AvailableTagCompletionCsvs = csvFiles.Select(f => f.Name).ToImmutableArray();
// Set selected to current if exists
var settingsCsv = settingsManager.Settings.TagCompletionCsv;
if (settingsCsv is not null && AvailableTagCompletionCsvs.Contains(settingsCsv))
{
SelectedTagCompletionCsv = settingsCsv;
}
}
[RelayCommand(FlowExceptionsToTaskScheduler = true)]
private async Task ImportTagCsv()
{
var storage = App.StorageProvider;
var files = await storage.OpenFilePickerAsync(
new FilePickerOpenOptions
{
FileTypeFilter = new List<FilePickerFileType>
{
new("CSV") { Patterns = new[] { "*.csv" }, }
}
}
);
if (files.Count == 0)
return;
var sourceFile = new FilePath(files[0].TryGetLocalPath()!);
var tagsDir = settingsManager.TagsDirectory;
tagsDir.Create();
// Copy to tags directory
var targetFile = tagsDir.JoinFile(sourceFile.Name);
await sourceFile.CopyToAsync(targetFile);
// Update index
UpdateAvailableTagCompletionCsvs();
// Trigger load
completionProvider.BackgroundLoadFromFile(targetFile, true);
notificationService.Show(
$"Imported {sourceFile.Name}",
$"The {sourceFile.Name} file has been imported.",
NotificationType.Success
);
}
#endregion
#region System
/// <summary>
/// Adds Stability Matrix to Start Menu for the current user.
/// </summary>
[RelayCommand]
private async Task AddToStartMenu()
{
if (!Compat.IsWindows)
{
notificationService.Show("Not supported", "This feature is only supported on Windows.");
return;
}
await using var _ = new MinimumDelay(200, 300);
var shortcutDir = new DirectoryPath(
Environment.GetFolderPath(Environment.SpecialFolder.StartMenu),
"Programs"
);
var shortcutLink = shortcutDir.JoinFile("Stability Matrix.lnk");
var appPath = Compat.AppCurrentPath;
var iconPath = shortcutDir.JoinFile("Stability Matrix.ico");
await Assets.AppIcon.ExtractTo(iconPath);
WindowsShortcuts.CreateShortcut(shortcutLink, appPath, iconPath, "Stability Matrix");
notificationService.Show(
"Added to Start Menu",
"Stability Matrix has been added to the Start Menu.",
NotificationType.Success
);
}
/// <summary>
/// Add Stability Matrix to Start Menu for all users.
/// <remarks>Requires Admin elevation.</remarks>
/// </summary>
[RelayCommand]
private async Task AddToGlobalStartMenu()
{
if (!Compat.IsWindows)
{
notificationService.Show("Not supported", "This feature is only supported on Windows.");
return;
}
// Confirmation dialog
var dialog = new BetterContentDialog
{
Title =
"This will create a shortcut for Stability Matrix in the Start Menu for all users",
Content = "You will be prompted for administrator privileges. Continue?",
PrimaryButtonText = Resources.Action_Yes,
CloseButtonText = Resources.Action_Cancel,
DefaultButton = ContentDialogButton.Primary
};
if (await dialog.ShowAsync() != ContentDialogResult.Primary)
{
return;
}
await using var _ = new MinimumDelay(200, 300);
var shortcutDir = new DirectoryPath(
Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu),
"Programs"
);
var shortcutLink = shortcutDir.JoinFile("Stability Matrix.lnk");
var appPath = Compat.AppCurrentPath;
var iconPath = shortcutDir.JoinFile("Stability Matrix.ico");
// We can't directly write to the targets, so extract to temporary directory first
using var tempDir = new TempDirectoryPath();
await Assets.AppIcon.ExtractTo(tempDir.JoinFile("Stability Matrix.ico"));
WindowsShortcuts.CreateShortcut(
tempDir.JoinFile("Stability Matrix.lnk"),
appPath,
iconPath,
"Stability Matrix"
);
// Move to target
try
{
var moveLinkResult = await WindowsElevated.MoveFiles(
(tempDir.JoinFile("Stability Matrix.lnk"), shortcutLink),
(tempDir.JoinFile("Stability Matrix.ico"), iconPath)
);
if (moveLinkResult != 0)
{
notificationService.ShowPersistent(
"Failed to create shortcut",
$"Could not copy shortcut",
NotificationType.Error
);
}
}
catch (Win32Exception e)
{
// We'll get this exception if user cancels UAC
Logger.Warn(e, "Could not create shortcut");
notificationService.Show("Could not create shortcut", "", NotificationType.Warning);
return;
}
notificationService.Show(
"Added to Start Menu",
"Stability Matrix has been added to the Start Menu for all users.",
NotificationType.Success
);
}
public async Task PickNewDataDirectory()
{
var viewModel = dialogFactory.Get<SelectDataDirectoryViewModel>();
var dialog = new BetterContentDialog
{
IsPrimaryButtonEnabled = false,
IsSecondaryButtonEnabled = false,
IsFooterVisible = false,
Content = new SelectDataDirectoryDialog { DataContext = viewModel }
};
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
// 1. For portable mode, call settings.SetPortableMode()
if (viewModel.IsPortableMode)
{
settingsManager.SetPortableMode();
}
// 2. For custom path, call settings.SetLibraryPath(path)
else
{
settingsManager.SetLibraryPath(viewModel.DataDirectory);
}
// Restart
var restartDialog = new BetterContentDialog
{
Title = "Restart required",
Content = "Stability Matrix must be restarted for the changes to take effect.",
PrimaryButtonText = Resources.Action_Restart,
DefaultButton = ContentDialogButton.Primary,
IsSecondaryButtonEnabled = false,
};
await restartDialog.ShowAsync();
Process.Start(Compat.AppCurrentPath);
App.Shutdown();
}
}
#endregion
#region Debug Section
public void LoadDebugInfo()
{
var assembly = Assembly.GetExecutingAssembly();
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
DebugPaths = $"""
Current Working Directory [Environment.CurrentDirectory]
"{Environment.CurrentDirectory}"
App Directory [Assembly.GetExecutingAssembly().Location]
"{assembly.Location}"
App Directory [AppContext.BaseDirectory]
"{AppContext.BaseDirectory}"
AppData Directory [SpecialFolder.ApplicationData]
"{appData}"
""";
// 1. Check portable mode
var appDir = Compat.AppCurrentDir;
var expectedPortableFile = Path.Combine(appDir, "Data", ".sm-portable");
var isPortableMode = File.Exists(expectedPortableFile);
DebugCompatInfo = $"""
Platform: {Compat.Platform}
AppData: {Compat.AppData}
AppDataHome: {Compat.AppDataHome}
AppCurrentDir: {Compat.AppCurrentDir}
ExecutableName: {Compat.GetExecutableName()}
-- Settings --
Expected Portable Marker file: {expectedPortableFile}
Portable Marker file exists: {isPortableMode}
IsLibraryDirSet = {settingsManager.IsLibraryDirSet}
IsPortableMode = {settingsManager.IsPortableMode}
""";
// Get Gpu info
var gpuInfo = "";
foreach (var (i, gpu) in HardwareHelper.IterGpuInfo().Enumerate())
{
gpuInfo += $"[{i + 1}] {gpu}\n";
}
DebugGpuInfo = gpuInfo;
}
// Debug buttons
[RelayCommand]
private void DebugNotification()
{
notificationService.Show(
new Notification(
title: "Test Notification",
message: "Here is some message",
type: NotificationType.Information
)
);
}
[RelayCommand]
private async Task DebugContentDialog()
{
var dialog = new ContentDialog
{
DefaultButton = ContentDialogButton.Primary,
Title = "Test title",
PrimaryButtonText = Resources.Action_OK,
CloseButtonText = Resources.Action_Close
};
var result = await dialog.ShowAsync();
notificationService.Show(new Notification("Content dialog closed", $"Result: {result}"));
}
[RelayCommand]
private void DebugThrowException()
{
throw new OperationCanceledException("Example Message");
}
[RelayCommand(FlowExceptionsToTaskScheduler = true)]
private async Task DebugThrowAsyncException()
{
await Task.Yield();
throw new ApplicationException("Example Message");
}
[RelayCommand]
private async Task DebugMakeImageGrid()
{
var provider = App.StorageProvider;
var files = await provider.OpenFilePickerAsync(
new FilePickerOpenOptions() { AllowMultiple = true }
);
if (files.Count == 0)
return;
var images = await files.SelectAsync(
async f => SKImage.FromEncodedData(await f.OpenReadAsync())
);
var grid = ImageProcessor.CreateImageGrid(images.ToImmutableArray());
// Show preview
using var peekPixels = grid.PeekPixels();
using var data = peekPixels.Encode(SKEncodedImageFormat.Jpeg, 100);
await using var stream = data.AsStream();
var bitmap = WriteableBitmap.Decode(stream);
var galleryImages = new List<ImageSource> { new(bitmap), };
galleryImages.AddRange(files.Select(f => new ImageSource(f.Path.ToString())));
var imageBox = new ImageGalleryCard
{
Width = 1000,
Height = 900,
DataContext = dialogFactory.Get<ImageGalleryCardViewModel>(vm =>
{
vm.ImageSources.AddRange(galleryImages);
})
};
var dialog = new BetterContentDialog
{
MaxDialogWidth = 1000,
MaxDialogHeight = 1000,
FullSizeDesired = true,
Content = imageBox,
CloseButtonText = "Close",
ContentVerticalScrollBarVisibility = ScrollBarVisibility.Disabled,
};
await dialog.ShowAsync();
}
[RelayCommand]
private async Task DebugLoadCompletionCsv()
{
var provider = App.StorageProvider;
var files = await provider.OpenFilePickerAsync(new FilePickerOpenOptions());
if (files.Count == 0)
return;
await completionProvider.LoadFromFile(files[0].TryGetLocalPath()!, true);
notificationService.Show("Loaded completion file", "");
}
[RelayCommand]
private async Task DebugImageMetadata()
{
var provider = App.StorageProvider;
var files = await provider.OpenFilePickerAsync(new FilePickerOpenOptions());
if (files.Count == 0)
return;
var metadata = ImageMetadata.ParseFile(files[0].TryGetLocalPath()!);
var textualTags = metadata.GetTextualData()?.ToArray();
if (textualTags is null)
{
notificationService.Show("No textual data found", "");
return;
}
if (metadata.GetGenerationParameters() is { } parameters)
{
var parametersJson = JsonSerializer.Serialize(parameters);
var dialog = DialogHelper.CreateJsonDialog(parametersJson, "Generation Parameters");
await dialog.ShowAsync();
}
}
[RelayCommand]
private async Task DebugRefreshModelsIndex()
{
await modelIndexService.RefreshIndex();
}
[RelayCommand]
private async Task DebugTrackedDownload()
{
var textFields = new TextBoxField[]
{
new() { Label = "Url", },
new() { Label = "File path" }
};
var dialog = DialogHelper.CreateTextEntryDialog("Add download", "", textFields);
if (await dialog.ShowAsync() == ContentDialogResult.Primary)
{
var url = textFields[0].Text;
var filePath = textFields[1].Text;
var download = trackedDownloadService.NewDownload(new Uri(url), new FilePath(filePath));
download.Start();
}
}
#endregion
#region Info Section
public void OnVersionClick()
{
// Ignore if already enabled
if (SharedState.IsDebugMode)
return;
VersionTapCount++;
switch (VersionTapCount)
{
// Reached required threshold
case >= VersionTapCountThreshold:
{
IsVersionTapTeachingTipOpen = false;
// Enable debug options
SharedState.IsDebugMode = true;
notificationService.Show(
"Debug options enabled",
"Warning: Improper use may corrupt application state or cause loss of data."
);
VersionTapCount = 0;
break;
}
// Open teaching tip above 3rd click
case >= 3:
IsVersionTapTeachingTipOpen = true;
break;
}
}
[RelayCommand]
private async Task ShowLicensesDialog()
{
try
{
var markdown = GetLicensesMarkdown();
var dialog = DialogHelper.CreateMarkdownDialog(markdown, "Licenses");
dialog.MaxDialogHeight = 600;
await dialog.ShowAsync();
}
catch (Exception e)
{
notificationService.Show(
"Failed to read licenses information",
$"{e}",
NotificationType.Error
);
}
}
private static string GetLicensesMarkdown()
{
// Read licenses.json
using var reader = new StreamReader(Assets.LicensesJson.Open());
var licenses =
JsonSerializer.Deserialize<IReadOnlyList<LicenseInfo>>(reader.ReadToEnd())
?? throw new InvalidOperationException("Failed to read licenses.json");
// Generate markdown
var builder = new StringBuilder();
foreach (var license in licenses)
{
builder.AppendLine(
$"## [{license.PackageName}]({license.PackageUrl}) by {string.Join(", ", license.Authors)}"
);
builder.AppendLine();
builder.AppendLine(license.Description);
builder.AppendLine();
builder.AppendLine($"[{license.LicenseUrl}]({license.LicenseUrl})");
builder.AppendLine();
}
return builder.ToString();
}
#endregion
}

907
StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs

@ -1,51 +1,13 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reactive.Linq;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls.Notifications;
using Avalonia.Controls.Primitives;
using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage;
using Avalonia.Styling;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DynamicData.Binding;
using DynamicData;
using FluentAvalonia.UI.Controls;
using NLog;
using SkiaSharp;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Extensions;
using StabilityMatrix.Avalonia.Helpers;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Models.Inference;
using StabilityMatrix.Avalonia.Models.TagCompletion;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Avalonia.ViewModels.Inference;
using StabilityMatrix.Avalonia.ViewModels.Settings;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Python;
using StabilityMatrix.Core.Services;
using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
@ -55,877 +17,48 @@ namespace StabilityMatrix.Avalonia.ViewModels;
[Singleton]
public partial class SettingsViewModel : PageViewModelBase
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private readonly INotificationService notificationService;
private readonly ISettingsManager settingsManager;
private readonly IPrerequisiteHelper prerequisiteHelper;
private readonly IPyRunner pyRunner;
private readonly ServiceManager<ViewModelBase> dialogFactory;
private readonly ICompletionProvider completionProvider;
private readonly ITrackedDownloadService trackedDownloadService;
private readonly IModelIndexService modelIndexService;
public SharedState SharedState { get; }
public override string Title => "Settings";
public override IconSource IconSource =>
new SymbolIconSource { Symbol = Symbol.Settings, IsFilled = true };
// ReSharper disable once MemberCanBeMadeStatic.Global
public string AppVersion =>
$"Version {Compat.AppVersion}" + (Program.IsDebugBuild ? " (Debug)" : "");
// Theme section
[ObservableProperty]
private string? selectedTheme;
public IReadOnlyList<string> AvailableThemes { get; } = new[] { "Light", "Dark", "System", };
[ObservableProperty]
private CultureInfo selectedLanguage;
// ReSharper disable once MemberCanBeMadeStatic.Global
public IReadOnlyList<CultureInfo> AvailableLanguages => Cultures.SupportedCultures;
public IReadOnlyList<float> AnimationScaleOptions { get; } =
new[] { 0f, 0.25f, 0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f, };
[ObservableProperty]
private float selectedAnimationScale;
// Shared folder options
[ObservableProperty]
private bool removeSymlinksOnShutdown;
// Inference UI section
[ObservableProperty]
private bool isPromptCompletionEnabled = true;
[ObservableProperty]
private IReadOnlyList<string> availableTagCompletionCsvs = Array.Empty<string>();
[ObservableProperty]
private string? selectedTagCompletionCsv;
[ObservableProperty]
private bool isCompletionRemoveUnderscoresEnabled = true;
[ObservableProperty]
[CustomValidation(typeof(SettingsViewModel), nameof(ValidateOutputImageFileNameFormat))]
private string? outputImageFileNameFormat;
[ObservableProperty]
private string? outputImageFileNameFormatSample;
public IEnumerable<FileNameFormatVar> OutputImageFileNameFormatVars =>
FileNameFormatProvider
.GetSample()
.Substitutions.Select(
kv =>
new FileNameFormatVar
{
Variable = $"{{{kv.Key}}}",
Example = kv.Value.Invoke()
}
);
[ObservableProperty]
private bool isImageViewerPixelGridEnabled = true;
// Integrations section
[ObservableProperty]
private bool isDiscordRichPresenceEnabled;
// Debug section
[ObservableProperty]
private string? debugPaths;
[ObservableProperty]
private string? debugCompatInfo;
public IReadOnlyList<PageViewModelBase> SubPages { get; }
[ObservableProperty]
private string? debugGpuInfo;
// Info section
private const int VersionTapCountThreshold = 7;
[ObservableProperty, NotifyPropertyChangedFor(nameof(VersionFlyoutText))]
private int versionTapCount;
private ObservableCollection<PageViewModelBase> currentPagePath = new();
[ObservableProperty]
private bool isVersionTapTeachingTipOpen;
public string VersionFlyoutText =>
$"You are {VersionTapCountThreshold - VersionTapCount} clicks away from enabling Debug options.";
public string DataDirectory =>
settingsManager.IsLibraryDirSet ? settingsManager.LibraryDir : "Not set";
public SettingsViewModel(
INotificationService notificationService,
ISettingsManager settingsManager,
IPrerequisiteHelper prerequisiteHelper,
IPyRunner pyRunner,
ServiceManager<ViewModelBase> dialogFactory,
ITrackedDownloadService trackedDownloadService,
SharedState sharedState,
ICompletionProvider completionProvider,
IModelIndexService modelIndexService
)
{
this.notificationService = notificationService;
this.settingsManager = settingsManager;
this.prerequisiteHelper = prerequisiteHelper;
this.pyRunner = pyRunner;
this.dialogFactory = dialogFactory;
this.trackedDownloadService = trackedDownloadService;
this.completionProvider = completionProvider;
this.modelIndexService = modelIndexService;
SharedState = sharedState;
SelectedTheme = settingsManager.Settings.Theme ?? AvailableThemes[1];
SelectedLanguage = Cultures.GetSupportedCultureOrDefault(settingsManager.Settings.Language);
RemoveSymlinksOnShutdown = settingsManager.Settings.RemoveFolderLinksOnShutdown;
SelectedAnimationScale = settingsManager.Settings.AnimationScale;
settingsManager.RelayPropertyFor(this, vm => vm.SelectedTheme, settings => settings.Theme);
settingsManager.RelayPropertyFor(
this,
vm => vm.IsDiscordRichPresenceEnabled,
settings => settings.IsDiscordRichPresenceEnabled,
true
);
settingsManager.RelayPropertyFor(
this,
vm => vm.SelectedAnimationScale,
settings => settings.AnimationScale
);
settingsManager.RelayPropertyFor(
this,
vm => vm.SelectedTagCompletionCsv,
settings => settings.TagCompletionCsv
);
settingsManager.RelayPropertyFor(
this,
vm => vm.IsPromptCompletionEnabled,
settings => settings.IsPromptCompletionEnabled,
true
);
settingsManager.RelayPropertyFor(
this,
vm => vm.IsCompletionRemoveUnderscoresEnabled,
settings => settings.IsCompletionRemoveUnderscoresEnabled,
true
);
this.WhenPropertyChanged(vm => vm.OutputImageFileNameFormat)
.Throttle(TimeSpan.FromMilliseconds(50))
.Subscribe(formatProperty =>
{
var provider = FileNameFormatProvider.GetSample();
var template = formatProperty.Value ?? string.Empty;
if (
!string.IsNullOrEmpty(template)
&& provider.Validate(template) == ValidationResult.Success
)
{
var format = FileNameFormat.Parse(template, provider);
OutputImageFileNameFormatSample = format.GetFileName() + ".png";
}
else
{
// Use default format if empty
var defaultFormat = FileNameFormat.Parse(
FileNameFormat.DefaultTemplate,
provider
);
OutputImageFileNameFormatSample = defaultFormat.GetFileName() + ".png";
}
});
settingsManager.RelayPropertyFor(
this,
vm => vm.OutputImageFileNameFormat,
settings => settings.InferenceOutputImageFileNameFormat,
true
);
settingsManager.RelayPropertyFor(
this,
vm => vm.IsImageViewerPixelGridEnabled,
settings => settings.IsImageViewerPixelGridEnabled,
true
);
DebugThrowAsyncExceptionCommand.WithNotificationErrorHandler(
notificationService,
LogLevel.Warn
);
ImportTagCsvCommand.WithNotificationErrorHandler(notificationService, LogLevel.Warn);
}
/// <inheritdoc />
public override async Task OnLoadedAsync()
{
await base.OnLoadedAsync();
await notificationService.TryAsync(completionProvider.Setup());
private PageViewModelBase? currentPage;
UpdateAvailableTagCompletionCsvs();
}
public static ValidationResult ValidateOutputImageFileNameFormat(
string? format,
ValidationContext context
)
public SettingsViewModel(ServiceManager<ViewModelBase> vmFactory)
{
return FileNameFormatProvider.GetSample().Validate(format ?? string.Empty);
}
partial void OnSelectedThemeChanged(string? value)
{
// In case design / tests
if (Application.Current is null)
return;
// Change theme
Application.Current.RequestedThemeVariant = value switch
SubPages = new PageViewModelBase[]
{
"Dark" => ThemeVariant.Dark,
"Light" => ThemeVariant.Light,
_ => ThemeVariant.Default
vmFactory.Get<MainSettingsViewModel>(),
vmFactory.Get<InferenceSettingsViewModel>(),
vmFactory.Get<AccountSettingsViewModel>()
};
}
partial void OnSelectedLanguageChanged(CultureInfo? oldValue, CultureInfo newValue)
{
if (oldValue is null || newValue.Name == Cultures.Current?.Name)
return;
// Set locale
if (AvailableLanguages.Contains(newValue))
{
Logger.Info("Changing language from {Old} to {New}", oldValue, newValue);
Cultures.TrySetSupportedCulture(newValue);
settingsManager.Transaction(s => s.Language = newValue.Name);
var dialog = new BetterContentDialog
{
Title = Resources.Label_RelaunchRequired,
Content = Resources.Text_RelaunchRequiredToApplyLanguage,
DefaultButton = ContentDialogButton.Primary,
PrimaryButtonText = Resources.Action_Relaunch,
CloseButtonText = Resources.Action_RelaunchLater
};
Dispatcher.UIThread.InvokeAsync(async () =>
{
if (await dialog.ShowAsync() == ContentDialogResult.Primary)
{
Process.Start(Compat.AppCurrentPath);
App.Shutdown();
}
});
}
else
{
Logger.Info(
"Requested invalid language change from {Old} to {New}",
oldValue,
newValue
);
}
}
partial void OnRemoveSymlinksOnShutdownChanged(bool value)
{
settingsManager.Transaction(s => s.RemoveFolderLinksOnShutdown = value);
}
public async Task ResetCheckpointCache()
{
settingsManager.Transaction(s => s.InstalledModelHashes = new HashSet<string>());
await Task.Run(() => settingsManager.IndexCheckpoints());
notificationService.Show(
"Checkpoint cache reset",
"The checkpoint cache has been reset.",
NotificationType.Success
);
}
#region Package Environment
[RelayCommand]
private async Task OpenEnvVarsDialog()
{
var viewModel = dialogFactory.Get<EnvVarsViewModel>();
// Load current settings
var current =
settingsManager.Settings.EnvironmentVariables ?? new Dictionary<string, string>();
viewModel.EnvVars = new ObservableCollection<EnvVarKeyPair>(
current.Select(kvp => new EnvVarKeyPair(kvp.Key, kvp.Value))
);
var dialog = new BetterContentDialog
{
Content = new EnvVarsDialog { DataContext = viewModel },
PrimaryButtonText = Resources.Action_Save,
IsPrimaryButtonEnabled = true,
CloseButtonText = Resources.Action_Cancel,
};
if (await dialog.ShowAsync() == ContentDialogResult.Primary)
{
// Save settings
var newEnvVars = viewModel.EnvVars
.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key))
.GroupBy(kvp => kvp.Key, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().Value, StringComparer.Ordinal);
settingsManager.Transaction(s => s.EnvironmentVariables = newEnvVars);
}
}
[RelayCommand]
private async Task CheckPythonVersion()
{
var isInstalled = prerequisiteHelper.IsPythonInstalled;
Logger.Debug($"Check python installed: {isInstalled}");
// Ensure python installed
if (!prerequisiteHelper.IsPythonInstalled)
{
// Need 7z as well for site packages repack
Logger.Debug("Python not installed, unpacking resources...");
await prerequisiteHelper.UnpackResourcesIfNecessary();
Logger.Debug("Unpacked resources, installing python...");
await prerequisiteHelper.InstallPythonIfNecessary();
}
// Get python version
await pyRunner.Initialize();
var result = await pyRunner.GetVersionInfo();
// Show dialog box
var dialog = new ContentDialog
{
Title = Resources.Label_PythonVersionInfo,
Content = result,
PrimaryButtonText = Resources.Action_OK,
IsPrimaryButtonEnabled = true
};
await dialog.ShowAsync();
}
#endregion
#region Inference UI
private void UpdateAvailableTagCompletionCsvs()
{
if (!settingsManager.IsLibraryDirSet)
return;
var tagsDir = settingsManager.TagsDirectory;
if (!tagsDir.Exists)
return;
var csvFiles = tagsDir.Info.EnumerateFiles("*.csv");
AvailableTagCompletionCsvs = csvFiles.Select(f => f.Name).ToImmutableArray();
// Set selected to current if exists
var settingsCsv = settingsManager.Settings.TagCompletionCsv;
if (settingsCsv is not null && AvailableTagCompletionCsvs.Contains(settingsCsv))
{
SelectedTagCompletionCsv = settingsCsv;
}
}
[RelayCommand(FlowExceptionsToTaskScheduler = true)]
private async Task ImportTagCsv()
{
var storage = App.StorageProvider;
var files = await storage.OpenFilePickerAsync(
new FilePickerOpenOptions
{
FileTypeFilter = new List<FilePickerFileType>
{
new("CSV") { Patterns = new[] { "*.csv" }, }
}
}
);
if (files.Count == 0)
return;
var sourceFile = new FilePath(files[0].TryGetLocalPath()!);
var tagsDir = settingsManager.TagsDirectory;
tagsDir.Create();
// Copy to tags directory
var targetFile = tagsDir.JoinFile(sourceFile.Name);
await sourceFile.CopyToAsync(targetFile);
// Update index
UpdateAvailableTagCompletionCsvs();
// Trigger load
completionProvider.BackgroundLoadFromFile(targetFile, true);
notificationService.Show(
$"Imported {sourceFile.Name}",
$"The {sourceFile.Name} file has been imported.",
NotificationType.Success
);
}
#endregion
#region System
/// <summary>
/// Adds Stability Matrix to Start Menu for the current user.
/// </summary>
[RelayCommand]
private async Task AddToStartMenu()
{
if (!Compat.IsWindows)
{
notificationService.Show("Not supported", "This feature is only supported on Windows.");
return;
}
await using var _ = new MinimumDelay(200, 300);
var shortcutDir = new DirectoryPath(
Environment.GetFolderPath(Environment.SpecialFolder.StartMenu),
"Programs"
);
var shortcutLink = shortcutDir.JoinFile("Stability Matrix.lnk");
var appPath = Compat.AppCurrentPath;
var iconPath = shortcutDir.JoinFile("Stability Matrix.ico");
await Assets.AppIcon.ExtractTo(iconPath);
WindowsShortcuts.CreateShortcut(shortcutLink, appPath, iconPath, "Stability Matrix");
notificationService.Show(
"Added to Start Menu",
"Stability Matrix has been added to the Start Menu.",
NotificationType.Success
);
}
/// <summary>
/// Add Stability Matrix to Start Menu for all users.
/// <remarks>Requires Admin elevation.</remarks>
/// </summary>
[RelayCommand]
private async Task AddToGlobalStartMenu()
{
if (!Compat.IsWindows)
{
notificationService.Show("Not supported", "This feature is only supported on Windows.");
return;
}
// Confirmation dialog
var dialog = new BetterContentDialog
{
Title =
"This will create a shortcut for Stability Matrix in the Start Menu for all users",
Content = "You will be prompted for administrator privileges. Continue?",
PrimaryButtonText = Resources.Action_Yes,
CloseButtonText = Resources.Action_Cancel,
DefaultButton = ContentDialogButton.Primary
};
CurrentPagePath.AddRange(SubPages);
if (await dialog.ShowAsync() != ContentDialogResult.Primary)
{
return;
CurrentPage = SubPages[0];
}
await using var _ = new MinimumDelay(200, 300);
var shortcutDir = new DirectoryPath(
Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu),
"Programs"
);
var shortcutLink = shortcutDir.JoinFile("Stability Matrix.lnk");
var appPath = Compat.AppCurrentPath;
var iconPath = shortcutDir.JoinFile("Stability Matrix.ico");
// We can't directly write to the targets, so extract to temporary directory first
using var tempDir = new TempDirectoryPath();
await Assets.AppIcon.ExtractTo(tempDir.JoinFile("Stability Matrix.ico"));
WindowsShortcuts.CreateShortcut(
tempDir.JoinFile("Stability Matrix.lnk"),
appPath,
iconPath,
"Stability Matrix"
);
// Move to target
try
partial void OnCurrentPageChanged(PageViewModelBase? value)
{
var moveLinkResult = await WindowsElevated.MoveFiles(
(tempDir.JoinFile("Stability Matrix.lnk"), shortcutLink),
(tempDir.JoinFile("Stability Matrix.ico"), iconPath)
);
if (moveLinkResult != 0)
{
notificationService.ShowPersistent(
"Failed to create shortcut",
$"Could not copy shortcut",
NotificationType.Error
);
}
}
catch (Win32Exception e)
if (value is null)
{
// We'll get this exception if user cancels UAC
Logger.Warn(e, "Could not create shortcut");
notificationService.Show("Could not create shortcut", "", NotificationType.Warning);
return;
}
notificationService.Show(
"Added to Start Menu",
"Stability Matrix has been added to the Start Menu for all users.",
NotificationType.Success
);
}
public async Task PickNewDataDirectory()
if (value is MainSettingsViewModel)
{
var viewModel = dialogFactory.Get<SelectDataDirectoryViewModel>();
var dialog = new BetterContentDialog
{
IsPrimaryButtonEnabled = false,
IsSecondaryButtonEnabled = false,
IsFooterVisible = false,
Content = new SelectDataDirectoryDialog { DataContext = viewModel }
};
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
// 1. For portable mode, call settings.SetPortableMode()
if (viewModel.IsPortableMode)
{
settingsManager.SetPortableMode();
CurrentPagePath.Clear();
CurrentPagePath.Add(value);
}
// 2. For custom path, call settings.SetLibraryPath(path)
else
{
settingsManager.SetLibraryPath(viewModel.DataDirectory);
}
// Restart
var restartDialog = new BetterContentDialog
{
Title = "Restart required",
Content = "Stability Matrix must be restarted for the changes to take effect.",
PrimaryButtonText = Resources.Action_Restart,
DefaultButton = ContentDialogButton.Primary,
IsSecondaryButtonEnabled = false,
};
await restartDialog.ShowAsync();
Process.Start(Compat.AppCurrentPath);
App.Shutdown();
}
}
#endregion
#region Debug Section
public void LoadDebugInfo()
{
var assembly = Assembly.GetExecutingAssembly();
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
DebugPaths = $"""
Current Working Directory [Environment.CurrentDirectory]
"{Environment.CurrentDirectory}"
App Directory [Assembly.GetExecutingAssembly().Location]
"{assembly.Location}"
App Directory [AppContext.BaseDirectory]
"{AppContext.BaseDirectory}"
AppData Directory [SpecialFolder.ApplicationData]
"{appData}"
""";
// 1. Check portable mode
var appDir = Compat.AppCurrentDir;
var expectedPortableFile = Path.Combine(appDir, "Data", ".sm-portable");
var isPortableMode = File.Exists(expectedPortableFile);
DebugCompatInfo = $"""
Platform: {Compat.Platform}
AppData: {Compat.AppData}
AppDataHome: {Compat.AppDataHome}
AppCurrentDir: {Compat.AppCurrentDir}
ExecutableName: {Compat.GetExecutableName()}
-- Settings --
Expected Portable Marker file: {expectedPortableFile}
Portable Marker file exists: {isPortableMode}
IsLibraryDirSet = {settingsManager.IsLibraryDirSet}
IsPortableMode = {settingsManager.IsPortableMode}
""";
// Get Gpu info
var gpuInfo = "";
foreach (var (i, gpu) in HardwareHelper.IterGpuInfo().Enumerate())
{
gpuInfo += $"[{i + 1}] {gpu}\n";
}
DebugGpuInfo = gpuInfo;
}
// Debug buttons
[RelayCommand]
private void DebugNotification()
{
notificationService.Show(
new Notification(
title: "Test Notification",
message: "Here is some message",
type: NotificationType.Information
)
);
}
[RelayCommand]
private async Task DebugContentDialog()
{
var dialog = new ContentDialog
{
DefaultButton = ContentDialogButton.Primary,
Title = "Test title",
PrimaryButtonText = Resources.Action_OK,
CloseButtonText = Resources.Action_Close
};
var result = await dialog.ShowAsync();
notificationService.Show(new Notification("Content dialog closed", $"Result: {result}"));
CurrentPagePath.Clear();
CurrentPagePath.AddRange(new[] { SubPages[0], value });
}
[RelayCommand]
private void DebugThrowException()
{
throw new OperationCanceledException("Example Message");
}
[RelayCommand(FlowExceptionsToTaskScheduler = true)]
private async Task DebugThrowAsyncException()
{
await Task.Yield();
throw new ApplicationException("Example Message");
}
[RelayCommand]
private async Task DebugMakeImageGrid()
{
var provider = App.StorageProvider;
var files = await provider.OpenFilePickerAsync(
new FilePickerOpenOptions() { AllowMultiple = true }
);
if (files.Count == 0)
return;
var images = await files.SelectAsync(
async f => SKImage.FromEncodedData(await f.OpenReadAsync())
);
var grid = ImageProcessor.CreateImageGrid(images.ToImmutableArray());
// Show preview
using var peekPixels = grid.PeekPixels();
using var data = peekPixels.Encode(SKEncodedImageFormat.Jpeg, 100);
await using var stream = data.AsStream();
var bitmap = WriteableBitmap.Decode(stream);
var galleryImages = new List<ImageSource> { new(bitmap), };
galleryImages.AddRange(files.Select(f => new ImageSource(f.Path.ToString())));
var imageBox = new ImageGalleryCard
{
Width = 1000,
Height = 900,
DataContext = dialogFactory.Get<ImageGalleryCardViewModel>(vm =>
{
vm.ImageSources.AddRange(galleryImages);
})
};
var dialog = new BetterContentDialog
{
MaxDialogWidth = 1000,
MaxDialogHeight = 1000,
FullSizeDesired = true,
Content = imageBox,
CloseButtonText = "Close",
ContentVerticalScrollBarVisibility = ScrollBarVisibility.Disabled,
};
await dialog.ShowAsync();
}
[RelayCommand]
private async Task DebugLoadCompletionCsv()
{
var provider = App.StorageProvider;
var files = await provider.OpenFilePickerAsync(new FilePickerOpenOptions());
if (files.Count == 0)
return;
await completionProvider.LoadFromFile(files[0].TryGetLocalPath()!, true);
notificationService.Show("Loaded completion file", "");
}
[RelayCommand]
private async Task DebugImageMetadata()
{
var provider = App.StorageProvider;
var files = await provider.OpenFilePickerAsync(new FilePickerOpenOptions());
if (files.Count == 0)
return;
var metadata = ImageMetadata.ParseFile(files[0].TryGetLocalPath()!);
var textualTags = metadata.GetTextualData()?.ToArray();
if (textualTags is null)
{
notificationService.Show("No textual data found", "");
return;
}
if (metadata.GetGenerationParameters() is { } parameters)
{
var parametersJson = JsonSerializer.Serialize(parameters);
var dialog = DialogHelper.CreateJsonDialog(parametersJson, "Generation Parameters");
await dialog.ShowAsync();
}
}
[RelayCommand]
private async Task DebugRefreshModelsIndex()
{
await modelIndexService.RefreshIndex();
}
[RelayCommand]
private async Task DebugTrackedDownload()
{
var textFields = new TextBoxField[]
{
new() { Label = "Url", },
new() { Label = "File path" }
};
var dialog = DialogHelper.CreateTextEntryDialog("Add download", "", textFields);
if (await dialog.ShowAsync() == ContentDialogResult.Primary)
{
var url = textFields[0].Text;
var filePath = textFields[1].Text;
var download = trackedDownloadService.NewDownload(new Uri(url), new FilePath(filePath));
download.Start();
}
}
#endregion
#region Info Section
public void OnVersionClick()
{
// Ignore if already enabled
if (SharedState.IsDebugMode)
return;
VersionTapCount++;
switch (VersionTapCount)
{
// Reached required threshold
case >= VersionTapCountThreshold:
{
IsVersionTapTeachingTipOpen = false;
// Enable debug options
SharedState.IsDebugMode = true;
notificationService.Show(
"Debug options enabled",
"Warning: Improper use may corrupt application state or cause loss of data."
);
VersionTapCount = 0;
break;
}
// Open teaching tip above 3rd click
case >= 3:
IsVersionTapTeachingTipOpen = true;
break;
}
}
[RelayCommand]
private async Task ShowLicensesDialog()
{
try
{
var markdown = GetLicensesMarkdown();
var dialog = DialogHelper.CreateMarkdownDialog(markdown, "Licenses");
dialog.MaxDialogHeight = 600;
await dialog.ShowAsync();
}
catch (Exception e)
{
notificationService.Show(
"Failed to read licenses information",
$"{e}",
NotificationType.Error
);
}
}
private static string GetLicensesMarkdown()
{
// Read licenses.json
using var reader = new StreamReader(Assets.LicensesJson.Open());
var licenses =
JsonSerializer.Deserialize<IReadOnlyList<LicenseInfo>>(reader.ReadToEnd())
?? throw new InvalidOperationException("Failed to read licenses.json");
// Generate markdown
var builder = new StringBuilder();
foreach (var license in licenses)
{
builder.AppendLine(
$"## [{license.PackageName}]({license.PackageUrl}) by {string.Join(", ", license.Authors)}"
);
builder.AppendLine();
builder.AppendLine(license.Description);
builder.AppendLine();
builder.AppendLine($"[{license.LicenseUrl}]({license.LicenseUrl})");
builder.AppendLine();
}
return builder.ToString();
}
#endregion
}

250
StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml

@ -16,57 +16,56 @@
x:CompileBindings="True"
x:Class="StabilityMatrix.Avalonia.Views.CheckpointBrowserPage">
<UserControl.Styles>
<Style Selector="Border#HoverBorder Border#ModelCardBottom">
<Setter Property="Background" Value="#99000000" />
</Style>
<Style Selector="Border#HoverBorder:pointerover Border#ModelCardBottom">
<Setter Property="Background" Value="#CC000000" />
</Style>
<Style Selector="Border#HoverBorder">
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Cursor" Value="Arrow" />
<Setter Property="Margin" Value="8" />
</Style>
<Style Selector="Border#HoverBorder:pointerover">
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColorDark1}" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Margin" Value="8,4,8,12" />
</Style>
<Style Selector="Border#HoverBorder controls|BetterAdvancedImage">
</Style>
<Style Selector="Border#HoverBorder:pointerover controls|BetterAdvancedImage">
</Style>
</UserControl.Styles>
<UserControl.Resources>
<DataTemplate DataType="{x:Type checkpointBrowser:CheckpointBrowserCardViewModel}" x:Key="CivitModelTemplate">
<controls:Card
Margin="8"
MaxHeight="450"
<Border
Height="404"
Width="334"
Name="HoverBorder"
HorizontalAlignment="Center"
CornerRadius="8"
Padding="0">
<Button
Padding="-1"
CornerRadius="12"
Name="ModelCard"
Width="330">
<StackPanel Orientation="Vertical">
<Grid ColumnDefinitions="*, Auto"
Margin="0,-4,0,0">
<TextBlock
Foreground="{DynamicResource TextControlForeground}"
Text="{Binding CivitModel.Name}"
VerticalAlignment="Center" />
<Button
Grid.Column="1"
HorizontalContentAlignment="Right"
HorizontalAlignment="Right"
VerticalContentAlignment="Top"
VerticalAlignment="Top"
Padding="4,2"
Margin="4,0,0,0"
Classes="transparent"
Width="24"
BorderThickness="0">
<ui:SymbolIcon FontSize="18" Symbol="MoreVertical" />
<Button.Flyout>
<MenuFlyout>
<MenuItem Header="{x:Static lang:Resources.Action_OpenOnCivitAi}"
Command="{Binding OpenModelCommand}">
<MenuItem.Icon>
<ui:SymbolIcon Symbol="Open" />
</MenuItem.Icon>
</MenuItem>
</MenuFlyout>
</Button.Flyout>
</Button>
</Grid>
<TextBlock
FontSize="11"
Foreground="{DynamicResource TextControlForeground}"
Margin="0,2,0,0"
Text="{Binding CivitModel.LatestModelVersionName, FallbackValue=''}"
VerticalAlignment="Center" />
<Grid>
<controls:BetterAdvancedImage
Margin="0,8,0,8"
Height="300"
Width="300"
IsEnabled="{Binding !IsImporting}"
Command="{Binding ShowVersionDialogCommand}"
CommandParameter="{Binding CivitModel}"
HorizontalAlignment="Center"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Top">
<Grid RowDefinitions="*, Auto"
HorizontalAlignment="Center">
<controls:BetterAdvancedImage Grid.RowSpan="2"
Height="400"
Width="330"
StretchDirection="Both"
CornerRadius="8"
VerticalContentAlignment="Top"
@ -74,12 +73,13 @@
Source="{Binding CardImage}"
Stretch="UniformToFill" />
<StackPanel Grid.Row="0" Orientation="Horizontal" HorizontalAlignment="Right">
<Button
Command="{Binding ToggleFavoriteCommand}"
HorizontalAlignment="Right"
Margin="0,16,8,0"
Margin="0,8,8,0"
FontSize="20"
Classes="info"
Classes="transparent-info"
IsVisible="{Binding !IsFavorite}"
VerticalAlignment="Top">
<Grid>
@ -89,7 +89,7 @@
<Button
Command="{Binding ToggleFavoriteCommand}"
HorizontalAlignment="Right"
Margin="0,16,8,0"
Margin="0,8,8,0"
FontSize="20"
Classes="success"
IsVisible="{Binding IsFavorite}"
@ -98,14 +98,16 @@
<ui:SymbolIcon Symbol="StarFilled" />
</Grid>
</Button>
</StackPanel>
<StackPanel Orientation="Horizontal">
<StackPanel Grid.Row="0" Orientation="Horizontal">
<controls:Card
Classes="info"
Height="24"
HorizontalAlignment="Left"
Padding="4"
Margin="4,16,0,0"
Margin="8,8,0,0"
VerticalAlignment="Top">
<TextBlock
@ -115,13 +117,12 @@
Text="{Binding CivitModel.Type}"
VerticalAlignment="Center" />
</controls:Card>
<controls:Card
Classes="info"
Height="24"
HorizontalAlignment="Left"
Padding="4"
Margin="4,16,0,0"
Margin="4,8,0,0"
VerticalAlignment="Top">
<TextBlock
@ -131,14 +132,13 @@
Text="{Binding CivitModel.BaseModelType}"
VerticalAlignment="Center" />
</controls:Card>
<controls:Card
Classes="success"
Height="24"
HorizontalAlignment="Left"
Padding="4"
IsVisible="{Binding ShowUpdateCard}"
Margin="4,16,0,0"
Margin="4,8,0,0"
VerticalAlignment="Top">
<TextBlock
@ -149,18 +149,21 @@
VerticalAlignment="Center" />
</controls:Card>
</StackPanel>
<Rectangle
Fill="#DD000000"
RadiusX="8"
RadiusY="8"
<Border Grid.Row="0"
Grid.RowSpan="2"
Background="#DD000000"
CornerRadius="8"
HorizontalAlignment="Stretch"
Margin="0,8,0,8"
Margin="0,0,0,0"
VerticalAlignment="Stretch"
ZIndex="1"
IsVisible="{Binding IsImporting}" />
<StackPanel
<StackPanel Grid.Row="0"
Grid.RowSpan="2"
HorizontalAlignment="Stretch"
Orientation="Vertical"
VerticalAlignment="Center"
ZIndex="2"
IsVisible="{Binding IsImporting}">
<controls:ProgressRing
HorizontalAlignment="Center"
@ -177,40 +180,93 @@
Text="{Binding Text, TargetNullValue=Importing...}"
VerticalAlignment="Center" />
</StackPanel>
</Grid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Button
Classes="accent"
Margin="0,8,0,0"
Command="{Binding ImportCommand}"
CommandParameter="{Binding CivitModel}"
IsEnabled="{Binding !IsImporting}"
CornerRadius="6"
HorizontalAlignment="Stretch">
<Button.Content>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{x:Static lang:Resources.Label_ImportLatest}" />
<TextBlock Margin="4,0,0,0"
Text="{Binding CivitModel.FullFilesSize}" />
<Border Grid.Row="1" Name="ModelCardBottom"
CornerRadius="0,0,8,8">
<Grid RowDefinitions="Auto, Auto, Auto"
ColumnDefinitions="*, Auto">
<TextBlock
Grid.ColumnSpan="2"
HorizontalAlignment="Left"
Margin="8,0,8,0"
FontWeight="SemiBold"
LetterSpacing="0.33"
Foreground="{DynamicResource TextControlForeground}"
Text="{Binding CivitModel.Name}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap"
MaxWidth="250"
ToolTip.Tip="{Binding CivitModel.Name}"
VerticalAlignment="Center" />
<TextBlock Grid.Row="1"
Grid.Column="0"
FontSize="11"
Foreground="{DynamicResource TextControlForeground}"
Margin="8,-4,0,0"
Text="{Binding CivitModel.LatestModelVersionName, FallbackValue=''}"
VerticalAlignment="Center" />
<StackPanel Orientation="Horizontal"
Grid.Column="0"
Grid.Row="2">
<controls:StarsRating
FontSize="16"
Margin="8,8,0,8"
Value="{Binding CivitModel.Stats.Rating}"
Foreground="{DynamicResource ThemeEldenRingOrangeColor}"
Background="#66000000" />
<TextBlock Text="{Binding CivitModel.Stats.RatingCount}"
TextAlignment="Center"
Margin="4,0,0,0"
VerticalAlignment="Center"/>
</StackPanel>
</Button.Content>
</Button>
<Button Grid.Column="1"
CornerRadius="6"
Margin="8,8,0,0"
Classes="accent"
IsEnabled="{Binding !IsImporting}"
Command="{Binding ShowVersionDialogCommand}"
CommandParameter="{Binding CivitModel}"
Content="{x:Static lang:Resources.Label_AllVersions}" />
</Grid>
<StackPanel Grid.Row="2"
Grid.Column="1"
Orientation="Horizontal"
HorizontalAlignment="Right">
<avalonia:Icon Value="fa-solid fa-heart"/>
<TextBlock Text="{Binding CivitModel.Stats.FavoriteCount}"
VerticalAlignment="Center"
Margin="4,0"/>
<avalonia:Icon Value="fa-solid fa-download"
Margin="4,0"/>
<TextBlock Text="{Binding CivitModel.Stats.DownloadCount}"
VerticalAlignment="Center"
Margin="0,0,4,0"/>
</StackPanel>
</controls:Card>
<Button
Grid.Row="0" Grid.Column="1"
HorizontalContentAlignment="Right"
HorizontalAlignment="Right"
VerticalContentAlignment="Top"
VerticalAlignment="Top"
Margin="0,4,4,0"
Classes="transparent"
Width="32"
BorderThickness="0">
<ui:SymbolIcon FontSize="18" Symbol="MoreVertical" />
<Button.Flyout>
<MenuFlyout>
<MenuItem Header="{x:Static lang:Resources.Action_OpenOnCivitAi}"
Command="{Binding OpenModelCommand}">
<MenuItem.Icon>
<ui:SymbolIcon Symbol="Open" />
</MenuItem.Icon>
</MenuItem>
</MenuFlyout>
</Button.Flyout>
</Button>
</Grid>
</Border>
</Grid>
</Button>
</Border>
</DataTemplate>
</UserControl.Resources>
@ -325,26 +381,26 @@
IsEnabled="{Binding CanGoToFirstPage}"
ToolTip.Tip="{x:Static lang:Resources.Label_FirstPage}"
Margin="0,0,8,0">
<avalonia:Icon Value="fa-solid fa-backward-fast"/>
<avalonia:Icon Value="fa-solid fa-backward-fast" />
</Button>
<Button
Command="{Binding PreviousPage}"
ToolTip.Tip="{x:Static lang:Resources.Label_PreviousPage}"
IsEnabled="{Binding CanGoToPreviousPage}"
Margin="0,0,8,0">
<avalonia:Icon Value="fa-solid fa-caret-left"/>
<avalonia:Icon Value="fa-solid fa-caret-left" />
</Button>
<Button Command="{Binding NextPage}"
IsEnabled="{Binding CanGoToNextPage}"
ToolTip.Tip="{x:Static lang:Resources.Label_NextPage}"
Margin="0,0,8,0">
<avalonia:Icon Value="fa-solid fa-caret-right"/>
<avalonia:Icon Value="fa-solid fa-caret-right" />
</Button>
<Button
Command="{Binding LastPage}"
ToolTip.Tip="{x:Static lang:Resources.Label_LastPage}"
IsEnabled="{Binding CanGoToLastPage}">
<avalonia:Icon Value="fa-solid fa-forward-fast"/>
<avalonia:Icon Value="fa-solid fa-forward-fast" />
</Button>
</StackPanel>
</StackPanel>

110
StabilityMatrix.Avalonia/Views/Dialogs/LykosLoginDialog.axaml

@ -0,0 +1,110 @@
<controls:UserControlBase
x:Class="StabilityMatrix.Avalonia.Views.Dialogs.LykosLoginDialog"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia"
xmlns:system="clr-namespace:System;assembly=System.Runtime"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:vmDialogs="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Dialogs"
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"
xmlns:sm="clr-namespace:StabilityMatrix.Avalonia"
Focusable="True"
d:DataContext="{x:Static mocks:DesignData.LykosLoginViewModel}"
d:DesignHeight="300"
d:DesignWidth="400"
x:DataType="vmDialogs:LykosLoginViewModel"
mc:Ignorable="d">
<sg:SpacedGrid MinWidth="400" MinHeight="300">
<!--<TextBlock Text="Log in or sign up" Theme="{DynamicResource SubtitleTextBlockStyle}" />-->
<TabControl SelectedIndex="{Binding IsSignupMode}">
<TabItem
FontSize="20"
Header="Login">
<sg:SpacedGrid
Margin="-4,32,-4,8"
RowDefinitions="Auto,Auto,Auto,*"
RowSpacing="8">
<!-- Username -->
<TextBox
Watermark="{x:Static lang:Resources.Label_Email}"
Text="{Binding Email}"
UseFloatingWatermark="True"
Grid.Row="0" />
<!-- Password -->
<MaskedTextBox
PasswordChar="*"
Text="{Binding Password}"
Watermark="{x:Static lang:Resources.Label_Password}"
Grid.Row="1" />
<ui:InfoBar
Severity="Error"
IsOpen="{Binding LoginError, Converter={x:Static ObjectConverters.IsNotNull}}"
Title="{Binding LoginError.Message}"
Message="{Binding LoginError.Details}"
Grid.Row="2"/>
<controls:HyperlinkIconButton
VerticalAlignment="Bottom"
Content="Forgot Password?"
NavigateUri="{x:Static sm:Assets.LykosForgotPasswordUrl}"
Grid.Row="3"/>
</sg:SpacedGrid>
</TabItem>
<TabItem
FontSize="20"
Header="Sign up">
<sg:SpacedGrid
Margin="-4,32,-4,8"
RowDefinitions="Auto,Auto,Auto,Auto,Auto"
RowSpacing="8">
<!-- Email -->
<TextBox
Watermark="{x:Static lang:Resources.Label_Email}"
Text="{Binding Email}"
UseFloatingWatermark="True"
Grid.Row="0" />
<!-- Username -->
<TextBox
Watermark="{x:Static lang:Resources.Label_Username}"
Text="{Binding Username}"
UseFloatingWatermark="True"
Grid.Row="1" />
<!-- Password -->
<TextBox
PasswordChar="*"
Text="{Binding Password}"
Watermark="{x:Static lang:Resources.Label_Password}"
UseFloatingWatermark="True"
Grid.Row="2" />
<!-- Confirm Password -->
<TextBox
PasswordChar="*"
Text="{Binding ConfirmPassword}"
Watermark="{x:Static lang:Resources.Label_ConfirmPassword}"
UseFloatingWatermark="True"
Grid.Row="3" />
<ui:InfoBar
Severity="Error"
IsOpen="{Binding SignupError, Converter={x:Static ObjectConverters.IsNotNull}}"
Title="{Binding SignupError.Message}"
Message="{Binding SignupError.Details}"
Grid.Row="4"/>
</sg:SpacedGrid>
</TabItem>
</TabControl>
</sg:SpacedGrid>
</controls:UserControlBase>

13
StabilityMatrix.Avalonia/Views/Dialogs/LykosLoginDialog.axaml.cs

@ -0,0 +1,13 @@
using Avalonia.Controls;
using StabilityMatrix.Core.Attributes;
namespace StabilityMatrix.Avalonia.Views.Dialogs;
[Transient]
public partial class LykosLoginDialog : UserControl
{
public LykosLoginDialog()
{
InitializeComponent();
}
}

40
StabilityMatrix.Avalonia/Views/Dialogs/OAuthConnectDialog.axaml

@ -0,0 +1,40 @@
<controls:UserControlBase xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:system="clr-namespace:System;assembly=System.Runtime"
xmlns:vmDialogs="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Dialogs"
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"
xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia"
d:DataContext="{x:Static mocks:DesignData.OAuthConnectViewModel}"
x:DataType="vmDialogs:OAuthConnectViewModel"
mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="400"
x:Class="StabilityMatrix.Avalonia.Views.Dialogs.OAuthConnectDialog">
<sg:SpacedGrid RowDefinitions="Auto,Auto,Auto" Margin="8,4" RowSpacing="12">
<sg:SpacedGrid.Styles>
<Style Selector="ui|HyperlinkButton > TextBlock">
<Setter Property="TextWrapping" Value="WrapWithOverflow"/>
</Style>
</sg:SpacedGrid.Styles>
<ui:HyperlinkButton
Padding="6,0"
Content="{Binding Url}"
NavigateUri="{Binding Url}"/>
<TextBlock
Grid.Row="1"
Margin="6,0"
TextWrapping="WrapWithOverflow"
Text="{Binding Description}"/>
<ProgressBar
IsIndeterminate="True"
Grid.Row="2"/>
</sg:SpacedGrid>
</controls:UserControlBase>

13
StabilityMatrix.Avalonia/Views/Dialogs/OAuthConnectDialog.axaml.cs

@ -0,0 +1,13 @@
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Core.Attributes;
namespace StabilityMatrix.Avalonia.Views.Dialogs;
[Transient]
public partial class OAuthConnectDialog : UserControlBase
{
public OAuthConnectDialog()
{
InitializeComponent();
}
}

17
StabilityMatrix.Avalonia/Views/MainWindow.axaml.cs

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using AsyncImageLoader;
using Avalonia;
using Avalonia.Controls;
@ -26,6 +27,7 @@ using StabilityMatrix.Avalonia.Animations;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Extensions;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels;
using StabilityMatrix.Avalonia.ViewModels.Base;
@ -44,7 +46,7 @@ namespace StabilityMatrix.Avalonia.Views;
public partial class MainWindow : AppWindowBase
{
private readonly INotificationService notificationService;
private readonly INavigationService navigationService;
private readonly INavigationService<MainWindowViewModel> navigationService;
private FlyoutBase? progressFlyout;
@ -58,7 +60,7 @@ public partial class MainWindow : AppWindowBase
public MainWindow(
INotificationService notificationService,
INavigationService navigationService
INavigationService<MainWindowViewModel> navigationService
)
{
this.notificationService = notificationService;
@ -74,6 +76,8 @@ public partial class MainWindow : AppWindowBase
TitleBar.ExtendsContentIntoTitleBar = true;
TitleBar.TitleBarHitTestType = TitleBarHitTestType.Complex;
navigationService.TypedNavigation += NavigationService_OnTypedNavigation;
EventManager.Instance.ToggleProgressFlyout += (_, _) => progressFlyout?.Hide();
EventManager.Instance.CultureChanged += (_, _) => SetDefaultFonts();
EventManager.Instance.UpdateAvailable += OnUpdateAvailable;
@ -158,6 +162,15 @@ public partial class MainWindow : AppWindowBase
}
}
private void NavigationService_OnTypedNavigation(object? sender, TypedNavigationEventArgs e)
{
var mainViewModel = (MainWindowViewModel)DataContext!;
mainViewModel.SelectedCategory = mainViewModel.Pages
.Concat(mainViewModel.FooterPages)
.FirstOrDefault(x => x.GetType() == e.ViewModelType);
}
private void OnUpdateAvailable(object? sender, UpdateInfo? updateInfo)
{
Dispatcher.UIThread.Post(() =>

16
StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml

@ -51,13 +51,17 @@
BorderBrush="#33000000"
BorderThickness="2"
CornerRadius="8">
<Grid ColumnDefinitions="*,Auto" RowDefinitions="*,Auto">
<Grid ColumnDefinitions="Auto,Auto" RowDefinitions="Auto,Auto">
<StackPanel Grid.Row="0" Grid.Column="0">
<TextBlock Margin="0,0" Text="{Binding Name, Mode=OneWay}" />
<Grid Grid.Row="0" Grid.Column="0" RowDefinitions="Auto, Auto">
<TextBlock
MaxWidth="200"
Text="{Binding Name, Mode=OneWay}"
TextWrapping="NoWrap"
TextTrimming="CharacterEllipsis"/>
<!-- non-indeterminate progress -->
<TextBlock
<TextBlock Grid.Row="1"
Margin="0,0"
IsVisible="{Binding !Progress.IsIndeterminate}">
<Run Text="{Binding Progress.Text, Mode=OneWay}" />
@ -65,11 +69,11 @@
</TextBlock>
<!-- indeterminate progress -->
<TextBlock
<TextBlock Grid.Row="1"
Margin="0,4"
IsVisible="{Binding Progress.IsIndeterminate}"
Text="{Binding Progress.Text, Mode=OneWay}" />
</StackPanel>
</Grid>
<!-- Buttons -->

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

@ -0,0 +1,171 @@
<controls:UserControlBase
x:Class="StabilityMatrix.Avalonia.Views.Settings.AccountSettingsPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:fluentIcons="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia"
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia"
xmlns:system="clr-namespace:System;assembly=System.Runtime"
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"
x:DataType="vmSettings:AccountSettingsViewModel"
mc:Ignorable="d">
<controls:UserControlBase.Resources>
<SolidColorBrush x:Key="Brush0" Color="#FF3C3C46" />
<SolidColorBrush x:Key="Brush1" Color="#FF3E4B77" />
<SolidColorBrush x:Key="Brush4" Color="#FF1375D5" />
<SolidColorBrush x:Key="Brush7" Color="#FF1B96E3" />
<SolidColorBrush x:Key="Brush13" Color="#FF5486BC" />
<DrawingImage x:Key="BrandsLykos">
<DrawingGroup>
<GeometryDrawing Brush="Transparent" Geometry="F1M0,0L587,0L587,618L0,618z" />
<DrawingGroup>
<DrawingGroup.Transform>
<MatrixTransform Matrix="0.99998295,0,0,0.99998295,0,0.24023438" />
</DrawingGroup.Transform>
<GeometryDrawing Brush="{DynamicResource Brush0}" Geometry="F0 M359.12 181.81L290.92 263.57L202.31 422.8L296.41 349.51L341.33 282.47L359.12 181.81Z" />
<GeometryDrawing Brush="{DynamicResource Brush1}" Geometry="F0 M354.51 195.18L296.41 349.51L287.33 463.38L402.14 326.47L405.15 227.55L342.3 165.1L354.51 195.18Z" />
<GeometryDrawing Brush="{DynamicResource Brush1}" Geometry="F0 M354.51 195.18L373.25 246.84L402.14 326.47L475.55 241.31L506.38 172.37L432.6 170.07L354.51 195.18Z" />
<GeometryDrawing Brush="{DynamicResource Brush0}" Geometry="F0 M506.38 172.37L402.14 326.47L431.69 421.7L493.66 289.28L506.38 172.37Z" />
<GeometryDrawing Brush="{DynamicResource Brush4}" Geometry="F0 M354.51 195.18L431.14 183.67L506.38 172.37L581.91 114.38L577.7 83.96L459.71 128.22L354.51 195.18Z" />
<GeometryDrawing Brush="{DynamicResource Brush1}" Geometry="F0 M354.51 195.18L577.7 83.96L587.01 36.06L555.98 24.72L444.52 90.52L354.51 195.18Z" />
<GeometryDrawing Brush="{DynamicResource Brush4}" Geometry="F0 M354.51 195.18L451.44 116.55L551.26 35.59L570.72 16.37L543.04 5.86L469.78 26.31L346.91 82.41L330.57 157.07L354.51 195.18Z" />
<GeometryDrawing Brush="{DynamicResource Brush7}" Geometry="F0 M258.82 209.64L283.43 257.76L354.51 195.18L354.73 143.18L354.97 88.91L300.3 145.74L258.82 209.64Z" />
<GeometryDrawing Brush="{DynamicResource Brush4}" Geometry="F0 M284.89 201.03L283.82 132.98L245.45 133.94L209.32 211.39L209.53 265.05L202.31 422.8L277.33 274.62L284.89 201.03Z" />
<GeometryDrawing Brush="{DynamicResource Brush1}" Geometry="F0 M354.51 195.18L318.27 198.22L284.89 201.03L202.31 422.8L268.99 323.08L354.51 195.18Z" />
<GeometryDrawing Brush="{DynamicResource Brush1}" Geometry="F0 M209.53 265.05L225.86 205.45L245.45 133.94L157.16 269.41L209.53 265.05Z" />
<GeometryDrawing Brush="{DynamicResource Brush4}" Geometry="F0 M245.45 133.94L301.48 136.45L354.97 88.91L275.66 59.42L139 44.21L245.45 133.94Z" />
<GeometryDrawing Brush="{DynamicResource Brush7}" Geometry="F0 M139 44.21L287.77 75.01L354.97 88.91L348.14 53.88L306.32 49.68L139 44.21Z" />
<GeometryDrawing Brush="{DynamicResource Brush13}" Geometry="F0 M393.94 11.53L306.32 49.68L354.97 88.91L412.38 57.61L441.91 26.85L393.94 11.53Z" />
<GeometryDrawing Brush="{DynamicResource Brush7}" Geometry="F0 M441.91 26.85L354.97 88.91L543.04 5.86L571.25 0L441.91 26.85Z" />
<GeometryDrawing Brush="{DynamicResource Brush13}" Geometry="F0 M571.25 0L543.04 5.86L551.26 35.59L587.01 36.06L571.25 0Z" />
<GeometryDrawing Brush="{DynamicResource Brush13}" Geometry="F0 M151.49 224.62L33.55 321.2L103.23 316.08L151.49 224.62Z" />
<GeometryDrawing Brush="{DynamicResource Brush4}" Geometry="F0 M145.63 400.32L200.67 282.86L0 447.89L145.63 400.32Z" />
<GeometryDrawing Brush="{DynamicResource Brush13}" Geometry="F0 M262.66 413.33L132.89 597.28L236.55 530.32L262.66 413.33Z" />
<GeometryDrawing Brush="{DynamicResource Brush1}" Geometry="F0 M374.93 617.53L350.75 441.52L396.59 364.19L412.64 459.88L374.93 617.53Z" />
<GeometryDrawing Brush="{DynamicResource Brush1}" Geometry="F0 M277.5 507.65L312.09 557.16L324.24 472.27L277.5 507.65Z" />
<GeometryDrawing Brush="{DynamicResource Brush1}" Geometry="F0 M185.82 455.66L119.65 488.21L183.32 389.39L185.82 455.66Z" />
<GeometryDrawing Brush="{DynamicResource Brush4}" Geometry="F0 M441.13 526.17L473.16 366.86L489.5 454.43L441.13 526.17Z" />
<GeometryDrawing Brush="{DynamicResource Brush1}" Geometry="F0 M354.97 88.91L354.97 88.91L245.45 133.94L284.89 201.03L316.79 149.99L354.97 88.91Z" />
<GeometryDrawing Brush="{DynamicResource Brush4}" Geometry="F0 M353.83 211.72L346.35 393L402.14 326.47L373.25 246.84L356.02 199.33L354.51 195.18L353.83 211.72Z" />
</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="2">
<sg:SpacedGrid Margin="0,4,0,16" ColumnDefinitions="*,*">
<StackPanel Orientation="Horizontal" Spacing="16">
<fluentIcons:SymbolIcon
IsVisible="{Binding LykosProfileImageUrl, Converter={x:Static StringConverters.IsNullOrEmpty}}"
Width="64"
Height="64"
IsFilled="True"
FontSize="64"
Symbol="Person" />
<controls:BetterAdvancedImage
HorizontalAlignment="Left"
IsVisible="{Binding LykosProfileImageUrl, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Source="{Binding LykosProfileImageUrl}"
Width="64"
Height="64"
CornerRadius="8" />
<StackPanel>
<TextBlock
Margin="-1,0,0,0"
Text="{Binding LykosStatus.User.Account.Name, FallbackValue=''}"
Theme="{DynamicResource SubtitleTextBlockStyle}" />
<TextBlock Text="{Binding LykosStatus.User.Id, FallbackValue=''}" />
</StackPanel>
</StackPanel>
</sg:SpacedGrid>
<controls:SettingsAccountLinkExpander
ConnectCommand="{Binding ConnectLykosCommand}"
DisconnectCommand="{Binding DisconnectLykosCommand}"
Header="Lykos"
HeaderTargetUri="{x:Static sm:Assets.LykosUrl}"
IsConnected="{Binding LykosStatus.IsConnected}"
OffDescription="Manage connected features in Stability Matrix">
<controls:SettingsAccountLinkExpander.IconSource>
<ui:ImageIconSource Source="{StaticResource BrandsLykos}" />
</controls:SettingsAccountLinkExpander.IconSource>
<controls:SettingsAccountLinkExpander.IsLoading>
<MultiBinding Converter="{x:Static BoolConverters.Or}">
<Binding Path="ConnectLykosCommand.IsRunning" />
<Binding Path="!IsInitialUpdateFinished" />
</MultiBinding>
</controls:SettingsAccountLinkExpander.IsLoading>
</controls:SettingsAccountLinkExpander>
<controls:SettingsAccountLinkExpander
ConnectCommand="{Binding ConnectPatreonCommand}"
DisconnectCommand="{Binding DisconnectPatreonCommand}"
Header="Patreon"
IsEnabled="{Binding LykosStatus.IsConnected}"
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.IsLoading>
<MultiBinding Converter="{x:Static BoolConverters.Or}">
<Binding Path="ConnectPatreonCommand.IsRunning" />
<Binding Path="!IsInitialUpdateFinished" />
</MultiBinding>
</controls:SettingsAccountLinkExpander.IsLoading>
</controls:SettingsAccountLinkExpander>
<sg:SpacedGrid RowDefinitions="Auto,*,*" RowSpacing="4" Margin="0,6,0,0">
<TextBlock
Margin="0,0,0,4"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_Integrations}" />
<controls:SettingsAccountLinkExpander
Grid.Row="2"
ConnectCommand="{Binding ConnectCivitCommand}"
DisconnectCommand="{Binding DisconnectCivitCommand}"
Header="CivitAI"
HeaderTargetUri="{x:Static sm:Assets.CivitAIUrl}"
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" />
<Binding Path="!IsInitialUpdateFinished" />
</MultiBinding>
</controls:SettingsAccountLinkExpander.IsLoading>
</controls:SettingsAccountLinkExpander>
</sg:SpacedGrid>
</StackPanel>
</ScrollViewer>
</controls:UserControlBase>

13
StabilityMatrix.Avalonia/Views/Settings/AccountSettingsPage.axaml.cs

@ -0,0 +1,13 @@
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Core.Attributes;
namespace StabilityMatrix.Avalonia.Views.Settings;
[Singleton]
public partial class AccountSettingsPage : UserControlBase
{
public AccountSettingsPage()
{
InitializeComponent();
}
}

67
StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml

@ -1,16 +1,69 @@
<controls:UserControlBase xmlns="https://github.com/avaloniaui"
<controls:UserControlBase
x:Class="StabilityMatrix.Avalonia.Views.Settings.InferenceSettingsPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:system="clr-namespace:System;assembly=System.Runtime"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:vmSettings="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Settings"
d:DataContext="{x:Static mocks:DesignData.InferenceSettingsViewModel}"
d:DesignHeight="450"
d:DesignWidth="800"
x:DataType="vmSettings:InferenceSettingsViewModel"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="StabilityMatrix.Avalonia.Views.Settings.InferenceSettingsPage">
Welcome to Avalonia!
mc:Ignorable="d">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="8,16" Spacing="8">
<!-- Theme -->
<Grid RowDefinitions="Auto,*,*,*">
<TextBlock
Margin="0,0,0,8"
FontWeight="Medium"
Text="Stuff" />
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0,8,4"
Header="{x:Static lang:Resources.Label_Theme}"
IconSource="WeatherMoon">
<ui:SettingsExpander.Footer>
<ComboBox MinWidth="100" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander
Grid.Row="2"
Margin="8,0,8,4"
Header="{x:Static lang:Resources.Label_Language}"
IconSource="Character">
<ui:SettingsExpander.Footer>
<ComboBox MinWidth="100" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander
Grid.Row="3"
Margin="8,0,8,4"
Header="Inference"
IconSource="Code" />
</Grid>
<!-- Checkpoints Manager Options -->
<Grid RowDefinitions="auto,*,Auto">
<TextBlock
Margin="0,0,0,8"
FontWeight="Medium"
Text="Other stuff" />
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0"
IconSource="Folder">
<ui:SettingsExpander.Footer>
<CheckBox Margin="8" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Grid>
</StackPanel>
</ScrollViewer>
</controls:UserControlBase>

10
StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml.cs

@ -1,19 +1,13 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Core.Attributes;
namespace StabilityMatrix.Avalonia.Views.Settings;
[Singleton]
public partial class InferenceSettingsPage : UserControl
public partial class InferenceSettingsPage : UserControlBase
{
public InferenceSettingsPage()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

550
StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml

@ -0,0 +1,550 @@
<controls:UserControlBase
x:Class="StabilityMatrix.Avalonia.Views.Settings.MainSettingsPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:avalonia="https://github.com/projektanker/icons.avalonia"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:converters="clr-namespace:StabilityMatrix.Avalonia.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:fluentIcons="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia"
xmlns:inference="clr-namespace:StabilityMatrix.Avalonia.Models.Inference"
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:system="clr-namespace:System;assembly=System.Runtime"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:vmSettings="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Settings"
xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia"
Focusable="True"
d:DataContext="{x:Static mocks:DesignData.MainSettingsViewModel}"
d:DesignHeight="700"
d:DesignWidth="800"
x:CompileBindings="True"
x:DataType="vmSettings:MainSettingsViewModel"
mc:Ignorable="d">
<controls:UserControlBase.Resources>
<converters:CultureInfoDisplayConverter x:Key="CultureInfoDisplayConverter" />
</controls:UserControlBase.Resources>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="8,16" Spacing="8">
<!-- Theme -->
<Grid RowDefinitions="Auto,*,*,*">
<TextBlock
Margin="0,0,0,8"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_Appearance}" />
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0,8,4"
Header="{x:Static lang:Resources.Label_Theme}"
IconSource="WeatherMoon">
<ui:SettingsExpander.Footer>
<ComboBox
MinWidth="100"
ItemsSource="{Binding AvailableThemes}"
SelectedItem="{Binding SelectedTheme}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander
Grid.Row="2"
Margin="8,0,8,4"
Header="{x:Static lang:Resources.Label_Language}"
IconSource="Character">
<ui:SettingsExpander.Footer>
<ComboBox
MinWidth="100"
DisplayMemberBinding="{Binding Converter={StaticResource CultureInfoDisplayConverter}}"
ItemsSource="{Binding AvailableLanguages}"
SelectedItem="{Binding SelectedLanguage}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander
Grid.Row="3"
IsVisible="{Binding SharedState.IsDebugMode}"
Margin="8,0,8,4"
IsClickEnabled="True"
Command="{Binding NavigateToSubPageCommand}"
CommandParameter="{x:Type vmSettings:InferenceSettingsViewModel}"
Header="Inference (Test)"
IconSource="Code"
ActionIconSource="ChevronRight">
</ui:SettingsExpander>
</Grid>
<!-- Checkpoints Manager Options -->
<Grid RowDefinitions="auto,*,Auto">
<TextBlock
Margin="0,0,0,8"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_CheckpointManager}" />
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0"
Description="{x:Static lang:Resources.Label_RemoveSymlinksOnShutdown_Details}"
Header="{x:Static lang:Resources.Label_RemoveSymlinksOnShutdown}"
IconSource="Folder">
<ui:SettingsExpander.Footer>
<CheckBox Margin="8" IsChecked="{Binding RemoveSymlinksOnShutdown}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander
Grid.Row="2"
Margin="8,4"
Description="{x:Static lang:Resources.Label_ResetCheckpointsCache_Details}"
Header="{x:Static lang:Resources.Label_ResetCheckpointsCache}"
IconSource="Refresh">
<ui:SettingsExpander.Footer>
<Button Command="{Binding ResetCheckpointCache}" Content="{x:Static lang:Resources.Label_ResetCheckpointsCache}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Grid>
<!-- Inference UI -->
<Grid Margin="0,8,0,0" RowDefinitions="auto,*,*,*">
<TextBlock
Margin="0,0,0,8"
FontWeight="Medium"
Text="Inference" />
<!-- Auto Completion -->
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0,8,4"
Header="Prompt Auto Completion">
<ui:SettingsExpander.IconSource>
<controls:FASymbolIconSource Symbol="fa-solid fa-wand-magic-sparkles" />
</ui:SettingsExpander.IconSource>
<!-- Enable toggle -->
<ui:SettingsExpanderItem Content="Enable">
<ui:SettingsExpanderItem.Footer>
<ToggleSwitch IsChecked="{Binding IsPromptCompletionEnabled}" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<!-- Tag csv selection -->
<ui:SettingsExpanderItem
Content="Tag Source"
Description="Tags to use for completion in .csv format (Compatible with a1111-sd-webui-tagcomplete)"
IconSource="Tag"
IsEnabled="{Binding IsPromptCompletionEnabled}">
<ui:SettingsExpanderItem.Footer>
<ui:FAComboBox ItemsSource="{Binding AvailableTagCompletionCsvs}" SelectedItem="{Binding SelectedTagCompletionCsv}" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<!-- Tag csv import -->
<ui:SettingsExpanderItem
Content="Import Tag Source .csv"
IconSource="Add"
IsEnabled="{Binding IsPromptCompletionEnabled}">
<ui:SettingsExpanderItem.Footer>
<Button Command="{Binding ImportTagCsvCommand}" Content="Import" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<!-- Remove underscores -->
<ui:SettingsExpanderItem
Content="Replace underscores with spaces when inserting completions"
IconSource="Underline"
IsEnabled="{Binding IsPromptCompletionEnabled}">
<ui:SettingsExpanderItem.Footer>
<CheckBox Margin="8" IsChecked="{Binding IsCompletionRemoveUnderscoresEnabled}" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<!-- Image Viewer -->
<ui:SettingsExpander
Grid.Row="2"
Margin="8,0,8,4"
Header="Image Viewer"
IconSource="Image">
<!-- Pixel grid -->
<ui:SettingsExpanderItem Content="Show pixel grid at high zoom levels" IconSource="ViewAll">
<ui:SettingsExpanderItem.Footer>
<CheckBox Margin="8" IsChecked="{Binding IsImageViewerPixelGridEnabled}" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<!-- Output Image Files -->
<ui:SettingsExpander
Grid.Row="3"
Margin="8,0,8,4"
Header="Output Image Files">
<ui:SettingsExpander.IconSource>
<fluentIcons:SymbolIconSource Symbol="TabDesktopImage" />
</ui:SettingsExpander.IconSource>
<!-- File name pattern -->
<ui:SettingsExpanderItem
Content="File name pattern"
Description="{Binding OutputImageFileNameFormatSample}"
IconSource="Rename">
<ui:SettingsExpanderItem.Footer>
<TextBox
Name="OutputImageFileNameFormatTextBox"
MinWidth="150"
FontFamily="Cascadia Code,Consolas,Menlo,Monospace"
FontSize="13"
Text="{Binding OutputImageFileNameFormat}"
Watermark="{x:Static inference:FileNameFormat.DefaultTemplate}" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<ui:TeachingTip
Title="Format Variables"
Grid.Row="3"
IsOpen="{Binding #OutputImageFileNameFormatTextBox.IsFocused}"
PreferredPlacement="Top"
Target="{Binding #OutputImageFileNameFormatTextBox, Mode=OneWay}">
<DataGrid AutoGenerateColumns="True" ItemsSource="{Binding OutputImageFileNameFormatVars}" />
<!--<mdxaml:MarkdownScrollViewer
Markdown="{Binding OutputImageFileNameFormatGuideMarkdown}"/>-->
</ui:TeachingTip>
</Grid>
<!-- Environment Options -->
<Grid RowDefinitions="Auto, Auto, Auto">
<TextBlock
Margin="0,0,0,8"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_PackageEnvironment}" />
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0"
Header="{x:Static lang:Resources.Label_EnvironmentVariables}"
IconSource="OtherUser">
<ui:SettingsExpander.Footer>
<Button Command="{Binding OpenEnvVarsDialogCommand}" Content="{x:Static lang:Resources.Action_Edit}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander
Grid.Row="2"
Margin="8,4"
Header="{x:Static lang:Resources.Label_EmbeddedPython}">
<ui:SettingsExpander.IconSource>
<controls:FASymbolIconSource Symbol="fa-brands fa-python" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<StackPanel Orientation="Horizontal" Spacing="16">
<controls:ProgressRing
BorderThickness="3"
IsEnabled="{Binding CheckPythonVersionCommand.IsRunning}"
IsIndeterminate="True"
IsVisible="{Binding CheckPythonVersionCommand.IsRunning}" />
<Button Command="{Binding CheckPythonVersionCommand}" Content="{x:Static lang:Resources.Action_CheckVersion}" />
</StackPanel>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Grid>
<!-- Integrations -->
<sg:SpacedGrid RowDefinitions="Auto,*,*" RowSpacing="4">
<TextBlock
Margin="0,0,0,4"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_Integrations}" />
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0"
IsClickEnabled="True"
Command="{Binding NavigateToSubPageCommand}"
CommandParameter="{x:Type vmSettings:AccountSettingsViewModel}"
Header="{x:Static lang:Resources.Label_Accounts}"
ActionIconSource="ChevronRight">
<ui:SettingsExpander.IconSource>
<fluentIcons:SymbolIconSource
FontSize="10"
IsFilled="True"
Symbol="Person"/>
</ui:SettingsExpander.IconSource>
</ui:SettingsExpander>
<ui:SettingsExpander
Grid.Row="2"
Margin="8,0,8,4"
Header="{x:Static lang:Resources.Label_DiscordRichPresence}">
<ui:SettingsExpander.IconSource>
<controls:FASymbolIconSource Symbol="fa-brands fa-discord" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding IsDiscordRichPresenceEnabled}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</sg:SpacedGrid>
<!-- System Options -->
<Grid RowDefinitions="auto, auto, auto">
<TextBlock
Margin="0,0,0,8"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_System}" />
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0,8,4"
Description="{x:Static lang:Resources.Label_AddToStartMenu_Details}"
Header="{x:Static lang:Resources.Label_AddToStartMenu}"
IconSource="StarAdd"
ToolTip.Tip="{OnPlatform Default={x:Static lang:Resources.Label_OnlyAvailableOnWindows},
Windows={x:Null}}">
<ui:SettingsExpander.Footer>
<StackPanel Orientation="Horizontal" Spacing="8">
<controls:ProgressRing
BorderThickness="3"
IsEnabled="{Binding IsVisible, RelativeSource={RelativeSource Self}}"
IsIndeterminate="True">
<controls:ProgressRing.IsVisible>
<MultiBinding Converter="{x:Static BoolConverters.Or}">
<Binding Path="AddToStartMenuCommand.IsRunning" />
<Binding Path="AddToGlobalStartMenuCommand.IsRunning" />
</MultiBinding>
</controls:ProgressRing.IsVisible>
</controls:ProgressRing>
<SplitButton
Command="{Binding AddToStartMenuCommand}"
Content="{x:Static lang:Resources.Action_AddForCurrentUser}"
IsEnabled="{OnPlatform Default=False,
Windows=True}">
<SplitButton.Flyout>
<ui:FAMenuFlyout Placement="Bottom">
<ui:MenuFlyoutItem
Command="{Binding AddToGlobalStartMenuCommand}"
IconSource="Admin"
Text="{x:Static lang:Resources.Action_AddForAllUsers}" />
</ui:FAMenuFlyout>
</SplitButton.Flyout>
</SplitButton>
</StackPanel>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander
Grid.Row="2"
Margin="8,0"
Description="{x:Static lang:Resources.Label_SelectNewDataDirectory_Details}"
IconSource="MoveToFolder">
<ui:SettingsExpander.Header>
<StackPanel Orientation="Vertical">
<TextBlock Text="{x:Static lang:Resources.Label_SelectNewDataDirectory}" />
<TextBlock FontSize="12" Foreground="{DynamicResource TextFillColorSecondaryBrush}">
<Run Text="{x:Static lang:Resources.Label_CurrentDirectory}" />
<Run Text="{Binding DataDirectory}" />
</TextBlock>
</StackPanel>
</ui:SettingsExpander.Header>
<ui:SettingsExpander.Footer>
<Button Command="{Binding PickNewDataDirectory}">
<Grid ColumnDefinitions="Auto, Auto">
<avalonia:Icon
Grid.Row="0"
Margin="0,0,8,0"
VerticalAlignment="Center"
Value="fa-solid fa-folder-open" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
Text="{x:Static lang:Resources.Action_SelectDirectory}" />
</Grid>
</Button>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Grid>
<!-- Debug Options -->
<Grid IsVisible="{Binding SharedState.IsDebugMode}" RowDefinitions="auto,*">
<TextBlock
Margin="0,0,0,8"
FontWeight="Medium"
Text="Debug Options" />
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0,8,0"
Command="{Binding LoadDebugInfo}"
Header="Debug Options"
IconSource="Code">
<ui:SettingsExpanderItem
Margin="4,0"
Description="Paths"
IconSource="Folder">
<SelectableTextBlock
Foreground="{DynamicResource TextControlPlaceholderForeground}"
Text="{Binding DebugPaths}"
TextWrapping="WrapWithOverflow" />
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Description="Compat Info"
IconSource="StarFilled">
<SelectableTextBlock
Foreground="{DynamicResource TextControlPlaceholderForeground}"
Text="{Binding DebugCompatInfo}"
TextWrapping="WrapWithOverflow" />
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Description="GPU Info"
IconSource="FullScreenMaximize">
<SelectableTextBlock
Foreground="{DynamicResource TextControlPlaceholderForeground}"
Text="{Binding DebugGpuInfo}"
TextWrapping="WrapWithOverflow" />
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Content="Animation Scale"
Description="Lower values = faster animations. 0x means animations are instant."
IconSource="Clock">
<ui:SettingsExpanderItem.Footer>
<ComboBox ItemsSource="{Binding AnimationScaleOptions}" SelectedItem="{Binding SelectedAnimationScale}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock>
<Run Text="{Binding}" /><Run Text="x" />
</TextBlock>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Content="Notification"
IconSource="CommentAdd">
<ui:SettingsExpanderItem.Footer>
<Button Command="{Binding DebugNotificationCommand}" Content="New Notification" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Content="Content Dialog"
IconSource="NewWindow">
<ui:SettingsExpanderItem.Footer>
<Button Command="{Binding DebugContentDialogCommand}" Content="Show Dialog" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Content="Exceptions"
IconSource="Flag">
<ui:SettingsExpanderItem.Footer>
<Button Command="{Binding DebugThrowExceptionCommand}" Content="Unhandled Exception" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0,4,4"
Content="Download Manager tests"
IconSource="Flag">
<ui:SettingsExpanderItem.Footer>
<Button
Margin="0,8"
Command="{Binding DebugTrackedDownloadCommand}"
Content="Add Tracked Download" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0,4,4"
Content="Refresh Models Index"
IconSource="SyncFolder">
<ui:SettingsExpanderItem.Footer>
<Button
Margin="0,8"
Command="{Binding DebugRefreshModelsIndexCommand}"
Content="Refresh Index" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0,4,4"
Content="Make image grid"
IconSource="Image">
<ui:SettingsExpanderItem.Footer>
<Button
Margin="0,8"
Command="{Binding DebugMakeImageGridCommand}"
Content="Select images" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0,4,4"
Content="Image metadata parser"
IconSource="Flag">
<ui:SettingsExpanderItem.Footer>
<Button
Margin="0,8"
Command="{Binding DebugImageMetadataCommand}"
Content="Choose image" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
</Grid>
<!-- TODO: Directories card -->
<Grid RowDefinitions="auto,*">
<StackPanel
Grid.Row="1"
HorizontalAlignment="Left"
Orientation="Vertical">
<TextBlock
Margin="0,8"
FontSize="15"
FontWeight="Bold"
Text="{x:Static lang:Resources.Label_About}" />
<Image
Width="112"
Height="112"
Margin="8"
HorizontalAlignment="Left"
Source="/Assets/Icon.png" />
<TextBlock
Margin="8"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_StabilityMatrix}" />
<Panel>
<Button
Name="VersionButton"
Margin="8,0,8,8"
Padding="2,0,2,0"
BorderThickness="0"
Classes="transparent"
Command="{Binding OnVersionClick}"
Content="{Binding AppVersion}" />
<ui:TeachingTip
Title="{Binding VersionFlyoutText}"
IsOpen="{Binding IsVersionTapTeachingTipOpen}"
PreferredPlacement="RightTop"
Target="{Binding #VersionButton}" />
</Panel>
<StackPanel HorizontalAlignment="Left" Orientation="Horizontal">
<Button
Margin="8"
HorizontalAlignment="Left"
Command="{Binding ShowLicensesDialogCommand}"
Content="{x:Static lang:Resources.Label_LicenseAndOpenSourceNotices}" />
</StackPanel>
</StackPanel>
</Grid>
<!-- Extra space at the bottom -->
<Panel Margin="0,0,0,16" />
</StackPanel>
</ScrollViewer>
</controls:UserControlBase>

13
StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml.cs

@ -0,0 +1,13 @@
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Core.Attributes;
namespace StabilityMatrix.Avalonia.Views.Settings;
[Singleton]
public partial class MainSettingsPage : UserControlBase
{
public MainSettingsPage()
{
InitializeComponent();
}
}

529
StabilityMatrix.Avalonia/Views/SettingsPage.axaml

@ -2,19 +2,14 @@
x:Class="StabilityMatrix.Avalonia.Views.SettingsPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:avalonia="clr-namespace:Projektanker.Icons.Avalonia;assembly=Projektanker.Icons.Avalonia"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:fluentIcons="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"
xmlns:converters="clr-namespace:StabilityMatrix.Avalonia.Converters"
xmlns:avaloniaEdit="https://github.com/avaloniaui/avaloniaedit"
xmlns:inference="clr-namespace:StabilityMatrix.Avalonia.Models.Inference"
xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia.Tight"
xmlns:vmBase="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Base"
xmlns:local="clr-namespace:StabilityMatrix.Avalonia"
Focusable="True"
d:DataContext="{x:Static mocks:DesignData.SettingsViewModel}"
d:DesignHeight="700"
@ -24,513 +19,33 @@
mc:Ignorable="d">
<controls:UserControlBase.Resources>
<converters:CultureInfoDisplayConverter x:Key="CultureInfoDisplayConverter" />
<x:Double x:Key="BreadcrumbBarItemThemeFontSize">24</x:Double>
<x:Double x:Key="BreadcrumbBarChevronFontSize">17</x:Double>
<Thickness x:Key="BreadcrumbBarChevronPadding">6,3</Thickness>
<FontWeight x:Key="BreadcrumbBarItemFontWeight">Medium</FontWeight>
</controls:UserControlBase.Resources>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="8,16" Spacing="8">
<!-- Theme -->
<Grid RowDefinitions="Auto,*,*">
<TextBlock
Margin="0,0,0,8"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_Appearance}" />
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0,8,4"
Header="{x:Static lang:Resources.Label_Theme}"
IconSource="WeatherMoon">
<ui:SettingsExpander.Footer>
<ComboBox
MinWidth="100"
ItemsSource="{Binding AvailableThemes}"
SelectedItem="{Binding SelectedTheme}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander
Grid.Row="2"
Margin="8,0,8,4"
Header="{x:Static lang:Resources.Label_Language}"
IconSource="Character">
<ui:SettingsExpander.Footer>
<ComboBox
MinWidth="100"
ItemsSource="{Binding AvailableLanguages}"
DisplayMemberBinding="{Binding Converter={StaticResource CultureInfoDisplayConverter}}"
SelectedItem="{Binding SelectedLanguage}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Grid>
<!-- Checkpoints Manager Options -->
<Grid RowDefinitions="auto,*,Auto">
<TextBlock
Margin="0,0,0,8"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_CheckpointManager}" />
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0"
Description="{x:Static lang:Resources.Label_RemoveSymlinksOnShutdown_Details}"
Header="{x:Static lang:Resources.Label_RemoveSymlinksOnShutdown}"
IconSource="Folder">
<ui:SettingsExpander.Footer>
<CheckBox Margin="8" IsChecked="{Binding RemoveSymlinksOnShutdown}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander
Grid.Row="2"
Margin="8,4"
Description="{x:Static lang:Resources.Label_ResetCheckpointsCache_Details}"
Header="{x:Static lang:Resources.Label_ResetCheckpointsCache}"
IconSource="Refresh">
<ui:SettingsExpander.Footer>
<Button
Command="{Binding ResetCheckpointCache}"
Content="{x:Static lang:Resources.Label_ResetCheckpointsCache}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Grid>
<!-- Inference UI -->
<Grid Margin="0,8,0,0" RowDefinitions="auto,*,*,*">
<TextBlock
FontWeight="Medium"
Text="Inference"
Margin="0,0,0,8" />
<!-- Auto Completion -->
<ui:SettingsExpander Grid.Row="1"
Header="Prompt Auto Completion"
Margin="8,0,8,4">
<ui:SettingsExpander.IconSource>
<controls:FASymbolIconSource Symbol="fa-solid fa-wand-magic-sparkles"/>
</ui:SettingsExpander.IconSource>
<!-- Enable toggle -->
<ui:SettingsExpanderItem Content="Enable">
<ui:SettingsExpanderItem.Footer>
<ToggleSwitch
IsChecked="{Binding IsPromptCompletionEnabled}" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<!-- Tag csv selection -->
<ui:SettingsExpanderItem Content="Tag Source"
IconSource="Tag"
IsEnabled="{Binding IsPromptCompletionEnabled}"
Description="Tags to use for completion in .csv format (Compatible with a1111-sd-webui-tagcomplete)">
<ui:SettingsExpanderItem.Footer>
<ui:FAComboBox
ItemsSource="{Binding AvailableTagCompletionCsvs}"
SelectedItem="{Binding SelectedTagCompletionCsv}"/>
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<!-- Tag csv import -->
<ui:SettingsExpanderItem Content="Import Tag Source .csv"
IconSource="Add"
IsEnabled="{Binding IsPromptCompletionEnabled}">
<ui:SettingsExpanderItem.Footer>
<Button
Command="{Binding ImportTagCsvCommand}"
Content="Import"/>
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<!-- Remove underscores -->
<ui:SettingsExpanderItem
Content="Replace underscores with spaces when inserting completions"
IconSource="Underline"
IsEnabled="{Binding IsPromptCompletionEnabled}">
<ui:SettingsExpanderItem.Footer>
<CheckBox Margin="8"
IsChecked="{Binding IsCompletionRemoveUnderscoresEnabled}"/>
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<!-- Image Viewer -->
<ui:SettingsExpander Grid.Row="2"
Header="Image Viewer"
IconSource="Image"
Margin="8,0,8,4">
<!-- Pixel grid -->
<ui:SettingsExpanderItem
Content="Show pixel grid at high zoom levels"
IconSource="ViewAll">
<ui:SettingsExpanderItem.Footer>
<CheckBox Margin="8"
IsChecked="{Binding IsImageViewerPixelGridEnabled}"/>
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<!-- Output Image Files -->
<ui:SettingsExpander Grid.Row="3"
Header="Output Image Files"
Margin="8,0,8,4">
<ui:SettingsExpander.IconSource>
<fluentIcons:SymbolIconSource Symbol="TabDesktopImage"/>
</ui:SettingsExpander.IconSource>
<!-- File name pattern -->
<ui:SettingsExpanderItem
Content="File name pattern"
Description="{Binding OutputImageFileNameFormatSample}"
IconSource="Rename">
<ui:SettingsExpanderItem.Footer>
<TextBox
Name="OutputImageFileNameFormatTextBox"
Watermark="{x:Static inference:FileNameFormat.DefaultTemplate}"
FontSize="13"
MinWidth="150"
Text="{Binding OutputImageFileNameFormat}"
FontFamily="Cascadia Code,Consolas,Menlo,Monospace"/>
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<ui:TeachingTip
IsOpen="{Binding #OutputImageFileNameFormatTextBox.IsFocused}"
Target="{Binding #OutputImageFileNameFormatTextBox, Mode=OneWay}"
PreferredPlacement="Top"
Title="Format Variables"
Grid.Row="3">
<DataGrid
AutoGenerateColumns="True"
ItemsSource="{Binding OutputImageFileNameFormatVars}" />
<!--<mdxaml:MarkdownScrollViewer
Markdown="{Binding OutputImageFileNameFormatGuideMarkdown}"/>-->
</ui:TeachingTip>
</Grid>
<!-- Environment Options -->
<Grid RowDefinitions="Auto, Auto, Auto">
<TextBlock
Margin="0,0,0,8"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_PackageEnvironment}" />
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0"
Header="{x:Static lang:Resources.Label_EnvironmentVariables}"
IconSource="OtherUser">
<ui:SettingsExpander.Footer>
<Button
Command="{Binding OpenEnvVarsDialogCommand}"
Content="{x:Static lang:Resources.Action_Edit}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander
Grid.Row="2"
Margin="8,4"
Header="{x:Static lang:Resources.Label_EmbeddedPython}">
<ui:SettingsExpander.IconSource>
<controls:FASymbolIconSource Symbol="fa-brands fa-python" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<StackPanel Orientation="Horizontal" Spacing="16">
<controls:ProgressRing
BorderThickness="3"
IsEnabled="{Binding CheckPythonVersionCommand.IsRunning}"
IsIndeterminate="True"
IsVisible="{Binding CheckPythonVersionCommand.IsRunning}" />
<Button Command="{Binding CheckPythonVersionCommand}" Content="{x:Static lang:Resources.Action_CheckVersion}" />
</StackPanel>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Grid>
<!-- Integrations -->
<Grid RowDefinitions="auto,*">
<TextBlock
Margin="0,0,0,8"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_Integrations}" />
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0,8,4"
Header="{x:Static lang:Resources.Label_DiscordRichPresence}">
<ui:SettingsExpander.IconSource>
<controls:FASymbolIconSource Symbol="fa-brands fa-discord" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding IsDiscordRichPresenceEnabled}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Grid>
<!-- System Options -->
<Grid RowDefinitions="auto, auto, auto">
<TextBlock
Margin="0,0,0,8"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_System}" />
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0,8,4"
Description="{x:Static lang:Resources.Label_AddToStartMenu_Details}"
Header="{x:Static lang:Resources.Label_AddToStartMenu}"
IconSource="StarAdd"
ToolTip.Tip="{OnPlatform Default={x:Static lang:Resources.Label_OnlyAvailableOnWindows}, Windows={x:Null}}">
<ui:SettingsExpander.Footer>
<StackPanel Orientation="Horizontal" Spacing="8">
<controls:ProgressRing
BorderThickness="3"
IsEnabled="{Binding IsVisible, RelativeSource={RelativeSource Self}}"
IsIndeterminate="True">
<controls:ProgressRing.IsVisible>
<MultiBinding Converter="{x:Static BoolConverters.Or}">
<Binding Path="AddToStartMenuCommand.IsRunning" />
<Binding Path="AddToGlobalStartMenuCommand.IsRunning" />
</MultiBinding>
</controls:ProgressRing.IsVisible>
</controls:ProgressRing>
<SplitButton
Command="{Binding AddToStartMenuCommand}"
Content="{x:Static lang:Resources.Action_AddForCurrentUser}"
IsEnabled="{OnPlatform Default=False, Windows=True}">
<SplitButton.Flyout>
<ui:FAMenuFlyout Placement="Bottom">
<ui:MenuFlyoutItem
Command="{Binding AddToGlobalStartMenuCommand}"
IconSource="Admin"
Text="{x:Static lang:Resources.Action_AddForAllUsers}" />
</ui:FAMenuFlyout>
</SplitButton.Flyout>
</SplitButton>
</StackPanel>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander
Grid.Row="2"
Margin="8,0"
Description="{x:Static lang:Resources.Label_SelectNewDataDirectory_Details}"
IconSource="MoveToFolder">
<ui:SettingsExpander.Header>
<StackPanel Orientation="Vertical">
<TextBlock Text="{x:Static lang:Resources.Label_SelectNewDataDirectory}"/>
<TextBlock Foreground="{DynamicResource TextFillColorSecondaryBrush}"
FontSize="12">
<Run Text="{x:Static lang:Resources.Label_CurrentDirectory}"/>
<Run Text="{Binding DataDirectory}"/>
</TextBlock>
</StackPanel>
</ui:SettingsExpander.Header>
<ui:SettingsExpander.Footer>
<Button Command="{Binding PickNewDataDirectory}">
<Grid ColumnDefinitions="Auto, Auto">
<avalonia:Icon
<Grid RowDefinitions="Auto,*">
<ui:BreadcrumbBar
Grid.Row="0"
Margin="0,0,8,0"
VerticalAlignment="Center"
Value="fa-solid fa-folder-open" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
Text="{x:Static lang:Resources.Action_SelectDirectory}" />
</Grid>
</Button>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Grid>
<!-- Debug Options -->
<Grid
IsVisible="{Binding SharedState.IsDebugMode}"
RowDefinitions="auto,*">
<TextBlock
Margin="0,0,0,8"
FontWeight="Medium"
Text="Debug Options" />
<ui:SettingsExpander
Grid.Row="1"
Margin="8,0,8,0"
Command="{Binding LoadDebugInfo}"
Header="Debug Options"
IconSource="Code">
<ui:SettingsExpanderItem
Margin="4,0"
Description="Paths"
IconSource="Folder">
<SelectableTextBlock
Foreground="{DynamicResource TextControlPlaceholderForeground}"
Text="{Binding DebugPaths}"
TextWrapping="WrapWithOverflow" />
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Description="Compat Info"
IconSource="StarFilled">
<SelectableTextBlock
Foreground="{DynamicResource TextControlPlaceholderForeground}"
Text="{Binding DebugCompatInfo}"
TextWrapping="WrapWithOverflow" />
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Description="GPU Info"
IconSource="FullScreenMaximize">
<SelectableTextBlock
Foreground="{DynamicResource TextControlPlaceholderForeground}"
Text="{Binding DebugGpuInfo}"
TextWrapping="WrapWithOverflow" />
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Content="Animation Scale"
Description="Lower values = faster animations. 0x means animations are instant."
IconSource="Clock">
<ui:SettingsExpanderItem.Footer>
<ComboBox ItemsSource="{Binding AnimationScaleOptions}" SelectedItem="{Binding SelectedAnimationScale}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock>
<Run Text="{Binding}" /><Run Text="x" />
</TextBlock>
Margin="16,8"
x:Name="BreadcrumbBar"
ItemsSource="{Binding CurrentPagePath}">
<ui:BreadcrumbBar.ItemTemplate>
<DataTemplate x:DataType="vmBase:PageViewModelBase">
<ui:BreadcrumbBarItem Content="{Binding Title}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Content="Notification"
IconSource="CommentAdd">
<ui:SettingsExpanderItem.Footer>
<Button Command="{Binding DebugNotificationCommand}" Content="New Notification" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
</ui:BreadcrumbBar.ItemTemplate>
</ui:BreadcrumbBar>
<ui:SettingsExpanderItem
Margin="4,0"
Content="Content Dialog"
IconSource="NewWindow">
<ui:SettingsExpanderItem.Footer>
<Button Command="{Binding DebugContentDialogCommand}" Content="Show Dialog" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0"
Content="Exceptions"
IconSource="Flag">
<ui:SettingsExpanderItem.Footer>
<Button Command="{Binding DebugThrowExceptionCommand}" Content="Unhandled Exception" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0,4,4"
Content="Download Manager tests"
IconSource="Flag">
<ui:SettingsExpanderItem.Footer>
<Button
Margin="0,8"
Command="{Binding DebugTrackedDownloadCommand}"
Content="Add Tracked Download" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0,4,4"
Content="Refresh Models Index"
IconSource="SyncFolder">
<ui:SettingsExpanderItem.Footer>
<Button
Margin="0,8"
Command="{Binding DebugRefreshModelsIndexCommand}"
Content="Refresh Index" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0,4,4"
Content="Make image grid"
IconSource="Image">
<ui:SettingsExpanderItem.Footer>
<Button
Margin="0,8"
Command="{Binding DebugMakeImageGridCommand}"
Content="Select images" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
<ui:SettingsExpanderItem
Margin="4,0,4,4"
Content="Image metadata parser"
IconSource="Flag">
<ui:SettingsExpanderItem.Footer>
<Button
Margin="0,8"
Command="{Binding DebugImageMetadataCommand}"
Content="Choose image" />
</ui:SettingsExpanderItem.Footer>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
</Grid>
<!-- TODO: Directories card -->
<Grid RowDefinitions="auto,*">
<StackPanel
<ui:Frame
Grid.Row="1"
HorizontalAlignment="Left"
Orientation="Vertical">
<TextBlock
Margin="0,8"
FontSize="15"
FontWeight="Bold"
Text="{x:Static lang:Resources.Label_About}" />
<Image
Width="112"
Height="112"
Margin="8"
HorizontalAlignment="Left"
Source="/Assets/Icon.png" />
<TextBlock
Margin="8"
FontWeight="Medium"
Text="{x:Static lang:Resources.Label_StabilityMatrix}" />
<Panel>
<Button
Name="VersionButton"
Margin="8,0,8,8"
Padding="2,0,2,0"
BorderThickness="0"
Classes="transparent"
Command="{Binding OnVersionClick}"
Content="{Binding AppVersion}" />
<ui:TeachingTip
Title="{Binding VersionFlyoutText}"
IsOpen="{Binding IsVersionTapTeachingTipOpen}"
PreferredPlacement="RightTop"
Target="{Binding #VersionButton}" />
</Panel>
<StackPanel HorizontalAlignment="Left" Orientation="Horizontal">
<Button
Margin="8"
HorizontalAlignment="Left"
Command="{Binding ShowLicensesDialogCommand}"
Content="{x:Static lang:Resources.Label_LicenseAndOpenSourceNotices}" />
</StackPanel>
</StackPanel>
Name="FrameView">
<ui:Frame.NavigationPageFactory>
<local:ViewLocator/>
</ui:Frame.NavigationPageFactory>
</ui:Frame>
</Grid>
<!-- Extra space at the bottom -->
<Panel Margin="0,0,0,16" />
</StackPanel>
</ScrollViewer>
</controls:UserControlBase>

87
StabilityMatrix.Avalonia/Views/SettingsPage.axaml.cs

@ -1,5 +1,19 @@
using Avalonia.Markup.Xaml;
using System;
using System.ComponentModel;
using System.Linq;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using Avalonia.Threading;
using FluentAvalonia.UI.Controls;
using FluentAvalonia.UI.Media.Animation;
using FluentAvalonia.UI.Navigation;
using Microsoft.Extensions.DependencyInjection;
using StabilityMatrix.Avalonia.Animations;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Core.Attributes;
namespace StabilityMatrix.Avalonia.Views;
@ -7,13 +21,80 @@ namespace StabilityMatrix.Avalonia.Views;
[Singleton]
public partial class SettingsPage : UserControlBase
{
private readonly INavigationService<SettingsViewModel> settingsNavigationService;
private bool hasLoaded;
private SettingsViewModel ViewModel => (SettingsViewModel)DataContext!;
[DesignOnly(true)]
[Obsolete("For XAML use only", true)]
public SettingsPage()
: this(App.Services.GetRequiredService<INavigationService<SettingsViewModel>>()) { }
public SettingsPage(INavigationService<SettingsViewModel> settingsNavigationService)
{
this.settingsNavigationService = settingsNavigationService;
InitializeComponent();
settingsNavigationService.SetFrame(FrameView);
settingsNavigationService.TypedNavigation += NavigationService_OnTypedNavigation;
FrameView.Navigated += FrameView_Navigated;
BreadcrumbBar.ItemClicked += BreadcrumbBar_ItemClicked;
}
/// <inheritdoc />
protected override void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);
if (!hasLoaded)
{
// Initial load, navigate to first page
Dispatcher.UIThread.Post(
() =>
settingsNavigationService.NavigateTo(
ViewModel.SubPages[0],
new SuppressNavigationTransitionInfo()
)
);
hasLoaded = true;
}
}
private void InitializeComponent()
private void NavigationService_OnTypedNavigation(object? sender, TypedNavigationEventArgs e)
{
AvaloniaXamlLoader.Load(this);
ViewModel.CurrentPage = ViewModel.SubPages.FirstOrDefault(
x => x.GetType() == e.ViewModelType
);
}
private async void FrameView_Navigated(object? sender, NavigationEventArgs args)
{
if (args.Content is not PageViewModelBase vm)
{
return;
}
ViewModel.CurrentPage = vm;
}
private async void BreadcrumbBar_ItemClicked(
BreadcrumbBar sender,
BreadcrumbBarItemClickedEventArgs args
)
{
// Skip if already on same page
if (args.Item is not PageViewModelBase viewModel || viewModel == ViewModel.CurrentPage)
{
return;
}
settingsNavigationService.NavigateTo(
viewModel,
BetterSlideNavigationTransition.PageSlideFromLeft
);
}
}

48
StabilityMatrix.Core/Api/ICivitTRPCApi.cs

@ -0,0 +1,48 @@
using Refit;
using StabilityMatrix.Core.Models.Api.CivitTRPC;
namespace StabilityMatrix.Core.Api;
[Headers(
"Content-Type: application/x-www-form-urlencoded",
"Referer: https://civitai.com",
"Origin: https://civitai.com"
)]
public interface ICivitTRPCApi
{
[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
);
}

66
StabilityMatrix.Core/Api/ILykosAuthApi.cs

@ -0,0 +1,66 @@
using System.Net;
using Refit;
using StabilityMatrix.Core.Models.Api.Lykos;
namespace StabilityMatrix.Core.Api;
[Headers("User-Agent: StabilityMatrix")]
public interface ILykosAuthApi
{
[Headers("Authorization: Bearer")]
[Get("/api/Users/{email}")]
Task<GetUserResponse> GetUser(string email, CancellationToken cancellationToken = default);
[Headers("Authorization: Bearer")]
[Get("/api/Users/me")]
Task<GetUserResponse> GetUserSelf(CancellationToken cancellationToken = default);
[Post("/api/Accounts")]
Task<LykosAccountTokens> PostAccount(
[Body] PostAccountRequest request,
CancellationToken cancellationToken = default
);
[Post("/api/Login")]
Task<LykosAccountTokens> PostLogin(
[Body] PostLoginRequest request,
CancellationToken cancellationToken = default
);
[Post("/api/Login/Refresh")]
Task<LykosAccountTokens> PostLoginRefresh(
[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);
}

7
StabilityMatrix.Core/Api/ITokenProvider.cs

@ -0,0 +1,7 @@
namespace StabilityMatrix.Core.Api;
public interface ITokenProvider
{
Task<string> GetAccessTokenAsync();
Task<(string AccessToken, string RefreshToken)> RefreshTokensAsync();
}

52
StabilityMatrix.Core/Api/LykosAuthTokenProvider.cs

@ -0,0 +1,52 @@
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Models.Api.Lykos;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Core.Api;
[Singleton]
public class LykosAuthTokenProvider : ITokenProvider
{
private readonly ISecretsManager secretsManager;
private readonly Lazy<ILykosAuthApi> lazyLykosAuthApi;
public LykosAuthTokenProvider(
Lazy<ILykosAuthApi> lazyLykosAuthApi,
ISecretsManager secretsManager
)
{
// Lazy as instantiating requires the current class to be instantiated.
this.lazyLykosAuthApi = lazyLykosAuthApi;
this.secretsManager = secretsManager;
}
/// <inheritdoc />
public async Task<string> GetAccessTokenAsync()
{
var secrets = await secretsManager.SafeLoadAsync().ConfigureAwait(false);
return secrets.LykosAccount?.AccessToken ?? "";
}
/// <inheritdoc />
public async Task<(string AccessToken, string RefreshToken)> RefreshTokensAsync()
{
var secrets = await secretsManager.SafeLoadAsync().ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(secrets.LykosAccount?.RefreshToken))
{
throw new InvalidOperationException("No refresh token found");
}
var lykosAuthApi = lazyLykosAuthApi.Value;
var newTokens = await lykosAuthApi
.PostLoginRefresh(new PostLoginRefreshRequest(secrets.LykosAccount.RefreshToken))
.ConfigureAwait(false);
secrets = secrets with { LykosAccount = newTokens };
await secretsManager.SaveAsync(secrets).ConfigureAwait(false);
return (newTokens.AccessToken, newTokens.RefreshToken);
}
}

69
StabilityMatrix.Core/Api/TokenAuthHeaderHandler.cs

@ -0,0 +1,69 @@
using System.Net;
using System.Net.Http.Headers;
using NLog;
using Polly;
using Polly.Retry;
using StabilityMatrix.Core.Helper;
namespace StabilityMatrix.Core.Api;
public class TokenAuthHeaderHandler : DelegatingHandler
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private readonly AsyncRetryPolicy<HttpResponseMessage> policy;
private readonly ITokenProvider tokenProvider;
public TokenAuthHeaderHandler(ITokenProvider tokenProvider)
{
this.tokenProvider = tokenProvider;
policy = Policy
.HandleResult<HttpResponseMessage>(
r =>
r.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden
&& r.RequestMessage?.Headers.Authorization
is { Scheme: "Bearer", Parameter: not null }
)
.RetryAsync(
async (result, _) =>
{
var oldToken = ObjectHash.GetStringSignature(
await tokenProvider.GetAccessTokenAsync().ConfigureAwait(false)
);
Logger.Info(
"Refreshing access token for status ({StatusCode})",
result.Result.StatusCode
);
var (newToken, _) = await tokenProvider
.RefreshTokensAsync()
.ConfigureAwait(false);
Logger.Info(
"Access token refreshed: {OldToken} -> {NewToken}",
ObjectHash.GetStringSignature(oldToken),
ObjectHash.GetStringSignature(newToken)
);
}
);
// InnerHandler must be left as null when using DI, but must be assigned a value when
// using RestService.For<IMyApi>
// InnerHandler = new HttpClientHandler();
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken
)
{
return policy.ExecuteAsync(async () =>
{
var accessToken = await tokenProvider.GetAccessTokenAsync().ConfigureAwait(false);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
});
}
}

11
StabilityMatrix.Core/Attributes/SingletonAttribute.cs

@ -3,16 +3,25 @@
namespace StabilityMatrix.Core.Attributes;
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]
[AttributeUsage(AttributeTargets.Class)]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
public class SingletonAttribute : Attribute
{
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]
public Type? InterfaceType { get; init; }
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]
public Type? ImplType { get; init; }
public SingletonAttribute() { }
public SingletonAttribute(Type interfaceType)
{
InterfaceType = interfaceType;
}
public SingletonAttribute(Type interfaceType, Type implType)
{
InterfaceType = implType;
ImplType = implType;
}
}

12
StabilityMatrix.Core/Exceptions/AppException.cs

@ -5,5 +5,17 @@ namespace StabilityMatrix.Core.Exceptions;
/// </summary>
public class AppException : ApplicationException
{
public override string Message { get; }
public string? Details { get; init; }
public AppException(string message)
{
Message = message;
}
public AppException(string message, string details)
{
Message = message;
Details = details;
}
}

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('/')}"
)
);
}
}

5
StabilityMatrix.Core/Helper/CodeTimer.cs

@ -11,6 +11,7 @@ public class CodeTimer : IDisposable
private readonly string name;
private readonly Stopwatch stopwatch;
private bool isDisposed;
private CodeTimer? ParentTimer { get; }
private List<CodeTimer> SubTimers { get; } = new();
@ -97,6 +98,9 @@ public class CodeTimer : IDisposable
public void Dispose()
{
if (isDisposed)
return;
stopwatch.Stop();
// Remove ourselves from the stack
@ -119,6 +123,7 @@ public class CodeTimer : IDisposable
SubTimers.Clear();
}
isDisposed = true;
GC.SuppressFinalize(this);
}
}

25
StabilityMatrix.Core/Helper/LazyInstance.cs

@ -0,0 +1,25 @@
using Microsoft.Extensions.DependencyInjection;
namespace StabilityMatrix.Core.Helper;
/// <summary>
/// Lazy instance of a DI service.
/// </summary>
public class LazyInstance<T> : Lazy<T>
where T : notnull
{
public LazyInstance(IServiceProvider serviceProvider)
: base(serviceProvider.GetRequiredService<T>) { }
}
public static class LazyInstanceServiceExtensions
{
/// <summary>
/// Register <see cref="LazyInstance{T}"/> to be used when resolving <see cref="Lazy{T}"/> instances.
/// </summary>
public static IServiceCollection AddLazyInstance(this IServiceCollection services)
{
services.AddTransient(typeof(Lazy<>), typeof(LazyInstance<>));
return services;
}
}

26
StabilityMatrix.Core/Helper/ObjectHash.cs

@ -1,9 +1,10 @@
using System.Text;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StabilityMatrix.Core.Helper;
public class ObjectHash
public static class ObjectHash
{
/// <summary>
/// Return a GUID based on the MD5 hash of the JSON representation of the object.
@ -16,4 +17,25 @@ public class ObjectHash
var hash = md5.ComputeHash(bytes);
return new Guid(hash);
}
/// <summary>
/// Return a short Sha256 signature of a string
/// </summary>
public static string GetStringSignature(string? str)
{
if (str is null)
{
return "null";
}
if (string.IsNullOrEmpty(str))
{
return "";
}
var bytes = Encoding.UTF8.GetBytes(str);
var hash = Convert.ToBase64String(SHA256.HashData(bytes));
return $"[..{str.Length}, {hash[..7]}]";
}
}

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; }
}
}

20
StabilityMatrix.Core/Models/Api/CivitTRPC/CivitUserProfileRequest.cs

@ -0,0 +1,20 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Web;
namespace StabilityMatrix.Core.Models.Api.CivitTRPC;
public record CivitUserProfileRequest : IFormattable
{
[JsonPropertyName("username")]
public required string Username { get; set; }
[JsonPropertyName("authed")]
public bool Authed { get; set; }
/// <inheritdoc />
public string ToString(string? format, IFormatProvider? formatProvider)
{
return JsonSerializer.Serialize(new { json = this });
}
}

91
StabilityMatrix.Core/Models/Api/CivitTRPC/CivitUserProfileResponse.cs

@ -0,0 +1,91 @@
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace StabilityMatrix.Core.Models.Api.CivitTRPC;
/*
* Example:
* {
"result": {
"data": {
"json": {
"id": 1020931,
"username": "owo",
"deletedAt": null,
"image": "https://lh3.googleusercontent.com/a/...",
"leaderboardShowcase": null,
"createdAt": "2023-02-01T21:05:31.125Z",
"cosmetics": [],
"links": [],
"rank": null,
"stats": null,
"profile": {
"bio": null,
"coverImageId": null,
"coverImage": null,
"message": null,
"messageAddedAt": null,
"profileSectionsSettings": [
{
"key": "showcase",
"enabled": true
},
{
"key": "popularModels",
"enabled": true
},
{
"key": "popularArticles",
"enabled": true
},
{
"key": "modelsOverview",
"enabled": true
},
{
"key": "imagesOverview",
"enabled": true
},
{
"key": "recentReviews",
"enabled": true
}
],
"privacySettings": {
"showFollowerCount": true,
"showReviewsRating": true,
"showFollowingCount": true
},
"showcaseItems": [],
"location": null,
"nsfw": false,
"userId": 1020931
}
},
"meta": {
"values": {
"createdAt": [
"Date"
]
}
}
}
}
}
*
*/
public record CivitUserProfileResponse
{
[JsonPropertyName("result")]
public required JsonObject Result { get; init; }
public int? UserId => Result["data"]?["json"]?["id"]?.GetValue<int>();
public string? Username => Result["data"]?["json"]?["username"]?.GetValue<string>();
public string? ImageUrl => Result["data"]?["json"]?["image"]?.GetValue<string>();
public DateTimeOffset? CreatedAt =>
Result["data"]?["json"]?["createdAt"]?.GetValue<DateTimeOffset>();
}

9
StabilityMatrix.Core/Models/Api/Lykos/GetUserResponse.cs

@ -0,0 +1,9 @@
namespace StabilityMatrix.Core.Models.Api.Lykos;
public record GetUserResponse(
string Id,
LykosAccount Account,
int UserLevel,
string PatreonId,
bool IsEmailVerified
);

3
StabilityMatrix.Core/Models/Api/Lykos/LykosAccount.cs

@ -0,0 +1,3 @@
namespace StabilityMatrix.Core.Models.Api.Lykos;
public record LykosAccount(string Id, string Name);

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

@ -0,0 +1,12 @@
namespace StabilityMatrix.Core.Models.Api.Lykos;
public class LykosAccountStatusUpdateEventArgs : EventArgs
{
public static LykosAccountStatusUpdateEventArgs Disconnected { get; } = new();
public bool IsConnected { get; init; }
public GetUserResponse? User { get; init; }
public bool IsPatreonConnected => User?.PatreonId != null;
}

3
StabilityMatrix.Core/Models/Api/Lykos/LykosAccountTokens.cs

@ -0,0 +1,3 @@
namespace StabilityMatrix.Core.Models.Api.Lykos;
public record LykosAccountTokens(string AccessToken, string RefreshToken);

8
StabilityMatrix.Core/Models/Api/Lykos/PostAccountRequest.cs

@ -0,0 +1,8 @@
namespace StabilityMatrix.Core.Models.Api.Lykos;
public record PostAccountRequest(
string Email,
string Password,
string ConfirmPassword,
string AccountName
);

3
StabilityMatrix.Core/Models/Api/Lykos/PostLoginRefreshRequest.cs

@ -0,0 +1,3 @@
namespace StabilityMatrix.Core.Models.Api.Lykos;
public record PostLoginRefreshRequest(string RefreshToken);

3
StabilityMatrix.Core/Models/Api/Lykos/PostLoginRequest.cs

@ -0,0 +1,3 @@
namespace StabilityMatrix.Core.Models.Api.Lykos;
public record PostLoginRequest(string Email, string Password);

93
StabilityMatrix.Core/Models/GlobalUserSecrets.cs → StabilityMatrix.Core/Models/GlobalEncryptedSerializer.cs

@ -2,44 +2,59 @@
using System.Security;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using DeviceId;
using StabilityMatrix.Core.Models.FileInterfaces;
namespace StabilityMatrix.Core.Models;
internal record struct KeyInfo(byte[] Key, byte[] Salt, int Iterations);
/// <summary>
/// Global instance of user secrets.
/// Stored in %APPDATA%\StabilityMatrix\user-secrets.data
/// Encrypted MessagePack Serializer that uses a global key derived from the computer's SID.
/// Header contains additional random entropy as a salt that is used in decryption.
/// </summary>
public class GlobalUserSecrets
public static class GlobalEncryptedSerializer
{
private const int KeySize = 32;
private const int Iterations = 300;
private const int SaltSize = 16;
[JsonIgnore]
public static FilePath File { get; } = GlobalConfig.HomeDir + "user-secrets.data";
public static T Deserialize<T>(ReadOnlySpan<byte> data)
{
// Get salt from start of file
var salt = data[..SaltSize].ToArray();
// Get encrypted json from rest of file
var encryptedJson = data[SaltSize..];
var json = DecryptBytes(encryptedJson, salt);
return JsonSerializer.Deserialize<T>(json)
?? throw new Exception("Deserialize returned null");
}
public Dictionary<string, string> PatreonCookies { get; set; } = new();
public static byte[] Serialize<T>(T obj)
{
var json = JsonSerializer.SerializeToUtf8Bytes(obj);
var (encrypted, salt) = EncryptBytes(json);
// Prepend salt to encrypted json
var fileBytes = salt.Concat(encrypted).ToArray();
return fileBytes;
}
private static string? GetComputerSid()
{
var deviceId = new DeviceIdBuilder()
.AddMachineName()
.AddOsVersion()
.OnWindows(windows => windows
.OnWindows(
windows =>
windows
.AddProcessorId()
.AddMotherboardSerialNumber()
.AddSystemDriveSerialNumber())
.OnLinux(linux => linux
.AddMotherboardSerialNumber()
.AddSystemDriveSerialNumber())
.OnMac(mac => mac
.AddSystemDriveSerialNumber()
.AddPlatformSerialNumber())
)
.OnLinux(linux => linux.AddMotherboardSerialNumber().AddSystemDriveSerialNumber())
.OnMac(mac => mac.AddSystemDriveSerialNumber().AddPlatformSerialNumber())
.ToString();
return deviceId;
@ -63,14 +78,24 @@ public class GlobalUserSecrets
return result;
}
private static KeyInfo DeriveKeyWithSalt(SecureString password, int saltLength, int iterations, int keyLength)
private static KeyInfo DeriveKeyWithSalt(
SecureString password,
int saltLength,
int iterations,
int keyLength
)
{
var salt = RandomNumberGenerator.GetBytes(saltLength);
var key = DeriveKey(password, salt, iterations, keyLength);
return new KeyInfo(key, salt, iterations);
}
private static byte[] DeriveKey(SecureString password, byte[] salt, int iterations, int keyLength)
private static byte[] DeriveKey(
SecureString password,
byte[] salt,
int iterations,
int keyLength
)
{
var ptr = Marshal.SecureStringToBSTR(password);
try
@ -85,7 +110,7 @@ public class GlobalUserSecrets
passwordByteArray[i] = Marshal.ReadByte(ptr, i);
}
using var rfc2898 = new Rfc2898DeriveBytes(passwordByteArray, salt, iterations);
using var rfc2898 = new Rfc2898DeriveBytes(passwordByteArray, salt, iterations, HashAlgorithmName.SHA512);
return rfc2898.GetBytes(keyLength);
}
finally
@ -102,8 +127,7 @@ public class GlobalUserSecrets
private static (byte[], byte[]) EncryptBytes(byte[] data)
{
var keyInfo =
DeriveKeyWithSalt(GetComputerKeyPhrase(), SaltSize, Iterations, KeySize);
var keyInfo = DeriveKeyWithSalt(GetComputerKeyPhrase(), SaltSize, Iterations, KeySize);
using var aes = Aes.Create();
aes.Key = keyInfo.Key;
@ -115,10 +139,9 @@ public class GlobalUserSecrets
return (transform.TransformFinalBlock(data, 0, data.Length), keyInfo.Salt);
}
private static byte[] DecryptBytes(IReadOnlyCollection<byte> encryptedData, byte[] salt)
private static byte[] DecryptBytes(ReadOnlySpan<byte> encryptedData, byte[] salt)
{
var key =
DeriveKey(GetComputerKeyPhrase(), salt, Iterations, KeySize);
var key = DeriveKey(GetComputerKeyPhrase(), salt, Iterations, KeySize);
using var aes = Aes.Create();
aes.Key = key;
@ -127,28 +150,6 @@ public class GlobalUserSecrets
aes.Mode = CipherMode.CBC;
var transform = aes.CreateDecryptor();
return transform.TransformFinalBlock(encryptedData.ToArray(), 0, encryptedData.Count);
}
public void SaveToFile()
{
var json = JsonSerializer.SerializeToUtf8Bytes(this);
var (encrypted, salt) = EncryptBytes(json);
// Prepend salt to encrypted json
var fileBytes = salt.Concat(encrypted).ToArray();
File.WriteAllBytes(fileBytes);
}
public static GlobalUserSecrets? LoadFromFile()
{
var fileBytes = File.ReadAllBytes();
// Get salt from start of file
var salt = fileBytes.AsSpan(0, SaltSize).ToArray();
// Get encrypted json from rest of file
var encryptedJson = fileBytes.AsSpan(SaltSize).ToArray();
var json = DecryptBytes(encryptedJson, salt);
return JsonSerializer.Deserialize<GlobalUserSecrets>(json);
return transform.TransformFinalBlock(encryptedData.ToArray(), 0, encryptedData.Length);
}
}

11
StabilityMatrix.Core/Models/Secrets.cs

@ -0,0 +1,11 @@
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

@ -99,6 +99,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;
}
}

85
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,
@ -117,11 +127,16 @@ public class DownloadService : IDownloadService
? httpClientFactory.CreateClient()
: httpClientFactory.CreateClient(httpClientName);
using var noRedirectClient = httpClientFactory.CreateClient("DontFollowRedirects");
client.Timeout = TimeSpan.FromMinutes(10);
client.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue("StabilityMatrix", "2.0")
);
await AddConditionalHeaders(client, new Uri(downloadUrl)).ConfigureAwait(false);
await AddConditionalHeaders(noRedirectClient, new Uri(downloadUrl)).ConfigureAwait(false);
// Create file if it doesn't exist
if (!File.Exists(downloadPath))
{
@ -144,10 +159,10 @@ public class DownloadService : IDownloadService
// Total of the original content
long originalContentLength = 0;
using var request = new HttpRequestMessage();
request.Method = HttpMethod.Get;
request.RequestUri = new Uri(downloadUrl);
request.Headers.Range = new RangeHeaderValue(existingFileSize, null);
using var noRedirectRequest = new HttpRequestMessage();
noRedirectRequest.Method = HttpMethod.Get;
noRedirectRequest.RequestUri = new Uri(downloadUrl);
noRedirectRequest.Headers.Range = new RangeHeaderValue(existingFileSize, null);
HttpResponseMessage? response = null;
foreach (
@ -157,9 +172,38 @@ public class DownloadService : IDownloadService
)
)
{
var noRedirectResponse = await noRedirectClient
.SendAsync(
noRedirectRequest,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken
)
.ConfigureAwait(false);
if (
(int)noRedirectResponse.StatusCode > 299 && (int)noRedirectResponse.StatusCode < 400
)
{
var redirectUrl = noRedirectResponse.Headers.Location?.ToString();
if (redirectUrl != null && redirectUrl.Contains("reason=download-auth"))
{
throw new UnauthorizedAccessException();
}
}
using var redirectRequest = new HttpRequestMessage();
redirectRequest.Method = HttpMethod.Get;
redirectRequest.RequestUri = new Uri(downloadUrl);
redirectRequest.Headers.Range = new RangeHeaderValue(existingFileSize, null);
response = await client
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.SendAsync(
redirectRequest,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken
)
.ConfigureAwait(false);
remainingContentLength = response.Content.Headers.ContentLength ?? 0;
originalContentLength =
response.Content.Headers.ContentRange?.Length.GetValueOrDefault() ?? 0;
@ -234,6 +278,8 @@ public class DownloadService : IDownloadService
new ProductInfoHeaderValue("StabilityMatrix", "2.0")
);
await AddConditionalHeaders(client, new Uri(downloadUrl)).ConfigureAwait(false);
var contentLength = 0L;
foreach (
@ -265,6 +311,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 +323,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
);
}
}
}
}

21
StabilityMatrix.Core/Services/ISecretsManager.cs

@ -0,0 +1,21 @@
using StabilityMatrix.Core.Models;
namespace StabilityMatrix.Core.Services;
/// <summary>
/// Interface for managing secure settings and tokens.
/// </summary>
public interface ISecretsManager
{
/// <summary>
/// Load and return the secrets.
/// </summary>
Task<Secrets> LoadAsync();
/// <summary>
/// Load and return the secrets, or save and return a new instance on error.
/// </summary>
Task<Secrets> SafeLoadAsync();
Task SaveAsync(Secrets secrets);
}

76
StabilityMatrix.Core/Services/SecretsManager.cs

@ -0,0 +1,76 @@
using System.Reactive.Concurrency;
using Microsoft.Extensions.Logging;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.FileInterfaces;
namespace StabilityMatrix.Core.Services;
/// <summary>
/// Default implementation of <see cref="ISecretsManager"/>.
/// Data is encrypted at rest in %APPDATA%\StabilityMatrix\user-secrets.data
/// </summary>
[Singleton(typeof(ISecretsManager))]
public class SecretsManager : ISecretsManager
{
private readonly ILogger<SecretsManager> logger;
private static FilePath GlobalFile => GlobalConfig.HomeDir.JoinFile("user-secrets.data");
private static SemaphoreSlim GlobalFileLock { get; } = new(1, 1);
public SecretsManager(ILogger<SecretsManager> logger)
{
this.logger = logger;
}
/// <inheritdoc />
public async Task<Secrets> LoadAsync()
{
if (!GlobalFile.Exists)
{
return new Secrets();
}
var fileBytes = await GlobalFile.ReadAllBytesAsync().ConfigureAwait(false);
return GlobalEncryptedSerializer.Deserialize<Secrets>(fileBytes);
}
/// <inheritdoc />
public async Task<Secrets> SafeLoadAsync()
{
try
{
return await LoadAsync().ConfigureAwait(false);
}
catch (Exception e)
{
logger.LogWarning(
e,
"Failed to load secrets ({ExcType}), saving new instance",
e.GetType().Name
);
var secrets = new Secrets();
await SaveAsync(secrets).ConfigureAwait(false);
return secrets;
}
}
/// <inheritdoc />
public async Task SaveAsync(Secrets secrets)
{
await GlobalFileLock.WaitAsync().ConfigureAwait(false);
try
{
var fileBytes = GlobalEncryptedSerializer.Serialize(secrets);
await GlobalFile.WriteAllBytesAsync(fileBytes).ConfigureAwait(false);
}
finally
{
GlobalFileLock.Release();
}
}
}

25
StabilityMatrix.Core/StabilityMatrix.Core.csproj

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifiers>win-x64;linux-x64;osx-x64;osx-arm64</RuntimeIdentifiers>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
@ -15,26 +15,27 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="AsyncAwaitBestPractices" Version="6.0.6" />
<PackageReference Include="AsyncAwaitBestPractices" Version="7.0.0" />
<PackageReference Include="Blake3" Version="1.0.0" />
<PackageReference Include="Crc32.NET" Version="1.2.0" />
<PackageReference Include="DeviceId" Version="6.4.0" />
<PackageReference Include="DeviceId.Linux" Version="6.3.0" />
<PackageReference Include="DeviceId.Mac" Version="6.2.0" />
<PackageReference Include="DeviceId.Windows" Version="6.4.0" />
<PackageReference Include="DeviceId.Windows.Wmi" Version="6.5.0" />
<PackageReference Include="DeviceId" Version="6.5.0" />
<PackageReference Include="DeviceId.Linux" Version="6.3.1" />
<PackageReference Include="DeviceId.Mac" Version="6.2.1" />
<PackageReference Include="DeviceId.Windows" Version="6.5.0" />
<PackageReference Include="DeviceId.Windows.Wmi" Version="6.5.1" />
<PackageReference Include="DynamicData" Version="8.1.1" />
<PackageReference Include="LiteDB" Version="5.0.17" />
<PackageReference Include="LiteDB.Async" Version="0.1.7" />
<PackageReference Include="MetadataExtractor" Version="2.8.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
<PackageReference Include="NLog" Version="5.2.5" />
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.5" />
<PackageReference Include="NSec.Cryptography" Version="22.4.0" />
<PackageReference Include="Octokit" Version="8.1.1" />
<PackageReference Include="Octokit" Version="9.0.0" />
<PackageReference Include="OneOf" Version="3.0.263" />
<PackageReference Include="OneOf.SourceGenerator" Version="3.0.263" />
<PackageReference Include="Polly" Version="8.2.0" />
<PackageReference Include="Polly.Contrib.WaitAndRetry" Version="1.1.1" />
<PackageReference Include="pythonnet" Version="3.0.3" />
<PackageReference Include="Refit" Version="7.0.0" />
@ -42,8 +43,8 @@
<PackageReference Include="RockLib.Reflection.Optimized" Version="2.0.0" />
<PackageReference Include="Salaros.ConfigParser" Version="0.3.8" />
<PackageReference Include="Semver" Version="3.0.0-beta.1" />
<PackageReference Include="Sentry.NLog" Version="3.40.1" />
<PackageReference Include="SharpCompress" Version="0.34.1" />
<PackageReference Include="Sentry.NLog" Version="3.41.0" />
<PackageReference Include="SharpCompress" Version="0.34.2" />
<PackageReference Include="Websocket.Client" Version="5.0.0" />
<PackageReference Include="YamlDotNet" Version="13.7.1" />
<PackageReference Include="Yoh.Text.Json.NamingPolicies" Version="1.0.0" />

54
StabilityMatrix.Core/Validators/RequiresMatchAttribute.cs

@ -0,0 +1,54 @@
using System.ComponentModel.DataAnnotations;
namespace StabilityMatrix.Core.Validators;
/// <summary>
/// Validator that requires equality to another property
/// i.e. Confirm password must match password
/// </summary>
public sealed class RequiresMatchAttribute<T> : ValidationAttribute
where T : IEquatable<T>
{
public string PropertyName { get; }
public RequiresMatchAttribute(string propertyName)
{
PropertyName = propertyName;
}
public RequiresMatchAttribute(string propertyName, string errorMessage)
{
PropertyName = propertyName;
ErrorMessage = errorMessage;
}
protected override ValidationResult IsValid(object? value, ValidationContext validationContext)
{
var instance = validationContext.ObjectInstance;
var otherProperty =
instance.GetType().GetProperty(PropertyName)
?? throw new ArgumentException($"Property {PropertyName} not found");
if (otherProperty.PropertyType != typeof(T))
{
throw new ArgumentException($"Property {PropertyName} is not of type {typeof(T)}");
}
var otherValue = otherProperty.GetValue(instance);
if (otherValue == null && value == null)
{
return ValidationResult.Success!;
}
if (((IEquatable<T>?)otherValue)!.Equals(value))
{
return ValidationResult.Success!;
}
return new ValidationResult(
$"{validationContext.DisplayName} does not match {PropertyName}"
);
}
}

12
StabilityMatrix.Tests/StabilityMatrix.Tests.csproj

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifiers>win-x64;linux-x64;osx-x64;osx-arm64</RuntimeIdentifiers>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
@ -11,10 +11,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DotNext" Version="4.14.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="7.0.12" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageReference Include="DotNext" Version="4.15.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="MSTest.TestAdapter" Version="3.1.1" />
<PackageReference Include="MSTest.TestFramework" Version="3.1.1" />
<PackageReference Include="coverlet.collector" Version="6.0.0">
@ -22,7 +22,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="Polly" Version="8.0.0" />
<PackageReference Include="Polly" Version="8.2.0" />
<PackageReference Include="Polly.Contrib.WaitAndRetry" Version="1.1.1" />
</ItemGroup>

2
StabilityMatrix.UITests/MainWindowTests.cs

@ -8,6 +8,7 @@ using Microsoft.Extensions.DependencyInjection;
using StabilityMatrix.Avalonia;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.ViewModels;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.UITests.Extensions;
@ -33,6 +34,7 @@ public class MainWindowTests
vm => vm.FooterPages,
vm => vm.CurrentPage
);
settings.IgnoreMember<UpdateViewModel>(vm => vm.CurrentVersionText);
settings.DisableDiff();
return settings;
}

1
StabilityMatrix.UITests/Snapshots/MainWindowTests.MainWindowViewModel_ShouldOk.verified.txt

@ -30,7 +30,6 @@
Signature: IX5/CCXWJQG0oGkYWVnuF34gTqF/dJSrDrUd6fuNMYnncL39G3HSvkXrjvJvR18MA2rQNB5z13h3/qBSf9c7DA==
},
ShowProgressBar: false,
CurrentVersionText: v1.0.0,
NewVersionText: v2.999.0,
InstallUpdateCommand: UpdateViewModel.InstallUpdate(),
HasErrors: false

8
StabilityMatrix.UITests/StabilityMatrix.UITests.csproj

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
@ -11,13 +11,13 @@
<ItemGroup>
<PackageReference Include="Avalonia.Headless.XUnit" Version="11.0.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="Verify" Version="22.1.4" />
<PackageReference Include="Verify" Version="22.4.1" />
<PackageReference Include="Verify.CommunityToolkit.Mvvm" Version="0.1.0" />
<PackageReference Include="Verify.Avalonia" Version="1.0.1" />
<PackageReference Include="Verify.Phash" Version="3.1.0" />
<PackageReference Include="Verify.Xunit" Version="22.1.4" />
<PackageReference Include="Verify.Xunit" Version="22.4.1" />
<PackageReference Include="xunit" Version="2.6.1"/>
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

Loading…
Cancel
Save