Ionite
12 months ago
committed by
GitHub
142 changed files with 7984 additions and 2403 deletions
After Width: | Height: | Size: 18 KiB |
After Width: | Height: | Size: 50 KiB |
@ -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); |
||||
} |
@ -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> |
@ -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; |
||||
} |
||||
} |
||||
} |
@ -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> |
@ -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); |
||||
} |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,30 @@
|
||||
using System; |
||||
using System.Collections.Generic; |
||||
using System.Globalization; |
||||
using Avalonia.Data.Converters; |
||||
|
||||
namespace StabilityMatrix.Avalonia.Converters; |
||||
|
||||
public class BooleanChoiceMultiConverter : IMultiValueConverter |
||||
{ |
||||
/// <inheritdoc /> |
||||
public object? Convert( |
||||
IList<object?> values, |
||||
Type targetType, |
||||
object? parameter, |
||||
CultureInfo culture |
||||
) |
||||
{ |
||||
if (values.Count < 3) |
||||
{ |
||||
return null; |
||||
} |
||||
|
||||
if (values[0] is bool boolValue) |
||||
{ |
||||
return boolValue ? values[1] : values[2]; |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
} |
@ -0,0 +1,32 @@
|
||||
using System; |
||||
using System.Globalization; |
||||
using Avalonia.Data.Converters; |
||||
using StabilityMatrix.Core.Extensions; |
||||
|
||||
namespace StabilityMatrix.Avalonia.Converters; |
||||
|
||||
public class EnumStringConverter : IValueConverter |
||||
{ |
||||
/// <inheritdoc /> |
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) |
||||
{ |
||||
if (value is not Enum enumValue) |
||||
return null; |
||||
|
||||
return enumValue.GetStringValue(); |
||||
} |
||||
|
||||
/// <inheritdoc /> |
||||
public object? ConvertBack( |
||||
object? value, |
||||
Type targetType, |
||||
object? parameter, |
||||
CultureInfo culture |
||||
) |
||||
{ |
||||
if (value is not string stringValue) |
||||
return null; |
||||
|
||||
return Enum.Parse(targetType, stringValue); |
||||
} |
||||
} |
@ -0,0 +1,24 @@
|
||||
using System; |
||||
using System.Globalization; |
||||
using Avalonia.Data; |
||||
using Avalonia.Data.Converters; |
||||
|
||||
namespace StabilityMatrix.Avalonia.Converters; |
||||
|
||||
public class EnumToBooleanConverter : IValueConverter |
||||
{ |
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) |
||||
{ |
||||
return value?.Equals(parameter); |
||||
} |
||||
|
||||
public object? ConvertBack( |
||||
object? value, |
||||
Type targetType, |
||||
object? parameter, |
||||
CultureInfo culture |
||||
) |
||||
{ |
||||
return value?.Equals(true) == true ? parameter : BindingOperations.DoNothing; |
||||
} |
||||
} |
@ -0,0 +1,50 @@
|
||||
using System; |
||||
|
||||
namespace StabilityMatrix.Avalonia.Converters; |
||||
|
||||
public class KiloFormatter : ICustomFormatter, IFormatProvider |
||||
{ |
||||
public object? GetFormat(Type? formatType) |
||||
{ |
||||
return formatType == typeof(ICustomFormatter) ? this : null; |
||||
} |
||||
|
||||
public string Format(string? format, object? arg, IFormatProvider? formatProvider) |
||||
{ |
||||
if (format == null || !format.Trim().StartsWith('K')) |
||||
{ |
||||
if (arg is IFormattable formatArg) |
||||
{ |
||||
return formatArg.ToString(format, formatProvider); |
||||
} |
||||
|
||||
return arg?.ToString() ?? string.Empty; |
||||
} |
||||
|
||||
var value = Convert.ToInt64(arg); |
||||
|
||||
return FormatNumber(value); |
||||
} |
||||
|
||||
private static string FormatNumber(long num) |
||||
{ |
||||
if (num >= 100000000) |
||||
{ |
||||
return (num / 1000000D).ToString("0.#M"); |
||||
} |
||||
if (num >= 1000000) |
||||
{ |
||||
return (num / 1000000D).ToString("0.##M"); |
||||
} |
||||
if (num >= 100000) |
||||
{ |
||||
return (num / 1000D).ToString("0.#K"); |
||||
} |
||||
if (num >= 10000) |
||||
{ |
||||
return (num / 1000D).ToString("0.##K"); |
||||
} |
||||
|
||||
return num.ToString("#,0"); |
||||
} |
||||
} |
@ -0,0 +1,25 @@
|
||||
using System; |
||||
using System.Globalization; |
||||
using Avalonia.Data.Converters; |
||||
|
||||
namespace StabilityMatrix.Avalonia.Converters; |
||||
|
||||
public class KiloFormatterStringConverter : IValueConverter |
||||
{ |
||||
/// <inheritdoc /> |
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) |
||||
{ |
||||
return value is null ? null : string.Format(new KiloFormatter(), "{0:K}", value); |
||||
} |
||||
|
||||
/// <inheritdoc /> |
||||
public object? ConvertBack( |
||||
object? value, |
||||
Type targetType, |
||||
object? parameter, |
||||
CultureInfo culture |
||||
) |
||||
{ |
||||
return value is null ? null : throw new NotImplementedException(); |
||||
} |
||||
} |
@ -0,0 +1,19 @@
|
||||
using CommunityToolkit.Mvvm.Input; |
||||
using StabilityMatrix.Core.Processes; |
||||
|
||||
namespace StabilityMatrix.Avalonia.Helpers; |
||||
|
||||
public static class IOCommands |
||||
{ |
||||
public static RelayCommand<string?> OpenUrlCommand { get; } = |
||||
new( |
||||
url => |
||||
{ |
||||
if (string.IsNullOrWhiteSpace(url)) |
||||
return; |
||||
|
||||
ProcessRunner.OpenUrl(url); |
||||
}, |
||||
url => !string.IsNullOrWhiteSpace(url) |
||||
); |
||||
} |
@ -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(); |
||||
} |
||||
} |
@ -1,43 +1,87 @@
|
||||
namespace StabilityMatrix.Avalonia.Models; |
||||
using System; |
||||
using CommandLine; |
||||
|
||||
namespace StabilityMatrix.Avalonia.Models; |
||||
|
||||
/// <summary> |
||||
/// Command line arguments passed to the application. |
||||
/// </summary> |
||||
public class AppArgs |
||||
{ |
||||
/// <summary> |
||||
/// Whether to enable debug mode |
||||
/// </summary> |
||||
[Option("debug", HelpText = "Enable debug mode")] |
||||
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. |
||||
/// </summary> |
||||
[Option("debug-exception-dialog", HelpText = "Use exception dialog while debugger is attached")] |
||||
public bool DebugExceptionDialog { get; set; } |
||||
|
||||
/// <summary> |
||||
/// Whether to use Sentry when a debugger is attached. |
||||
/// </summary> |
||||
[Option("debug-sentry", HelpText = "Use Sentry when debugger is attached")] |
||||
public bool DebugSentry { get; set; } |
||||
|
||||
/// <summary> |
||||
/// Whether to force show the one-click install dialog. |
||||
/// </summary> |
||||
[Option("debug-one-click-install", HelpText = "Force show the one-click install dialog")] |
||||
public bool DebugOneClickInstall { get; set; } |
||||
|
||||
/// <summary> |
||||
/// Whether to disable Sentry. |
||||
/// </summary> |
||||
[Option("no-sentry", HelpText = "Disable Sentry")] |
||||
public bool NoSentry { get; set; } |
||||
|
||||
/// <summary> |
||||
/// Whether to disable window chrome effects |
||||
/// </summary> |
||||
[Option("no-window-chrome-effects", HelpText = "Disable window chrome effects")] |
||||
public bool NoWindowChromeEffects { get; set; } |
||||
|
||||
/// <summary> |
||||
/// Flag to indicate if we should reset the saved window position back to (O,0) |
||||
/// </summary> |
||||
[Option("reset-window-position", HelpText = "Reset the saved window position back to (0,0)")] |
||||
public bool ResetWindowPosition { get; set; } |
||||
|
||||
/// <summary> |
||||
/// Flag for disabling hardware acceleration / GPU rendering |
||||
/// </summary> |
||||
[Option("disable-gpu-rendering", HelpText = "Disable hardware acceleration / GPU rendering")] |
||||
public bool DisableGpuRendering { get; set; } |
||||
|
||||
/// <summary> |
||||
/// Override global app home directory |
||||
/// Defaults to (%APPDATA%|~/.config)/StabilityMatrix |
||||
/// </summary> |
||||
[Option("home-dir", HelpText = "Override global app home directory")] |
||||
public string? HomeDirectoryOverride { get; set; } |
||||
|
||||
/// <summary> |
||||
/// Override data directory |
||||
/// This takes precedence over relative portable directory and global directory |
||||
/// </summary> |
||||
[Option("data-dir", HelpText = "Override data directory")] |
||||
public string? DataDirectoryOverride { get; set; } |
||||
|
||||
/// <summary> |
||||
/// Custom Uri protocol handler |
||||
/// This will send the Uri to the running instance of the app via IPC and exit |
||||
/// </summary> |
||||
[Option("uri", Hidden = true)] |
||||
public string? Uri { get; set; } |
||||
|
||||
/// <summary> |
||||
/// If provided, the app will wait for the process with this PID to exit |
||||
/// before starting up. Mainly used by the updater. |
||||
/// </summary> |
||||
[Option("wait-for-exit-pid", Hidden = true)] |
||||
public int? WaitForExitPid { get; set; } |
||||
} |
||||
|
@ -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; } |
||||
} |
@ -0,0 +1,70 @@
|
||||
using System; |
||||
using CommunityToolkit.Mvvm.ComponentModel; |
||||
using Semver; |
||||
using StabilityMatrix.Core.Extensions; |
||||
using StabilityMatrix.Core.Helper; |
||||
using StabilityMatrix.Core.Models.Update; |
||||
|
||||
namespace StabilityMatrix.Avalonia.Models; |
||||
|
||||
public partial class UpdateChannelCard : ObservableObject |
||||
{ |
||||
public UpdateChannel UpdateChannel { get; init; } |
||||
|
||||
public string DisplayName => UpdateChannel.GetStringValue(); |
||||
|
||||
public string? Description { get; init; } |
||||
|
||||
[ObservableProperty] |
||||
[NotifyPropertyChangedFor(nameof(LatestVersionString))] |
||||
[NotifyPropertyChangedFor(nameof(IsLatestVersionUpdateable))] |
||||
private SemVersion? latestVersion; |
||||
|
||||
public string? LatestVersionString => |
||||
LatestVersion is null ? null : $"Latest: v{LatestVersion}"; |
||||
|
||||
[ObservableProperty] |
||||
private bool isSelectable = true; |
||||
|
||||
/// <summary> |
||||
/// Whether the <see cref="LatestVersion"/> is available for update. |
||||
/// </summary> |
||||
public bool IsLatestVersionUpdateable |
||||
{ |
||||
get |
||||
{ |
||||
if (LatestVersion is null) |
||||
{ |
||||
return false; |
||||
} |
||||
|
||||
switch (LatestVersion.ComparePrecedenceTo(Compat.AppVersion)) |
||||
{ |
||||
case > 0: |
||||
// Newer version available |
||||
return true; |
||||
case 0: |
||||
{ |
||||
// Same version available, check if we both have commit hash metadata |
||||
var updateHash = LatestVersion.Metadata; |
||||
var appHash = Compat.AppVersion.Metadata; |
||||
|
||||
// Trim both to the lower length, to a minimum of 7 characters |
||||
var minLength = Math.Min(7, Math.Min(updateHash.Length, appHash.Length)); |
||||
updateHash = updateHash[..minLength]; |
||||
appHash = appHash[..minLength]; |
||||
|
||||
// If different, we can update |
||||
if (updateHash != appHash) |
||||
{ |
||||
return true; |
||||
} |
||||
|
||||
break; |
||||
} |
||||
} |
||||
|
||||
return false; |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,15 @@
|
||||
using System; |
||||
|
||||
namespace StabilityMatrix.Avalonia.Models; |
||||
|
||||
/// <summary> |
||||
/// |
||||
/// </summary> |
||||
[Flags] |
||||
public enum ViewModelState : uint |
||||
{ |
||||
/// <summary> |
||||
/// View Model has been initially loaded |
||||
/// </summary> |
||||
InitialLoaded = 1 << 0, |
||||
} |
@ -0,0 +1,254 @@
|
||||
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 |
||||
&& !string.IsNullOrWhiteSpace(secrets.LykosAccount?.RefreshToken) |
||||
&& !string.IsNullOrWhiteSpace(secrets.LykosAccount?.AccessToken) |
||||
) |
||||
{ |
||||
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 (InvalidOperationException e) |
||||
{ |
||||
logger.LogWarning(e, "Failed to get authentication token"); |
||||
} |
||||
catch (ApiException e) |
||||
{ |
||||
if (e.StatusCode is HttpStatusCode.Unauthorized) { } |
||||
else |
||||
{ |
||||
logger.LogWarning(e, "Failed to get user info from Lykos"); |
||||
} |
||||
} |
||||
catch (Exception e) |
||||
{ |
||||
logger.LogError(e, "Unknown error while refreshing Lykos account status"); |
||||
} |
||||
} |
||||
|
||||
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); |
||||
} |
||||
} |
@ -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(); |
||||
} |
@ -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> |
@ -0,0 +1,25 @@
|
||||
<ResourceDictionary xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> |
||||
|
||||
<!-- ListBoxItem theme that removes ListBox visuals (i.e. for using with radiobuttons) --> |
||||
<ControlTheme x:Key="ListBoxItemBorderlessTheme" TargetType="ListBoxItem"> |
||||
<Setter Property="Template"> |
||||
<ControlTemplate> |
||||
<Panel> |
||||
<ContentPresenter |
||||
Name="PART_ContentPresenter" |
||||
Margin="2,0" |
||||
Padding="{TemplateBinding Padding}" |
||||
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}" |
||||
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" |
||||
Background="{TemplateBinding Background}" |
||||
BorderBrush="{TemplateBinding BorderBrush}" |
||||
BorderThickness="{TemplateBinding BorderThickness}" |
||||
Content="{TemplateBinding Content}" |
||||
ContentTemplate="{TemplateBinding ContentTemplate}" |
||||
CornerRadius="{TemplateBinding CornerRadius}" /> |
||||
</Panel> |
||||
</ControlTemplate> |
||||
</Setter> |
||||
|
||||
</ControlTheme> |
||||
</ResourceDictionary> |
@ -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) { } |
||||
} |
@ -0,0 +1,141 @@
|
||||
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 string SignupFooterMarkdown { get; } = |
||||
"""
|
||||
By signing up, you are creating a |
||||
[lykos.ai](https://lykos.ai) Account and agree to our |
||||
[Terms](https://lykos.ai/terms-and-conditions) and |
||||
[Privacy Policy](https://lykos.ai/privacy) |
||||
""";
|
||||
|
||||
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)); |
||||
} |
||||
} |
@ -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 |
||||
}; |
||||
} |
||||
} |
@ -0,0 +1,286 @@
|
||||
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] |
||||
[NotifyCanExecuteChangedFor(nameof(ConnectLykosCommand))] |
||||
[NotifyCanExecuteChangedFor(nameof(ConnectPatreonCommand))] |
||||
[NotifyCanExecuteChangedFor(nameof(ConnectCivitCommand))] |
||||
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; |
||||
} |
||||
} |
||||
} |
@ -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 }; |
||||
} |
||||
|
@ -0,0 +1,960 @@
|
||||
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 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.ToDisplayString()}" + (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 |
||||
} |
@ -0,0 +1,236 @@
|
||||
using System.Collections.Generic; |
||||
using System.Linq; |
||||
using System.Threading.Tasks; |
||||
using AsyncAwaitBestPractices; |
||||
using Avalonia.Controls; |
||||
using Avalonia.Threading; |
||||
using CommunityToolkit.Mvvm.ComponentModel; |
||||
using CommunityToolkit.Mvvm.Input; |
||||
using Exceptionless.DateTimeExtensions; |
||||
using FluentAvalonia.UI.Controls; |
||||
using FluentAvalonia.UI.Media.Animation; |
||||
using Semver; |
||||
using StabilityMatrix.Avalonia.Languages; |
||||
using StabilityMatrix.Avalonia.Models; |
||||
using StabilityMatrix.Avalonia.Services; |
||||
using StabilityMatrix.Avalonia.ViewModels.Base; |
||||
using StabilityMatrix.Avalonia.Views.Settings; |
||||
using StabilityMatrix.Core.Attributes; |
||||
using StabilityMatrix.Core.Models.Update; |
||||
using StabilityMatrix.Core.Processes; |
||||
using StabilityMatrix.Core.Services; |
||||
using StabilityMatrix.Core.Updater; |
||||
using Symbol = FluentIcons.Common.Symbol; |
||||
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; |
||||
|
||||
namespace StabilityMatrix.Avalonia.ViewModels.Settings; |
||||
|
||||
[View(typeof(UpdateSettingsPage))] |
||||
[Singleton, ManagedService] |
||||
public partial class UpdateSettingsViewModel : PageViewModelBase |
||||
{ |
||||
private readonly IUpdateHelper updateHelper; |
||||
private readonly IAccountsService accountsService; |
||||
private readonly INavigationService<SettingsViewModel> settingsNavService; |
||||
|
||||
[ObservableProperty] |
||||
[NotifyPropertyChangedFor(nameof(IsUpdateAvailable))] |
||||
[NotifyPropertyChangedFor(nameof(HeaderText))] |
||||
[NotifyPropertyChangedFor(nameof(SubtitleText))] |
||||
private UpdateStatusChangedEventArgs? updateStatus; |
||||
|
||||
public bool IsUpdateAvailable => UpdateStatus?.LatestUpdate != null; |
||||
|
||||
public string HeaderText => |
||||
IsUpdateAvailable ? Resources.Label_UpdateAvailable : Resources.Label_YouAreUpToDate; |
||||
|
||||
public string? SubtitleText => |
||||
UpdateStatus is null |
||||
? null |
||||
: string.Format( |
||||
Resources.TextTemplate_LastChecked, |
||||
UpdateStatus.CheckedAt.ToApproximateAgeString() |
||||
); |
||||
|
||||
[ObservableProperty] |
||||
private bool isAutoCheckUpdatesEnabled = true; |
||||
|
||||
[ObservableProperty] |
||||
[NotifyPropertyChangedFor(nameof(SelectedUpdateChannelCard))] |
||||
private UpdateChannel preferredUpdateChannel = UpdateChannel.Stable; |
||||
|
||||
public UpdateChannelCard? SelectedUpdateChannelCard |
||||
{ |
||||
get => AvailableUpdateChannelCards.First(c => c.UpdateChannel == PreferredUpdateChannel); |
||||
set => PreferredUpdateChannel = value?.UpdateChannel ?? UpdateChannel.Stable; |
||||
} |
||||
|
||||
public IReadOnlyList<UpdateChannelCard> AvailableUpdateChannelCards { get; } = |
||||
new UpdateChannelCard[] |
||||
{ |
||||
new() |
||||
{ |
||||
UpdateChannel = UpdateChannel.Development, |
||||
Description = Resources.Label_UpdatesDevChannelDescription |
||||
}, |
||||
new() |
||||
{ |
||||
UpdateChannel = UpdateChannel.Preview, |
||||
Description = Resources.Label_UpdatesPreviewChannelDescription |
||||
}, |
||||
new() { UpdateChannel = UpdateChannel.Stable } |
||||
}; |
||||
|
||||
public UpdateSettingsViewModel( |
||||
ISettingsManager settingsManager, |
||||
IUpdateHelper updateHelper, |
||||
IAccountsService accountsService, |
||||
INavigationService<SettingsViewModel> settingsNavService |
||||
) |
||||
{ |
||||
this.updateHelper = updateHelper; |
||||
this.accountsService = accountsService; |
||||
this.settingsNavService = settingsNavService; |
||||
|
||||
settingsManager.RelayPropertyFor( |
||||
this, |
||||
vm => vm.PreferredUpdateChannel, |
||||
settings => settings.PreferredUpdateChannel, |
||||
true |
||||
); |
||||
|
||||
settingsManager.RelayPropertyFor( |
||||
this, |
||||
vm => vm.IsAutoCheckUpdatesEnabled, |
||||
settings => settings.CheckForUpdates, |
||||
true |
||||
); |
||||
|
||||
accountsService.LykosAccountStatusUpdate += (_, args) => |
||||
{ |
||||
var isBetaChannelsEnabled = args.User?.IsActiveSupporter == true; |
||||
|
||||
foreach ( |
||||
var card in AvailableUpdateChannelCards.Where( |
||||
c => c.UpdateChannel > UpdateChannel.Stable |
||||
) |
||||
) |
||||
{ |
||||
card.IsSelectable = isBetaChannelsEnabled; |
||||
} |
||||
}; |
||||
|
||||
// On update status changed |
||||
updateHelper.UpdateStatusChanged += (_, args) => |
||||
{ |
||||
UpdateStatus = args; |
||||
}; |
||||
} |
||||
|
||||
/// <inheritdoc /> |
||||
public override async Task OnLoadedAsync() |
||||
{ |
||||
if (UpdateStatus is null) |
||||
{ |
||||
await CheckForUpdates(); |
||||
} |
||||
OnPropertyChanged(nameof(SubtitleText)); |
||||
} |
||||
|
||||
[RelayCommand] |
||||
private async Task CheckForUpdates() |
||||
{ |
||||
if (Design.IsDesignMode) |
||||
{ |
||||
return; |
||||
} |
||||
await updateHelper.CheckForUpdate(); |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Verify a new channel selection is valid, else returns false. |
||||
/// </summary> |
||||
/// <param name="card"></param> |
||||
/// <returns></returns> |
||||
public bool VerifyChannelSelection(UpdateChannelCard card) |
||||
{ |
||||
if (card.UpdateChannel == UpdateChannel.Stable) |
||||
{ |
||||
return true; |
||||
} |
||||
|
||||
if (accountsService.LykosStatus?.User?.IsActiveSupporter == true) |
||||
{ |
||||
return true; |
||||
} |
||||
|
||||
return false; |
||||
} |
||||
|
||||
public void ShowLoginRequiredDialog() |
||||
{ |
||||
Dispatcher.UIThread |
||||
.InvokeAsync(async () => |
||||
{ |
||||
var dialog = DialogHelper.CreateTaskDialog( |
||||
"Become a Supporter", |
||||
"" |
||||
+ "Support the Stability Matrix Team and get access to early development builds and be the first to test new features. " |
||||
); |
||||
|
||||
dialog.Buttons = new[] |
||||
{ |
||||
new(Resources.Label_Accounts, TaskDialogStandardResult.OK), |
||||
TaskDialogButton.CloseButton |
||||
}; |
||||
|
||||
dialog.Commands = new[] |
||||
{ |
||||
new TaskDialogCommand |
||||
{ |
||||
Text = "Patreon", |
||||
Description = "https://patreon.com/StabilityMatrix", |
||||
Command = new RelayCommand(() => |
||||
{ |
||||
ProcessRunner.OpenUrl("https://patreon.com/StabilityMatrix"); |
||||
}) |
||||
} |
||||
}; |
||||
|
||||
if (await dialog.ShowAsync(true) is TaskDialogStandardResult.OK) |
||||
{ |
||||
settingsNavService.NavigateTo<AccountSettingsViewModel>( |
||||
new SuppressNavigationTransitionInfo() |
||||
); |
||||
} |
||||
}) |
||||
.SafeFireAndForget(); |
||||
} |
||||
|
||||
partial void OnUpdateStatusChanged(UpdateStatusChangedEventArgs? value) |
||||
{ |
||||
// Update the update channel cards |
||||
|
||||
// Use maximum version from platforms equal or lower than current |
||||
foreach (var card in AvailableUpdateChannelCards) |
||||
{ |
||||
card.LatestVersion = value?.UpdateChannels |
||||
.Where(kv => kv.Key <= card.UpdateChannel) |
||||
.Select(kv => kv.Value) |
||||
.MaxBy(info => info.Version, SemVersion.PrecedenceComparer) |
||||
?.Version; |
||||
} |
||||
} |
||||
|
||||
partial void OnPreferredUpdateChannelChanged(UpdateChannel value) |
||||
{ |
||||
CheckForUpdatesCommand.ExecuteAsync(null).SafeFireAndForget(); |
||||
} |
||||
|
||||
/// <inheritdoc /> |
||||
public override string Title => "Updates"; |
||||
|
||||
/// <inheritdoc /> |
||||
public override IconSource IconSource => |
||||
new SymbolIconSource { Symbol = Symbol.Settings, IsFilled = true }; |
||||
} |
@ -0,0 +1,130 @@
|
||||
<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:ui="using:FluentAvalonia.UI.Controls" |
||||
xmlns:vmDialogs="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Dialogs" |
||||
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages" |
||||
xmlns:sm="clr-namespace:StabilityMatrix.Avalonia" |
||||
xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia.Tight" |
||||
xmlns:ctxt="clr-namespace:ColorTextBlock.Avalonia;assembly=ColorTextBlock.Avalonia" |
||||
Focusable="True" |
||||
d:DataContext="{x:Static mocks:DesignData.LykosLoginViewModel}" |
||||
d:DesignHeight="350" |
||||
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,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"/> |
||||
|
||||
<mdxaml:MarkdownScrollViewer |
||||
Grid.Row="5" |
||||
Margin="4,0" |
||||
VerticalAlignment="Bottom" |
||||
MaxWidth="380" |
||||
TextElement.Foreground="{DynamicResource TextFillColorTertiaryBrush}" |
||||
Markdown="{Binding SignupFooterMarkdown}"> |
||||
<mdxaml:MarkdownScrollViewer.Styles> |
||||
<Style Selector="ctxt|CHyperlink"> |
||||
<Style.Setters> |
||||
<Setter Property="IsUnderline" Value="False"/> |
||||
</Style.Setters> |
||||
</Style> |
||||
<Style Selector="ctxt|CHyperlink:pointerover"> |
||||
<Setter Property="IsUnderline" Value="True"/> |
||||
<Setter Property="Foreground" Value="{StaticResource ThemeEldenRingOrangeColor}"/> |
||||
</Style> |
||||
</mdxaml:MarkdownScrollViewer.Styles> |
||||
</mdxaml:MarkdownScrollViewer> |
||||
</sg:SpacedGrid> |
||||
|
||||
</TabItem> |
||||
</TabControl> |
||||
</sg:SpacedGrid> |
||||
|
||||
|
||||
</controls:UserControlBase> |
@ -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(); |
||||
} |
||||
} |
@ -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> |
@ -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(); |
||||
} |
||||
} |
@ -0,0 +1,179 @@
|
||||
<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" /> |
||||
<ui:HyperlinkButton |
||||
ToolTip.Tip="Edit on Gravatar" |
||||
NavigateUri="http://gravatar.com/emails/" |
||||
IsVisible="{Binding LykosProfileImageUrl, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" |
||||
Padding="2" |
||||
CornerRadius="8" |
||||
HorizontalAlignment="Left" |
||||
Classes="transparent-full"> |
||||
<controls:BetterAdvancedImage |
||||
Source="{Binding LykosProfileImageUrl}" |
||||
Width="64" |
||||
Height="64" |
||||
CornerRadius="8" /> |
||||
</ui:HyperlinkButton> |
||||
|
||||
|
||||
<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> |
@ -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(); |
||||
} |
||||
} |
@ -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> |
||||
|
@ -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); |
||||
} |
||||
} |
||||
|
@ -0,0 +1,572 @@
|
||||
<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:models="clr-namespace:StabilityMatrix.Avalonia.Models" |
||||
xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia" |
||||
xmlns:system="clr-namespace:System;assembly=System.Runtime" |
||||
xmlns:ui="using:FluentAvalonia.UI.Controls" |
||||
xmlns:update="clr-namespace:StabilityMatrix.Core.Models.Update;assembly=StabilityMatrix.Core" |
||||
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels" |
||||
xmlns:vmSettings="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Settings" |
||||
d:DataContext="{x:Static mocks:DesignData.MainSettingsViewModel}" |
||||
d:DesignHeight="700" |
||||
d:DesignWidth="800" |
||||
x:CompileBindings="True" |
||||
x:DataType="vmSettings:MainSettingsViewModel" |
||||
Focusable="True" |
||||
mc:Ignorable="d"> |
||||
|
||||
<controls:UserControlBase.Resources> |
||||
<converters:CultureInfoDisplayConverter x:Key="CultureInfoDisplayConverter" /> |
||||
<converters:EnumStringConverter x:Key="EnumStringConverter" /> |
||||
<converters:EnumToBooleanConverter x:Key="EnumBoolConverter" /> |
||||
</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" |
||||
Margin="8,0,8,4" |
||||
ActionIconSource="ChevronRight" |
||||
Command="{Binding NavigateToSubPageCommand}" |
||||
CommandParameter="{x:Type vmSettings:InferenceSettingsViewModel}" |
||||
Header="Inference (Test)" |
||||
IconSource="Code" |
||||
IsClickEnabled="True" |
||||
IsVisible="{Binding SharedState.IsDebugMode}" /> |
||||
</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" |
||||
ActionIconSource="ChevronRight" |
||||
Command="{Binding NavigateToSubPageCommand}" |
||||
CommandParameter="{x:Type vmSettings:AccountSettingsViewModel}" |
||||
Header="{x:Static lang:Resources.Label_Accounts}" |
||||
IsClickEnabled="True"> |
||||
<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 --> |
||||
<sg:SpacedGrid RowDefinitions="Auto,Auto,Auto,Auto" RowSpacing="4"> |
||||
<TextBlock |
||||
Margin="0,0,0,4" |
||||
FontWeight="Medium" |
||||
Text="{x:Static lang:Resources.Label_System}" /> |
||||
|
||||
<!-- Updates page --> |
||||
<ui:SettingsExpander |
||||
Grid.Row="1" |
||||
Margin="8,0" |
||||
ActionIconSource="ChevronRight" |
||||
Command="{Binding NavigateToSubPageCommand}" |
||||
CommandParameter="{x:Type vmSettings:UpdateSettingsViewModel}" |
||||
Header="{x:Static lang:Resources.Label_Updates}" |
||||
IsClickEnabled="True"> |
||||
<ui:SettingsExpander.IconSource> |
||||
<fluentIcons:SymbolIconSource |
||||
FontSize="10" |
||||
IsFilled="True" |
||||
Symbol="ArrowSync" /> |
||||
</ui:SettingsExpander.IconSource> |
||||
</ui:SettingsExpander> |
||||
|
||||
<ui:SettingsExpander |
||||
Grid.Row="2" |
||||
Margin="8,0" |
||||
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="3" |
||||
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> |
||||
</sg:SpacedGrid> |
||||
|
||||
<!-- 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> |
@ -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(); |
||||
} |
||||
} |
@ -0,0 +1,154 @@
|
||||
<controls:UserControlBase |
||||
x:Class="StabilityMatrix.Avalonia.Views.Settings.UpdateSettingsPage" |
||||
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:models="clr-namespace:StabilityMatrix.Avalonia.Models" |
||||
xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia" |
||||
xmlns:sm="clr-namespace:StabilityMatrix.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:converters="clr-namespace:StabilityMatrix.Avalonia.Converters" |
||||
d:DataContext="{x:Static mocks:DesignData.UpdateSettingsViewModel}" |
||||
d:DesignHeight="550" |
||||
d:DesignWidth="800" |
||||
x:DataType="vmSettings:UpdateSettingsViewModel" |
||||
mc:Ignorable="d"> |
||||
|
||||
<controls:UserControlBase.Resources> |
||||
<converters:BooleanChoiceMultiConverter x:Key="BoolChoiceMultiConverter" /> |
||||
</controls:UserControlBase.Resources> |
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto"> |
||||
<StackPanel Margin="16,16,24,16" Spacing="2"> |
||||
|
||||
<sg:SpacedGrid Margin="0,4,0,16" ColumnDefinitions="*,Auto"> |
||||
<StackPanel Orientation="Horizontal" Spacing="16"> |
||||
<fluentIcons:SymbolIcon |
||||
Width="64" |
||||
Height="64" |
||||
FontSize="64" |
||||
IsFilled="True" |
||||
Symbol="ArrowSync" /> |
||||
|
||||
<StackPanel VerticalAlignment="Center"> |
||||
<TextBlock |
||||
Margin="-1,0,0,0" |
||||
HorizontalAlignment="Left" |
||||
Text="{Binding HeaderText}" |
||||
Theme="{DynamicResource SubtitleTextBlockStyle}" /> |
||||
<TextBlock |
||||
HorizontalAlignment="Left" |
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}" |
||||
Text="{Binding SubtitleText}" /> |
||||
</StackPanel> |
||||
</StackPanel> |
||||
|
||||
<Button |
||||
Grid.Column="1" |
||||
Classes="accent" |
||||
Content="{x:Static lang:Resources.Action_CheckForUpdates}" |
||||
Command="{Binding CheckForUpdatesCommand}" |
||||
HorizontalAlignment="Right"> |
||||
</Button> |
||||
</sg:SpacedGrid> |
||||
|
||||
<!-- Auto updates toggle --> |
||||
<ui:SettingsExpander |
||||
Description="Periodically checks for updates" |
||||
Header="Notify when updates are ready to install"> |
||||
<ui:SettingsExpander.IconSource> |
||||
<fluentIcons:SymbolIconSource Symbol="Megaphone" /> |
||||
</ui:SettingsExpander.IconSource> |
||||
<ui:SettingsExpander.Footer> |
||||
<ToggleSwitch IsChecked="{Binding IsAutoCheckUpdatesEnabled}" /> |
||||
</ui:SettingsExpander.Footer> |
||||
</ui:SettingsExpander> |
||||
|
||||
<!-- Channel radio buttons --> |
||||
<ui:SettingsExpander Header="Preferred Update Channel" IsExpanded="True"> |
||||
<ui:SettingsExpander.IconSource> |
||||
<fluentIcons:SymbolIconSource Symbol="Branch" /> |
||||
</ui:SettingsExpander.IconSource> |
||||
|
||||
<ui:SettingsExpanderItem> |
||||
<ListBox |
||||
SelectionChanged="ChannelListBox_OnSelectionChanged" |
||||
IsEnabled="{Binding IsAutoCheckUpdatesEnabled}" |
||||
ItemContainerTheme="{DynamicResource ListBoxItemBorderlessTheme}" |
||||
ItemsSource="{Binding AvailableUpdateChannelCards}" |
||||
SelectedItem="{Binding SelectedUpdateChannelCard}"> |
||||
<ListBox.ItemTemplate> |
||||
<DataTemplate DataType="{x:Type models:UpdateChannelCard}"> |
||||
<RadioButton |
||||
GroupName="UpdateChannel" |
||||
IsChecked="{Binding $parent[ListBoxItem].IsSelected}" > |
||||
<StackPanel VerticalAlignment="Top" HorizontalAlignment="Left"> |
||||
<StackPanel Orientation="Horizontal" Spacing="5"> |
||||
<TextBlock |
||||
VerticalAlignment="Center" |
||||
Text="{Binding DisplayName}"> |
||||
<TextBlock.Foreground> |
||||
<MultiBinding Converter="{StaticResource BoolChoiceMultiConverter}"> |
||||
<Binding Path="IsSelectable" /> |
||||
<DynamicResource ResourceKey="TextFillColorPrimaryBrush"/> |
||||
<DynamicResource ResourceKey="TextFillColorTertiaryBrush"/> |
||||
</MultiBinding> |
||||
</TextBlock.Foreground> |
||||
</TextBlock> |
||||
<Ellipse |
||||
HorizontalAlignment="Left" |
||||
VerticalAlignment="Center" |
||||
IsVisible="{Binding LatestVersionString, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" |
||||
Width="4" |
||||
Height="4" |
||||
Fill="{DynamicResource TextFillColorTertiaryBrush}" /> |
||||
<TextBlock |
||||
VerticalAlignment="Center" |
||||
FontSize="13" |
||||
Foreground="{DynamicResource TextFillColorTertiaryBrush}" |
||||
IsVisible="{Binding LatestVersionString, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" |
||||
Text="{Binding LatestVersionString}" /> |
||||
|
||||
<!-- Update available icon --> |
||||
<fluentIcons:SymbolIcon |
||||
ToolTip.Tip="{x:Static lang:Resources.Label_UpdateAvailable}" |
||||
IsVisible="{Binding IsLatestVersionUpdateable}" |
||||
VerticalAlignment="Center" |
||||
FontSize="16" |
||||
Foreground="#3592c4" |
||||
Symbol="ArrowCircleUpRight" /> |
||||
</StackPanel> |
||||
|
||||
<TextBlock |
||||
FontSize="13" |
||||
TextAlignment="Start" |
||||
Margin="0,0,0,0" |
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" |
||||
Text="{Binding Description}" |
||||
Theme="{DynamicResource CaptionTextBlockStyle}" /> |
||||
</StackPanel> |
||||
</RadioButton> |
||||
</DataTemplate> |
||||
</ListBox.ItemTemplate> |
||||
<ListBox.ItemsPanel> |
||||
<ItemsPanelTemplate> |
||||
<StackPanel Spacing="8" /> |
||||
</ItemsPanelTemplate> |
||||
</ListBox.ItemsPanel> |
||||
</ListBox> |
||||
</ui:SettingsExpanderItem> |
||||
|
||||
|
||||
</ui:SettingsExpander> |
||||
|
||||
</StackPanel> |
||||
</ScrollViewer> |
||||
</controls:UserControlBase> |
@ -0,0 +1,41 @@
|
||||
using System.Linq; |
||||
using Avalonia.Controls; |
||||
using StabilityMatrix.Avalonia.Controls; |
||||
using StabilityMatrix.Avalonia.Models; |
||||
using StabilityMatrix.Avalonia.ViewModels.Settings; |
||||
using StabilityMatrix.Core.Attributes; |
||||
using StabilityMatrix.Core.Models.Update; |
||||
|
||||
namespace StabilityMatrix.Avalonia.Views.Settings; |
||||
|
||||
[Singleton] |
||||
public partial class UpdateSettingsPage : UserControlBase |
||||
{ |
||||
public UpdateSettingsPage() |
||||
{ |
||||
InitializeComponent(); |
||||
} |
||||
|
||||
private void ChannelListBox_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) |
||||
{ |
||||
var listBox = (ListBox)sender!; |
||||
|
||||
if (e.AddedItems.Count == 0 || e.AddedItems[0] is not UpdateChannelCard item) |
||||
{ |
||||
return; |
||||
} |
||||
|
||||
var vm = (UpdateSettingsViewModel)DataContext!; |
||||
|
||||
if (!vm.VerifyChannelSelection(item)) |
||||
{ |
||||
listBox.Selection.Clear(); |
||||
|
||||
listBox.Selection.SelectedItem = vm.AvailableUpdateChannelCards.First( |
||||
c => c.UpdateChannel == UpdateChannel.Stable |
||||
); |
||||
|
||||
vm.ShowLoginRequiredDialog(); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,55 @@
|
||||
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 |
||||
); |
||||
|
||||
[Post("/api/trpc/user.toggleFavoriteModel")] |
||||
Task<HttpResponseMessage> ToggleFavoriteModel( |
||||
[Body] CivitUserToggleFavoriteModelRequest request, |
||||
[Authorize] string bearerToken, |
||||
CancellationToken cancellationToken = default |
||||
); |
||||
} |
@ -0,0 +1,73 @@
|
||||
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); |
||||
|
||||
[Headers("Authorization: Bearer")] |
||||
[Get("/api/files/download")] |
||||
Task<GetFilesDownloadResponse> GetFilesDownload( |
||||
string path, |
||||
CancellationToken cancellationToken = default |
||||
); |
||||
} |
@ -0,0 +1,7 @@
|
||||
namespace StabilityMatrix.Core.Api; |
||||
|
||||
public interface ITokenProvider |
||||
{ |
||||
Task<string> GetAccessTokenAsync(); |
||||
Task<(string AccessToken, string RefreshToken)> RefreshTokensAsync(); |
||||
} |
@ -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); |
||||
} |
||||
} |
@ -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); |
||||
}); |
||||
} |
||||
} |
@ -0,0 +1,24 @@
|
||||
using Semver; |
||||
|
||||
namespace StabilityMatrix.Core.Extensions; |
||||
|
||||
public static class SemVersionExtensions |
||||
{ |
||||
public static string ToDisplayString(this SemVersion version) |
||||
{ |
||||
var versionString = $"{version.Major}.{version.Minor}.{version.Patch}"; |
||||
|
||||
// Add the build metadata if we have pre-release information |
||||
if (version.PrereleaseIdentifiers.Count > 0) |
||||
{ |
||||
versionString += $"-{version.Prerelease}"; |
||||
|
||||
if (!string.IsNullOrWhiteSpace(version.Metadata)) |
||||
{ |
||||
// First 7 characters of the commit hash |
||||
versionString += $"+{version.Metadata[..7]}"; |
||||
} |
||||
} |
||||
return versionString; |
||||
} |
||||
} |
@ -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('/')}" |
||||
) |
||||
); |
||||
} |
||||
} |
@ -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; |
||||
} |
||||
} |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue