From 8b2c12ea224c786fed97656e10f1b06f2dd45325 Mon Sep 17 00:00:00 2001 From: Ionite Date: Fri, 10 Nov 2023 23:04:55 -0500 Subject: [PATCH 001/144] Add settings subpage framework and breadcrumb bar --- StabilityMatrix.Avalonia/App.axaml | 3 + StabilityMatrix.Avalonia/App.axaml.cs | 28 +- .../DesignData/DesignData.cs | 4 +- .../Models/TypedNavigationEventArgs.cs | 10 + .../Services/INavigationService.cs | 8 +- .../Services/NavigationService.cs | 39 +- .../StabilityMatrix.Avalonia.csproj | 1 + .../Base/InferenceTabViewModelBase.cs | 2 +- .../InferenceConnectionHelpViewModel.cs | 4 +- .../Dialogs/OneClickInstallViewModel.cs | 4 +- .../ViewModels/OutputsPageViewModel.cs | 4 +- .../PackageManager/PackageCardViewModel.cs | 4 +- .../Settings/InferenceSettingsViewModel.cs | 17 +- .../Settings/MainSettingsViewModel.cs | 950 ++++++++++++++++++ .../ViewModels/SettingsViewModel.cs | 903 +---------------- .../Views/MainWindow.axaml.cs | 18 +- .../Settings/InferenceSettingsPage.axaml | 83 +- .../Settings/InferenceSettingsPage.axaml.cs | 10 +- .../Views/Settings/MainSettingsPage.axaml | 533 ++++++++++ .../Views/Settings/MainSettingsPage.axaml.cs | 13 + .../Views/SettingsPage.axaml | 539 +--------- .../Views/SettingsPage.axaml.cs | 84 +- .../Attributes/SingletonAttribute.cs | 11 +- 23 files changed, 1813 insertions(+), 1459 deletions(-) create mode 100644 StabilityMatrix.Avalonia/Models/TypedNavigationEventArgs.cs create mode 100644 StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs create mode 100644 StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml create mode 100644 StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml.cs diff --git a/StabilityMatrix.Avalonia/App.axaml b/StabilityMatrix.Avalonia/App.axaml index f33a4d5c..d41fd95c 100644 --- a/StabilityMatrix.Avalonia/App.axaml +++ b/StabilityMatrix.Avalonia/App.axaml @@ -27,6 +27,8 @@ 700 + 32 + avares://StabilityMatrix.Avalonia/Assets/Fonts/NotoSansJP#Noto Sans JP @@ -39,6 +41,7 @@ + diff --git a/StabilityMatrix.Avalonia/App.axaml.cs b/StabilityMatrix.Avalonia/App.axaml.cs index d15dc543..1bf930b5 100644 --- a/StabilityMatrix.Avalonia/App.axaml.cs +++ b/StabilityMatrix.Avalonia/App.axaml.cs @@ -352,17 +352,31 @@ public sealed class App : Application t1.attributes is { Length: > 0 } && !t1.t.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase) ) - .Select(t1 => new { Type = t1.t, Attribute = (SingletonAttribute)t1.attributes[0] }); + .Select( + t1 => + new + { + Type = t1.t, + Attributes = t1.attributes.Cast().ToArray() + } + ); foreach (var typePair in singletonTypes) { - if (typePair.Attribute.InterfaceType is null) + foreach (var attribute in typePair.Attributes) { - services.AddSingleton(typePair.Type); - } - else - { - services.AddSingleton(typePair.Attribute.InterfaceType, typePair.Type); + if (attribute.InterfaceType is null) + { + services.AddSingleton(typePair.Type); + } + else if (attribute.ImplType is not null) + { + services.AddSingleton(attribute.InterfaceType, attribute.ImplType); + } + else + { + services.AddSingleton(attribute.InterfaceType, typePair.Type); + } } } diff --git a/StabilityMatrix.Avalonia/DesignData/DesignData.cs b/StabilityMatrix.Avalonia/DesignData/DesignData.cs index 4d785605..512548f7 100644 --- a/StabilityMatrix.Avalonia/DesignData/DesignData.cs +++ b/StabilityMatrix.Avalonia/DesignData/DesignData.cs @@ -103,7 +103,6 @@ public static class DesignData // General services services .AddLogging() - .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() @@ -437,6 +436,9 @@ public static class DesignData public static InferenceSettingsViewModel InferenceSettingsViewModel => Services.GetRequiredService(); + public static MainSettingsViewModel MainSettingsViewModel => + Services.GetRequiredService(); + public static CheckpointBrowserViewModel CheckpointBrowserViewModel => Services.GetRequiredService(); diff --git a/StabilityMatrix.Avalonia/Models/TypedNavigationEventArgs.cs b/StabilityMatrix.Avalonia/Models/TypedNavigationEventArgs.cs new file mode 100644 index 00000000..121b456c --- /dev/null +++ b/StabilityMatrix.Avalonia/Models/TypedNavigationEventArgs.cs @@ -0,0 +1,10 @@ +using System; + +namespace StabilityMatrix.Avalonia.Models; + +public class TypedNavigationEventArgs : EventArgs +{ + public required Type ViewModelType { get; init; } + + public object? ViewModel { get; init; } +} diff --git a/StabilityMatrix.Avalonia/Services/INavigationService.cs b/StabilityMatrix.Avalonia/Services/INavigationService.cs index 4afa4d9f..8c8ee417 100644 --- a/StabilityMatrix.Avalonia/Services/INavigationService.cs +++ b/StabilityMatrix.Avalonia/Services/INavigationService.cs @@ -1,11 +1,15 @@ -using FluentAvalonia.UI.Controls; +using System; +using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Media.Animation; +using StabilityMatrix.Avalonia.Models; using StabilityMatrix.Avalonia.ViewModels.Base; namespace StabilityMatrix.Avalonia.Services; -public interface INavigationService +public interface INavigationService { + event EventHandler? TypedNavigation; + /// /// Set the frame to use for navigation. /// diff --git a/StabilityMatrix.Avalonia/Services/NavigationService.cs b/StabilityMatrix.Avalonia/Services/NavigationService.cs index b9fb1952..c22a5eb4 100644 --- a/StabilityMatrix.Avalonia/Services/NavigationService.cs +++ b/StabilityMatrix.Avalonia/Services/NavigationService.cs @@ -4,6 +4,7 @@ using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Media.Animation; using FluentAvalonia.UI.Navigation; using StabilityMatrix.Avalonia.Animations; +using StabilityMatrix.Avalonia.Models; using StabilityMatrix.Avalonia.ViewModels; using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Core.Attributes; @@ -11,11 +12,20 @@ using StabilityMatrix.Core.Services; namespace StabilityMatrix.Avalonia.Services; -[Singleton(typeof(INavigationService))] -public class NavigationService : INavigationService +[Singleton( + ImplType = typeof(NavigationService), + InterfaceType = typeof(INavigationService) +)] +[Singleton( + ImplType = typeof(NavigationService), + InterfaceType = typeof(INavigationService) +)] +public class NavigationService : INavigationService { private Frame? _frame; + public event EventHandler? TypedNavigation; + /// public void SetFrame(Frame frame) { @@ -60,18 +70,10 @@ public class NavigationService : INavigationService } ); - if (!typeof(TViewModel).IsAssignableTo(typeof(PageViewModelBase))) - return; - - if ( - App.Services.GetService(typeof(MainWindowViewModel)) - is MainWindowViewModel mainViewModel - ) - { - mainViewModel.SelectedCategory = mainViewModel.Pages.FirstOrDefault( - x => x.GetType() == typeof(TViewModel) - ); - } + TypedNavigation?.Invoke( + this, + new TypedNavigationEventArgs { ViewModelType = typeof(TViewModel) } + ); } /// @@ -106,5 +108,14 @@ public class NavigationService : INavigationService TransitionInfoOverride = transitionInfo ?? new SuppressNavigationTransitionInfo() } ); + + TypedNavigation?.Invoke( + this, + new TypedNavigationEventArgs + { + ViewModelType = viewModel.GetType(), + ViewModel = viewModel + } + ); } } diff --git a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj index a0e6c8cc..4b29bdc7 100644 --- a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj +++ b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj @@ -38,6 +38,7 @@ + diff --git a/StabilityMatrix.Avalonia/ViewModels/Base/InferenceTabViewModelBase.cs b/StabilityMatrix.Avalonia/ViewModels/Base/InferenceTabViewModelBase.cs index bc1d60ff..63e5b131 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Base/InferenceTabViewModelBase.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Base/InferenceTabViewModelBase.cs @@ -127,7 +127,7 @@ public abstract partial class InferenceTabViewModelBase // ResetViewState(); // TODO: Dock reset not working, using this hack for now to get a new view - var navService = App.Services.GetRequiredService(); + var navService = App.Services.GetRequiredService>(); navService.NavigateTo(new SuppressNavigationTransitionInfo()); ((IPersistentViewProvider)this).AttachedPersistentView = null; navService.NavigateTo(new BetterEntranceNavigationTransition()); diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/InferenceConnectionHelpViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/InferenceConnectionHelpViewModel.cs index 351f9a64..aad4cc87 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/InferenceConnectionHelpViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/InferenceConnectionHelpViewModel.cs @@ -27,7 +27,7 @@ namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; public partial class InferenceConnectionHelpViewModel : ContentDialogViewModelBase { private readonly ISettingsManager settingsManager; - private readonly INavigationService navigationService; + private readonly INavigationService navigationService; private readonly IPackageFactory packageFactory; [ObservableProperty] @@ -56,7 +56,7 @@ public partial class InferenceConnectionHelpViewModel : ContentDialogViewModelBa public InferenceConnectionHelpViewModel( ISettingsManager settingsManager, - INavigationService navigationService, + INavigationService navigationService, IPackageFactory packageFactory ) { diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/OneClickInstallViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/OneClickInstallViewModel.cs index a7d1d26d..718630a7 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/OneClickInstallViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/OneClickInstallViewModel.cs @@ -30,7 +30,7 @@ public partial class OneClickInstallViewModel : ContentDialogViewModelBase private readonly IPrerequisiteHelper prerequisiteHelper; private readonly ILogger logger; private readonly IPyRunner pyRunner; - private readonly INavigationService navigationService; + private readonly INavigationService navigationService; private const string DefaultPackageName = "stable-diffusion-webui"; [ObservableProperty] @@ -71,7 +71,7 @@ public partial class OneClickInstallViewModel : ContentDialogViewModelBase IPrerequisiteHelper prerequisiteHelper, ILogger logger, IPyRunner pyRunner, - INavigationService navigationService + INavigationService navigationService ) { this.settingsManager = settingsManager; diff --git a/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs index 8352f6d5..aa296d35 100644 --- a/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs @@ -49,7 +49,7 @@ public partial class OutputsPageViewModel : PageViewModelBase private readonly ISettingsManager settingsManager; private readonly IPackageFactory packageFactory; private readonly INotificationService notificationService; - private readonly INavigationService navigationService; + private readonly INavigationService navigationService; private readonly ILogger logger; public override string Title => Resources.Label_OutputsPageTitle; @@ -99,7 +99,7 @@ public partial class OutputsPageViewModel : PageViewModelBase ISettingsManager settingsManager, IPackageFactory packageFactory, INotificationService notificationService, - INavigationService navigationService, + INavigationService navigationService, ILogger logger ) { diff --git a/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs index c9893634..182280de 100644 --- a/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs @@ -36,7 +36,7 @@ public partial class PackageCardViewModel : ProgressViewModel private readonly IPackageFactory packageFactory; private readonly INotificationService notificationService; private readonly ISettingsManager settingsManager; - private readonly INavigationService navigationService; + private readonly INavigationService navigationService; private readonly ServiceManager vmFactory; [ObservableProperty] @@ -80,7 +80,7 @@ public partial class PackageCardViewModel : ProgressViewModel IPackageFactory packageFactory, INotificationService notificationService, ISettingsManager settingsManager, - INavigationService navigationService, + INavigationService navigationService, ServiceManager vmFactory ) { diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs index 80d5c7ba..4ce798cf 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs @@ -1,9 +1,20 @@ -using StabilityMatrix.Avalonia.ViewModels.Base; +using FluentAvalonia.UI.Controls; +using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.Views.Settings; using StabilityMatrix.Core.Attributes; +using Symbol = FluentIcons.Common.Symbol; +using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; namespace StabilityMatrix.Avalonia.ViewModels.Settings; [View(typeof(InferenceSettingsPage))] -[Singleton] -public partial class InferenceSettingsViewModel : ViewModelBase { } +[Singleton, ManagedService] +public class InferenceSettingsViewModel : PageViewModelBase +{ + /// + public override string Title => "Inference"; + + /// + public override IconSource IconSource => + new SymbolIconSource { Symbol = Symbol.Settings, IsFilled = true }; +} diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs new file mode 100644 index 00000000..628ceb64 --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs @@ -0,0 +1,950 @@ +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 Avalonia; +using Avalonia.Controls.Notifications; +using Avalonia.Controls.Primitives; +using Avalonia.Media.Imaging; +using Avalonia.Platform.Storage; +using Avalonia.Styling; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using DynamicData.Binding; +using FluentAvalonia.UI.Controls; +using FluentAvalonia.UI.Media.Animation; +using NLog; +using SkiaSharp; +using StabilityMatrix.Avalonia.Animations; +using StabilityMatrix.Avalonia.Controls; +using StabilityMatrix.Avalonia.Extensions; +using StabilityMatrix.Avalonia.Helpers; +using StabilityMatrix.Avalonia.Languages; +using StabilityMatrix.Avalonia.Models; +using StabilityMatrix.Avalonia.Models.Inference; +using StabilityMatrix.Avalonia.Models.TagCompletion; +using StabilityMatrix.Avalonia.Services; +using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Avalonia.ViewModels.Dialogs; +using StabilityMatrix.Avalonia.ViewModels.Inference; +using StabilityMatrix.Avalonia.Views.Dialogs; +using StabilityMatrix.Avalonia.Views.Settings; +using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Extensions; +using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Python; +using StabilityMatrix.Core.Services; +using Symbol = FluentIcons.Common.Symbol; +using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; + +namespace StabilityMatrix.Avalonia.ViewModels.Settings; + +[View(typeof(MainSettingsPage))] +[Singleton, ManagedService] +public partial class MainSettingsViewModel : PageViewModelBase +{ + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + private readonly INotificationService notificationService; + private readonly ISettingsManager settingsManager; + private readonly IPrerequisiteHelper prerequisiteHelper; + private readonly IPyRunner pyRunner; + private readonly ServiceManager dialogFactory; + private readonly ICompletionProvider completionProvider; + private readonly ITrackedDownloadService trackedDownloadService; + private readonly IModelIndexService modelIndexService; + private readonly INavigationService settingsNavigationService; + + public SharedState SharedState { get; } + + public override string Title => "Settings"; + public override IconSource IconSource => + new SymbolIconSource { Symbol = Symbol.Settings, IsFilled = true }; + + // ReSharper disable once MemberCanBeMadeStatic.Global + public string AppVersion => + $"Version {Compat.AppVersion}" + (Program.IsDebugBuild ? " (Debug)" : ""); + + // Theme section + [ObservableProperty] + private string? selectedTheme; + + public IReadOnlyList AvailableThemes { get; } = new[] { "Light", "Dark", "System", }; + + [ObservableProperty] + private CultureInfo selectedLanguage; + + // ReSharper disable once MemberCanBeMadeStatic.Global + public IReadOnlyList AvailableLanguages => Cultures.SupportedCultures; + + public IReadOnlyList 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 availableTagCompletionCsvs = Array.Empty(); + + [ObservableProperty] + private string? selectedTagCompletionCsv; + + [ObservableProperty] + private bool isCompletionRemoveUnderscoresEnabled = true; + + [ObservableProperty] + [CustomValidation(typeof(MainSettingsViewModel), nameof(ValidateOutputImageFileNameFormat))] + private string? outputImageFileNameFormat; + + [ObservableProperty] + private string? outputImageFileNameFormatSample; + + public IEnumerable 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 dialogFactory, + ITrackedDownloadService trackedDownloadService, + SharedState sharedState, + ICompletionProvider completionProvider, + IModelIndexService modelIndexService, + INavigationService settingsNavigationService + ) + { + 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; + + SharedState = sharedState; + + SelectedTheme = settingsManager.Settings.Theme ?? AvailableThemes[1]; + SelectedLanguage = Cultures.GetSupportedCultureOrDefault(settingsManager.Settings.Language); + RemoveSymlinksOnShutdown = settingsManager.Settings.RemoveFolderLinksOnShutdown; + SelectedAnimationScale = settingsManager.Settings.AnimationScale; + + settingsManager.RelayPropertyFor(this, vm => vm.SelectedTheme, settings => settings.Theme); + + settingsManager.RelayPropertyFor( + this, + vm => vm.IsDiscordRichPresenceEnabled, + settings => settings.IsDiscordRichPresenceEnabled, + true + ); + + settingsManager.RelayPropertyFor( + this, + vm => vm.SelectedAnimationScale, + settings => settings.AnimationScale + ); + + settingsManager.RelayPropertyFor( + this, + vm => vm.SelectedTagCompletionCsv, + settings => settings.TagCompletionCsv + ); + + settingsManager.RelayPropertyFor( + this, + vm => vm.IsPromptCompletionEnabled, + settings => settings.IsPromptCompletionEnabled, + true + ); + + settingsManager.RelayPropertyFor( + this, + vm => vm.IsCompletionRemoveUnderscoresEnabled, + settings => settings.IsCompletionRemoveUnderscoresEnabled, + true + ); + + this.WhenPropertyChanged(vm => vm.OutputImageFileNameFormat) + .Throttle(TimeSpan.FromMilliseconds(50)) + .Subscribe(formatProperty => + { + var provider = FileNameFormatProvider.GetSample(); + var template = formatProperty.Value ?? string.Empty; + + if ( + !string.IsNullOrEmpty(template) + && provider.Validate(template) == ValidationResult.Success + ) + { + var format = FileNameFormat.Parse(template, provider); + OutputImageFileNameFormatSample = format.GetFileName() + ".png"; + } + else + { + // Use default format if empty + var defaultFormat = FileNameFormat.Parse( + FileNameFormat.DefaultTemplate, + provider + ); + OutputImageFileNameFormatSample = defaultFormat.GetFileName() + ".png"; + } + }); + + settingsManager.RelayPropertyFor( + this, + vm => vm.OutputImageFileNameFormat, + settings => settings.InferenceOutputImageFileNameFormat, + true + ); + + settingsManager.RelayPropertyFor( + this, + vm => vm.IsImageViewerPixelGridEnabled, + settings => settings.IsImageViewerPixelGridEnabled, + true + ); + + DebugThrowAsyncExceptionCommand.WithNotificationErrorHandler( + notificationService, + LogLevel.Warn + ); + ImportTagCsvCommand.WithNotificationErrorHandler(notificationService, LogLevel.Warn); + } + + /// + public override async Task OnLoadedAsync() + { + await base.OnLoadedAsync(); + + await notificationService.TryAsync(completionProvider.Setup()); + + UpdateAvailableTagCompletionCsvs(); + } + + 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()); + await Task.Run(() => settingsManager.IndexCheckpoints()); + notificationService.Show( + "Checkpoint cache reset", + "The checkpoint cache has been reset.", + NotificationType.Success + ); + } + + [RelayCommand] + private void NavigateToInferenceSettings() + { + Dispatcher.UIThread.Post( + () => + settingsNavigationService.NavigateTo( + new BetterSlideNavigationTransition + { + Effect = SlideNavigationTransitionEffect.FromRight + } + ) + ); + } + + #region Package Environment + + [RelayCommand] + private async Task OpenEnvVarsDialog() + { + var viewModel = dialogFactory.Get(); + + // Load current settings + var current = + settingsManager.Settings.EnvironmentVariables ?? new Dictionary(); + viewModel.EnvVars = new ObservableCollection( + 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 + { + 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 + + /// + /// Adds Stability Matrix to Start Menu for the current user. + /// + [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 + ); + } + + /// + /// Add Stability Matrix to Start Menu for all users. + /// Requires Admin elevation. + /// + [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(); + 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 { new(bitmap), }; + galleryImages.AddRange(files.Select(f => new ImageSource(f.Path.ToString()))); + + var imageBox = new ImageGalleryCard + { + Width = 1000, + Height = 900, + DataContext = dialogFactory.Get(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>(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 +} diff --git a/StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs index 74dc145b..948960d3 100644 --- a/StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs @@ -1,51 +1,16 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; +using System.Collections.Generic; using System.Collections.ObjectModel; -using System.ComponentModel; -using System.ComponentModel.DataAnnotations; -using System.Diagnostics; -using System.Globalization; -using System.IO; using System.Linq; -using System.Reactive.Linq; -using System.Reflection; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using Avalonia; -using Avalonia.Controls.Notifications; -using Avalonia.Controls.Primitives; -using Avalonia.Media.Imaging; -using Avalonia.Platform.Storage; -using Avalonia.Styling; -using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; +using DynamicData; using DynamicData.Binding; using FluentAvalonia.UI.Controls; using NLog; -using SkiaSharp; -using StabilityMatrix.Avalonia.Controls; -using StabilityMatrix.Avalonia.Extensions; -using StabilityMatrix.Avalonia.Helpers; -using StabilityMatrix.Avalonia.Languages; -using StabilityMatrix.Avalonia.Models; -using StabilityMatrix.Avalonia.Models.Inference; -using StabilityMatrix.Avalonia.Models.TagCompletion; using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels.Base; -using StabilityMatrix.Avalonia.ViewModels.Dialogs; -using StabilityMatrix.Avalonia.ViewModels.Inference; +using StabilityMatrix.Avalonia.ViewModels.Settings; using StabilityMatrix.Avalonia.Views; -using StabilityMatrix.Avalonia.Views.Dialogs; using StabilityMatrix.Core.Attributes; -using StabilityMatrix.Core.Extensions; -using StabilityMatrix.Core.Helper; -using StabilityMatrix.Core.Models; -using StabilityMatrix.Core.Models.FileInterfaces; -using StabilityMatrix.Core.Python; -using StabilityMatrix.Core.Services; using Symbol = FluentIcons.Common.Symbol; using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; @@ -57,875 +22,47 @@ public partial class SettingsViewModel : PageViewModelBase { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); - private readonly INotificationService notificationService; - private readonly ISettingsManager settingsManager; - private readonly IPrerequisiteHelper prerequisiteHelper; - private readonly IPyRunner pyRunner; - private readonly ServiceManager dialogFactory; - private readonly ICompletionProvider completionProvider; - private readonly ITrackedDownloadService trackedDownloadService; - private readonly IModelIndexService modelIndexService; - - public SharedState SharedState { get; } - public override string Title => "Settings"; public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.Settings, IsFilled = true }; - // ReSharper disable once MemberCanBeMadeStatic.Global - public string AppVersion => - $"Version {Compat.AppVersion}" + (Program.IsDebugBuild ? " (Debug)" : ""); - - // Theme section - [ObservableProperty] - private string? selectedTheme; - - public IReadOnlyList AvailableThemes { get; } = new[] { "Light", "Dark", "System", }; - - [ObservableProperty] - private CultureInfo selectedLanguage; - - // ReSharper disable once MemberCanBeMadeStatic.Global - public IReadOnlyList AvailableLanguages => Cultures.SupportedCultures; - - public IReadOnlyList 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 availableTagCompletionCsvs = Array.Empty(); + public IReadOnlyList SubPages { get; } [ObservableProperty] - private string? selectedTagCompletionCsv; + private ObservableCollection currentPagePath = new(); [ObservableProperty] - private bool isCompletionRemoveUnderscoresEnabled = true; - - [ObservableProperty] - [CustomValidation(typeof(SettingsViewModel), nameof(ValidateOutputImageFileNameFormat))] - private string? outputImageFileNameFormat; - - [ObservableProperty] - private string? outputImageFileNameFormatSample; - - public IEnumerable 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 SettingsViewModel( - INotificationService notificationService, - ISettingsManager settingsManager, - IPrerequisiteHelper prerequisiteHelper, - IPyRunner pyRunner, - ServiceManager dialogFactory, - ITrackedDownloadService trackedDownloadService, - SharedState sharedState, - ICompletionProvider completionProvider, - IModelIndexService modelIndexService - ) - { - this.notificationService = notificationService; - this.settingsManager = settingsManager; - this.prerequisiteHelper = prerequisiteHelper; - this.pyRunner = pyRunner; - this.dialogFactory = dialogFactory; - this.trackedDownloadService = trackedDownloadService; - this.completionProvider = completionProvider; - this.modelIndexService = modelIndexService; - - SharedState = sharedState; - - SelectedTheme = settingsManager.Settings.Theme ?? AvailableThemes[1]; - SelectedLanguage = Cultures.GetSupportedCultureOrDefault(settingsManager.Settings.Language); - RemoveSymlinksOnShutdown = settingsManager.Settings.RemoveFolderLinksOnShutdown; - SelectedAnimationScale = settingsManager.Settings.AnimationScale; - - settingsManager.RelayPropertyFor(this, vm => vm.SelectedTheme, settings => settings.Theme); - - settingsManager.RelayPropertyFor( - this, - vm => vm.IsDiscordRichPresenceEnabled, - settings => settings.IsDiscordRichPresenceEnabled, - true - ); - - settingsManager.RelayPropertyFor( - this, - vm => vm.SelectedAnimationScale, - settings => settings.AnimationScale - ); - - settingsManager.RelayPropertyFor( - this, - vm => vm.SelectedTagCompletionCsv, - settings => settings.TagCompletionCsv - ); - - settingsManager.RelayPropertyFor( - this, - vm => vm.IsPromptCompletionEnabled, - settings => settings.IsPromptCompletionEnabled, - true - ); - - settingsManager.RelayPropertyFor( - this, - vm => vm.IsCompletionRemoveUnderscoresEnabled, - settings => settings.IsCompletionRemoveUnderscoresEnabled, - true - ); - - this.WhenPropertyChanged(vm => vm.OutputImageFileNameFormat) - .Throttle(TimeSpan.FromMilliseconds(50)) - .Subscribe(formatProperty => - { - var provider = FileNameFormatProvider.GetSample(); - var template = formatProperty.Value ?? string.Empty; - - if ( - !string.IsNullOrEmpty(template) - && provider.Validate(template) == ValidationResult.Success - ) - { - var format = FileNameFormat.Parse(template, provider); - OutputImageFileNameFormatSample = format.GetFileName() + ".png"; - } - else - { - // Use default format if empty - var defaultFormat = FileNameFormat.Parse( - FileNameFormat.DefaultTemplate, - provider - ); - OutputImageFileNameFormatSample = defaultFormat.GetFileName() + ".png"; - } - }); - - settingsManager.RelayPropertyFor( - this, - vm => vm.OutputImageFileNameFormat, - settings => settings.InferenceOutputImageFileNameFormat, - true - ); - - settingsManager.RelayPropertyFor( - this, - vm => vm.IsImageViewerPixelGridEnabled, - settings => settings.IsImageViewerPixelGridEnabled, - true - ); - - DebugThrowAsyncExceptionCommand.WithNotificationErrorHandler( - notificationService, - LogLevel.Warn - ); - ImportTagCsvCommand.WithNotificationErrorHandler(notificationService, LogLevel.Warn); - } - - /// - public override async Task OnLoadedAsync() - { - await base.OnLoadedAsync(); - - await notificationService.TryAsync(completionProvider.Setup()); - - UpdateAvailableTagCompletionCsvs(); - } - - 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()); - await Task.Run(() => settingsManager.IndexCheckpoints()); - notificationService.Show( - "Checkpoint cache reset", - "The checkpoint cache has been reset.", - NotificationType.Success - ); - } - - #region Package Environment - - [RelayCommand] - private async Task OpenEnvVarsDialog() - { - var viewModel = dialogFactory.Get(); - - // Load current settings - var current = - settingsManager.Settings.EnvironmentVariables ?? new Dictionary(); - viewModel.EnvVars = new ObservableCollection( - 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 - { - 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 + private PageViewModelBase? currentPage; - /// - /// Adds Stability Matrix to Start Menu for the current user. - /// - [RelayCommand] - private async Task AddToStartMenu() + public SettingsViewModel(ServiceManager vmFactory) { - if (!Compat.IsWindows) + SubPages = new PageViewModelBase[] { - 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 - ); - } - - /// - /// Add Stability Matrix to Start Menu for all users. - /// Requires Admin elevation. - /// - [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 + vmFactory.Get(), + vmFactory.Get(), }; - 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; - } + CurrentPagePath.AddRange(SubPages); - notificationService.Show( - "Added to Start Menu", - "Stability Matrix has been added to the Start Menu for all users.", - NotificationType.Success - ); + CurrentPage = SubPages[0]; } - public async Task PickNewDataDirectory() + partial void OnCurrentPageChanged(PageViewModelBase? value) { - var viewModel = dialogFactory.Get(); - var dialog = new BetterContentDialog + if (value is null) { - 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 { new(bitmap), }; - galleryImages.AddRange(files.Select(f => new ImageSource(f.Path.ToString()))); - - var imageBox = new ImageGalleryCard - { - Width = 1000, - Height = 900, - DataContext = dialogFactory.Get(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) + if (value is MainSettingsViewModel) { - notificationService.Show( - "Failed to read licenses information", - $"{e}", - NotificationType.Error - ); + CurrentPagePath.Clear(); + CurrentPagePath.Add(value); } - } - - private static string GetLicensesMarkdown() - { - // Read licenses.json - using var reader = new StreamReader(Assets.LicensesJson.Open()); - var licenses = - JsonSerializer.Deserialize>(reader.ReadToEnd()) - ?? throw new InvalidOperationException("Failed to read licenses.json"); - - // Generate markdown - var builder = new StringBuilder(); - foreach (var license in licenses) + else { - 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(); + CurrentPagePath.Clear(); + CurrentPagePath.AddRange(new[] { SubPages[0], value }); } - - return builder.ToString(); } - - #endregion } diff --git a/StabilityMatrix.Avalonia/Views/MainWindow.axaml.cs b/StabilityMatrix.Avalonia/Views/MainWindow.axaml.cs index 57fc1c04..ab02c19d 100644 --- a/StabilityMatrix.Avalonia/Views/MainWindow.axaml.cs +++ b/StabilityMatrix.Avalonia/Views/MainWindow.axaml.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Linq; using AsyncImageLoader; using Avalonia; using Avalonia.Controls; @@ -24,8 +25,10 @@ using Microsoft.Extensions.DependencyInjection; using NLog; using StabilityMatrix.Avalonia.Animations; using StabilityMatrix.Avalonia.Controls; +using StabilityMatrix.Avalonia.Diagnostics.LogViewer.Core.ViewModels; using StabilityMatrix.Avalonia.Extensions; using StabilityMatrix.Avalonia.Languages; +using StabilityMatrix.Avalonia.Models; using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels; using StabilityMatrix.Avalonia.ViewModels.Base; @@ -44,7 +47,7 @@ namespace StabilityMatrix.Avalonia.Views; public partial class MainWindow : AppWindowBase { private readonly INotificationService notificationService; - private readonly INavigationService navigationService; + private readonly INavigationService navigationService; private FlyoutBase? progressFlyout; @@ -58,7 +61,7 @@ public partial class MainWindow : AppWindowBase public MainWindow( INotificationService notificationService, - INavigationService navigationService + INavigationService navigationService ) { this.notificationService = notificationService; @@ -74,6 +77,8 @@ public partial class MainWindow : AppWindowBase TitleBar.ExtendsContentIntoTitleBar = true; TitleBar.TitleBarHitTestType = TitleBarHitTestType.Complex; + navigationService.TypedNavigation += NavigationService_OnTypedNavigation; + EventManager.Instance.ToggleProgressFlyout += (_, _) => progressFlyout?.Hide(); EventManager.Instance.CultureChanged += (_, _) => SetDefaultFonts(); EventManager.Instance.UpdateAvailable += OnUpdateAvailable; @@ -158,6 +163,15 @@ public partial class MainWindow : AppWindowBase } } + private void NavigationService_OnTypedNavigation(object? sender, TypedNavigationEventArgs e) + { + var mainViewModel = (MainWindowViewModel)DataContext!; + + mainViewModel.SelectedCategory = mainViewModel.Pages.FirstOrDefault( + x => x.GetType() == e.ViewModelType + ); + } + private void OnUpdateAvailable(object? sender, UpdateInfo? updateInfo) { Dispatcher.UIThread.Post(() => diff --git a/StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml b/StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml index 428c0483..a5abbfc3 100644 --- a/StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml +++ b/StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml @@ -1,16 +1,69 @@ - - Welcome to Avalonia! + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml.cs b/StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml.cs index 20cf1dd1..eea11ded 100644 --- a/StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml.cs +++ b/StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml.cs @@ -1,19 +1,13 @@ -using Avalonia.Controls; -using Avalonia.Markup.Xaml; +using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Core.Attributes; namespace StabilityMatrix.Avalonia.Views.Settings; [Singleton] -public partial class InferenceSettingsPage : UserControl +public partial class InferenceSettingsPage : UserControlBase { public InferenceSettingsPage() { InitializeComponent(); } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } } diff --git a/StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml b/StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml new file mode 100644 index 00000000..60c84b6c --- /dev/null +++ b/StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml @@ -0,0 +1,533 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Views/SettingsPage.axaml.cs b/StabilityMatrix.Avalonia/Views/SettingsPage.axaml.cs index ba3d18c9..aa34012d 100644 --- a/StabilityMatrix.Avalonia/Views/SettingsPage.axaml.cs +++ b/StabilityMatrix.Avalonia/Views/SettingsPage.axaml.cs @@ -1,5 +1,19 @@ -using Avalonia.Markup.Xaml; +using System; +using System.ComponentModel; +using System.Linq; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; +using Avalonia.Threading; +using FluentAvalonia.UI.Controls; +using FluentAvalonia.UI.Media.Animation; +using FluentAvalonia.UI.Navigation; +using Microsoft.Extensions.DependencyInjection; +using StabilityMatrix.Avalonia.Animations; using StabilityMatrix.Avalonia.Controls; +using StabilityMatrix.Avalonia.Models; +using StabilityMatrix.Avalonia.Services; +using StabilityMatrix.Avalonia.ViewModels; +using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Core.Attributes; namespace StabilityMatrix.Avalonia.Views; @@ -7,13 +21,77 @@ namespace StabilityMatrix.Avalonia.Views; [Singleton] public partial class SettingsPage : UserControlBase { + private readonly INavigationService settingsNavigationService; + + private SettingsViewModel ViewModel => (SettingsViewModel)DataContext!; + + [DesignOnly(true)] + [Obsolete("For XAML use only", true)] public SettingsPage() + : this(App.Services.GetRequiredService>()) { } + + public SettingsPage(INavigationService settingsNavigationService) { + this.settingsNavigationService = settingsNavigationService; + InitializeComponent(); + + settingsNavigationService.SetFrame(FrameView); + settingsNavigationService.TypedNavigation += NavigationService_OnTypedNavigation; + FrameView.Navigated += FrameView_Navigated; + BreadcrumbBar.ItemClicked += BreadcrumbBar_ItemClicked; + } + + /// + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + + Dispatcher.UIThread.Post( + () => + settingsNavigationService.NavigateTo( + ViewModel.SubPages[0], + new BetterSlideNavigationTransition + { + Effect = SlideNavigationTransitionEffect.FromBottom + } + ) + ); } - private void InitializeComponent() + private void NavigationService_OnTypedNavigation(object? sender, TypedNavigationEventArgs e) { - AvaloniaXamlLoader.Load(this); + ViewModel.CurrentPage = ViewModel.SubPages.FirstOrDefault( + x => x.GetType() == e.ViewModelType + ); + } + + private async void FrameView_Navigated(object? sender, NavigationEventArgs args) + { + if (args.Content is not PageViewModelBase vm) + { + return; + } + + ViewModel.CurrentPage = vm; + } + + private async void BreadcrumbBar_ItemClicked( + BreadcrumbBar sender, + BreadcrumbBarItemClickedEventArgs args + ) + { + if (args.Item is not PageViewModelBase viewModel) + { + return; + } + + settingsNavigationService.NavigateTo( + viewModel, + new BetterSlideNavigationTransition + { + Effect = SlideNavigationTransitionEffect.FromLeft + } + ); } } diff --git a/StabilityMatrix.Core/Attributes/SingletonAttribute.cs b/StabilityMatrix.Core/Attributes/SingletonAttribute.cs index d6538a4e..39e1c8cd 100644 --- a/StabilityMatrix.Core/Attributes/SingletonAttribute.cs +++ b/StabilityMatrix.Core/Attributes/SingletonAttribute.cs @@ -3,16 +3,25 @@ namespace StabilityMatrix.Core.Attributes; [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] -[AttributeUsage(AttributeTargets.Class)] +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] public class SingletonAttribute : Attribute { [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] public Type? InterfaceType { get; init; } + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] + public Type? ImplType { get; init; } + public SingletonAttribute() { } public SingletonAttribute(Type interfaceType) { InterfaceType = interfaceType; } + + public SingletonAttribute(Type interfaceType, Type implType) + { + InterfaceType = implType; + ImplType = implType; + } } From 00d881c59ac9a5fbffa3ef9effd24ef07da2e47d Mon Sep 17 00:00:00 2001 From: Ionite Date: Sat, 11 Nov 2023 00:36:43 -0500 Subject: [PATCH 002/144] Fix extra button, adjust breadcrumb spacing --- StabilityMatrix.Avalonia/Views/SettingsPage.axaml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/StabilityMatrix.Avalonia/Views/SettingsPage.axaml b/StabilityMatrix.Avalonia/Views/SettingsPage.axaml index 918411be..db6a5b5c 100644 --- a/StabilityMatrix.Avalonia/Views/SettingsPage.axaml +++ b/StabilityMatrix.Avalonia/Views/SettingsPage.axaml @@ -20,14 +20,12 @@ 24 + 17 + 6,3 + Medium - Date: Sat, 11 Nov 2023 00:37:27 -0500 Subject: [PATCH 003/144] Prevent navigation if on same page and initial if loaded --- .../Views/SettingsPage.axaml.cs | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/StabilityMatrix.Avalonia/Views/SettingsPage.axaml.cs b/StabilityMatrix.Avalonia/Views/SettingsPage.axaml.cs index aa34012d..3bbac21d 100644 --- a/StabilityMatrix.Avalonia/Views/SettingsPage.axaml.cs +++ b/StabilityMatrix.Avalonia/Views/SettingsPage.axaml.cs @@ -23,6 +23,8 @@ public partial class SettingsPage : UserControlBase { private readonly INavigationService settingsNavigationService; + private bool hasLoaded; + private SettingsViewModel ViewModel => (SettingsViewModel)DataContext!; [DesignOnly(true)] @@ -47,16 +49,19 @@ public partial class SettingsPage : UserControlBase { base.OnLoaded(e); - Dispatcher.UIThread.Post( - () => - settingsNavigationService.NavigateTo( - ViewModel.SubPages[0], - new BetterSlideNavigationTransition - { - Effect = SlideNavigationTransitionEffect.FromBottom - } - ) - ); + if (!hasLoaded) + { + // Initial load, navigate to first page + Dispatcher.UIThread.Post( + () => + settingsNavigationService.NavigateTo( + ViewModel.SubPages[0], + new SuppressNavigationTransitionInfo() + ) + ); + + hasLoaded = true; + } } private void NavigationService_OnTypedNavigation(object? sender, TypedNavigationEventArgs e) @@ -81,7 +86,8 @@ public partial class SettingsPage : UserControlBase BreadcrumbBarItemClickedEventArgs args ) { - if (args.Item is not PageViewModelBase viewModel) + // Skip if already on same page + if (args.Item is not PageViewModelBase viewModel || viewModel == ViewModel.CurrentPage) { return; } From 16f1dd636a48d713a8b4624553c1f08cff2ae979 Mon Sep 17 00:00:00 2001 From: Ionite Date: Sat, 11 Nov 2023 00:37:40 -0500 Subject: [PATCH 004/144] Add footer page so selected category can be set --- StabilityMatrix.Avalonia/Views/MainWindow.axaml.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/StabilityMatrix.Avalonia/Views/MainWindow.axaml.cs b/StabilityMatrix.Avalonia/Views/MainWindow.axaml.cs index ab02c19d..f67b261a 100644 --- a/StabilityMatrix.Avalonia/Views/MainWindow.axaml.cs +++ b/StabilityMatrix.Avalonia/Views/MainWindow.axaml.cs @@ -167,9 +167,9 @@ public partial class MainWindow : AppWindowBase { var mainViewModel = (MainWindowViewModel)DataContext!; - mainViewModel.SelectedCategory = mainViewModel.Pages.FirstOrDefault( - x => x.GetType() == e.ViewModelType - ); + mainViewModel.SelectedCategory = mainViewModel.Pages + .Concat(mainViewModel.FooterPages) + .FirstOrDefault(x => x.GetType() == e.ViewModelType); } private void OnUpdateAvailable(object? sender, UpdateInfo? updateInfo) From ada56a3a58ab0fd88abc7c8063a6de27838acf72 Mon Sep 17 00:00:00 2001 From: Ionite Date: Sat, 11 Nov 2023 01:03:07 -0500 Subject: [PATCH 005/144] Better slide animations for sub pages --- .../BetterSlideNavigationTransition.cs | 53 +++++++++++++++---- .../Settings/MainSettingsViewModel.cs | 5 +- .../Views/SettingsPage.axaml.cs | 5 +- 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/StabilityMatrix.Avalonia/Animations/BetterSlideNavigationTransition.cs b/StabilityMatrix.Avalonia/Animations/BetterSlideNavigationTransition.cs index 3a729ab2..44796451 100644 --- a/StabilityMatrix.Avalonia/Animations/BetterSlideNavigationTransition.cs +++ b/StabilityMatrix.Avalonia/Animations/BetterSlideNavigationTransition.cs @@ -1,22 +1,25 @@ using System; using System.Threading; +using System.Threading.Tasks; using Avalonia; using Avalonia.Animation; using Avalonia.Animation.Easings; using Avalonia.Media; using Avalonia.Styling; using FluentAvalonia.UI.Media.Animation; +using Projektanker.Icons.Avalonia; namespace StabilityMatrix.Avalonia.Animations; public class BetterSlideNavigationTransition : BaseTransitionInfo { public override TimeSpan Duration { get; set; } = TimeSpan.FromMilliseconds(167); - + /// /// Gets or sets the type of animation effect to play during the slide transition. /// - public SlideNavigationTransitionEffect Effect { get; set; } = SlideNavigationTransitionEffect.FromRight; + public SlideNavigationTransitionEffect Effect { get; set; } = + SlideNavigationTransitionEffect.FromRight; /// /// Gets or sets the HorizontalOffset used when animating from the Left or Right @@ -27,7 +30,12 @@ public class BetterSlideNavigationTransition : BaseTransitionInfo /// Gets or sets the VerticalOffset used when animating from the Top or Bottom /// public double FromVerticalOffset { get; set; } = 56; - + + /// + /// Gets or sets the easing function applied to the slide transition. + /// + public Easing Easing { get; set; } = new SplineEasing(0.1, 0.9, 0.2, 1.0); + public override async void RunAnimation(Animatable ctrl, CancellationToken cancellationToken) { double length = 0; @@ -52,24 +60,26 @@ public class BetterSlideNavigationTransition : BaseTransitionInfo var animation = new Animation { - Easing = new SplineEasing(0.1, 0.9, 0.2, 1.0), + Easing = Easing, Children = { new KeyFrame { Setters = { - new Setter(isVertical ? TranslateTransform.YProperty : TranslateTransform.XProperty, length), + new Setter( + isVertical + ? TranslateTransform.YProperty + : TranslateTransform.XProperty, + length + ), new Setter(Visual.OpacityProperty, 0d) }, Cue = new Cue(0d) }, new KeyFrame { - Setters= - { - new Setter(Visual.OpacityProperty, 1d) - }, + Setters = { new Setter(Visual.OpacityProperty, 1d) }, Cue = new Cue(0.05d) }, new KeyFrame @@ -77,7 +87,12 @@ public class BetterSlideNavigationTransition : BaseTransitionInfo Setters = { new Setter(Visual.OpacityProperty, 1d), - new Setter(isVertical ? TranslateTransform.YProperty : TranslateTransform.XProperty, 0.0) + new Setter( + isVertical + ? TranslateTransform.YProperty + : TranslateTransform.XProperty, + 0.0 + ) }, Cue = new Cue(1d) } @@ -93,4 +108,22 @@ public class BetterSlideNavigationTransition : BaseTransitionInfo visual.Opacity = 1; } } + + public static BetterSlideNavigationTransition PageSlideFromLeft => + new() + { + Duration = TimeSpan.FromMilliseconds(400), + Effect = SlideNavigationTransitionEffect.FromLeft, + FromHorizontalOffset = 150, + Easing = new SplineEasing(0.7, 0.4, 0.1, 0.2) + }; + + public static BetterSlideNavigationTransition PageSlideFromRight => + new() + { + Duration = TimeSpan.FromMilliseconds(400), + Effect = SlideNavigationTransitionEffect.FromRight, + FromHorizontalOffset = 150, + Easing = new SplineEasing(0.7, 0.4, 0.1, 0.2) + }; } diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs index 628ceb64..30b11dc7 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs @@ -371,10 +371,7 @@ public partial class MainSettingsViewModel : PageViewModelBase Dispatcher.UIThread.Post( () => settingsNavigationService.NavigateTo( - new BetterSlideNavigationTransition - { - Effect = SlideNavigationTransitionEffect.FromRight - } + BetterSlideNavigationTransition.PageSlideFromRight ) ); } diff --git a/StabilityMatrix.Avalonia/Views/SettingsPage.axaml.cs b/StabilityMatrix.Avalonia/Views/SettingsPage.axaml.cs index 3bbac21d..f2737b3d 100644 --- a/StabilityMatrix.Avalonia/Views/SettingsPage.axaml.cs +++ b/StabilityMatrix.Avalonia/Views/SettingsPage.axaml.cs @@ -94,10 +94,7 @@ public partial class SettingsPage : UserControlBase settingsNavigationService.NavigateTo( viewModel, - new BetterSlideNavigationTransition - { - Effect = SlideNavigationTransitionEffect.FromLeft - } + BetterSlideNavigationTransition.PageSlideFromLeft ); } } From 7a978be6ca3506e9249a964eada07a327488422b Mon Sep 17 00:00:00 2001 From: Ionite Date: Sat, 11 Nov 2023 01:18:00 -0500 Subject: [PATCH 006/144] Add type parameter overload for NavigateTo --- .../Services/INavigationService.cs | 12 ++++- .../Services/NavigationService.cs | 50 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/StabilityMatrix.Avalonia/Services/INavigationService.cs b/StabilityMatrix.Avalonia/Services/INavigationService.cs index 8c8ee417..3f7d0885 100644 --- a/StabilityMatrix.Avalonia/Services/INavigationService.cs +++ b/StabilityMatrix.Avalonia/Services/INavigationService.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Media.Animation; using StabilityMatrix.Avalonia.Models; @@ -6,7 +7,7 @@ using StabilityMatrix.Avalonia.ViewModels.Base; namespace StabilityMatrix.Avalonia.Services; -public interface INavigationService +public interface INavigationService<[SuppressMessage("ReSharper", "UnusedTypeParameter")] T> { event EventHandler? TypedNavigation; @@ -24,6 +25,15 @@ public interface INavigationService ) where TViewModel : ViewModelBase; + /// + /// Navigate to the view of the given view model type. + /// + void NavigateTo( + Type viewModelType, + NavigationTransitionInfo? transitionInfo = null, + object? param = null + ); + /// /// Navigate to the view of the given view model. /// diff --git a/StabilityMatrix.Avalonia/Services/NavigationService.cs b/StabilityMatrix.Avalonia/Services/NavigationService.cs index c22a5eb4..79341c70 100644 --- a/StabilityMatrix.Avalonia/Services/NavigationService.cs +++ b/StabilityMatrix.Avalonia/Services/NavigationService.cs @@ -76,6 +76,56 @@ public class NavigationService : INavigationService ); } + /// + public void NavigateTo( + Type viewModelType, + NavigationTransitionInfo? transitionInfo = null, + object? param = null + ) + { + if (!viewModelType.IsAssignableFrom(typeof(ViewModelBase))) + { + // ReSharper disable once LocalizableElement + throw new ArgumentException("Type must be a ViewModelBase.", nameof(viewModelType)); + } + + if (_frame is null) + { + throw new InvalidOperationException("SetFrame was not called before NavigateTo."); + } + + if (App.Services.GetService(typeof(ISettingsManager)) is ISettingsManager settingsManager) + { + // Handle animation scale + switch (transitionInfo) + { + // If the transition info is null or animation scale is 0, suppress the transition + case null: + case BaseTransitionInfo when settingsManager.Settings.AnimationScale == 0f: + transitionInfo = new SuppressNavigationTransitionInfo(); + break; + case BaseTransitionInfo baseTransitionInfo: + baseTransitionInfo.Duration *= settingsManager.Settings.AnimationScale; + break; + } + } + + _frame.NavigateToType( + viewModelType, + param, + new FrameNavigationOptions + { + IsNavigationStackEnabled = true, + TransitionInfoOverride = transitionInfo ?? new SuppressNavigationTransitionInfo() + } + ); + + TypedNavigation?.Invoke( + this, + new TypedNavigationEventArgs { ViewModelType = viewModelType } + ); + } + /// public void NavigateTo(ViewModelBase viewModel, NavigationTransitionInfo? transitionInfo = null) { From fb31f25f1f8a6d49e8e421ff77b670f22aba498e Mon Sep 17 00:00:00 2001 From: Ionite Date: Sat, 11 Nov 2023 01:24:05 -0500 Subject: [PATCH 007/144] Made animations faster --- .../Animations/BetterSlideNavigationTransition.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/StabilityMatrix.Avalonia/Animations/BetterSlideNavigationTransition.cs b/StabilityMatrix.Avalonia/Animations/BetterSlideNavigationTransition.cs index 44796451..183538a1 100644 --- a/StabilityMatrix.Avalonia/Animations/BetterSlideNavigationTransition.cs +++ b/StabilityMatrix.Avalonia/Animations/BetterSlideNavigationTransition.cs @@ -112,18 +112,18 @@ public class BetterSlideNavigationTransition : BaseTransitionInfo public static BetterSlideNavigationTransition PageSlideFromLeft => new() { - Duration = TimeSpan.FromMilliseconds(400), + Duration = TimeSpan.FromMilliseconds(300), Effect = SlideNavigationTransitionEffect.FromLeft, FromHorizontalOffset = 150, - Easing = new SplineEasing(0.7, 0.4, 0.1, 0.2) + Easing = new SplineEasing(0.6, 0.4, 0.1, 0.1) }; public static BetterSlideNavigationTransition PageSlideFromRight => new() { - Duration = TimeSpan.FromMilliseconds(400), + Duration = TimeSpan.FromMilliseconds(300), Effect = SlideNavigationTransitionEffect.FromRight, FromHorizontalOffset = 150, - Easing = new SplineEasing(0.7, 0.4, 0.1, 0.2) + Easing = new SplineEasing(0.6, 0.4, 0.1, 0.1) }; } From a6e837e906b605bb7c77a5bcaeb2aaa9674ed474 Mon Sep 17 00:00:00 2001 From: Ionite Date: Sat, 11 Nov 2023 01:24:26 -0500 Subject: [PATCH 008/144] Fix AssignableTo type usage --- StabilityMatrix.Avalonia/Services/NavigationService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/StabilityMatrix.Avalonia/Services/NavigationService.cs b/StabilityMatrix.Avalonia/Services/NavigationService.cs index 79341c70..5b4bd630 100644 --- a/StabilityMatrix.Avalonia/Services/NavigationService.cs +++ b/StabilityMatrix.Avalonia/Services/NavigationService.cs @@ -83,7 +83,7 @@ public class NavigationService : INavigationService object? param = null ) { - if (!viewModelType.IsAssignableFrom(typeof(ViewModelBase))) + if (!viewModelType.IsAssignableTo(typeof(ViewModelBase))) { // ReSharper disable once LocalizableElement throw new ArgumentException("Type must be a ViewModelBase.", nameof(viewModelType)); From 1b2ccb7c1597616439c2205589a837a0e832653a Mon Sep 17 00:00:00 2001 From: Ionite Date: Sun, 12 Nov 2023 00:54:50 -0500 Subject: [PATCH 009/144] Add WIP user accounts page for debug mode --- StabilityMatrix.Avalonia/App.axaml | 1 + .../Assets/brands-civitai.png | Bin 0 -> 18029 bytes .../SettingsAccountLinkExpander.axaml | 98 +++++++++++++ .../SettingsAccountLinkExpander.axaml.cs | 130 +++++++++++++++++ .../DesignData/DesignData.cs | 3 + .../Languages/Resources.Designer.cs | 18 +++ .../Languages/Resources.resx | 6 + .../StabilityMatrix.Avalonia.csproj | 5 + .../Styles/ThemeColors.axaml | 1 + .../Settings/AccountSettingsViewModel.cs | 138 ++++++++++++++++++ .../Settings/MainSettingsViewModel.cs | 8 +- .../ViewModels/SettingsViewModel.cs | 6 +- .../Views/Settings/AccountSettingsPage.axaml | 124 ++++++++++++++++ .../Settings/AccountSettingsPage.axaml.cs | 13 ++ .../Views/Settings/MainSettingsPage.axaml | 28 +++- StabilityMatrix.Core/Api/ICivitTRPCApi.cs | 16 ++ .../Api/CivitTRPC/CivitUserProfileRequest.cs | 20 +++ .../Api/CivitTRPC/CivitUserProfileResponse.cs | 91 ++++++++++++ 18 files changed, 693 insertions(+), 13 deletions(-) create mode 100644 StabilityMatrix.Avalonia/Assets/brands-civitai.png create mode 100644 StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml create mode 100644 StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml.cs create mode 100644 StabilityMatrix.Avalonia/ViewModels/Settings/AccountSettingsViewModel.cs create mode 100644 StabilityMatrix.Avalonia/Views/Settings/AccountSettingsPage.axaml create mode 100644 StabilityMatrix.Avalonia/Views/Settings/AccountSettingsPage.axaml.cs create mode 100644 StabilityMatrix.Core/Api/ICivitTRPCApi.cs create mode 100644 StabilityMatrix.Core/Models/Api/CivitTRPC/CivitUserProfileRequest.cs create mode 100644 StabilityMatrix.Core/Models/Api/CivitTRPC/CivitUserProfileResponse.cs diff --git a/StabilityMatrix.Avalonia/App.axaml b/StabilityMatrix.Avalonia/App.axaml index d41fd95c..88fdd395 100644 --- a/StabilityMatrix.Avalonia/App.axaml +++ b/StabilityMatrix.Avalonia/App.axaml @@ -65,6 +65,7 @@ + + diff --git a/StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml.cs b/StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml.cs new file mode 100644 index 00000000..8c8d8257 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml.cs @@ -0,0 +1,130 @@ +using System; +using System.Windows.Input; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.VisualTree; +using FluentAvalonia.UI.Controls; + +namespace StabilityMatrix.Avalonia.Controls; + +public class SettingsAccountLinkExpander : TemplatedControl +{ + // ReSharper disable MemberCanBePrivate.Global + public static readonly StyledProperty HeaderProperty = + HeaderedItemsControl.HeaderProperty.AddOwner(); + + public object? Header + { + get => GetValue(HeaderProperty); + set => SetValue(HeaderProperty, value); + } + + public static readonly StyledProperty IconSourceProperty = + SettingsExpander.IconSourceProperty.AddOwner(); + + public IconSource? IconSource + { + get => GetValue(IconSourceProperty); + set => SetValue(IconSourceProperty, value); + } + + public static readonly StyledProperty IsConnectedProperty = AvaloniaProperty.Register< + SettingsAccountLinkExpander, + bool + >("IsConnected"); + + public bool IsConnected + { + get => GetValue(IsConnectedProperty); + set => SetValue(IsConnectedProperty, value); + } + + public static readonly StyledProperty OnDescriptionProperty = + AvaloniaProperty.Register( + "OnDescription", + Languages.Resources.Label_Connected + ); + + public object? OnDescription + { + get => GetValue(OnDescriptionProperty); + set => SetValue(OnDescriptionProperty, value); + } + + public static readonly StyledProperty OffDescriptionProperty = + AvaloniaProperty.Register("OffDescription"); + + public object? OffDescription + { + get => GetValue(OffDescriptionProperty); + set => SetValue(OffDescriptionProperty, value); + } + + public static readonly StyledProperty ConnectCommandProperty = + AvaloniaProperty.Register( + nameof(ConnectCommand), + enableDataValidation: true + ); + + public ICommand? ConnectCommand + { + get => GetValue(ConnectCommandProperty); + set => SetValue(ConnectCommandProperty, value); + } + + public static readonly StyledProperty DisconnectCommandProperty = + AvaloniaProperty.Register( + nameof(DisconnectCommand), + enableDataValidation: true + ); + + public ICommand? DisconnectCommand + { + get => GetValue(DisconnectCommandProperty); + set => SetValue(DisconnectCommandProperty, value); + } + + // ReSharper restore MemberCanBePrivate.Global + + /// + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + if (ConnectCommand is { } command) + { + var connectButton = e.NameScope.Get @@ -95,4 +108,78 @@ + + diff --git a/StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml.cs b/StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml.cs index 8c8d8257..75394841 100644 --- a/StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml.cs +++ b/StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml.cs @@ -1,15 +1,23 @@ using System; +using System.Collections.Generic; using System.Windows.Input; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; +using Avalonia.Metadata; using Avalonia.VisualTree; using FluentAvalonia.UI.Controls; +using StabilityMatrix.Core.Processes; namespace StabilityMatrix.Avalonia.Controls; public class SettingsAccountLinkExpander : TemplatedControl { + private readonly List _items = new(); + + [Content] + public List Items => _items; + // ReSharper disable MemberCanBePrivate.Global public static readonly StyledProperty HeaderProperty = HeaderedItemsControl.HeaderProperty.AddOwner(); @@ -20,6 +28,17 @@ public class SettingsAccountLinkExpander : TemplatedControl set => SetValue(HeaderProperty, value); } + public static readonly StyledProperty HeaderTargetUriProperty = AvaloniaProperty.Register< + SettingsAccountLinkExpander, + Uri? + >("HeaderTargetUri"); + + public Uri? HeaderTargetUri + { + get => GetValue(HeaderTargetUriProperty); + set => SetValue(HeaderTargetUriProperty, value); + } + public static readonly StyledProperty IconSourceProperty = SettingsExpander.IconSourceProperty.AddOwner(); @@ -67,6 +86,17 @@ public class SettingsAccountLinkExpander : TemplatedControl enableDataValidation: true ); + public static readonly StyledProperty IsLoadingProperty = AvaloniaProperty.Register< + SettingsAccountLinkExpander, + bool + >(nameof(IsLoading)); + + public bool IsLoading + { + get => GetValue(IsLoadingProperty); + set => SetValue(IsLoadingProperty, value); + } + public ICommand? ConnectCommand { get => GetValue(ConnectCommandProperty); @@ -92,6 +122,23 @@ public class SettingsAccountLinkExpander : TemplatedControl { base.OnApplyTemplate(e); + // Bind tapped event on header + if ( + HeaderTargetUri is { } headerTargetUri + && e.NameScope.Find("PART_HeaderTextBlock") is { } headerTextBlock + ) + { + headerTextBlock.Tapped += (_, _) => + { + ProcessRunner.OpenUrl(headerTargetUri.ToString()); + }; + } + + if (e.NameScope.Find("PART_SettingsExpander") is { } expander) + { + expander.ItemsSource = Items; + } + if (ConnectCommand is { } command) { var connectButton = e.NameScope.Get - - - - - - - - - - diff --git a/StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml.cs b/StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml.cs index 75394841..0f770a96 100644 --- a/StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml.cs +++ b/StabilityMatrix.Avalonia/Controls/SettingsAccountLinkExpander.axaml.cs @@ -71,6 +71,15 @@ public class SettingsAccountLinkExpander : TemplatedControl set => SetValue(OnDescriptionProperty, value); } + public static readonly StyledProperty OnDescriptionExtraProperty = + AvaloniaProperty.Register("OnDescriptionExtra"); + + public object? OnDescriptionExtra + { + get => GetValue(OnDescriptionExtraProperty); + set => SetValue(OnDescriptionExtraProperty, value); + } + public static readonly StyledProperty OffDescriptionProperty = AvaloniaProperty.Register("OffDescription"); @@ -86,17 +95,6 @@ public class SettingsAccountLinkExpander : TemplatedControl enableDataValidation: true ); - public static readonly StyledProperty IsLoadingProperty = AvaloniaProperty.Register< - SettingsAccountLinkExpander, - bool - >(nameof(IsLoading)); - - public bool IsLoading - { - get => GetValue(IsLoadingProperty); - set => SetValue(IsLoadingProperty, value); - } - public ICommand? ConnectCommand { get => GetValue(ConnectCommandProperty); @@ -115,6 +113,30 @@ public class SettingsAccountLinkExpander : TemplatedControl set => SetValue(DisconnectCommandProperty, value); } + /*public static readonly StyledProperty IsLoading2Property = AvaloniaProperty.Register( + nameof(IsLoading2)); + + public bool IsLoading2 + { + get => GetValue(IsLoading2Property); + set => SetValue(IsLoading2Property, value); + }*/ + + private bool _isLoading; + + public static readonly DirectProperty IsLoadingProperty = + AvaloniaProperty.RegisterDirect( + "IsLoading", + o => o.IsLoading, + (o, v) => o.IsLoading = v + ); + + public bool IsLoading + { + get => _isLoading; + set => SetAndRaise(IsLoadingProperty, ref _isLoading, value); + } + // ReSharper restore MemberCanBePrivate.Global /// diff --git a/StabilityMatrix.Avalonia/DialogHelper.cs b/StabilityMatrix.Avalonia/DialogHelper.cs index 304f0876..0cdfeabb 100644 --- a/StabilityMatrix.Avalonia/DialogHelper.cs +++ b/StabilityMatrix.Avalonia/DialogHelper.cs @@ -13,6 +13,7 @@ using Avalonia.Layout; using Avalonia.LogicalTree; using Avalonia.Media; using Avalonia.Threading; +using Avalonia.VisualTree; using AvaloniaEdit; using AvaloniaEdit.TextMate; using CommunityToolkit.Mvvm.Input; @@ -46,10 +47,66 @@ public static class DialogHelper string description, IReadOnlyList textFields ) + { + return CreateTextEntryDialog( + title, + new MarkdownScrollViewer { Markdown = description }, + textFields + ); + } + + /// + /// Create a generic textbox entry content dialog. + /// + public static BetterContentDialog CreateTextEntryDialog( + string title, + string description, + string imageSource, + IReadOnlyList textFields + ) + { + var markdown = new MarkdownScrollViewer { Markdown = description }; + var image = new BetterAdvancedImage((Uri?)null) + { + Source = imageSource, + Stretch = Stretch.UniformToFill, + StretchDirection = StretchDirection.Both, + HorizontalAlignment = HorizontalAlignment.Center, + MaxWidth = 400, + }; + + Grid.SetRow(markdown, 0); + Grid.SetRow(image, 1); + + var grid = new Grid + { + RowDefinitions = + { + new RowDefinition(GridLength.Star), + new RowDefinition(GridLength.Auto) + }, + Children = { markdown, image } + }; + + return CreateTextEntryDialog(title, grid, textFields); + } + + /// + /// Create a generic textbox entry content dialog. + /// + public static BetterContentDialog CreateTextEntryDialog( + string title, + Control content, + IReadOnlyList textFields + ) { Dispatcher.UIThread.VerifyAccess(); - var stackPanel = new StackPanel(); + var stackPanel = new StackPanel { Spacing = 4 }; + + Grid.SetRow(content, 0); + Grid.SetRow(stackPanel, 1); + var grid = new Grid { RowDefinitions = @@ -57,17 +114,16 @@ public static class DialogHelper new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Star) }, - Children = - { - new TextBlock { Text = description }, - stackPanel - } + Children = { content, stackPanel } }; grid.Loaded += (_, _) => { - // Focus first textbox - var firstTextBox = stackPanel.Children.OfType().First(); - firstTextBox.Focus(); + // Focus first TextBox + var firstTextBox = stackPanel.Children + .OfType() + .FirstOrDefault() + .FindDescendantOfType(); + firstTextBox!.Focus(); firstTextBox.CaretIndex = firstTextBox.Text?.LastIndexOf('.') ?? 0; }; @@ -85,8 +141,7 @@ public static class DialogHelper // Create textboxes foreach (var field in textFields) { - var label = new TextBlock { Text = field.Label }; - stackPanel.Children.Add(label); + var label = new TextBlock { Text = field.Label, Margin = new Thickness(0, 0, 0, 4) }; var textBox = new TextBox { @@ -106,7 +161,7 @@ public static class DialogHelper }; } - stackPanel.Children.Add(textBox); + stackPanel.Children.Add(new StackPanel { Spacing = 4, Children = { label, textBox } }); // When IsValid property changes, update invalid count and primary button field.PropertyChanged += (_, args) => diff --git a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs index 1474d3d7..8d6c1599 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs +++ b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs @@ -617,6 +617,15 @@ namespace StabilityMatrix.Avalonia.Languages { } } + /// + /// Looks up a localized string similar to API Key. + /// + public static string Label_ApiKey { + get { + return ResourceManager.GetString("Label_ApiKey", resourceCulture); + } + } + /// /// Looks up a localized string similar to Appearance. /// diff --git a/StabilityMatrix.Avalonia/Languages/Resources.resx b/StabilityMatrix.Avalonia/Languages/Resources.resx index d61c7e31..6363e680 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.resx +++ b/StabilityMatrix.Avalonia/Languages/Resources.resx @@ -774,4 +774,7 @@ Confirm Password + + API Key + diff --git a/StabilityMatrix.Avalonia/Services/AccountsService.cs b/StabilityMatrix.Avalonia/Services/AccountsService.cs index ac2ba945..7c4b3f90 100644 --- a/StabilityMatrix.Avalonia/Services/AccountsService.cs +++ b/StabilityMatrix.Avalonia/Services/AccountsService.cs @@ -6,6 +6,8 @@ using Octokit; using StabilityMatrix.Core.Api; using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.Api; +using StabilityMatrix.Core.Models.Api.CivitTRPC; using StabilityMatrix.Core.Models.Api.Lykos; using StabilityMatrix.Core.Services; using ApiException = Refit.ApiException; @@ -23,8 +25,13 @@ public class AccountsService : IAccountsService /// public event EventHandler? LykosAccountStatusUpdate; + /// + public event EventHandler? CivitAccountStatusUpdate; + public LykosAccountStatusUpdateEventArgs? LykosStatus { get; private set; } + public CivitAccountStatusUpdateEventArgs? CivitStatus { get; private set; } + public AccountsService( ILogger logger, ISecretsManager secretsManager, @@ -47,9 +54,11 @@ public class AccountsService : IAccountsService var tokens = await lykosAuthApi.PostLogin(new PostLoginRequest(email, password)); - await secretsManager.SaveAsync(secrets with { LykosAccount = tokens }); + secrets = secrets with { LykosAccount = tokens }; - await RefreshAsync(); + await secretsManager.SaveAsync(secrets); + + await RefreshLykosAsync(secrets); } public async Task LykosSignupAsync(string email, string password, string username) @@ -76,7 +85,7 @@ public class AccountsService : IAccountsService } /// - public async Task LykosPatreonOAuthLoginAsync() + public async Task LykosPatreonOAuthLogoutAsync() { var secrets = await secretsManager.SafeLoadAsync(); if (secrets.LykosAccount is null) @@ -86,23 +95,40 @@ public class AccountsService : IAccountsService ); } - // TODO + await lykosAuthApi.DeletePatreonOAuth(); + + await RefreshLykosAsync(secrets); } - /// - public async Task LykosPatreonOAuthLogoutAsync() + public async Task CivitLoginAsync(string apiToken) { var secrets = await secretsManager.SafeLoadAsync(); - if (secrets.LykosAccount is null) - { - throw new InvalidOperationException( - "Lykos account must be connected in to manage OAuth connections" - ); - } - await lykosAuthApi.DeletePatreonOAuth(); + // Get id first using the api token + var userAccount = await civitTRPCApi.GetUserAccountDefault(apiToken); + var id = userAccount.Result.Data.Json.Id; - await RefreshLykosAsync(secrets); + // Then get the username using the id + var account = await civitTRPCApi.GetUserById( + new CivitGetUserByIdRequest { Id = id }, + apiToken + ); + var username = account.Result.Data.Json.Username; + + secrets = secrets with { CivitApi = new CivitApiTokens(apiToken, username) }; + + await secretsManager.SaveAsync(secrets); + + await RefreshCivitAsync(secrets); + } + + /// + public async Task CivitLogoutAsync() + { + var secrets = await secretsManager.SafeLoadAsync(); + await secretsManager.SaveAsync(secrets with { CivitApi = null }); + + OnCivitAccountStatusUpdate(CivitAccountStatusUpdateEventArgs.Disconnected); } public async Task RefreshAsync() @@ -110,6 +136,7 @@ public class AccountsService : IAccountsService var secrets = await secretsManager.SafeLoadAsync(); await RefreshLykosAsync(secrets); + await RefreshCivitAsync(secrets); } private async Task RefreshLykosAsync(Secrets secrets) @@ -143,6 +170,40 @@ public class AccountsService : IAccountsService OnLykosAccountStatusUpdate(LykosAccountStatusUpdateEventArgs.Disconnected); } + private async Task RefreshCivitAsync(Secrets secrets) + { + if (secrets.CivitApi is not null) + { + try + { + var user = await civitTRPCApi.GetUserProfile( + new CivitUserProfileRequest { Username = secrets.CivitApi.Username }, + secrets.CivitApi.ApiToken + ); + + OnCivitAccountStatusUpdate( + new CivitAccountStatusUpdateEventArgs { IsConnected = true, UserProfile = user } + ); + + return; + } + catch (OperationCanceledException) + { + logger.LogWarning("Timed out while fetching Civit Auth user info"); + } + catch (ApiException e) + { + if (e.StatusCode is HttpStatusCode.Unauthorized) { } + else + { + logger.LogWarning(e, "Failed to get user info from Civit"); + } + } + } + + OnCivitAccountStatusUpdate(CivitAccountStatusUpdateEventArgs.Disconnected); + } + private void OnLykosAccountStatusUpdate(LykosAccountStatusUpdateEventArgs e) { if (!e.IsConnected && LykosStatus?.IsConnected == true) @@ -160,4 +221,22 @@ public class AccountsService : IAccountsService LykosAccountStatusUpdate?.Invoke(this, e); } + + private void OnCivitAccountStatusUpdate(CivitAccountStatusUpdateEventArgs e) + { + if (!e.IsConnected && CivitStatus?.IsConnected == true) + { + logger.LogInformation("Civit account disconnected"); + } + else if (e.IsConnected && CivitStatus?.IsConnected == false) + { + logger.LogInformation( + "Civit account connected: {Id} ({Username})", + e.UserProfile?.UserId, + e.UserProfile?.Username + ); + } + + CivitAccountStatusUpdate?.Invoke(this, e); + } } diff --git a/StabilityMatrix.Avalonia/Services/IAccountsService.cs b/StabilityMatrix.Avalonia/Services/IAccountsService.cs index 4cfa87bc..c594e57a 100644 --- a/StabilityMatrix.Avalonia/Services/IAccountsService.cs +++ b/StabilityMatrix.Avalonia/Services/IAccountsService.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using StabilityMatrix.Core.Models.Api; using StabilityMatrix.Core.Models.Api.Lykos; namespace StabilityMatrix.Avalonia.Services; @@ -8,6 +9,8 @@ public interface IAccountsService { event EventHandler? LykosAccountStatusUpdate; + event EventHandler? CivitAccountStatusUpdate; + LykosAccountStatusUpdateEventArgs? LykosStatus { get; } Task LykosSignupAsync(string email, string password, string username); @@ -16,9 +19,11 @@ public interface IAccountsService Task LykosLogoutAsync(); - Task LykosPatreonOAuthLoginAsync(); - Task LykosPatreonOAuthLogoutAsync(); + Task CivitLoginAsync(string apiToken); + + Task CivitLogoutAsync(); + Task RefreshAsync(); } diff --git a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj index afde9d2f..07bb9134 100644 --- a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj +++ b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj @@ -99,6 +99,7 @@ + diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/AccountSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/AccountSettingsViewModel.cs index c7eae0ea..3d61dc34 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Settings/AccountSettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/AccountSettingsViewModel.cs @@ -1,8 +1,7 @@ using System; -using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Security.Cryptography; using System.Text; -using System.Text.Json; using System.Threading.Tasks; using AsyncAwaitBestPractices; using Avalonia.Controls; @@ -10,19 +9,22 @@ using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using FluentAvalonia.UI.Controls; +using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Avalonia.Languages; using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.ViewModels.Dialogs; -using StabilityMatrix.Avalonia.Views.Dialogs; using StabilityMatrix.Avalonia.Views.Settings; using StabilityMatrix.Core.Api; using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Extensions; +using StabilityMatrix.Core.Models.Api; using StabilityMatrix.Core.Models.Api.Lykos; using StabilityMatrix.Core.Processes; +using StabilityMatrix.Core.Services; using Symbol = FluentIcons.Common.Symbol; using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; +using TeachingTip = StabilityMatrix.Core.Models.Settings.TeachingTip; namespace StabilityMatrix.Avalonia.ViewModels.Settings; @@ -31,6 +33,7 @@ namespace StabilityMatrix.Avalonia.ViewModels.Settings; public partial class AccountSettingsViewModel : PageViewModelBase { private readonly IAccountsService accountsService; + private readonly ISettingsManager settingsManager; private readonly ServiceManager vmFactory; private readonly INotificationService notificationService; private readonly ILykosAuthApi lykosAuthApi; @@ -42,13 +45,6 @@ public partial class AccountSettingsViewModel : PageViewModelBase public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.Person, IsFilled = true }; - [ObservableProperty] - private bool isLykosConnected; - - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(LykosProfileImageUrl))] - private GetUserResponse? lykosUser; - [ObservableProperty] private string? lykosProfileImageUrl; @@ -56,33 +52,24 @@ public partial class AccountSettingsViewModel : PageViewModelBase private bool isPatreonConnected; [ObservableProperty] - private bool isCivitConnected; - - partial void OnLykosUserChanged(GetUserResponse? value) - { - if (value?.Id is { } userEmail) - { - userEmail = userEmail.Trim().ToLowerInvariant(); - - var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(userEmail)); - var hash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); + [NotifyPropertyChangedFor(nameof(LykosProfileImageUrl))] + private LykosAccountStatusUpdateEventArgs lykosStatus = + LykosAccountStatusUpdateEventArgs.Disconnected; - LykosProfileImageUrl = $"https://gravatar.com/avatar/{hash}?s=512&d=retro"; - } - else - { - LykosProfileImageUrl = null; - } - } + [ObservableProperty] + private CivitAccountStatusUpdateEventArgs civitStatus = + CivitAccountStatusUpdateEventArgs.Disconnected; public AccountSettingsViewModel( IAccountsService accountsService, + ISettingsManager settingsManager, ServiceManager vmFactory, INotificationService notificationService, ILykosAuthApi lykosAuthApi ) { this.accountsService = accountsService; + this.settingsManager = settingsManager; this.vmFactory = vmFactory; this.notificationService = notificationService; this.lykosAuthApi = lykosAuthApi; @@ -91,11 +78,18 @@ public partial class AccountSettingsViewModel : PageViewModelBase { Dispatcher.UIThread.Post(() => { - IsLykosConnected = args.IsConnected; - LykosUser = args.User; + LykosStatus = args; IsPatreonConnected = args.IsPatreonConnected; }); }; + + accountsService.CivitAccountStatusUpdate += (_, args) => + { + Dispatcher.UIThread.Post(() => + { + CivitStatus = args; + }); + }; } /// @@ -111,56 +105,67 @@ public partial class AccountSettingsViewModel : PageViewModelBase accountsService.RefreshAsync().SafeFireAndForget(); } - [RelayCommand] - private async Task ConnectCivit() + private async Task BeforeConnectCheck() { - var textFields = new TextBoxField[] { new() { Label = "API Key" } }; - - var dialog = DialogHelper.CreateTextEntryDialog("Connect CivitAI Account", "", textFields); - + // Show credentials storage notice if not seen if ( - await dialog.ShowAsync() != ContentDialogResult.Primary - || textFields[0].Text is not { } json + !settingsManager.Settings.SeenTeachingTips.Contains( + TeachingTip.AccountsCredentialsStorageNotice + ) ) { - return; - } - - // TODO - await Task.Delay(200); - - IsCivitConnected = true; - } + var dialog = new BetterContentDialog + { + Title = "About Account Credentials", + Content = """ + Account credentials and tokens are stored locally on your computer, with at-rest AES encryption. + + If you make changes to your computer hardware, you may need to re-login to your accounts. + + Account tokens will not be viewable after saving, please make a note of them if you need to use them elsewhere. + """, + PrimaryButtonText = Resources.Action_Continue, + CloseButtonText = Resources.Action_Cancel, + DefaultButton = ContentDialogButton.Primary, + MaxDialogWidth = 400 + }; + + if (await dialog.ShowAsync() != ContentDialogResult.Primary) + { + return false; + } - [RelayCommand] - private async Task DisconnectCivit() - { - await Task.Yield(); + settingsManager.Transaction( + s => s.SeenTeachingTips.Add(TeachingTip.AccountsCredentialsStorageNotice) + ); + } - IsCivitConnected = false; + return true; } [RelayCommand] private async Task ConnectLykos() { + if (!await BeforeConnectCheck()) + return; + var vm = vmFactory.Get(); - if (await vm.ShowDialogAsync() == TaskDialogStandardResult.OK) - { - IsLykosConnected = true; - await accountsService.RefreshAsync(); - } + await vm.ShowDialogAsync(); } [RelayCommand] - private async Task DisconnectLykos() + private Task DisconnectLykos() { - await accountsService.LykosLogoutAsync(); + return accountsService.LykosLogoutAsync(); } [RelayCommand] private async Task ConnectPatreon() { - if (LykosUser?.Id is null) + if (!await BeforeConnectCheck()) + return; + + if (LykosStatus.User?.Id is null) return; var urlResult = await notificationService.TryAsync( @@ -196,25 +201,78 @@ public partial class AccountSettingsViewModel : PageViewModelBase await notificationService.TryAsync(accountsService.LykosPatreonOAuthLogoutAsync()); } - /*[RelayCommand] - private async Task ConnectCivitAccountOld() + [RelayCommand] + private async Task ConnectCivit() { - var textFields = new TextBoxField[] { new() { Label = "API Key" } }; + if (!await BeforeConnectCheck()) + return; + + var textFields = new TextBoxField[] + { + new() + { + Label = Resources.Label_ApiKey, + Validator = s => + { + if (string.IsNullOrWhiteSpace(s)) + { + throw new ValidationException("API key is required"); + } + } + } + }; - var dialog = DialogHelper.CreateTextEntryDialog("Connect CivitAI Account", "", textFields); + var dialog = DialogHelper.CreateTextEntryDialog( + "Connect CivitAI Account", + """ + Login to [CivitAI](https://civitai.com/) and head to your [Account](https://civitai.com/user/account) page + + Add a new API key and paste it below + """, + "avares://StabilityMatrix.Avalonia/Assets/guide-civitai-api.webp", + textFields + ); + dialog.PrimaryButtonText = Resources.Action_Connect; if ( await dialog.ShowAsync() != ContentDialogResult.Primary - || textFields[0].Text is not { } json + || textFields[0].Text is not { } apiToken ) { return; } - var secrets = GlobalUserSecrets.LoadFromFile()!; - secrets.CivitApiToken = json; - secrets.SaveToFile(); + var result = await notificationService.TryAsync(accountsService.CivitLoginAsync(apiToken)); + + if (result.IsSuccessful) + { + await accountsService.RefreshAsync(); + } + } + + [RelayCommand] + private Task DisconnectCivit() + { + return accountsService.CivitLogoutAsync(); + } + + /// + /// Update the Lykos profile image URL when the user changes. + /// + partial void OnLykosStatusChanged(LykosAccountStatusUpdateEventArgs? value) + { + if (value?.User?.Id is { } userEmail) + { + userEmail = userEmail.Trim().ToLowerInvariant(); + + var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(userEmail)); + var hash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); - RefreshCivitAccount().SafeFireAndForget(); - }*/ + LykosProfileImageUrl = $"https://gravatar.com/avatar/{hash}?s=512&d=retro"; + } + else + { + LykosProfileImageUrl = null; + } + } } diff --git a/StabilityMatrix.Avalonia/Views/Settings/AccountSettingsPage.axaml b/StabilityMatrix.Avalonia/Views/Settings/AccountSettingsPage.axaml index 15c1aed7..3a45c145 100644 --- a/StabilityMatrix.Avalonia/Views/Settings/AccountSettingsPage.axaml +++ b/StabilityMatrix.Avalonia/Views/Settings/AccountSettingsPage.axaml @@ -93,9 +93,9 @@ - + @@ -106,7 +106,7 @@ IsLoading="{Binding ConnectLykosCommand.IsRunning}" Header="Lykos" HeaderTargetUri="{x:Static sm:Assets.LykosUrl}" - IsConnected="{Binding IsLykosConnected}" + IsConnected="{Binding LykosStatus.IsConnected}" OffDescription="Manage connected features in Stability Matrix"> @@ -118,7 +118,7 @@ DisconnectCommand="{Binding DisconnectPatreonCommand}" IsLoading="{Binding ConnectPatreonCommand.IsRunning}" Header="Patreon" - IsEnabled="{Binding IsLykosConnected}" + IsEnabled="{Binding LykosStatus.IsConnected}" HeaderTargetUri="{x:Static sm:Assets.PatreonUrl}" IsConnected="{Binding IsPatreonConnected}" OffDescription="Access Preview and Dev release channels for auto-updates"> @@ -128,12 +128,6 @@ - - + + + + + diff --git a/StabilityMatrix.Core/Api/ICivitTRPCApi.cs b/StabilityMatrix.Core/Api/ICivitTRPCApi.cs index 75fda31a..d764fcc2 100644 --- a/StabilityMatrix.Core/Api/ICivitTRPCApi.cs +++ b/StabilityMatrix.Core/Api/ICivitTRPCApi.cs @@ -3,14 +3,46 @@ using StabilityMatrix.Core.Models.Api.CivitTRPC; namespace StabilityMatrix.Core.Api; -[Headers("Referer: https://civitai.com")] +[Headers( + "Content-Type: application/x-www-form-urlencoded", + "Referer: https://civitai.com", + "Origin: https://civitai.com" +)] public interface ICivitTRPCApi { - [Headers("Content-Type: application/x-www-form-urlencoded")] + [QueryUriFormat(UriFormat.UriEscaped)] [Get("/api/trpc/userProfile.get")] Task GetUserProfile( [Query] CivitUserProfileRequest input, [Authorize] string bearerToken, CancellationToken cancellationToken = default ); + + [QueryUriFormat(UriFormat.UriEscaped)] + [Get("/api/trpc/buzz.getUserAccount")] + Task> GetUserAccount( + [Query] string input, + [Authorize] string bearerToken, + CancellationToken cancellationToken = default + ); + + Task> GetUserAccountDefault( + string bearerToken, + CancellationToken cancellationToken = default + ) + { + return GetUserAccount( + "{\"json\":null,\"meta\":{\"values\":[\"undefined\"]}}", + bearerToken, + cancellationToken + ); + } + + [QueryUriFormat(UriFormat.UriEscaped)] + [Get("/api/trpc/user.getById")] + Task> GetUserById( + [Query] CivitGetUserByIdRequest input, + [Authorize] string bearerToken, + CancellationToken cancellationToken = default + ); } diff --git a/StabilityMatrix.Core/Models/Api/CivitAccountStatusUpdateEventArgs.cs b/StabilityMatrix.Core/Models/Api/CivitAccountStatusUpdateEventArgs.cs new file mode 100644 index 00000000..57e3fbfd --- /dev/null +++ b/StabilityMatrix.Core/Models/Api/CivitAccountStatusUpdateEventArgs.cs @@ -0,0 +1,15 @@ +using StabilityMatrix.Core.Models.Api.CivitTRPC; + +namespace StabilityMatrix.Core.Models.Api; + +public class CivitAccountStatusUpdateEventArgs : EventArgs +{ + public static CivitAccountStatusUpdateEventArgs Disconnected { get; } = new(); + + public bool IsConnected { get; init; } + + public CivitUserProfileResponse? UserProfile { get; init; } + + public string? UsernameWithParentheses => + string.IsNullOrEmpty(UserProfile?.Username) ? null : $"({UserProfile.Username})"; +} diff --git a/StabilityMatrix.Core/Models/Api/CivitTRPC/CivitApiTokens.cs b/StabilityMatrix.Core/Models/Api/CivitTRPC/CivitApiTokens.cs new file mode 100644 index 00000000..475374c3 --- /dev/null +++ b/StabilityMatrix.Core/Models/Api/CivitTRPC/CivitApiTokens.cs @@ -0,0 +1,3 @@ +namespace StabilityMatrix.Core.Models.Api.CivitTRPC; + +public record CivitApiTokens(string ApiToken, string Username); diff --git a/StabilityMatrix.Core/Models/Api/CivitTRPC/CivitGetUserByIdRequest.cs b/StabilityMatrix.Core/Models/Api/CivitTRPC/CivitGetUserByIdRequest.cs new file mode 100644 index 00000000..aced063e --- /dev/null +++ b/StabilityMatrix.Core/Models/Api/CivitTRPC/CivitGetUserByIdRequest.cs @@ -0,0 +1,16 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace StabilityMatrix.Core.Models.Api.CivitTRPC; + +public record CivitGetUserByIdRequest : IFormattable +{ + [JsonPropertyName("id")] + public required int Id { get; set; } + + /// + public string ToString(string? format, IFormatProvider? formatProvider) + { + return JsonSerializer.Serialize(new { json = this }); + } +} diff --git a/StabilityMatrix.Core/Models/Api/CivitTRPC/CivitGetUserByIdResponse.cs b/StabilityMatrix.Core/Models/Api/CivitTRPC/CivitGetUserByIdResponse.cs new file mode 100644 index 00000000..594c5dad --- /dev/null +++ b/StabilityMatrix.Core/Models/Api/CivitTRPC/CivitGetUserByIdResponse.cs @@ -0,0 +1,3 @@ +namespace StabilityMatrix.Core.Models.Api.CivitTRPC; + +public record CivitGetUserByIdResponse(int Id, string Username, string? Image); diff --git a/StabilityMatrix.Core/Models/Api/CivitTRPC/CivitUserAccountResponse.cs b/StabilityMatrix.Core/Models/Api/CivitTRPC/CivitUserAccountResponse.cs new file mode 100644 index 00000000..3fffd4dc --- /dev/null +++ b/StabilityMatrix.Core/Models/Api/CivitTRPC/CivitUserAccountResponse.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; + +namespace StabilityMatrix.Core.Models.Api.CivitTRPC; + +public record CivitUserAccountResponse(int Id, int Balance, int LifetimeBalance); + +public record CivitTrpcResponse +{ + [JsonPropertyName("result")] + public required CivitTrpcResponseData Result { get; set; } + + public record CivitTrpcResponseData + { + [JsonPropertyName("data")] + public required CivitTrpcResponseDataJson Data { get; set; } + } + + public record CivitTrpcResponseDataJson + { + [JsonPropertyName("Json")] + public required TJson Json { get; set; } + } +} diff --git a/StabilityMatrix.Core/Models/Secrets.cs b/StabilityMatrix.Core/Models/Secrets.cs index 4d04534b..5a413205 100644 --- a/StabilityMatrix.Core/Models/Secrets.cs +++ b/StabilityMatrix.Core/Models/Secrets.cs @@ -1,8 +1,11 @@ -using StabilityMatrix.Core.Models.Api.Lykos; +using StabilityMatrix.Core.Models.Api.CivitTRPC; +using StabilityMatrix.Core.Models.Api.Lykos; namespace StabilityMatrix.Core.Models; public readonly record struct Secrets { public LykosAccountTokens? LykosAccount { get; init; } + + public CivitApiTokens? CivitApi { get; init; } } diff --git a/StabilityMatrix.Core/Models/Settings/Settings.cs b/StabilityMatrix.Core/Models/Settings/Settings.cs index 8997f144..67f2620f 100644 --- a/StabilityMatrix.Core/Models/Settings/Settings.cs +++ b/StabilityMatrix.Core/Models/Settings/Settings.cs @@ -94,6 +94,8 @@ public class Settings public HashSet FavoriteModels { get; set; } = new(); + public HashSet SeenTeachingTips { get; set; } = new(); + public Size InferenceImageSize { get; set; } = new(150, 190); public Size OutputsImageSize { get; set; } = new(300, 300); diff --git a/StabilityMatrix.Core/Models/Settings/TeachingTip.cs b/StabilityMatrix.Core/Models/Settings/TeachingTip.cs new file mode 100644 index 00000000..140bbafd --- /dev/null +++ b/StabilityMatrix.Core/Models/Settings/TeachingTip.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; +using StabilityMatrix.Core.Converters.Json; + +namespace StabilityMatrix.Core.Models.Settings; + +/// +/// Teaching tip names +/// +[JsonConverter(typeof(StringJsonConverter))] +public record TeachingTip(string Value) : StringValue(Value) +{ + public static TeachingTip AccountsCredentialsStorageNotice => + new("AccountsCredentialsStorageNotice"); + + /// + public override string ToString() + { + return base.ToString(); + } +} diff --git a/StabilityMatrix.Core/Models/StringValue.cs b/StabilityMatrix.Core/Models/StringValue.cs new file mode 100644 index 00000000..98e7ab48 --- /dev/null +++ b/StabilityMatrix.Core/Models/StringValue.cs @@ -0,0 +1,16 @@ +namespace StabilityMatrix.Core.Models; + +public abstract record StringValue(string Value) : IFormattable +{ + /// + public override string ToString() + { + return Value; + } + + /// + public string ToString(string? format, IFormatProvider? formatProvider) + { + return Value; + } +} diff --git a/StabilityMatrix.Core/Services/DownloadService.cs b/StabilityMatrix.Core/Services/DownloadService.cs index 91dbb573..05615dcc 100644 --- a/StabilityMatrix.Core/Services/DownloadService.cs +++ b/StabilityMatrix.Core/Services/DownloadService.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging; using Polly.Contrib.WaitAndRetry; using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Models.Progress; namespace StabilityMatrix.Core.Services; @@ -11,12 +12,18 @@ public class DownloadService : IDownloadService { private readonly ILogger logger; private readonly IHttpClientFactory httpClientFactory; + private readonly ISecretsManager secretsManager; private const int BufferSize = ushort.MaxValue; - public DownloadService(ILogger logger, IHttpClientFactory httpClientFactory) + public DownloadService( + ILogger logger, + IHttpClientFactory httpClientFactory, + ISecretsManager secretsManager + ) { this.logger = logger; this.httpClientFactory = httpClientFactory; + this.secretsManager = secretsManager; } public async Task DownloadToFileAsync( @@ -35,6 +42,9 @@ public class DownloadService : IDownloadService client.DefaultRequestHeaders.UserAgent.Add( new ProductInfoHeaderValue("StabilityMatrix", "2.0") ); + + await AddConditionalHeaders(client, new Uri(downloadUrl)).ConfigureAwait(false); + await using var file = new FileStream( downloadPath, FileMode.Create, @@ -122,6 +132,8 @@ public class DownloadService : IDownloadService new ProductInfoHeaderValue("StabilityMatrix", "2.0") ); + await AddConditionalHeaders(client, new Uri(downloadUrl)).ConfigureAwait(false); + // Create file if it doesn't exist if (!File.Exists(downloadPath)) { @@ -234,6 +246,8 @@ public class DownloadService : IDownloadService new ProductInfoHeaderValue("StabilityMatrix", "2.0") ); + await AddConditionalHeaders(client, new Uri(downloadUrl)).ConfigureAwait(false); + var contentLength = 0L; foreach ( @@ -265,6 +279,7 @@ public class DownloadService : IDownloadService client.DefaultRequestHeaders.UserAgent.Add( new ProductInfoHeaderValue("StabilityMatrix", "2.0") ); + await AddConditionalHeaders(client, new Uri(url)).ConfigureAwait(false); try { var response = await client.GetAsync(url).ConfigureAwait(false); @@ -276,4 +291,30 @@ public class DownloadService : IDownloadService return null; } } + + /// + /// Adds conditional headers to the HttpClient for the given URL + /// + private async Task AddConditionalHeaders(HttpClient client, Uri url) + { + // Check if civit download + if (url.Host.Equals("civitai.com", StringComparison.OrdinalIgnoreCase)) + { + // Add auth if we have it + if ( + await secretsManager.LoadAsync().ConfigureAwait(false) is { CivitApi: { } civitApi } + ) + { + logger.LogTrace( + "Adding Civit auth header {Signature} for download {Url}", + ObjectHash.GetStringSignature(civitApi.ApiToken), + url + ); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( + "Bearer", + civitApi.ApiToken + ); + } + } + } } From bee3bec549292e0976aead160d878684d98ffd63 Mon Sep 17 00:00:00 2001 From: Ionite Date: Wed, 15 Nov 2023 19:49:22 -0500 Subject: [PATCH 037/144] Add localization, remove debug only for accounts --- StabilityMatrix.Avalonia/Languages/Resources.Designer.cs | 9 +++++++++ StabilityMatrix.Avalonia/Languages/Resources.resx | 3 +++ .../Views/Settings/MainSettingsPage.axaml | 3 +-- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs index 8d6c1599..f9984748 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs +++ b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs @@ -581,6 +581,15 @@ namespace StabilityMatrix.Avalonia.Languages { } } + /// + /// Looks up a localized string similar to . + /// + public static string Label_Accounts { + get { + return ResourceManager.GetString("Label_Accounts", resourceCulture); + } + } + /// /// Looks up a localized string similar to Add Stability Matrix to the Start Menu. /// diff --git a/StabilityMatrix.Avalonia/Languages/Resources.resx b/StabilityMatrix.Avalonia/Languages/Resources.resx index 6363e680..9baa3860 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.resx +++ b/StabilityMatrix.Avalonia/Languages/Resources.resx @@ -777,4 +777,7 @@ API Key + + + diff --git a/StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml b/StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml index ee6d4ece..e9d960f0 100644 --- a/StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml +++ b/StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml @@ -255,10 +255,9 @@ Grid.Row="1" Margin="8,0" IsClickEnabled="True" - IsVisible="{Binding SharedState.IsDebugMode}" Command="{Binding NavigateToSubPageCommand}" CommandParameter="{x:Type vmSettings:AccountSettingsViewModel}" - Header="Accounts" + Header="{x:Static lang:Resources.Label_Accounts}" ActionIconSource="ChevronRight"> Date: Wed, 15 Nov 2023 22:02:59 -0500 Subject: [PATCH 038/144] Add missing accounts resource --- StabilityMatrix.Avalonia/Languages/Resources.Designer.cs | 2 +- StabilityMatrix.Avalonia/Languages/Resources.resx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs index f9984748..2bc83970 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs +++ b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs @@ -582,7 +582,7 @@ namespace StabilityMatrix.Avalonia.Languages { } /// - /// Looks up a localized string similar to . + /// Looks up a localized string similar to Accounts. /// public static string Label_Accounts { get { diff --git a/StabilityMatrix.Avalonia/Languages/Resources.resx b/StabilityMatrix.Avalonia/Languages/Resources.resx index 9baa3860..62d73aac 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.resx +++ b/StabilityMatrix.Avalonia/Languages/Resources.resx @@ -778,6 +778,6 @@ API Key - + Accounts From b683cf08698784d6335c13a23d0e3e441753b0d1 Mon Sep 17 00:00:00 2001 From: Ionite Date: Wed, 15 Nov 2023 22:03:14 -0500 Subject: [PATCH 039/144] Add Design time sub for IAccountsService to fix preview --- StabilityMatrix.Avalonia/DesignData/DesignData.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/StabilityMatrix.Avalonia/DesignData/DesignData.cs b/StabilityMatrix.Avalonia/DesignData/DesignData.cs index 68edc862..f7c83f7f 100644 --- a/StabilityMatrix.Avalonia/DesignData/DesignData.cs +++ b/StabilityMatrix.Avalonia/DesignData/DesignData.cs @@ -118,6 +118,7 @@ public static class DesignData .AddSingleton(Substitute.For()) .AddSingleton(Substitute.For()) .AddSingleton(Substitute.For()) + .AddSingleton(Substitute.For()) .AddSingleton() .AddSingleton() .AddSingleton() From 4883dc627b8c6c4fd73ce205052d09a1119fb580 Mon Sep 17 00:00:00 2001 From: Ionite Date: Wed, 15 Nov 2023 22:04:17 -0500 Subject: [PATCH 040/144] Fix nullref exception at design time --- StabilityMatrix.Avalonia/DesignData/DesignData.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/StabilityMatrix.Avalonia/DesignData/DesignData.cs b/StabilityMatrix.Avalonia/DesignData/DesignData.cs index f7c83f7f..f9e6eaab 100644 --- a/StabilityMatrix.Avalonia/DesignData/DesignData.cs +++ b/StabilityMatrix.Avalonia/DesignData/DesignData.cs @@ -304,6 +304,7 @@ public static class DesignData { Name = "BB95 Furry Mix", Description = "A furry mix of BB95", + Stats = new CivitModelStats { Rating = 3.5, RatingCount = 24 } }; }) }; From f431d3f47db682fd2227197298a7e8fe42375c32 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 15 Nov 2023 19:24:24 -0800 Subject: [PATCH 041/144] Handle CivitAI auth-required failure gracefully --- StabilityMatrix.Avalonia/App.axaml.cs | 7 +++ .../DesignData/DesignData.cs | 4 +- .../Languages/Resources.Designer.cs | 20 ++++++- .../Languages/Resources.resx | 8 ++- .../Progress/ProgressManagerViewModel.cs | 52 +++++++++++++++++-- .../Views/ProgressManagerPage.axaml | 16 +++--- .../Services/DownloadService.cs | 42 +++++++++++++-- 7 files changed, 132 insertions(+), 17 deletions(-) diff --git a/StabilityMatrix.Avalonia/App.axaml.cs b/StabilityMatrix.Avalonia/App.axaml.cs index 1761edc7..30a6b229 100644 --- a/StabilityMatrix.Avalonia/App.axaml.cs +++ b/StabilityMatrix.Avalonia/App.axaml.cs @@ -569,6 +569,13 @@ public sealed class App : Application .AddHttpClient("A3Client") .AddPolicyHandler(localTimeout.WrapAsync(localRetryPolicy)); + services + .AddHttpClient("DontFollowRedirects") + .ConfigurePrimaryHttpMessageHandler( + () => new HttpClientHandler { AllowAutoRedirect = false } + ) + .AddPolicyHandler(retryPolicy); + /*services.AddHttpClient("IComfyApi") .AddPolicyHandler(localTimeout.WrapAsync(localRetryPolicy));*/ diff --git a/StabilityMatrix.Avalonia/DesignData/DesignData.cs b/StabilityMatrix.Avalonia/DesignData/DesignData.cs index 68edc862..5692f82c 100644 --- a/StabilityMatrix.Avalonia/DesignData/DesignData.cs +++ b/StabilityMatrix.Avalonia/DesignData/DesignData.cs @@ -343,7 +343,9 @@ public static class DesignData new ProgressReport(0.5f, "Downloading...") ) ), - new MockDownloadProgressItemViewModel("Test File 2.exe"), + new MockDownloadProgressItemViewModel( + "Very Long Test File Name Need Even More Longness Thanks That's pRobably good 2.exe" + ), new PackageInstallProgressItemViewModel( new PackageModificationRunner { diff --git a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs index f9984748..36b4d489 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs +++ b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs @@ -582,7 +582,7 @@ namespace StabilityMatrix.Avalonia.Languages { } /// - /// Looks up a localized string similar to . + /// Looks up a localized string similar to Accounts. /// public static string Label_Accounts { get { @@ -743,6 +743,15 @@ namespace StabilityMatrix.Avalonia.Languages { } } + /// + /// Looks up a localized string similar to You must be logged in to download this checkpoint. Please enter a CivitAI API Key in the settings.. + /// + public static string Label_CivitAiLoginRequired { + get { + return ResourceManager.GetString("Label_CivitAiLoginRequired", resourceCulture); + } + } + /// /// Looks up a localized string similar to Close dialog when finished. /// @@ -923,6 +932,15 @@ namespace StabilityMatrix.Avalonia.Languages { } } + /// + /// Looks up a localized string similar to Download Failed. + /// + public static string Label_DownloadFailed { + get { + return ResourceManager.GetString("Label_DownloadFailed", resourceCulture); + } + } + /// /// Looks up a localized string similar to Downloads. /// diff --git a/StabilityMatrix.Avalonia/Languages/Resources.resx b/StabilityMatrix.Avalonia/Languages/Resources.resx index 9baa3860..def18acc 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.resx +++ b/StabilityMatrix.Avalonia/Languages/Resources.resx @@ -778,6 +778,12 @@ API Key - + Accounts + + + You must be logged in to download this checkpoint. Please enter a CivitAI API Key in the settings. + + + Download Failed diff --git a/StabilityMatrix.Avalonia/ViewModels/Progress/ProgressManagerViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Progress/ProgressManagerViewModel.cs index 445f0e0c..c0633697 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Progress/ProgressManagerViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Progress/ProgressManagerViewModel.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using AsyncAwaitBestPractices; @@ -6,9 +7,14 @@ using Avalonia.Collections; using Avalonia.Controls.Notifications; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; using FluentAvalonia.UI.Controls; +using FluentAvalonia.UI.Media.Animation; +using StabilityMatrix.Avalonia.Controls; +using StabilityMatrix.Avalonia.Languages; using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Avalonia.ViewModels.Settings; using StabilityMatrix.Avalonia.Views; using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Helper; @@ -27,6 +33,8 @@ namespace StabilityMatrix.Avalonia.ViewModels.Progress; public partial class ProgressManagerViewModel : PageViewModelBase { private readonly INotificationService notificationService; + private readonly INavigationService navigationService; + private readonly INavigationService settingsNavService; public override string Title => "Download Manager"; public override IconSource IconSource => @@ -38,10 +46,14 @@ public partial class ProgressManagerViewModel : PageViewModelBase public ProgressManagerViewModel( ITrackedDownloadService trackedDownloadService, - INotificationService notificationService + INotificationService notificationService, + INavigationService navigationService, + INavigationService settingsNavService ) { this.notificationService = notificationService; + this.navigationService = navigationService; + this.settingsNavService = settingsNavService; // Attach to the event trackedDownloadService.DownloadAdded += TrackedDownloadService_OnDownloadAdded; @@ -82,8 +94,42 @@ public partial class ProgressManagerViewModel : PageViewModelBase var msg = ""; if (download?.Exception is { } exception) { - msg = $"({exception.GetType().Name}) {exception.Message}"; + if ( + exception is UnauthorizedAccessException + || exception.InnerException is UnauthorizedAccessException + ) + { + Dispatcher.UIThread.InvokeAsync(async () => + { + var errorDialog = new BetterContentDialog + { + Title = Resources.Label_DownloadFailed, + Content = Resources.Label_CivitAiLoginRequired, + PrimaryButtonText = "Go to Settings", + SecondaryButtonText = "Close", + DefaultButton = ContentDialogButton.Primary, + }; + + var result = await errorDialog.ShowAsync(); + if (result == ContentDialogResult.Primary) + { + navigationService.NavigateTo( + new SuppressNavigationTransitionInfo() + ); + await Task.Delay(100); + settingsNavService.NavigateTo( + new SuppressNavigationTransitionInfo() + ); + } + }); + + return; + } + + msg = + $"({exception.GetType().Name}) {exception.InnerException?.Message ?? exception.Message}"; } + Dispatcher.UIThread.Post(() => { notificationService.ShowPersistent( diff --git a/StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml b/StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml index 53525002..5cafe300 100644 --- a/StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml +++ b/StabilityMatrix.Avalonia/Views/ProgressManagerPage.axaml @@ -51,13 +51,17 @@ BorderBrush="#33000000" BorderThickness="2" CornerRadius="8"> - + - - + + - @@ -65,11 +69,11 @@ - - + diff --git a/StabilityMatrix.Core/Services/DownloadService.cs b/StabilityMatrix.Core/Services/DownloadService.cs index 05615dcc..ee6b03a4 100644 --- a/StabilityMatrix.Core/Services/DownloadService.cs +++ b/StabilityMatrix.Core/Services/DownloadService.cs @@ -127,12 +127,15 @@ public class DownloadService : IDownloadService ? httpClientFactory.CreateClient() : httpClientFactory.CreateClient(httpClientName); + using var noRedirectClient = httpClientFactory.CreateClient("DontFollowRedirects"); + client.Timeout = TimeSpan.FromMinutes(10); client.DefaultRequestHeaders.UserAgent.Add( new ProductInfoHeaderValue("StabilityMatrix", "2.0") ); await AddConditionalHeaders(client, new Uri(downloadUrl)).ConfigureAwait(false); + await AddConditionalHeaders(noRedirectClient, new Uri(downloadUrl)).ConfigureAwait(false); // Create file if it doesn't exist if (!File.Exists(downloadPath)) @@ -156,10 +159,10 @@ public class DownloadService : IDownloadService // Total of the original content long originalContentLength = 0; - using var request = new HttpRequestMessage(); - request.Method = HttpMethod.Get; - request.RequestUri = new Uri(downloadUrl); - request.Headers.Range = new RangeHeaderValue(existingFileSize, null); + using var noRedirectRequest = new HttpRequestMessage(); + noRedirectRequest.Method = HttpMethod.Get; + noRedirectRequest.RequestUri = new Uri(downloadUrl); + noRedirectRequest.Headers.Range = new RangeHeaderValue(existingFileSize, null); HttpResponseMessage? response = null; foreach ( @@ -169,9 +172,38 @@ public class DownloadService : IDownloadService ) ) { + var noRedirectResponse = await noRedirectClient + .SendAsync( + noRedirectRequest, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken + ) + .ConfigureAwait(false); + + if ( + (int)noRedirectResponse.StatusCode > 299 && (int)noRedirectResponse.StatusCode < 400 + ) + { + var redirectUrl = noRedirectResponse.Headers.Location?.ToString(); + if (redirectUrl != null && redirectUrl.Contains("reason=download-auth")) + { + throw new UnauthorizedAccessException(); + } + } + + using var redirectRequest = new HttpRequestMessage(); + redirectRequest.Method = HttpMethod.Get; + redirectRequest.RequestUri = new Uri(downloadUrl); + redirectRequest.Headers.Range = new RangeHeaderValue(existingFileSize, null); + response = await client - .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .SendAsync( + redirectRequest, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken + ) .ConfigureAwait(false); + remainingContentLength = response.Content.Headers.ContentLength ?? 0; originalContentLength = response.Content.Headers.ContentRange?.Length.GetValueOrDefault() ?? 0; From 7f84302aa97e99e2f016fa440c8563c97f5eff25 Mon Sep 17 00:00:00 2001 From: Ionite Date: Wed, 15 Nov 2023 22:30:45 -0500 Subject: [PATCH 042/144] Add StarRating control --- StabilityMatrix.Avalonia/App.axaml | 1 + .../Controls/StarsRating.axaml | 59 ++++++ .../Controls/StarsRating.axaml.cs | 176 ++++++++++++++++++ 3 files changed, 236 insertions(+) create mode 100644 StabilityMatrix.Avalonia/Controls/StarsRating.axaml create mode 100644 StabilityMatrix.Avalonia/Controls/StarsRating.axaml.cs diff --git a/StabilityMatrix.Avalonia/App.axaml b/StabilityMatrix.Avalonia/App.axaml index 2bc8309d..c2821641 100644 --- a/StabilityMatrix.Avalonia/App.axaml +++ b/StabilityMatrix.Avalonia/App.axaml @@ -67,6 +67,7 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Controls/StarsRating.axaml.cs b/StabilityMatrix.Avalonia/Controls/StarsRating.axaml.cs new file mode 100644 index 00000000..189130e7 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/StarsRating.axaml.cs @@ -0,0 +1,176 @@ +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 StarItems => itemsControl!.ItemsSource!.Cast(); + + public static readonly StyledProperty IsEditableProperty = AvaloniaProperty.Register< + StarsRating, + bool + >("IsEditable"); + + public bool IsEditable + { + get => GetValue(IsEditableProperty); + set => SetValue(IsEditableProperty, value); + } + + public static readonly StyledProperty MaximumProperty = AvaloniaProperty.Register< + StarsRating, + int + >(nameof(Maximum), 5); + + public int Maximum + { + get => GetValue(MaximumProperty); + set => SetValue(MaximumProperty, value); + } + + public static readonly StyledProperty ValueProperty = AvaloniaProperty.Register< + StarsRating, + double + >(nameof(Value)); + + public double Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + /// + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + itemsControl = e.NameScope.Find("PART_StarsItemsControl")!; + + CreateStars(); + } + + /// + 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(); + + 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; + }; + } + } + + /// + /// Round a number to the nearest 0.5 + /// + 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 nearly equal + if (Math.Abs(tag - Value) < 0.01) + { + star.Symbol = Symbol.Star; + star.IsFilled = true; + star.Foreground = Foreground; + }*/ + // 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); + ; + } + } + } +} From 03f137ee535e9c5c0faa1c0ec3f2775195cbf10f Mon Sep 17 00:00:00 2001 From: Ionite Date: Wed, 15 Nov 2023 22:31:10 -0500 Subject: [PATCH 043/144] Add color --- StabilityMatrix.Avalonia/Styles/ThemeColors.axaml | 1 + 1 file changed, 1 insertion(+) diff --git a/StabilityMatrix.Avalonia/Styles/ThemeColors.axaml b/StabilityMatrix.Avalonia/Styles/ThemeColors.axaml index f7690bb2..011c07b6 100644 --- a/StabilityMatrix.Avalonia/Styles/ThemeColors.axaml +++ b/StabilityMatrix.Avalonia/Styles/ThemeColors.axaml @@ -29,6 +29,7 @@ #FFC107 #FF9800 #FF5722 + #FF4F00 #795548 #9E9E9E #607D8B From 199c196a5b26f4582de6d776db8fe3641a103539 Mon Sep 17 00:00:00 2001 From: Ionite Date: Wed, 15 Nov 2023 22:33:27 -0500 Subject: [PATCH 044/144] Add loading indicators and disable connect before first update --- .../Settings/AccountSettingsViewModel.cs | 11 ++++++++--- .../Views/Settings/AccountSettingsPage.axaml | 15 +++++++++++++-- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/AccountSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/AccountSettingsViewModel.cs index 3d61dc34..16a09176 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Settings/AccountSettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/AccountSettingsViewModel.cs @@ -45,6 +45,9 @@ public partial class AccountSettingsViewModel : PageViewModelBase public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.Person, IsFilled = true }; + [ObservableProperty] + private bool isInitialUpdateFinished; + [ObservableProperty] private string? lykosProfileImageUrl; @@ -78,6 +81,7 @@ public partial class AccountSettingsViewModel : PageViewModelBase { Dispatcher.UIThread.Post(() => { + IsInitialUpdateFinished = true; LykosStatus = args; IsPatreonConnected = args.IsPatreonConnected; }); @@ -87,6 +91,7 @@ public partial class AccountSettingsViewModel : PageViewModelBase { Dispatcher.UIThread.Post(() => { + IsInitialUpdateFinished = true; CivitStatus = args; }); }; @@ -143,7 +148,7 @@ public partial class AccountSettingsViewModel : PageViewModelBase return true; } - [RelayCommand] + [RelayCommand(CanExecute = nameof(IsInitialUpdateFinished))] private async Task ConnectLykos() { if (!await BeforeConnectCheck()) @@ -159,7 +164,7 @@ public partial class AccountSettingsViewModel : PageViewModelBase return accountsService.LykosLogoutAsync(); } - [RelayCommand] + [RelayCommand(CanExecute = nameof(IsInitialUpdateFinished))] private async Task ConnectPatreon() { if (!await BeforeConnectCheck()) @@ -201,7 +206,7 @@ public partial class AccountSettingsViewModel : PageViewModelBase await notificationService.TryAsync(accountsService.LykosPatreonOAuthLogoutAsync()); } - [RelayCommand] + [RelayCommand(CanExecute = nameof(IsInitialUpdateFinished))] private async Task ConnectCivit() { if (!await BeforeConnectCheck()) diff --git a/StabilityMatrix.Avalonia/Views/Settings/AccountSettingsPage.axaml b/StabilityMatrix.Avalonia/Views/Settings/AccountSettingsPage.axaml index 3a45c145..bdb12cd8 100644 --- a/StabilityMatrix.Avalonia/Views/Settings/AccountSettingsPage.axaml +++ b/StabilityMatrix.Avalonia/Views/Settings/AccountSettingsPage.axaml @@ -103,7 +103,6 @@ + + + + + + + + + + + + @@ -148,6 +158,7 @@ + From 3afb8afb40e719ca63a01daf1fcc73c9ed32a662 Mon Sep 17 00:00:00 2001 From: Ionite Date: Wed, 15 Nov 2023 22:42:32 -0500 Subject: [PATCH 045/144] Fix extra syntax --- StabilityMatrix.Avalonia/Controls/StarsRating.axaml.cs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/StabilityMatrix.Avalonia/Controls/StarsRating.axaml.cs b/StabilityMatrix.Avalonia/Controls/StarsRating.axaml.cs index 189130e7..2c91651e 100644 --- a/StabilityMatrix.Avalonia/Controls/StarsRating.axaml.cs +++ b/StabilityMatrix.Avalonia/Controls/StarsRating.axaml.cs @@ -142,13 +142,6 @@ public class StarsRating : TemplatedControl // Add 1 to tag since its index is 0-based var tag = (int)star.Tag! + 1; - /*// Fill if nearly equal - if (Math.Abs(tag - Value) < 0.01) - { - star.Symbol = Symbol.Star; - star.IsFilled = true; - star.Foreground = Foreground; - }*/ // Fill if current is equal or lower than floor of Value if (tag <= Math.Floor(RoundToHalf(Value))) { @@ -169,7 +162,6 @@ public class StarsRating : TemplatedControl star.Symbol = Symbol.Star; star.IsFilled = false; star.Foreground = new SolidColorBrush(Colors.DarkSlateGray); - ; } } } From 87991ae3f58598a39ee1fceb356507425eec925e Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 15 Nov 2023 20:42:39 -0800 Subject: [PATCH 046/144] Upgrade to .net 8 --- ...tabilityMatrix.Avalonia.Diagnostics.csproj | 2 +- .../StabilityMatrix.Avalonia.csproj | 50 +++++++++---------- .../StabilityMatrix.Core.csproj | 26 +++++----- .../StabilityMatrix.Tests.csproj | 12 ++--- StabilityMatrix.UITests/MainWindowTests.cs | 2 + ....MainWindowViewModel_ShouldOk.verified.txt | 1 - .../StabilityMatrix.UITests.csproj | 8 +-- 7 files changed, 51 insertions(+), 50 deletions(-) diff --git a/StabilityMatrix.Avalonia.Diagnostics/StabilityMatrix.Avalonia.Diagnostics.csproj b/StabilityMatrix.Avalonia.Diagnostics/StabilityMatrix.Avalonia.Diagnostics.csproj index 75091167..eb84e3f3 100644 --- a/StabilityMatrix.Avalonia.Diagnostics/StabilityMatrix.Avalonia.Diagnostics.csproj +++ b/StabilityMatrix.Avalonia.Diagnostics/StabilityMatrix.Avalonia.Diagnostics.csproj @@ -1,7 +1,7 @@ - net7.0 + net8.0 win-x64;linux-x64;osx-x64;osx-arm64 enable enable diff --git a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj index 07bb9134..fb16d35f 100644 --- a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj +++ b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj @@ -1,7 +1,7 @@  WinExe - net7.0 + net8.0 win-x64;linux-x64;osx-x64;osx-arm64 enable true @@ -22,53 +22,53 @@ - + - - - + + + - + - - - + + + - - - + + + - - + + - - - - - - + + + + + + - + - - + + - + - + diff --git a/StabilityMatrix.Core/StabilityMatrix.Core.csproj b/StabilityMatrix.Core/StabilityMatrix.Core.csproj index 333575d7..254f8efe 100644 --- a/StabilityMatrix.Core/StabilityMatrix.Core.csproj +++ b/StabilityMatrix.Core/StabilityMatrix.Core.csproj @@ -1,7 +1,7 @@ - net7.0 + net8.0 win-x64;linux-x64;osx-x64;osx-arm64 enable enable @@ -15,27 +15,27 @@ - + - - - - - + + + + + - - + + - + - + @@ -43,8 +43,8 @@ - - + + diff --git a/StabilityMatrix.Tests/StabilityMatrix.Tests.csproj b/StabilityMatrix.Tests/StabilityMatrix.Tests.csproj index f604bbea..87423475 100644 --- a/StabilityMatrix.Tests/StabilityMatrix.Tests.csproj +++ b/StabilityMatrix.Tests/StabilityMatrix.Tests.csproj @@ -1,7 +1,7 @@ - net7.0 + net8.0 win-x64;linux-x64;osx-x64;osx-arm64 enable enable @@ -11,10 +11,10 @@ - - - - + + + + @@ -22,7 +22,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/StabilityMatrix.UITests/MainWindowTests.cs b/StabilityMatrix.UITests/MainWindowTests.cs index 4c890b7a..cc2c3f13 100644 --- a/StabilityMatrix.UITests/MainWindowTests.cs +++ b/StabilityMatrix.UITests/MainWindowTests.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.DependencyInjection; using StabilityMatrix.Avalonia; using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Avalonia.ViewModels; +using StabilityMatrix.Avalonia.ViewModels.Dialogs; using StabilityMatrix.Avalonia.Views; using StabilityMatrix.Avalonia.Views.Dialogs; using StabilityMatrix.UITests.Extensions; @@ -33,6 +34,7 @@ public class MainWindowTests vm => vm.FooterPages, vm => vm.CurrentPage ); + settings.IgnoreMember(vm => vm.CurrentVersionText); settings.DisableDiff(); return settings; } diff --git a/StabilityMatrix.UITests/Snapshots/MainWindowTests.MainWindowViewModel_ShouldOk.verified.txt b/StabilityMatrix.UITests/Snapshots/MainWindowTests.MainWindowViewModel_ShouldOk.verified.txt index 9483f638..c8c3de54 100644 --- a/StabilityMatrix.UITests/Snapshots/MainWindowTests.MainWindowViewModel_ShouldOk.verified.txt +++ b/StabilityMatrix.UITests/Snapshots/MainWindowTests.MainWindowViewModel_ShouldOk.verified.txt @@ -30,7 +30,6 @@ Signature: IX5/CCXWJQG0oGkYWVnuF34gTqF/dJSrDrUd6fuNMYnncL39G3HSvkXrjvJvR18MA2rQNB5z13h3/qBSf9c7DA== }, ShowProgressBar: false, - CurrentVersionText: v1.0.0, NewVersionText: v2.999.0, InstallUpdateCommand: UpdateViewModel.InstallUpdate(), HasErrors: false diff --git a/StabilityMatrix.UITests/StabilityMatrix.UITests.csproj b/StabilityMatrix.UITests/StabilityMatrix.UITests.csproj index a7dcc3bb..8062d3bd 100644 --- a/StabilityMatrix.UITests/StabilityMatrix.UITests.csproj +++ b/StabilityMatrix.UITests/StabilityMatrix.UITests.csproj @@ -1,7 +1,7 @@ - net7.0 + net8.0 enable enable @@ -11,13 +11,13 @@ - + - + - + runtime; build; native; contentfiles; analyzers; buildtransitive From 6fe077a604bf341c3686553bcc82f12c9b1993a3 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 15 Nov 2023 20:54:22 -0800 Subject: [PATCH 047/144] also update yml files --- .github/workflows/build.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/test-ui.yml | 2 +- .github/workflows/version-bump.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ae9cc140..c5bb7927 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,7 +21,7 @@ jobs: - name: Set up .NET uses: actions/setup-dotnet@v3 with: - dotnet-version: '7.0.x' + dotnet-version: '8.0.x' - name: Install dependencies run: dotnet restore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b7d84081..39a9a82e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,7 +129,7 @@ jobs: - name: Set up .NET 7 uses: actions/setup-dotnet@v3 with: - dotnet-version: '7.0.x' + dotnet-version: '8.0.x' - name: Install dependencies run: dotnet restore diff --git a/.github/workflows/test-ui.yml b/.github/workflows/test-ui.yml index 244472bb..a0720e5d 100644 --- a/.github/workflows/test-ui.yml +++ b/.github/workflows/test-ui.yml @@ -18,7 +18,7 @@ jobs: - name: Set up .NET uses: actions/setup-dotnet@v3 with: - dotnet-version: '7.0.x' + dotnet-version: '8.0.x' - name: Install dependencies run: dotnet restore diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index 68103040..1b623413 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -25,7 +25,7 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v2 with: - dotnet-version: 6.0.x + dotnet-version: '8.0.x' - name: Bump versions uses: SiqiLu/dotnet-bump-version@2.0.0 From bf239f72cf479b71d00181c64b7d772e8340d763 Mon Sep 17 00:00:00 2001 From: JT Date: Thu, 16 Nov 2023 00:23:34 -0800 Subject: [PATCH 048/144] updates model browser cards to look cooler & show more info --- .../DesignData/DesignData.cs | 5 +- .../Views/CheckpointBrowserPage.axaml | 326 ++++++++++-------- 2 files changed, 195 insertions(+), 136 deletions(-) diff --git a/StabilityMatrix.Avalonia/DesignData/DesignData.cs b/StabilityMatrix.Avalonia/DesignData/DesignData.cs index fd8b9e60..9811c32d 100644 --- a/StabilityMatrix.Avalonia/DesignData/DesignData.cs +++ b/StabilityMatrix.Avalonia/DesignData/DesignData.cs @@ -304,7 +304,10 @@ public static class DesignData { Name = "BB95 Furry Mix", Description = "A furry mix of BB95", - Stats = new CivitModelStats { Rating = 3.5, RatingCount = 24 } + Stats = new CivitModelStats { Rating = 3.5, RatingCount = 24 }, + ModelVersions = [ + new() { Name = "v1.2.2-Inpainting" } + ] }; }) }; diff --git a/StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml b/StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml index e14edf4a..fe86c62d 100644 --- a/StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml +++ b/StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml @@ -16,96 +16,98 @@ x:CompileBindings="True" x:Class="StabilityMatrix.Avalonia.Views.CheckpointBrowserPage"> + + + + + + + + + + - - - - - - - - - - + + - - - + + + + + + - - - - + + - - - - - - - - + + - - + + + @@ -272,7 +328,7 @@ MinWidth="100" SelectedItem="{Binding SelectedModelType}" /> - + - + - + - From 932b522029133b6eb024070d5a80c323668a3cf0 Mon Sep 17 00:00:00 2001 From: Ionite Date: Sat, 18 Nov 2023 23:58:50 -0500 Subject: [PATCH 049/144] Add Borderless listbox styles --- StabilityMatrix.Avalonia/App.axaml | 1 + .../Styles/ControlThemes/ListBoxStyles.axaml | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 StabilityMatrix.Avalonia/Styles/ControlThemes/ListBoxStyles.axaml diff --git a/StabilityMatrix.Avalonia/App.axaml b/StabilityMatrix.Avalonia/App.axaml index c2821641..2ed2726d 100644 --- a/StabilityMatrix.Avalonia/App.axaml +++ b/StabilityMatrix.Avalonia/App.axaml @@ -23,6 +23,7 @@ + diff --git a/StabilityMatrix.Avalonia/Styles/ControlThemes/ListBoxStyles.axaml b/StabilityMatrix.Avalonia/Styles/ControlThemes/ListBoxStyles.axaml new file mode 100644 index 00000000..cacf6372 --- /dev/null +++ b/StabilityMatrix.Avalonia/Styles/ControlThemes/ListBoxStyles.axaml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + From 7409deb97a4f1693e39454db65d0b4ca934b6937 Mon Sep 17 00:00:00 2001 From: Ionite Date: Sun, 19 Nov 2023 00:02:45 -0500 Subject: [PATCH 050/144] Add Update preferences page --- .../Converters/EnumStringConverter.cs | 32 +++ .../Converters/EnumToBooleanConverter.cs | 24 ++ .../DesignData/DesignData.cs | 35 ++- .../Languages/Resources.Designer.cs | 54 ++++ .../Languages/Resources.resx | 18 ++ .../Models/UpdateChannelCard.cs | 63 +++++ .../StabilityMatrix.Avalonia.csproj | 1 + .../Settings/MainSettingsViewModel.cs | 1 - .../Settings/UpdateSettingsViewModel.cs | 237 ++++++++++++++++++ .../ViewModels/SettingsViewModel.cs | 3 +- .../Views/Settings/MainSettingsPage.axaml | 54 ++-- .../Views/Settings/UpdateSettingsPage.axaml | 143 +++++++++++ .../Settings/UpdateSettingsPage.axaml.cs | 41 +++ .../Models/Api/Lykos/GetUserResponse.cs | 18 +- .../Models/Api/Lykos/LykosRole.cs | 12 + .../Models/Settings/Settings.cs | 5 + StabilityMatrix.Core/Updater/IUpdateHelper.cs | 7 +- StabilityMatrix.Core/Updater/UpdateHelper.cs | 42 +++- .../Updater/UpdateStatusChangedEventArgs.cs | 13 + 19 files changed, 773 insertions(+), 30 deletions(-) create mode 100644 StabilityMatrix.Avalonia/Converters/EnumStringConverter.cs create mode 100644 StabilityMatrix.Avalonia/Converters/EnumToBooleanConverter.cs create mode 100644 StabilityMatrix.Avalonia/Models/UpdateChannelCard.cs create mode 100644 StabilityMatrix.Avalonia/ViewModels/Settings/UpdateSettingsViewModel.cs create mode 100644 StabilityMatrix.Avalonia/Views/Settings/UpdateSettingsPage.axaml create mode 100644 StabilityMatrix.Avalonia/Views/Settings/UpdateSettingsPage.axaml.cs create mode 100644 StabilityMatrix.Core/Models/Api/Lykos/LykosRole.cs create mode 100644 StabilityMatrix.Core/Updater/UpdateStatusChangedEventArgs.cs diff --git a/StabilityMatrix.Avalonia/Converters/EnumStringConverter.cs b/StabilityMatrix.Avalonia/Converters/EnumStringConverter.cs new file mode 100644 index 00000000..15ceaa4e --- /dev/null +++ b/StabilityMatrix.Avalonia/Converters/EnumStringConverter.cs @@ -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 +{ + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not Enum enumValue) + return null; + + return enumValue.GetStringValue(); + } + + /// + public object? ConvertBack( + object? value, + Type targetType, + object? parameter, + CultureInfo culture + ) + { + if (value is not string stringValue) + return null; + + return Enum.Parse(targetType, stringValue); + } +} diff --git a/StabilityMatrix.Avalonia/Converters/EnumToBooleanConverter.cs b/StabilityMatrix.Avalonia/Converters/EnumToBooleanConverter.cs new file mode 100644 index 00000000..0a047b43 --- /dev/null +++ b/StabilityMatrix.Avalonia/Converters/EnumToBooleanConverter.cs @@ -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; + } +} diff --git a/StabilityMatrix.Avalonia/DesignData/DesignData.cs b/StabilityMatrix.Avalonia/DesignData/DesignData.cs index 9811c32d..2addd46f 100644 --- a/StabilityMatrix.Avalonia/DesignData/DesignData.cs +++ b/StabilityMatrix.Avalonia/DesignData/DesignData.cs @@ -11,6 +11,7 @@ using DynamicData.Binding; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using NSubstitute.ReturnsExtensions; +using Semver; using StabilityMatrix.Avalonia.Controls.CodeCompletion; using StabilityMatrix.Avalonia.Models; using StabilityMatrix.Avalonia.Models.TagCompletion; @@ -36,6 +37,7 @@ using StabilityMatrix.Core.Models.Database; using StabilityMatrix.Core.Models.PackageModification; using StabilityMatrix.Core.Models.Packages; using StabilityMatrix.Core.Models.Progress; +using StabilityMatrix.Core.Models.Update; using StabilityMatrix.Core.Python; using StabilityMatrix.Core.Services; using StabilityMatrix.Core.Updater; @@ -449,6 +451,37 @@ public static class DesignData public static AccountSettingsViewModel AccountSettingsViewModel => Services.GetRequiredService(); + public static UpdateSettingsViewModel UpdateSettingsViewModel + { + get + { + var vm = Services.GetRequiredService(); + + var update = new UpdateInfo + { + Version = SemVersion.Parse("2.0.1"), + ReleaseDate = DateTimeOffset.Now, + Url = new Uri("https://example.org"), + Changelog = new Uri("https://example.org"), + HashBlake3 = "", + Signature = "", + }; + + vm.UpdateStatus = new UpdateStatusChangedEventArgs + { + LatestUpdate = update, + UpdateChannels = new Dictionary + { + [UpdateChannel.Stable] = update, + [UpdateChannel.Preview] = update, + [UpdateChannel.Development] = update + }, + CheckedAt = DateTimeOffset.UtcNow + }; + return vm; + } + } + public static CheckpointBrowserViewModel CheckpointBrowserViewModel => Services.GetRequiredService(); @@ -760,7 +793,7 @@ The gallery images are often inpainted, but you will get something very similar ) ); - public static Indexer Types => new(); + public static Indexer Types { get; } = new(); public class Indexer { diff --git a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs index 36b4d489..888d15e2 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs +++ b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs @@ -662,6 +662,15 @@ namespace StabilityMatrix.Avalonia.Languages { } } + /// + /// Looks up a localized string similar to Auto Updates. + /// + public static string Label_AutoUpdates { + get { + return ResourceManager.GetString("Label_AutoUpdates", resourceCulture); + } + } + /// /// Looks up a localized string similar to Base Model. /// @@ -1814,6 +1823,33 @@ namespace StabilityMatrix.Avalonia.Languages { } } + /// + /// Looks up a localized string similar to Updates. + /// + public static string Label_Updates { + get { + return ResourceManager.GetString("Label_Updates", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to For technical users. Be the first to access our Development builds from feature branches as soon as they are available. There may be some rough edges and bugs as we experiment with new features.. + /// + public static string Label_UpdatesDevChannelDescription { + get { + return ResourceManager.GetString("Label_UpdatesDevChannelDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to For early adopters. Preview builds will be more reliable than those from the Dev channel, and will be available closer to stable releases. Your feedback will help us greatly in discovering issues and polishing design elements.. + /// + public static string Label_UpdatesPreviewChannelDescription { + get { + return ResourceManager.GetString("Label_UpdatesPreviewChannelDescription", resourceCulture); + } + } + /// /// Looks up a localized string similar to Upscale. /// @@ -1886,6 +1922,15 @@ namespace StabilityMatrix.Avalonia.Languages { } } + /// + /// Looks up a localized string similar to You're up to date. + /// + public static string Label_YouAreUpToDate { + get { + return ResourceManager.GetString("Label_YouAreUpToDate", resourceCulture); + } + } + /// /// Looks up a localized string similar to Download complete. /// @@ -2048,6 +2093,15 @@ namespace StabilityMatrix.Avalonia.Languages { } } + /// + /// Looks up a localized string similar to Last checked: {0}. + /// + public static string TextTemplate_LastChecked { + get { + return ResourceManager.GetString("TextTemplate_LastChecked", resourceCulture); + } + } + /// /// Looks up a localized string similar to {0} has been updated to the latest version. /// diff --git a/StabilityMatrix.Avalonia/Languages/Resources.resx b/StabilityMatrix.Avalonia/Languages/Resources.resx index def18acc..531d2418 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.resx +++ b/StabilityMatrix.Avalonia/Languages/Resources.resx @@ -786,4 +786,22 @@ Download Failed + + Auto Updates + + + For early adopters. Preview builds will be more reliable than those from the Dev channel, and will be available closer to stable releases. Your feedback will help us greatly in discovering issues and polishing design elements. + + + For technical users. Be the first to access our Development builds from feature branches as soon as they are available. There may be some rough edges and bugs as we experiment with new features. + + + Updates + + + You're up to date + + + Last checked: {0} + diff --git a/StabilityMatrix.Avalonia/Models/UpdateChannelCard.cs b/StabilityMatrix.Avalonia/Models/UpdateChannelCard.cs new file mode 100644 index 00000000..6a098388 --- /dev/null +++ b/StabilityMatrix.Avalonia/Models/UpdateChannelCard.cs @@ -0,0 +1,63 @@ +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; + + /// + /// Whether the is available for update. + /// + 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; + // If different, we can update + if (updateHash != appHash) + { + return true; + } + + break; + } + } + + return false; + } + } +} diff --git a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj index fb16d35f..bf80f2c1 100644 --- a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj +++ b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj @@ -38,6 +38,7 @@ + diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs index abd4cb82..163abc04 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs @@ -25,7 +25,6 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using DynamicData.Binding; using FluentAvalonia.UI.Controls; -using FluentAvalonia.UI.Media.Animation; using NLog; using SkiaSharp; using StabilityMatrix.Avalonia.Animations; diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/UpdateSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/UpdateSettingsViewModel.cs new file mode 100644 index 00000000..c3a6f417 --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/UpdateSettingsViewModel.cs @@ -0,0 +1,237 @@ +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 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 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 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 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 isSelectable = args.IsPatreonConnected; + + foreach ( + var card in AvailableUpdateChannelCards.Where( + c => c.UpdateChannel > UpdateChannel.Stable + ) + ) + { + card.IsSelectable = isSelectable; + } + }; + + // On update status changed + updateHelper.UpdateStatusChanged += (_, args) => + { + UpdateStatus = args; + }; + } + + /// + 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(); + } + + /// + /// Verify a new channel selection is valid, else returns false. + /// + /// + /// + 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", "uwu"); + + 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( + new SuppressNavigationTransitionInfo() + ); + } + }) + .SafeFireAndForget(); + } + + partial void OnUpdateStatusChanged(UpdateStatusChangedEventArgs? value) + { + // Update the update channel cards + foreach (var card in AvailableUpdateChannelCards) + { + card.LatestVersion = value?.UpdateChannels + .GetValueOrDefault(card.UpdateChannel) + ?.Version; + } + } + + partial void OnPreferredUpdateChannelChanged(UpdateChannel oldValue, UpdateChannel newValue) + { + if (newValue == UpdateChannel.Stable) + { + return; + } + + if (accountsService.LykosStatus?.User?.IsActiveSupporter == true) + { + return; + } + + PreferredUpdateChannel = UpdateChannel.Stable; + } + + /// + public override string Title => "Updates"; + + /// + public override IconSource IconSource => + new SymbolIconSource { Symbol = Symbol.Settings, IsFilled = true }; +} diff --git a/StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs index e915c44a..a7fdf1a8 100644 --- a/StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs @@ -35,7 +35,8 @@ public partial class SettingsViewModel : PageViewModelBase { vmFactory.Get(), vmFactory.Get(), - vmFactory.Get() + vmFactory.Get(), + vmFactory.Get() }; CurrentPagePath.AddRange(SubPages); diff --git a/StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml b/StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml index e9d960f0..04be95a7 100644 --- a/StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml +++ b/StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml @@ -11,21 +11,25 @@ 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" - xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia" - Focusable="True" d:DataContext="{x:Static mocks:DesignData.MainSettingsViewModel}" d:DesignHeight="700" d:DesignWidth="800" x:CompileBindings="True" x:DataType="vmSettings:MainSettingsViewModel" + Focusable="True" mc:Ignorable="d"> - + + + @@ -63,17 +67,16 @@ - + IsClickEnabled="True" + IsVisible="{Binding SharedState.IsDebugMode}" /> - + + IsClickEnabled="True"> + Symbol="Person" /> - + + + + + + + + + + @@ -354,7 +376,7 @@ - + diff --git a/StabilityMatrix.Avalonia/Views/Settings/UpdateSettingsPage.axaml b/StabilityMatrix.Avalonia/Views/Settings/UpdateSettingsPage.axaml new file mode 100644 index 00000000..90a59e0a --- /dev/null +++ b/StabilityMatrix.Avalonia/Views/Settings/UpdateSettingsPage.axaml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Views/Settings/UpdateSettingsPage.axaml.cs b/StabilityMatrix.Avalonia/Views/Settings/UpdateSettingsPage.axaml.cs new file mode 100644 index 00000000..08dd277b --- /dev/null +++ b/StabilityMatrix.Avalonia/Views/Settings/UpdateSettingsPage.axaml.cs @@ -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(); + } + } +} diff --git a/StabilityMatrix.Core/Models/Api/Lykos/GetUserResponse.cs b/StabilityMatrix.Core/Models/Api/Lykos/GetUserResponse.cs index f79a8ae7..cb256d7e 100644 --- a/StabilityMatrix.Core/Models/Api/Lykos/GetUserResponse.cs +++ b/StabilityMatrix.Core/Models/Api/Lykos/GetUserResponse.cs @@ -1,9 +1,13 @@ namespace StabilityMatrix.Core.Models.Api.Lykos; -public record GetUserResponse( - string Id, - LykosAccount Account, - int UserLevel, - string PatreonId, - bool IsEmailVerified -); +public record GetUserResponse +{ + public required string Id { get; init; } + public required LykosAccount Account { get; init; } + public required HashSet UserRoles { get; init; } + public string? PatreonId { get; init; } + public bool IsEmailVerified { get; init; } + + public bool IsActiveSupporter => + UserRoles.Contains(LykosRole.PatreonSupporter) || UserRoles.Contains(LykosRole.Insider); +} diff --git a/StabilityMatrix.Core/Models/Api/Lykos/LykosRole.cs b/StabilityMatrix.Core/Models/Api/Lykos/LykosRole.cs new file mode 100644 index 00000000..d3bdca64 --- /dev/null +++ b/StabilityMatrix.Core/Models/Api/Lykos/LykosRole.cs @@ -0,0 +1,12 @@ +namespace StabilityMatrix.Core.Models.Api.Lykos; + +public enum LykosRole +{ + Unknown = -1, + Basic = 0, + Supporter = 1, + PatreonSupporter = 2, + Insider = 3, + BetaTester = 4, + Developer = 5 +} diff --git a/StabilityMatrix.Core/Models/Settings/Settings.cs b/StabilityMatrix.Core/Models/Settings/Settings.cs index 15d0ae8f..897b2fdd 100644 --- a/StabilityMatrix.Core/Models/Settings/Settings.cs +++ b/StabilityMatrix.Core/Models/Settings/Settings.cs @@ -43,6 +43,11 @@ public class Settings /// public UpdateChannel PreferredUpdateChannel { get; set; } = UpdateChannel.Stable; + /// + /// Whether to check for updates + /// + public bool CheckForUpdates { get; set; } = true; + /// /// The last auto-update version that had a notification dismissed by the user /// diff --git a/StabilityMatrix.Core/Updater/IUpdateHelper.cs b/StabilityMatrix.Core/Updater/IUpdateHelper.cs index 99f1b652..ce1349e5 100644 --- a/StabilityMatrix.Core/Updater/IUpdateHelper.cs +++ b/StabilityMatrix.Core/Updater/IUpdateHelper.cs @@ -5,8 +5,11 @@ namespace StabilityMatrix.Core.Updater; public interface IUpdateHelper { + event EventHandler? UpdateStatusChanged; + Task StartCheckingForUpdates(); - Task DownloadUpdate(UpdateInfo updateInfo, - IProgress progress); + Task CheckForUpdate(); + + Task DownloadUpdate(UpdateInfo updateInfo, IProgress progress); } diff --git a/StabilityMatrix.Core/Updater/UpdateHelper.cs b/StabilityMatrix.Core/Updater/UpdateHelper.cs index 7ca9586f..dcaf8049 100644 --- a/StabilityMatrix.Core/Updater/UpdateHelper.cs +++ b/StabilityMatrix.Core/Updater/UpdateHelper.cs @@ -29,6 +29,9 @@ public class UpdateHelper : IUpdateHelper public static FilePath ExecutablePath => UpdateFolder.JoinFile(Compat.GetExecutableName()); + /// + public event EventHandler? UpdateStatusChanged; + public UpdateHelper( ILogger logger, IHttpClientFactory httpClientFactory, @@ -118,7 +121,7 @@ public class UpdateHelper : IUpdateHelper } } - private async Task CheckForUpdate() + public async Task CheckForUpdate() { try { @@ -159,12 +162,31 @@ public class UpdateHelper : IUpdateHelper && ValidateUpdate(update) ) { - NotifyUpdateAvailable(update); + OnUpdateStatusChanged( + new UpdateStatusChangedEventArgs + { + LatestUpdate = update, + UpdateChannels = updateManifest.Updates.ToDictionary( + kv => kv.Key, + kv => kv.Value.GetInfoForCurrentPlatform() + )! + } + ); return; } } logger.LogInformation("No update available"); + + OnUpdateStatusChanged( + new UpdateStatusChangedEventArgs + { + UpdateChannels = updateManifest.Updates.ToDictionary( + kv => kv.Key, + kv => kv.Value.GetInfoForCurrentPlatform() + )! + } + ); } catch (Exception e) { @@ -215,6 +237,22 @@ public class UpdateHelper : IUpdateHelper return false; } + private void OnUpdateStatusChanged(UpdateStatusChangedEventArgs args) + { + UpdateStatusChanged?.Invoke(this, args); + + if (args.LatestUpdate is { } update) + { + logger.LogInformation( + "Update available {AppVer} -> {UpdateVer}", + Compat.AppVersion, + update.Version + ); + + EventManager.Instance.OnUpdateAvailable(update); + } + } + private void NotifyUpdateAvailable(UpdateInfo update) { logger.LogInformation( diff --git a/StabilityMatrix.Core/Updater/UpdateStatusChangedEventArgs.cs b/StabilityMatrix.Core/Updater/UpdateStatusChangedEventArgs.cs new file mode 100644 index 00000000..8eebe4fc --- /dev/null +++ b/StabilityMatrix.Core/Updater/UpdateStatusChangedEventArgs.cs @@ -0,0 +1,13 @@ +using StabilityMatrix.Core.Models.Update; + +namespace StabilityMatrix.Core.Updater; + +public class UpdateStatusChangedEventArgs : EventArgs +{ + public UpdateInfo? LatestUpdate { get; init; } + + public IReadOnlyDictionary UpdateChannels { get; init; } = + new Dictionary(); + + public DateTimeOffset CheckedAt { get; init; } = DateTimeOffset.UtcNow; +} From 486dd67fb999331a37a5a08275f79fae28de139d Mon Sep 17 00:00:00 2001 From: Ionite Date: Sun, 19 Nov 2023 00:38:28 -0500 Subject: [PATCH 051/144] Add authenticated update url getting --- StabilityMatrix.Core/Updater/UpdateHelper.cs | 32 ++++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/StabilityMatrix.Core/Updater/UpdateHelper.cs b/StabilityMatrix.Core/Updater/UpdateHelper.cs index dcaf8049..6ddca4e1 100644 --- a/StabilityMatrix.Core/Updater/UpdateHelper.cs +++ b/StabilityMatrix.Core/Updater/UpdateHelper.cs @@ -1,7 +1,9 @@ using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using StabilityMatrix.Core.Api; using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Models.Configs; using StabilityMatrix.Core.Models.FileInterfaces; @@ -18,6 +20,7 @@ public class UpdateHelper : IUpdateHelper private readonly IHttpClientFactory httpClientFactory; private readonly IDownloadService downloadService; private readonly ISettingsManager settingsManager; + private readonly ILykosAuthApi lykosAuthApi; private readonly DebugOptions debugOptions; private readonly System.Timers.Timer timer = new(TimeSpan.FromMinutes(60)); @@ -37,13 +40,15 @@ public class UpdateHelper : IUpdateHelper IHttpClientFactory httpClientFactory, IDownloadService downloadService, IOptions debugOptions, - ISettingsManager settingsManager + ISettingsManager settingsManager, + ILykosAuthApi lykosAuthApi ) { this.logger = logger; this.httpClientFactory = httpClientFactory; this.downloadService = downloadService; this.settingsManager = settingsManager; + this.lykosAuthApi = lykosAuthApi; this.debugOptions = debugOptions.Value; timer.Elapsed += async (_, _) => @@ -70,10 +75,31 @@ public class UpdateHelper : IUpdateHelper try { - // download the file from URL + var url = updateInfo.Url.ToString(); + + // check if need authenticated download + const string authedPathPrefix = "/s1/"; + if ( + updateInfo.Url.Host.Equals("cdn.lykos.ai", StringComparison.OrdinalIgnoreCase) + && updateInfo.Url.PathAndQuery.StartsWith( + authedPathPrefix, + StringComparison.OrdinalIgnoreCase + ) + ) + { + logger.LogInformation( + "Handling authenticated update download: {Url}", + updateInfo.Url + ); + + var path = updateInfo.Url.PathAndQuery.StripStart(authedPathPrefix); + url = await lykosAuthApi.GetDownloadUrl(path).ConfigureAwait(false); + } + + // Download update await downloadService .DownloadToFileAsync( - updateInfo.Url.ToString(), + url, downloadFile, progress: progress, httpClientName: "UpdateClient" From f2038843ede65ba0bff6da8cb9432b88a49f8b69 Mon Sep 17 00:00:00 2001 From: Ionite Date: Sun, 19 Nov 2023 00:56:12 -0500 Subject: [PATCH 052/144] Add GetDownload apis --- StabilityMatrix.Core/Api/ILykosAuthApi.cs | 26 +++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/StabilityMatrix.Core/Api/ILykosAuthApi.cs b/StabilityMatrix.Core/Api/ILykosAuthApi.cs index 2ca76415..2d543244 100644 --- a/StabilityMatrix.Core/Api/ILykosAuthApi.cs +++ b/StabilityMatrix.Core/Api/ILykosAuthApi.cs @@ -63,4 +63,30 @@ public interface ILykosAuthApi [Headers("Authorization: Bearer")] [Delete("/api/oauth/patreon")] Task DeletePatreonOAuth(CancellationToken cancellationToken = default); + + [Headers("Authorization: Bearer")] + [Get("/api/files/download")] + Task GetDownloadRedirect( + string path, + CancellationToken cancellationToken = default + ); + + public async Task GetDownloadUrl( + string path, + CancellationToken cancellationToken = default + ) + { + var result = await GetDownloadRedirect(path, 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"); + } } From efbb656d8b2c4cfde4763480851e91b381170a6f Mon Sep 17 00:00:00 2001 From: Ionite Date: Sun, 19 Nov 2023 01:13:01 -0500 Subject: [PATCH 053/144] Update to .net 8 in CI --- .github/workflows/release.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47e890f5..fc00513c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,10 +82,10 @@ jobs: sudo apt-get -y install libfuse2 dotnet tool install --framework net6.0 -g KuiperZone.PupNet - - name: Set up .NET 7 + - name: Set up .NET 8 uses: actions/setup-dotnet@v3 with: - dotnet-version: '7.0.x' + dotnet-version: '8.0.x' - name: PupNet Build env: @@ -143,7 +143,7 @@ jobs: echo "Using version ${{ github.event.inputs.version }}" echo "RELEASE_VERSION=${{ github.event.inputs.version }}" >> $env:GITHUB_ENV - - name: Set up .NET 7 + - name: Set up .NET 8 uses: actions/setup-dotnet@v3 with: dotnet-version: '8.0.x' From 36e6ccacb7c7312fd79ffa3c24aeb9553629c406 Mon Sep 17 00:00:00 2001 From: Ionite Date: Sun, 19 Nov 2023 01:29:04 -0500 Subject: [PATCH 054/144] Remove changelog step and add secrets to env --- .github/workflows/release.yml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fc00513c..fea2820b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -238,6 +238,12 @@ jobs: run: pip install stability-matrix-tools~=0.2.7 - name: Publish Auto-Update Release + env: + SM_B2_API_ID: ${{ secrets.SM_B2_API_ID }} + SM_B2_API_KEY: ${{ secrets.SM_B2_API_KEY }} + SM_CF_CACHE_PURGE_TOKEN: ${{ secrets.SM_CF_CACHE_PURGE_TOKEN }} + SM_CF_ZONE_ID: ${{ secrets.SM_CF_ZONE_ID }} + SM_SIGNING_PRIVATE_KEY: ${{ secrets.SM_SIGNING_PRIVATE_KEY }} run: sm-tools updates publish-matrix -v $RELEASE_VERSION -y publish-auto-update-b2: @@ -259,11 +265,6 @@ jobs: - name: Install Python Dependencies run: pip install stability-matrix-tools~=0.2.7 - - - name: Download Changelog - run: > - sm-tools updates download-changelog -v $RELEASE_VERSION -y - --changelog # Zip each build - name: Zip Artifacts @@ -288,6 +289,12 @@ jobs: fi - name: Publish Auto-Update Release + env: + SM_B2_API_ID: ${{ secrets.SM_B2_API_ID }} + SM_B2_API_KEY: ${{ secrets.SM_B2_API_KEY }} + SM_CF_CACHE_PURGE_TOKEN: ${{ secrets.SM_CF_CACHE_PURGE_TOKEN }} + SM_CF_ZONE_ID: ${{ secrets.SM_CF_ZONE_ID }} + SM_SIGNING_PRIVATE_KEY: ${{ secrets.SM_SIGNING_PRIVATE_KEY }} run: > sm-tools updates publish-files-v3 -v $RELEASE_VERSION -y --channel ${{ github.event.inputs.auto-update-release-channel }} From 5b6f86a672413da4ed48ba71aab2d929afbceb52 Mon Sep 17 00:00:00 2001 From: Ionite Date: Sun, 19 Nov 2023 01:56:47 -0500 Subject: [PATCH 055/144] Fix release CI zip --- .github/workflows/release.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fea2820b..20b0cbbf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -197,8 +197,8 @@ jobs: # Zip each build - name: Zip Artifacts run: | - zip -r StabilityMatrix-win-x64.zip StabilityMatrix-win-x64/* - zip -r StabilityMatrix-linux-x64.zip StabilityMatrix-linux-x64/* + zip -r StabilityMatrix-win-x64.zip . -i StabilityMatrix-win-x64/* + zip -r StabilityMatrix-linux-x64.zip . -i StabilityMatrix-linux-x64/* - name: Create Github Release id: create_release @@ -269,8 +269,8 @@ jobs: # Zip each build - name: Zip Artifacts run: | - zip -r StabilityMatrix-win-x64.zip StabilityMatrix-win-x64/* - zip -r StabilityMatrix-linux-x64.zip StabilityMatrix-linux-x64/* + zip -r StabilityMatrix-win-x64.zip . -i StabilityMatrix-win-x64/* + zip -r StabilityMatrix-linux-x64.zip . -i StabilityMatrix-linux-x64/* # Check that the zips and CHANGELOG.md are in the current working directory - name: Check files From b91b4cb5557d8bd2005ebee4e924d4a11f6de90a Mon Sep 17 00:00:00 2001 From: Ionite Date: Sun, 19 Nov 2023 02:07:08 -0500 Subject: [PATCH 056/144] Remove bucket name arg --- .github/workflows/release.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 20b0cbbf..cb02407b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -301,4 +301,3 @@ jobs: --changelog CHANGELOG.md --win-x64 StabilityMatrix-win-x64.zip --linux-x64 StabilityMatrix-linux-x64.zip - --b2-bucket-name ${{ secrets.B2_BUCKET_NAME }} From caa1b5baf1552649dd1e16e0eb89e87c6f635ad3 Mon Sep 17 00:00:00 2001 From: Ionite Date: Sun, 19 Nov 2023 02:29:16 -0500 Subject: [PATCH 057/144] Fix env vars in CI --- .github/workflows/release.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cb02407b..b75eb1b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -244,7 +244,7 @@ jobs: SM_CF_CACHE_PURGE_TOKEN: ${{ secrets.SM_CF_CACHE_PURGE_TOKEN }} SM_CF_ZONE_ID: ${{ secrets.SM_CF_ZONE_ID }} SM_SIGNING_PRIVATE_KEY: ${{ secrets.SM_SIGNING_PRIVATE_KEY }} - run: sm-tools updates publish-matrix -v $RELEASE_VERSION -y + run: sm-tools updates publish-matrix -v ${{ github.event.inputs.version }} -y publish-auto-update-b2: name: Publish Auto-Update Release (B2) @@ -296,8 +296,9 @@ jobs: SM_CF_ZONE_ID: ${{ secrets.SM_CF_ZONE_ID }} SM_SIGNING_PRIVATE_KEY: ${{ secrets.SM_SIGNING_PRIVATE_KEY }} run: > - sm-tools updates publish-files-v3 -v $RELEASE_VERSION -y + sm-tools updates publish-files-v3 -v ${{ github.event.inputs.version }} --channel ${{ github.event.inputs.auto-update-release-channel }} --changelog CHANGELOG.md --win-x64 StabilityMatrix-win-x64.zip --linux-x64 StabilityMatrix-linux-x64.zip + -y From 106ae8100bb7d4adc5e09db514fc01d919739334 Mon Sep 17 00:00:00 2001 From: Ionite Date: Sun, 19 Nov 2023 03:13:13 -0500 Subject: [PATCH 058/144] Add preferred update channel filter to UpdateHelper --- StabilityMatrix.Core/Updater/UpdateHelper.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/StabilityMatrix.Core/Updater/UpdateHelper.cs b/StabilityMatrix.Core/Updater/UpdateHelper.cs index 6ddca4e1..402b5144 100644 --- a/StabilityMatrix.Core/Updater/UpdateHelper.cs +++ b/StabilityMatrix.Core/Updater/UpdateHelper.cs @@ -179,7 +179,11 @@ public class UpdateHelper : IUpdateHelper foreach ( var channel in Enum.GetValues(typeof(UpdateChannel)) .Cast() - .Where(c => c > UpdateChannel.Unknown) + .Where( + c => + c > UpdateChannel.Unknown + && c <= settingsManager.Settings.PreferredUpdateChannel + ) ) { if ( From 7d8d9e69222e2588875a131796caaa15999ee8e0 Mon Sep 17 00:00:00 2001 From: Ionite Date: Sun, 19 Nov 2023 03:19:00 -0500 Subject: [PATCH 059/144] Add dev role alias --- StabilityMatrix.Core/Models/Api/Lykos/GetUserResponse.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/StabilityMatrix.Core/Models/Api/Lykos/GetUserResponse.cs b/StabilityMatrix.Core/Models/Api/Lykos/GetUserResponse.cs index cb256d7e..4d5ce09a 100644 --- a/StabilityMatrix.Core/Models/Api/Lykos/GetUserResponse.cs +++ b/StabilityMatrix.Core/Models/Api/Lykos/GetUserResponse.cs @@ -9,5 +9,7 @@ public record GetUserResponse public bool IsEmailVerified { get; init; } public bool IsActiveSupporter => - UserRoles.Contains(LykosRole.PatreonSupporter) || UserRoles.Contains(LykosRole.Insider); + UserRoles.Contains(LykosRole.PatreonSupporter) + || UserRoles.Contains(LykosRole.Insider) + || (UserRoles.Contains(LykosRole.Developer) && PatreonId is not null); } From b008ddc69c077f7a4ff09fab3d79e01bd045a671 Mon Sep 17 00:00:00 2001 From: Ionite Date: Sun, 19 Nov 2023 03:20:32 -0500 Subject: [PATCH 060/144] Update sm tools in CI --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b75eb1b6..05d5e381 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -235,7 +235,7 @@ jobs: python-version: '3.11' - name: Install Python Dependencies - run: pip install stability-matrix-tools~=0.2.7 + run: pip install stability-matrix-tools>=0.2.9 - name: Publish Auto-Update Release env: @@ -264,7 +264,7 @@ jobs: python-version: '3.11' - name: Install Python Dependencies - run: pip install stability-matrix-tools~=0.2.7 + run: pip install stability-matrix-tools>=0.2.9 # Zip each build - name: Zip Artifacts From 67ed311ba4003abc4eba18bc1b7c09f1cdaaf35f Mon Sep 17 00:00:00 2001 From: Ionite Date: Sun, 19 Nov 2023 15:51:02 -0500 Subject: [PATCH 061/144] Update auth path prefix --- StabilityMatrix.Core/Updater/UpdateHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/StabilityMatrix.Core/Updater/UpdateHelper.cs b/StabilityMatrix.Core/Updater/UpdateHelper.cs index 402b5144..c4dd55e3 100644 --- a/StabilityMatrix.Core/Updater/UpdateHelper.cs +++ b/StabilityMatrix.Core/Updater/UpdateHelper.cs @@ -78,7 +78,7 @@ public class UpdateHelper : IUpdateHelper var url = updateInfo.Url.ToString(); // check if need authenticated download - const string authedPathPrefix = "/s1/"; + const string authedPathPrefix = "/lykos-s1/"; if ( updateInfo.Url.Host.Equals("cdn.lykos.ai", StringComparison.OrdinalIgnoreCase) && updateInfo.Url.PathAndQuery.StartsWith( From bd4d571b644ea00fdf567ce6d2b2a24306c86870 Mon Sep 17 00:00:00 2001 From: Ionite Date: Sun, 19 Nov 2023 15:51:19 -0500 Subject: [PATCH 062/144] Add prerelease changelog --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28a6b5be..bcf0bceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to Stability Matrix will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2.0.0.html). +## v2.7.0-dev.1 +### Added +- Accounts Settings Subpage + - Lykos Account sign-up and login - currently for Patreon OAuth connections but GitHub requests caching and settings sync are planned + - Supporters can now connect your Patreon accounts, then head to the Updates page to choose to receive auto-updates from the Dev or Preview channels + - CivitAI Account login with API key - enables downloading models from the Browser page that require CivitAI logins, more integrations like liking and commenting are also planned +- Updates Settings Subpage + - Toggle auto-update notifications and manually check for updates + - Choose between Stable, Preview, and Dev update channels +## Changed +- Model Browser page has been redesigned, featuring more information like rating and download counts + ## v2.6.2 ### Changed - Backend changes for auto-update schema v3, supporting customizable release channels and faster downloads with zip compression From 2c9347f16569b963ca0dea5ec1e58307e0f0b52f Mon Sep 17 00:00:00 2001 From: Ionite Date: Sun, 19 Nov 2023 16:33:26 -0500 Subject: [PATCH 063/144] Add BooleanChoiceMultiConverter --- .../Converters/BooleanChoiceMultiConverter.cs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 StabilityMatrix.Avalonia/Converters/BooleanChoiceMultiConverter.cs diff --git a/StabilityMatrix.Avalonia/Converters/BooleanChoiceMultiConverter.cs b/StabilityMatrix.Avalonia/Converters/BooleanChoiceMultiConverter.cs new file mode 100644 index 00000000..1eb7d5b7 --- /dev/null +++ b/StabilityMatrix.Avalonia/Converters/BooleanChoiceMultiConverter.cs @@ -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 +{ + /// + public object? Convert( + IList 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; + } +} From 0a4a856aee833a5929ef9b16358b7e759cc69a68 Mon Sep 17 00:00:00 2001 From: Ionite Date: Sun, 19 Nov 2023 16:33:37 -0500 Subject: [PATCH 064/144] Fix command can execute status not updating --- .../ViewModels/Settings/AccountSettingsViewModel.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/AccountSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/AccountSettingsViewModel.cs index 16a09176..15cdf372 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Settings/AccountSettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/AccountSettingsViewModel.cs @@ -46,6 +46,9 @@ public partial class AccountSettingsViewModel : PageViewModelBase new SymbolIconSource { Symbol = Symbol.Person, IsFilled = true }; [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(ConnectLykosCommand))] + [NotifyCanExecuteChangedFor(nameof(ConnectPatreonCommand))] + [NotifyCanExecuteChangedFor(nameof(ConnectCivitCommand))] private bool isInitialUpdateFinished; [ObservableProperty] From 4523f6fa2e564af1afb04c8a4890a52af70a4018 Mon Sep 17 00:00:00 2001 From: Ionite Date: Sun, 19 Nov 2023 16:33:54 -0500 Subject: [PATCH 065/144] Improve update settings ui state updates --- .../Settings/UpdateSettingsViewModel.cs | 34 +++++++++---------- .../Views/Settings/UpdateSettingsPage.axaml | 17 ++++++++-- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/UpdateSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/UpdateSettingsViewModel.cs index c3a6f417..a9b36129 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Settings/UpdateSettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/UpdateSettingsViewModel.cs @@ -9,6 +9,7 @@ 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; @@ -107,7 +108,7 @@ public partial class UpdateSettingsViewModel : PageViewModelBase accountsService.LykosAccountStatusUpdate += (_, args) => { - var isSelectable = args.IsPatreonConnected; + var isBetaChannelsEnabled = args.User?.IsActiveSupporter == true; foreach ( var card in AvailableUpdateChannelCards.Where( @@ -115,7 +116,7 @@ public partial class UpdateSettingsViewModel : PageViewModelBase ) ) { - card.IsSelectable = isSelectable; + card.IsSelectable = isBetaChannelsEnabled; } }; @@ -171,7 +172,11 @@ public partial class UpdateSettingsViewModel : PageViewModelBase Dispatcher.UIThread .InvokeAsync(async () => { - var dialog = DialogHelper.CreateTaskDialog("Become a Supporter", "uwu"); + 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[] { @@ -205,27 +210,20 @@ public partial class UpdateSettingsViewModel : PageViewModelBase 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 - .GetValueOrDefault(card.UpdateChannel) + .Where(kv => kv.Key <= card.UpdateChannel) + .Select(kv => kv.Value) + .MaxBy(info => info.Version, SemVersion.PrecedenceComparer) ?.Version; - } - } - - partial void OnPreferredUpdateChannelChanged(UpdateChannel oldValue, UpdateChannel newValue) - { - if (newValue == UpdateChannel.Stable) - { - return; - } - if (accountsService.LykosStatus?.User?.IsActiveSupporter == true) - { - return; + /*card.LatestVersion = value?.UpdateChannels + .GetValueOrDefault(card.UpdateChannel) + ?.Version;*/ } - - PreferredUpdateChannel = UpdateChannel.Stable; } /// diff --git a/StabilityMatrix.Avalonia/Views/Settings/UpdateSettingsPage.axaml b/StabilityMatrix.Avalonia/Views/Settings/UpdateSettingsPage.axaml index 90a59e0a..9a8e4c06 100644 --- a/StabilityMatrix.Avalonia/Views/Settings/UpdateSettingsPage.axaml +++ b/StabilityMatrix.Avalonia/Views/Settings/UpdateSettingsPage.axaml @@ -15,11 +15,16 @@ 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"> + + + + @@ -84,14 +89,20 @@ + Text="{Binding DisplayName}"> + + + + + + + + Date: Sun, 19 Nov 2023 16:37:19 -0500 Subject: [PATCH 066/144] Check for updates on channel changes --- .../ViewModels/Settings/UpdateSettingsViewModel.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/UpdateSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/UpdateSettingsViewModel.cs index a9b36129..ff7a263d 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Settings/UpdateSettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/UpdateSettingsViewModel.cs @@ -219,13 +219,14 @@ public partial class UpdateSettingsViewModel : PageViewModelBase .Select(kv => kv.Value) .MaxBy(info => info.Version, SemVersion.PrecedenceComparer) ?.Version; - - /*card.LatestVersion = value?.UpdateChannels - .GetValueOrDefault(card.UpdateChannel) - ?.Version;*/ } } + partial void OnPreferredUpdateChannelChanged(UpdateChannel value) + { + CheckForUpdatesCommand.ExecuteAsync(null).SafeFireAndForget(); + } + /// public override string Title => "Updates"; From 5f798fc0c578cdd0c76bd63767d7ace0c2101822 Mon Sep 17 00:00:00 2001 From: Ionite Date: Sun, 19 Nov 2023 16:59:48 -0500 Subject: [PATCH 067/144] Fix UpdateViewModel not using preferred channel, Formatting --- .../ViewModels/Dialogs/UpdateViewModel.cs | 216 +++++++++--------- 1 file changed, 114 insertions(+), 102 deletions(-) diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs index 1078eb02..e5f8d37b 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs @@ -82,84 +82,6 @@ public partial class UpdateViewModel : ContentDialogViewModelBase updateHelper.StartCheckingForUpdates().SafeFireAndForget(); } - /// - /// Formats changelog markdown including up to the current version - /// - /// Markdown to format - /// Versions equal or below this are excluded - /// Maximum channel level to include - internal static string? FormatChangelog( - string markdown, - SemVersion currentVersion, - UpdateChannel maxChannel = UpdateChannel.Stable - ) - { - var pattern = RegexChangelog(); - - var results = pattern - .Matches(markdown) - .Select( - m => - new - { - Block = m.Groups[1].Value.Trim(), - Version = SemVersion.TryParse( - m.Groups[2].Value.Trim(), - SemVersionStyles.AllowV, - out var version - ) - ? version - : null, - Content = m.Groups[3].Value.Trim() - } - ) - .Where(x => x.Version is not null) - .ToList(); - - // Join all blocks until and excluding the current version - // If we're on a pre-release, include the current release - var currentVersionBlock = results.FindIndex( - x => x.Version == currentVersion.WithoutMetadata() - ); - - // Support for previous pre-release without changelogs - if (currentVersionBlock == -1) - { - currentVersionBlock = results.FindIndex( - x => x.Version == currentVersion.WithoutPrereleaseOrMetadata() - ); - - // Add 1 if found to include the current release - if (currentVersionBlock != -1) - { - currentVersionBlock++; - } - } - - // Still not found, just include all - if (currentVersionBlock == -1) - { - currentVersionBlock = results.Count; - } - - // Filter out pre-releases - var blocks = results - .Take(currentVersionBlock) - .Where( - x => - x.Version!.PrereleaseIdentifiers.Count == 0 - || x.Version.PrereleaseIdentifiers[0].Value switch - { - "pre" when maxChannel >= UpdateChannel.Preview => true, - "dev" when maxChannel >= UpdateChannel.Development => true, - _ => false - } - ) - .Select(x => x.Block); - - return string.Join(Environment.NewLine + Environment.NewLine, blocks); - } - public async Task Preload() { if (UpdateInfo is null) @@ -168,30 +90,6 @@ public partial class UpdateViewModel : ContentDialogViewModelBase ReleaseNotes = await GetReleaseNotes(UpdateInfo.Changelog.ToString()); } - internal async Task GetReleaseNotes(string changelogUrl) - { - using var client = httpClientFactory.CreateClient(); - var response = await client.GetAsync(changelogUrl); - if (response.IsSuccessStatusCode) - { - var changelog = await response.Content.ReadAsStringAsync(); - - // Formatting for new changelog format - // https://keepachangelog.com/en/1.1.0/ - if (changelogUrl.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) - { - return FormatChangelog(changelog, Compat.AppVersion) - ?? "## Unable to format release notes"; - } - - return changelog; - } - else - { - return "## Unable to load release notes"; - } - } - partial void OnUpdateInfoChanged(UpdateInfo? value) { CurrentVersionText = $"v{Compat.AppVersion}"; @@ -261,4 +159,118 @@ public partial class UpdateViewModel : ContentDialogViewModelBase Process.Start(UpdateHelper.ExecutablePath); App.Shutdown(); } + + internal async Task GetReleaseNotes(string changelogUrl) + { + using var client = httpClientFactory.CreateClient(); + var response = await client.GetAsync(changelogUrl); + if (response.IsSuccessStatusCode) + { + var changelog = await response.Content.ReadAsStringAsync(); + + // Formatting for new changelog format + // https://keepachangelog.com/en/1.1.0/ + if (changelogUrl.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + { + return FormatChangelog( + changelog, + Compat.AppVersion, + settingsManager.Settings.PreferredUpdateChannel + ) ?? "## Unable to format release notes"; + } + + return changelog; + } + else + { + return "## Unable to load release notes"; + } + } + + /// + /// Formats changelog markdown including up to the current version + /// + /// Markdown to format + /// Versions equal or below this are excluded + /// Maximum channel level to include + internal static string? FormatChangelog( + string markdown, + SemVersion currentVersion, + UpdateChannel maxChannel = UpdateChannel.Stable + ) + { + var pattern = RegexChangelog(); + + var results = pattern + .Matches(markdown) + .Select( + m => + new + { + Block = m.Groups[1].Value.Trim(), + Version = SemVersion.TryParse( + m.Groups[2].Value.Trim(), + SemVersionStyles.AllowV, + out var version + ) + ? version + : null, + Content = m.Groups[3].Value.Trim() + } + ) + .Where(x => x.Version is not null) + .ToList(); + + // Join all blocks until and excluding the current version + // If we're on a pre-release, include the current release + var currentVersionBlock = results.FindIndex( + x => x.Version == currentVersion.WithoutMetadata() + ); + + // For mismatching build metadata, add one + if ( + currentVersionBlock != -1 + && results[currentVersionBlock].Version?.Metadata != currentVersion.Metadata + ) + { + currentVersionBlock++; + } + + // Support for previous pre-release without changelogs + if (currentVersionBlock == -1) + { + currentVersionBlock = results.FindIndex( + x => x.Version == currentVersion.WithoutPrereleaseOrMetadata() + ); + + // Add 1 if found to include the current release + if (currentVersionBlock != -1) + { + currentVersionBlock++; + } + } + + // Still not found, just include all + if (currentVersionBlock == -1) + { + currentVersionBlock = results.Count; + } + + // Filter out pre-releases + var blocks = results + .Take(currentVersionBlock) + .Where( + x => + x.Version!.PrereleaseIdentifiers.Count == 0 + || x.Version.PrereleaseIdentifiers[0].Value switch + { + "pre" when maxChannel >= UpdateChannel.Preview => true, + "dev" when maxChannel >= UpdateChannel.Development => true, + _ => false + } + ) + .Select(x => x.Block); + + return string.Join(Environment.NewLine + Environment.NewLine, blocks); + } } From 32fa7fab12e39c0765b80d104755fe7d552a8a63 Mon Sep 17 00:00:00 2001 From: Ionite Date: Sun, 19 Nov 2023 17:00:03 -0500 Subject: [PATCH 068/144] UrlDecode update url before auth --- StabilityMatrix.Core/Updater/UpdateHelper.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/StabilityMatrix.Core/Updater/UpdateHelper.cs b/StabilityMatrix.Core/Updater/UpdateHelper.cs index c4dd55e3..aacd0dce 100644 --- a/StabilityMatrix.Core/Updater/UpdateHelper.cs +++ b/StabilityMatrix.Core/Updater/UpdateHelper.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Web; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StabilityMatrix.Core.Api; @@ -93,6 +94,7 @@ public class UpdateHelper : IUpdateHelper ); var path = updateInfo.Url.PathAndQuery.StripStart(authedPathPrefix); + path = HttpUtility.UrlDecode(path); url = await lykosAuthApi.GetDownloadUrl(path).ConfigureAwait(false); } From f9647c1eca9d9b9dc768c88629a3322fc3ddf8bc Mon Sep 17 00:00:00 2001 From: Ionite Date: Sun, 19 Nov 2023 17:06:04 -0500 Subject: [PATCH 069/144] Update GetFilesDownload for api response changes --- StabilityMatrix.Core/Api/ILykosAuthApi.cs | 21 +------------------ .../Models/Api/Lykos/GetDownloadResponse.cs | 8 +++++++ StabilityMatrix.Core/Updater/UpdateHelper.cs | 4 +++- 3 files changed, 12 insertions(+), 21 deletions(-) create mode 100644 StabilityMatrix.Core/Models/Api/Lykos/GetDownloadResponse.cs diff --git a/StabilityMatrix.Core/Api/ILykosAuthApi.cs b/StabilityMatrix.Core/Api/ILykosAuthApi.cs index 2d543244..cbc62335 100644 --- a/StabilityMatrix.Core/Api/ILykosAuthApi.cs +++ b/StabilityMatrix.Core/Api/ILykosAuthApi.cs @@ -66,27 +66,8 @@ public interface ILykosAuthApi [Headers("Authorization: Bearer")] [Get("/api/files/download")] - Task GetDownloadRedirect( + Task GetFilesDownload( string path, CancellationToken cancellationToken = default ); - - public async Task GetDownloadUrl( - string path, - CancellationToken cancellationToken = default - ) - { - var result = await GetDownloadRedirect(path, 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"); - } } diff --git a/StabilityMatrix.Core/Models/Api/Lykos/GetDownloadResponse.cs b/StabilityMatrix.Core/Models/Api/Lykos/GetDownloadResponse.cs new file mode 100644 index 00000000..52e35212 --- /dev/null +++ b/StabilityMatrix.Core/Models/Api/Lykos/GetDownloadResponse.cs @@ -0,0 +1,8 @@ +namespace StabilityMatrix.Core.Models.Api.Lykos; + +public record GetFilesDownloadResponse +{ + public required Uri DownloadUrl { get; set; } + + public DateTimeOffset? ExpiresAt { get; set; } +} diff --git a/StabilityMatrix.Core/Updater/UpdateHelper.cs b/StabilityMatrix.Core/Updater/UpdateHelper.cs index aacd0dce..291a2994 100644 --- a/StabilityMatrix.Core/Updater/UpdateHelper.cs +++ b/StabilityMatrix.Core/Updater/UpdateHelper.cs @@ -95,7 +95,9 @@ public class UpdateHelper : IUpdateHelper var path = updateInfo.Url.PathAndQuery.StripStart(authedPathPrefix); path = HttpUtility.UrlDecode(path); - url = await lykosAuthApi.GetDownloadUrl(path).ConfigureAwait(false); + url = ( + await lykosAuthApi.GetFilesDownload(path).ConfigureAwait(false) + ).DownloadUrl.ToString(); } // Download update From 8449c04e82e89913d71592c64ae00c0328b30ad3 Mon Sep 17 00:00:00 2001 From: Ionite Date: Sun, 19 Nov 2023 17:23:36 -0500 Subject: [PATCH 070/144] Add gravatar link button on profile --- .../Views/Settings/AccountSettingsPage.axaml | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/StabilityMatrix.Avalonia/Views/Settings/AccountSettingsPage.axaml b/StabilityMatrix.Avalonia/Views/Settings/AccountSettingsPage.axaml index bdb12cd8..d5073ac8 100644 --- a/StabilityMatrix.Avalonia/Views/Settings/AccountSettingsPage.axaml +++ b/StabilityMatrix.Avalonia/Views/Settings/AccountSettingsPage.axaml @@ -82,13 +82,21 @@ IsFilled="True" FontSize="64" Symbol="Person" /> - + Padding="2" + CornerRadius="8" + HorizontalAlignment="Left" + Classes="transparent-full"> + + + Date: Sun, 19 Nov 2023 17:27:15 -0500 Subject: [PATCH 071/144] Add update to python dep install in CI --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 05d5e381..cc3fc6bc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -235,7 +235,7 @@ jobs: python-version: '3.11' - name: Install Python Dependencies - run: pip install stability-matrix-tools>=0.2.9 + run: pip install stability-matrix-tools>=0.2.10 --upgrade - name: Publish Auto-Update Release env: @@ -264,7 +264,7 @@ jobs: python-version: '3.11' - name: Install Python Dependencies - run: pip install stability-matrix-tools>=0.2.9 + run: pip install stability-matrix-tools>=0.2.10 --upgrade # Zip each build - name: Zip Artifacts From 408e72479af601854d949e2ff2c232040c925373 Mon Sep 17 00:00:00 2001 From: Ionite Date: Sun, 19 Nov 2023 17:38:00 -0500 Subject: [PATCH 072/144] Fix CI for zipping --- .github/workflows/release.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc3fc6bc..a4a262a7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -197,8 +197,8 @@ jobs: # Zip each build - name: Zip Artifacts run: | - zip -r StabilityMatrix-win-x64.zip . -i StabilityMatrix-win-x64/* - zip -r StabilityMatrix-linux-x64.zip . -i StabilityMatrix-linux-x64/* + cd StabilityMatrix-win-x64 && zip -r ../StabilityMatrix-win-x64.zip ./. && cd $OLDPWD + cd StabilityMatrix-linux-x64 && zip -r ../StabilityMatrix-linux-x64.zip ./. && cd $OLDPWD - name: Create Github Release id: create_release @@ -269,8 +269,8 @@ jobs: # Zip each build - name: Zip Artifacts run: | - zip -r StabilityMatrix-win-x64.zip . -i StabilityMatrix-win-x64/* - zip -r StabilityMatrix-linux-x64.zip . -i StabilityMatrix-linux-x64/* + cd StabilityMatrix-win-x64 && zip -r ../StabilityMatrix-win-x64.zip ./. && cd $OLDPWD + cd StabilityMatrix-linux-x64 && zip -r ../StabilityMatrix-linux-x64.zip ./. && cd $OLDPWD # Check that the zips and CHANGELOG.md are in the current working directory - name: Check files From 6fd04dbbac3f35e43b71d4d182e3397d0687f80b Mon Sep 17 00:00:00 2001 From: Ionite Date: Sun, 19 Nov 2023 19:26:05 -0500 Subject: [PATCH 073/144] Add download artifacts step --- .github/workflows/release.yml | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a4a262a7..1b1e23ae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -259,18 +259,22 @@ jobs: echo "Using version ${{ github.event.inputs.version }}" echo "RELEASE_VERSION=${{ github.event.inputs.version }}" >> $env:GITHUB_ENV - - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Install Python Dependencies - run: pip install stability-matrix-tools>=0.2.10 --upgrade + # Downloads all previous artifacts to the current working directory + - name: Download Artifacts + uses: actions/download-artifact@v3 # Zip each build - name: Zip Artifacts run: | cd StabilityMatrix-win-x64 && zip -r ../StabilityMatrix-win-x64.zip ./. && cd $OLDPWD cd StabilityMatrix-linux-x64 && zip -r ../StabilityMatrix-linux-x64.zip ./. && cd $OLDPWD + + - uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install Python Dependencies + run: pip install stability-matrix-tools>=0.2.10 --upgrade # Check that the zips and CHANGELOG.md are in the current working directory - name: Check files From 1910cbfe1080f53d7852014f7031c1f6aad68ec9 Mon Sep 17 00:00:00 2001 From: Ionite Date: Mon, 20 Nov 2023 00:37:00 -0500 Subject: [PATCH 074/144] Add better version displays with auto .net8 hash strings --- .../ViewModels/Dialogs/UpdateViewModel.cs | 5 ++-- .../Settings/MainSettingsViewModel.cs | 2 +- .../Extensions/SemVersionExtensions.cs | 24 +++++++++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 StabilityMatrix.Core/Extensions/SemVersionExtensions.cs diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs index e5f8d37b..e5bb16fe 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs @@ -13,6 +13,7 @@ using StabilityMatrix.Avalonia.Languages; using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.Views.Dialogs; using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Models.Update; @@ -92,8 +93,8 @@ public partial class UpdateViewModel : ContentDialogViewModelBase partial void OnUpdateInfoChanged(UpdateInfo? value) { - CurrentVersionText = $"v{Compat.AppVersion}"; - NewVersionText = $"v{value?.Version}"; + CurrentVersionText = $"v{Compat.AppVersion.ToDisplayString()}"; + NewVersionText = $"v{value?.Version.ToDisplayString()}"; } public override async Task OnLoadedAsync() diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs index 163abc04..8ef5c63f 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs @@ -78,7 +78,7 @@ public partial class MainSettingsViewModel : PageViewModelBase // ReSharper disable once MemberCanBeMadeStatic.Global public string AppVersion => - $"Version {Compat.AppVersion}" + (Program.IsDebugBuild ? " (Debug)" : ""); + $"Version {Compat.AppVersion.ToDisplayString()}" + (Program.IsDebugBuild ? " (Debug)" : ""); // Theme section [ObservableProperty] diff --git a/StabilityMatrix.Core/Extensions/SemVersionExtensions.cs b/StabilityMatrix.Core/Extensions/SemVersionExtensions.cs new file mode 100644 index 00000000..8dbff4c6 --- /dev/null +++ b/StabilityMatrix.Core/Extensions/SemVersionExtensions.cs @@ -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; + } +} From ff34b4320bedc7b44dcfa44947feda94a18338ee Mon Sep 17 00:00:00 2001 From: Ionite Date: Mon, 20 Nov 2023 00:37:18 -0500 Subject: [PATCH 075/144] Trim build metadata in update version comparisons --- StabilityMatrix.Core/Updater/UpdateHelper.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/StabilityMatrix.Core/Updater/UpdateHelper.cs b/StabilityMatrix.Core/Updater/UpdateHelper.cs index 291a2994..7a7a1289 100644 --- a/StabilityMatrix.Core/Updater/UpdateHelper.cs +++ b/StabilityMatrix.Core/Updater/UpdateHelper.cs @@ -258,6 +258,12 @@ public class UpdateHelper : IUpdateHelper // Same version available, check if we both have commit hash metadata var updateHash = update.Version.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) { From 1789711ad6327daa1fe01779aac6a389f52d8560 Mon Sep 17 00:00:00 2001 From: Ionite Date: Mon, 20 Nov 2023 00:39:30 -0500 Subject: [PATCH 076/144] Add indeterminate progress at start of update --- StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs index e5bb16fe..14b8a751 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs @@ -121,6 +121,7 @@ public partial class UpdateViewModel : ContentDialogViewModelBase } ShowProgressBar = true; + IsProgressIndeterminate = true; UpdateText = string.Format( Resources.TextTemplate_UpdatingPackage, Resources.Label_StabilityMatrix From aea0cd3b6739245beaae12199b8d7616cf2bc56d Mon Sep 17 00:00:00 2001 From: Ionite Date: Mon, 20 Nov 2023 00:47:58 -0500 Subject: [PATCH 077/144] Also update new hash checking to UpdateChannelCard indicator --- StabilityMatrix.Avalonia/Models/UpdateChannelCard.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/StabilityMatrix.Avalonia/Models/UpdateChannelCard.cs b/StabilityMatrix.Avalonia/Models/UpdateChannelCard.cs index 6a098388..c29d4974 100644 --- a/StabilityMatrix.Avalonia/Models/UpdateChannelCard.cs +++ b/StabilityMatrix.Avalonia/Models/UpdateChannelCard.cs @@ -1,4 +1,5 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using System; +using CommunityToolkit.Mvvm.ComponentModel; using Semver; using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Helper; @@ -47,6 +48,12 @@ public partial class UpdateChannelCard : ObservableObject // 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) { From ecc9086ef7e76853ae3126f411ce9b62daacb37a Mon Sep 17 00:00:00 2001 From: Ionite Date: Mon, 20 Nov 2023 03:27:08 -0500 Subject: [PATCH 078/144] Add checks for empty tokens before refresh --- StabilityMatrix.Avalonia/Services/AccountsService.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/StabilityMatrix.Avalonia/Services/AccountsService.cs b/StabilityMatrix.Avalonia/Services/AccountsService.cs index 7c4b3f90..e23b0b7d 100644 --- a/StabilityMatrix.Avalonia/Services/AccountsService.cs +++ b/StabilityMatrix.Avalonia/Services/AccountsService.cs @@ -141,7 +141,11 @@ public class AccountsService : IAccountsService private async Task RefreshLykosAsync(Secrets secrets) { - if (secrets.LykosAccount is not null) + if ( + secrets.LykosAccount is not null + && !string.IsNullOrWhiteSpace(secrets.LykosAccount?.RefreshToken) + && !string.IsNullOrWhiteSpace(secrets.LykosAccount?.AccessToken) + ) { try { From 55d85cdddf0f678f98ec9af6db3d534bc5144378 Mon Sep 17 00:00:00 2001 From: Ionite Date: Mon, 20 Nov 2023 03:29:05 -0500 Subject: [PATCH 079/144] Add InvalidOperation and unknown error handling --- StabilityMatrix.Avalonia/Services/AccountsService.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/StabilityMatrix.Avalonia/Services/AccountsService.cs b/StabilityMatrix.Avalonia/Services/AccountsService.cs index e23b0b7d..66e74f10 100644 --- a/StabilityMatrix.Avalonia/Services/AccountsService.cs +++ b/StabilityMatrix.Avalonia/Services/AccountsService.cs @@ -161,6 +161,10 @@ public class AccountsService : IAccountsService { 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) { } @@ -169,6 +173,10 @@ public class AccountsService : IAccountsService 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); From baf2702747f7e7d9c815a849116b184b18fede7f Mon Sep 17 00:00:00 2001 From: Ionite Date: Mon, 20 Nov 2023 03:30:53 -0500 Subject: [PATCH 080/144] Add fix changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index af87a898..2c231e7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to Stability Matrix will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2.0.0.html). +## v2.7.0-dev.2 +### Fixed +- Fixed InvalidOperation errors when signing into accounts shortly after signing out, while the previous account update is still running + ## v2.7.0-dev.1 ### Added - Accounts Settings Subpage From 6e54919f854d1ef3a514148436b0d53b6e91beb0 Mon Sep 17 00:00:00 2001 From: Ionite Date: Mon, 20 Nov 2023 03:31:14 -0500 Subject: [PATCH 081/144] Version bump --- StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj index bf80f2c1..555de930 100644 --- a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj +++ b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj @@ -8,7 +8,7 @@ app.manifest true ./Assets/Icon.ico - 2.7.0-dev.1 + 2.7.0-dev.2 $(Version) true true From c4d2eeda2804fcfc8eb34591e617b63e814fa9f0 Mon Sep 17 00:00:00 2001 From: Ionite Date: Mon, 20 Nov 2023 21:18:30 -0500 Subject: [PATCH 082/144] Add output page refresh and update layout --- .../ViewModels/OutputsPageViewModel.cs | 5 + .../Views/OutputsPage.axaml | 308 +++++++++--------- 2 files changed, 163 insertions(+), 150 deletions(-) diff --git a/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs index 923f9ca9..76d3101c 100644 --- a/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs @@ -297,6 +297,11 @@ public partial class OutputsPageViewModel : PageViewModelBase public Task OpenImage(string imagePath) => ProcessRunner.OpenFileBrowser(imagePath); + public void Refresh() + { + Dispatcher.UIThread.Post(OnLoaded); + } + public async Task DeleteImage(OutputImageViewModel? item) { if (item is null) diff --git a/StabilityMatrix.Avalonia/Views/OutputsPage.axaml b/StabilityMatrix.Avalonia/Views/OutputsPage.axaml index 5f2da842..b6fef1d1 100644 --- a/StabilityMatrix.Avalonia/Views/OutputsPage.axaml +++ b/StabilityMatrix.Avalonia/Views/OutputsPage.axaml @@ -2,226 +2,234 @@ x:Class="StabilityMatrix.Avalonia.Views.OutputsPage" xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:avalonia="clr-namespace:Projektanker.Icons.Avalonia;assembly=Projektanker.Icons.Avalonia" xmlns:avaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit" xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:fluentAvalonia="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:ui="using:FluentAvalonia.UI.Controls" - xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels" - xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages" xmlns:models1="clr-namespace:StabilityMatrix.Avalonia.Models" - xmlns:fluentAvalonia="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia" xmlns:outputsPage="clr-namespace:StabilityMatrix.Avalonia.ViewModels.OutputsPage" - xmlns:selectableImageCard="clr-namespace:StabilityMatrix.Avalonia.Controls.SelectableImageCard" - xmlns:avalonia="clr-namespace:Projektanker.Icons.Avalonia;assembly=Projektanker.Icons.Avalonia" xmlns:scroll="clr-namespace:StabilityMatrix.Avalonia.Controls.Scroll" + xmlns:selectableImageCard="clr-namespace:StabilityMatrix.Avalonia.Controls.SelectableImageCard" + xmlns:ui="using:FluentAvalonia.UI.Controls" + xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels" d:DataContext="{x:Static mocks:DesignData.OutputsPageViewModel}" - d:DesignHeight="450" - d:DesignWidth="700" + d:DesignHeight="650" + d:DesignWidth="800" x:CompileBindings="True" x:DataType="vm:OutputsPageViewModel" + Focusable="True" mc:Ignorable="d"> - - - - + + + + + + + + + + - - + - - - - - - - - - + + - - - + + + + + + - @@ -322,8 +321,8 @@ - + diff --git a/StabilityMatrix.Avalonia/Views/OutputsPage.axaml b/StabilityMatrix.Avalonia/Views/OutputsPage.axaml index b6fef1d1..b9f29cfc 100644 --- a/StabilityMatrix.Avalonia/Views/OutputsPage.axaml +++ b/StabilityMatrix.Avalonia/Views/OutputsPage.axaml @@ -97,12 +97,21 @@ VerticalAlignment="Center" VerticalContentAlignment="Center" Text="{Binding SearchQuery, Mode=TwoWay}" - Watermark="{x:Static lang:Resources.Action_Search}"> + Watermark="{x:Static lang:Resources.Action_Search}" + KeyDown="InputElement_OnKeyDown"> - + + + + diff --git a/StabilityMatrix.Avalonia/Views/OutputsPage.axaml.cs b/StabilityMatrix.Avalonia/Views/OutputsPage.axaml.cs index 744d9482..b489897b 100644 --- a/StabilityMatrix.Avalonia/Views/OutputsPage.axaml.cs +++ b/StabilityMatrix.Avalonia/Views/OutputsPage.axaml.cs @@ -39,4 +39,12 @@ public partial class OutputsPage : UserControlBase e.Handled = true; } + + private void InputElement_OnKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Escape && DataContext is OutputsPageViewModel viewModel) + { + viewModel.ClearSearchQuery(); + } + } } diff --git a/StabilityMatrix.Core/Helper/FileTransfers.cs b/StabilityMatrix.Core/Helper/FileTransfers.cs index d2f84db8..ff8b1f04 100644 --- a/StabilityMatrix.Core/Helper/FileTransfers.cs +++ b/StabilityMatrix.Core/Helper/FileTransfers.cs @@ -190,35 +190,45 @@ public static class FileTransfers var sourceFile = sourceDir.JoinFile(file.Name); var destinationFile = destinationDir.JoinFile(file.Name); - if (destinationFile.Exists) + await MoveFileAsync(sourceFile, destinationFile, overwrite, overwriteIfHashMatches) + .ConfigureAwait(false); + } + } + + public static async Task MoveFileAsync( + FilePath sourceFile, + FilePath destinationFile, + bool overwrite = false, + bool overwriteIfHashMatches = false + ) + { + if (destinationFile.Exists) + { + if (overwriteIfHashMatches) { - if (overwriteIfHashMatches) - { - // Check if files hashes are the same - var sourceHash = await FileHash - .GetBlake3Async(sourceFile) - .ConfigureAwait(false); - var destinationHash = await FileHash - .GetBlake3Async(destinationFile) - .ConfigureAwait(false); - // For same hash, just delete original file - if (sourceHash == destinationHash) - { - Logger.Info( - $"Deleted source file {file.Name} as it already exists in {destinationDir}." - + $" Matching Blake3 hash: {sourceHash}" - ); - sourceFile.Delete(); - continue; - } - } - else if (!overwrite) + // Check if files hashes are the same + var sourceHash = await FileHash.GetBlake3Async(sourceFile).ConfigureAwait(false); + var destinationHash = await FileHash + .GetBlake3Async(destinationFile) + .ConfigureAwait(false); + // For same hash, just delete original file + if (sourceHash == destinationHash) { - throw new FileTransferExistsException(sourceFile, destinationFile); + Logger.Info( + $"Deleted source file {sourceFile.Name} as it already exists in {Path.GetDirectoryName(destinationFile)}." + + $" Matching Blake3 hash: {sourceHash}" + ); + sourceFile.Delete(); + return; } } - // Move the file - await sourceFile.MoveToAsync(destinationFile).ConfigureAwait(false); + else if (!overwrite) + { + throw new FileTransferExistsException(sourceFile, destinationFile); + } } + + // Move the file + await sourceFile.MoveToAsync(destinationFile).ConfigureAwait(false); } } From da92d45452692528aed3c6e917de015a06bd35f2 Mon Sep 17 00:00:00 2001 From: JT Date: Tue, 21 Nov 2023 00:56:40 -0800 Subject: [PATCH 085/144] drag on files too --- .../CheckpointManager/CheckpointFile.cs | 9 ++- .../CheckpointManager/CheckpointFolder.cs | 2 +- .../Views/CheckpointsPage.axaml | 7 ++- .../Views/CheckpointsPage.axaml.cs | 60 ++++++++++++++++--- 4 files changed, 66 insertions(+), 12 deletions(-) diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFile.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFile.cs index b8e302b9..9e56a004 100644 --- a/StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFile.cs +++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFile.cs @@ -55,6 +55,9 @@ public partial class CheckpointFile : ViewModelBase [ObservableProperty] private CivitModelType modelType; + [ObservableProperty] + private CheckpointFolder parentFolder; + public string FileName => Path.GetFileName(FilePath); public ObservableCollection Badges { get; set; } = new(); @@ -220,6 +223,7 @@ public partial class CheckpointFile : ViewModelBase /// - {filename}.cm-info.json (connected model info) /// public static IEnumerable FromDirectoryIndex( + CheckpointFolder parentFolder, string directory, SearchOption searchOption = SearchOption.TopDirectoryOnly ) @@ -268,6 +272,8 @@ public partial class CheckpointFile : ViewModelBase checkpointFile.PreviewImagePath = Assets.NoImage.ToString(); } + checkpointFile.ParentFolder = parentFolder; + yield return checkpointFile; } } @@ -329,13 +335,14 @@ public partial class CheckpointFile : ViewModelBase /// Index with progress reporting. /// public static IEnumerable FromDirectoryIndex( + CheckpointFolder parentFolder, string directory, IProgress progress, SearchOption searchOption = SearchOption.TopDirectoryOnly ) { var current = 0ul; - foreach (var checkpointFile in FromDirectoryIndex(directory, searchOption)) + foreach (var checkpointFile in FromDirectoryIndex(parentFolder, directory, searchOption)) { current++; progress.Report(new ProgressReport(current, "Indexing", checkpointFile.FileName)); diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFolder.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFolder.cs index 44f9639f..b5fc8d53 100644 --- a/StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFolder.cs +++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointManager/CheckpointFolder.cs @@ -575,7 +575,7 @@ public partial class CheckpointFolder : ViewModelBase return Enumerable.Empty(); } - return CheckpointFile.FromDirectoryIndex(DirectoryPath); + return CheckpointFile.FromDirectoryIndex(this, DirectoryPath); } /// diff --git a/StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml b/StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml index 6b00f6cf..a2f33f71 100644 --- a/StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml +++ b/StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml @@ -44,7 +44,7 @@ @@ -400,10 +400,10 @@ HorizontalAlignment="Right" Margin="16,0" Orientation="Horizontal"> - @@ -459,6 +459,7 @@ Grid.Column="0" Grid.ColumnSpan="2" Grid.Row="1" + x:Name="MainScrollViewer" HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto"> diff --git a/StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml.cs b/StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml.cs index c6e06b95..34fcc7aa 100644 --- a/StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml.cs +++ b/StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Markup.Xaml; @@ -7,7 +8,9 @@ using Avalonia.VisualTree; using DynamicData.Binding; using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Avalonia.ViewModels; +using StabilityMatrix.Avalonia.ViewModels.CheckpointManager; using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Models.FileInterfaces; using CheckpointFolder = StabilityMatrix.Avalonia.ViewModels.CheckpointManager.CheckpointFolder; namespace StabilityMatrix.Avalonia.Views; @@ -59,12 +62,36 @@ public partial class CheckpointsPage : UserControlBase } } - private static async void OnDrop(object? sender, DragEventArgs e) + private async void OnDrop(object? sender, DragEventArgs e) { var sourceDataContext = (e.Source as Control)?.DataContext; - if (sourceDataContext is CheckpointFolder folder) + switch (sourceDataContext) { - await folder.OnDrop(e); + case CheckpointFolder folder: + { + if (e.Data.Get("Context") is not CheckpointFile file) + return; + + var filePath = new FilePath(file.FilePath); + if (filePath.Directory?.FullPath != folder.DirectoryPath) + { + await folder.OnDrop(e); + } + break; + } + case CheckpointFile file: + { + if (e.Data.Get("Context") is not CheckpointFile dragFile) + return; + + var parentFolder = file.ParentFolder; + var dragFilePath = new FilePath(dragFile.FilePath); + if (dragFilePath.Directory?.FullPath != parentFolder.DirectoryPath) + { + await parentFolder.OnDrop(e); + } + break; + } } } @@ -77,7 +104,7 @@ public partial class CheckpointsPage : UserControlBase } } - private static void OnDragEnter(object? sender, DragEventArgs e) + private void OnDragEnter(object? sender, DragEventArgs e) { // Only allow Copy or Link as Drop Operations. e.DragEffects &= DragDropEffects.Copy | DragDropEffects.Link; @@ -90,10 +117,29 @@ public partial class CheckpointsPage : UserControlBase // Forward to view model var sourceDataContext = (e.Source as Control)?.DataContext; - if (sourceDataContext is CheckpointFolder folder) + switch (sourceDataContext) { - folder.IsExpanded = true; - folder.IsCurrentDragTarget = true; + case CheckpointFolder folder: + { + folder.IsExpanded = true; + if (e.Data.Get("Context") is not CheckpointFile file) + return; + + var filePath = new FilePath(file.FilePath); + folder.IsCurrentDragTarget = filePath.Directory?.FullPath != folder.DirectoryPath; + break; + } + case CheckpointFile file: + { + if (e.Data.Get("Context") is not CheckpointFile dragFile) + return; + + var parentFolder = file.ParentFolder; + var dragFilePath = new FilePath(dragFile.FilePath); + parentFolder.IsCurrentDragTarget = + dragFilePath.Directory?.FullPath != parentFolder.DirectoryPath; + break; + } } } From 9461e181c57bf6c66035309a155ce179bb2ea7a2 Mon Sep 17 00:00:00 2001 From: Ionite Date: Tue, 21 Nov 2023 15:24:54 -0500 Subject: [PATCH 086/144] Switch to CommandLineParser and add directory override args --- StabilityMatrix.Avalonia/Models/AppArgs.cs | 34 +++++++++- StabilityMatrix.Avalonia/Program.cs | 63 +++++++++++-------- .../StabilityMatrix.Avalonia.csproj | 1 + 3 files changed, 72 insertions(+), 26 deletions(-) diff --git a/StabilityMatrix.Avalonia/Models/AppArgs.cs b/StabilityMatrix.Avalonia/Models/AppArgs.cs index 30bc391b..236aa8dc 100644 --- a/StabilityMatrix.Avalonia/Models/AppArgs.cs +++ b/StabilityMatrix.Avalonia/Models/AppArgs.cs @@ -1,4 +1,7 @@ -namespace StabilityMatrix.Avalonia.Models; +using System; +using CommandLine; + +namespace StabilityMatrix.Avalonia.Models; /// /// Command line arguments passed to the application. @@ -8,41 +11,70 @@ public class AppArgs /// /// Whether to enable debug mode /// + [Option("debug", HelpText = "Enable debug mode")] public bool DebugMode { get; set; } /// /// Whether to use the exception dialog while debugger is attached. /// When no debugger is attached, the exception dialog is always used. /// + [Option("debug-exception-dialog", HelpText = "Use exception dialog while debugger is attached")] public bool DebugExceptionDialog { get; set; } /// /// Whether to use Sentry when a debugger is attached. /// + [Option("debug-sentry", HelpText = "Use Sentry when debugger is attached")] public bool DebugSentry { get; set; } /// /// Whether to force show the one-click install dialog. /// + [Option("debug-one-click-install", HelpText = "Force show the one-click install dialog")] public bool DebugOneClickInstall { get; set; } /// /// Whether to disable Sentry. /// + [Option("no-sentry", HelpText = "Disable Sentry")] public bool NoSentry { get; set; } /// /// Whether to disable window chrome effects /// + [Option("no-window-chrome-effects", HelpText = "Disable window chrome effects")] public bool NoWindowChromeEffects { get; set; } /// /// Flag to indicate if we should reset the saved window position back to (O,0) /// + [Option("reset-window-position", HelpText = "Reset the saved window position back to (0,0)")] public bool ResetWindowPosition { get; set; } /// /// Flag for disabling hardware acceleration / GPU rendering /// + [Option("disable-gpu-rendering", HelpText = "Disable hardware acceleration / GPU rendering")] public bool DisableGpuRendering { get; set; } + + /// + /// Override global app data directory + /// Defaults to (%APPDATA%|~/.config)/StabilityMatrix + /// + [Option("global-dir", HelpText = "Override global app data directory")] + public string? GlobalDirectory { get; set; } + + /// + /// Override data directory + /// This takes precedence over relative portable directory and global directory + /// + [Option("data-dir", HelpText = "Override data directory")] + public string? DataDirectory { get; set; } + + /// + /// Custom Uri protocol handler + /// This will send the Uri to the running instance of the app via IPC and exit + /// + [Option("uri", Hidden = true)] + public string? Uri { get; set; } } diff --git a/StabilityMatrix.Avalonia/Program.cs b/StabilityMatrix.Avalonia/Program.cs index 229c605b..8dbe9e07 100644 --- a/StabilityMatrix.Avalonia/Program.cs +++ b/StabilityMatrix.Avalonia/Program.cs @@ -2,7 +2,6 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Linq; using System.Reflection; using System.Runtime.InteropServices; using System.Threading; @@ -13,7 +12,7 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Threading; -using FluentAvalonia.Core; +using CommandLine; using NLog; using Polly.Contrib.WaitAndRetry; using Projektanker.Icons.Avalonia; @@ -29,14 +28,12 @@ using StabilityMatrix.Core.Updater; namespace StabilityMatrix.Avalonia; -[SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")] -[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] -public class Program +public static class Program { private static Logger? _logger; private static Logger Logger => _logger ??= LogManager.GetCurrentClassLogger(); - public static AppArgs Args { get; } = new(); + public static AppArgs Args { get; private set; } = new(); public static bool IsDebugBuild { get; private set; } @@ -54,30 +51,46 @@ public class Program { StartupTimer.Start(); - Args.DebugMode = args.Contains("--debug"); - Args.DebugExceptionDialog = args.Contains("--debug-exception-dialog"); - Args.DebugSentry = args.Contains("--debug-sentry"); - Args.DebugOneClickInstall = args.Contains("--debug-one-click-install"); - Args.NoSentry = args.Contains("--no-sentry"); - Args.NoWindowChromeEffects = args.Contains("--no-window-chrome-effects"); - Args.ResetWindowPosition = args.Contains("--reset-window-position"); - Args.DisableGpuRendering = args.Contains("--disable-gpu"); - - // Launched for custom URI scheme, send to main process - if (args.Contains("--uri")) + SetDebugBuild(); + + var parseResult = Parser.Default + .ParseArguments(args) + .WithNotParsed(errors => + { + foreach (var error in errors) + { + Console.Error.WriteLine(error.ToString()); + } + }); + + Args = parseResult.Value; + + // Launched for custom URI scheme, handle and exit + if (Args.Uri is { } uriArg) { - var uriArg = args.ElementAtOrDefault(args.IndexOf("--uri") + 1); - if ( - Uri.TryCreate(uriArg, UriKind.Absolute, out var uri) - && string.Equals(uri.Scheme, UriHandler.Scheme, StringComparison.OrdinalIgnoreCase) - ) + try { - UriHandler.SendAndExit(uri); + if ( + Uri.TryCreate(uriArg, UriKind.Absolute, out var uri) + && string.Equals( + uri.Scheme, + UriHandler.Scheme, + StringComparison.OrdinalIgnoreCase + ) + ) + { + UriHandler.SendAndExit(uri); + } + + Environment.Exit(0); + } + catch (Exception e) + { + Console.Error.WriteLine($"Uri handler encountered an error: {e.Message}"); + Environment.Exit(1); } } - SetDebugBuild(); - HandleUpdateReplacement(); var infoVersion = Assembly diff --git a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj index 555de930..6bcb2cb3 100644 --- a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj +++ b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj @@ -32,6 +32,7 @@ + From d734b0666a6822d59a4d21e4a05fefec1a2081d7 Mon Sep 17 00:00:00 2001 From: Ionite Date: Tue, 21 Nov 2023 15:25:24 -0500 Subject: [PATCH 087/144] Add ViewModelBase.ViewModelState and OnInitialLoaded virtual method --- .../Models/ViewModelState.cs | 15 ++++++ .../ViewModels/Base/ViewModelBase.cs | 53 +++++++++++++++---- 2 files changed, 57 insertions(+), 11 deletions(-) create mode 100644 StabilityMatrix.Avalonia/Models/ViewModelState.cs diff --git a/StabilityMatrix.Avalonia/Models/ViewModelState.cs b/StabilityMatrix.Avalonia/Models/ViewModelState.cs new file mode 100644 index 00000000..d195a7a6 --- /dev/null +++ b/StabilityMatrix.Avalonia/Models/ViewModelState.cs @@ -0,0 +1,15 @@ +using System; + +namespace StabilityMatrix.Avalonia.Models; + +/// +/// +/// +[Flags] +public enum ViewModelState : uint +{ + /// + /// View Model has been initially loaded + /// + InitialLoaded = 1 << 0, +} diff --git a/StabilityMatrix.Avalonia/ViewModels/Base/ViewModelBase.cs b/StabilityMatrix.Avalonia/ViewModels/Base/ViewModelBase.cs index 0cce47ba..0d9899cc 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Base/ViewModelBase.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Base/ViewModelBase.cs @@ -2,14 +2,18 @@ using System.Threading.Tasks; using AsyncAwaitBestPractices; using CommunityToolkit.Mvvm.ComponentModel; +using JetBrains.Annotations; using StabilityMatrix.Avalonia.Models; namespace StabilityMatrix.Avalonia.ViewModels.Base; public class ViewModelBase : ObservableValidator, IRemovableListItem { + [PublicAPI] + protected ViewModelState ViewModelState { get; private set; } + private WeakEventManager? parentListRemoveRequestedEventManager; - + public event EventHandler ParentListRemoveRequested { add @@ -20,24 +24,51 @@ public class ViewModelBase : ObservableValidator, IRemovableListItem remove => parentListRemoveRequestedEventManager?.RemoveEventHandler(value); } - protected void RemoveFromParentList() => parentListRemoveRequestedEventManager?.RaiseEvent( - this, EventArgs.Empty, nameof(ParentListRemoveRequested)); - + protected void RemoveFromParentList() => + parentListRemoveRequestedEventManager?.RaiseEvent( + this, + EventArgs.Empty, + nameof(ParentListRemoveRequested) + ); + + /// + /// Called when the view's LoadedEvent is fired. + /// public virtual void OnLoaded() { - + if (!ViewModelState.HasFlag(ViewModelState.InitialLoaded)) + { + ViewModelState |= ViewModelState.InitialLoaded; + OnInitialLoaded(); + } } + /// + /// Called the first time the view's LoadedEvent is fired. + /// Sets the flag. + /// + protected virtual void OnInitialLoaded() { } + + /// + /// Called asynchronously when the view's LoadedEvent is fired. + /// Runs on the UI thread via Dispatcher.UIThread.InvokeAsync. + /// The view loading will not wait for this to complete. + /// public virtual Task OnLoadedAsync() { return Task.CompletedTask; } - - public virtual void OnUnloaded() - { - - } - + + /// + /// Called when the view's UnloadedEvent is fired. + /// + public virtual void OnUnloaded() { } + + /// + /// Called asynchronously when the view's UnloadedEvent is fired. + /// Runs on the UI thread via Dispatcher.UIThread.InvokeAsync. + /// The view loading will not wait for this to complete. + /// public virtual Task OnUnloadedAsync() { return Task.CompletedTask; From faeda29f8d67cb5c8e07086bcdb26febc5132de1 Mon Sep 17 00:00:00 2001 From: Ionite Date: Tue, 21 Nov 2023 16:49:27 -0500 Subject: [PATCH 088/144] Add process exit wait during updates --- StabilityMatrix.Avalonia/Models/AppArgs.cs | 7 + StabilityMatrix.Avalonia/Program.cs | 139 ++++++++++++------ .../ViewModels/Dialogs/UpdateViewModel.cs | 46 ++++-- 3 files changed, 138 insertions(+), 54 deletions(-) diff --git a/StabilityMatrix.Avalonia/Models/AppArgs.cs b/StabilityMatrix.Avalonia/Models/AppArgs.cs index 236aa8dc..03ff8b5a 100644 --- a/StabilityMatrix.Avalonia/Models/AppArgs.cs +++ b/StabilityMatrix.Avalonia/Models/AppArgs.cs @@ -77,4 +77,11 @@ public class AppArgs /// [Option("uri", Hidden = true)] public string? Uri { get; set; } + + /// + /// If provided, the app will wait for the process with this PID to exit + /// before starting up. Mainly used by the updater. + /// + [Option("wait-for-exit-pid", Hidden = true)] + public int? WaitForExitPid { get; set; } } diff --git a/StabilityMatrix.Avalonia/Program.cs b/StabilityMatrix.Avalonia/Program.cs index 8dbe9e07..16a667f2 100644 --- a/StabilityMatrix.Avalonia/Program.cs +++ b/StabilityMatrix.Avalonia/Program.cs @@ -91,7 +91,13 @@ public static class Program } } + if (Args.WaitForExitPid is { } waitExitPid) + { + WaitForPidExit(waitExitPid, TimeSpan.FromSeconds(30)); + } + HandleUpdateReplacement(); + HandleUpdateCleanup(); var infoVersion = Assembly .GetExecutingAssembly() @@ -159,57 +165,70 @@ public static class Program private static void HandleUpdateReplacement() { // Check if we're in the named update folder or the legacy update folder for 1.2.0 -> 2.0.0 - if (Compat.AppCurrentDir is { Name: UpdateHelper.UpdateFolderName } or { Name: "Update" }) - { - var parentDir = Compat.AppCurrentDir.Parent; - if (parentDir is null) - return; - - var retryDelays = Backoff.DecorrelatedJitterBackoffV2( - TimeSpan.FromMilliseconds(350), - retryCount: 5 - ); + if (Compat.AppCurrentDir.Name is not (UpdateHelper.UpdateFolderName or "Update")) + return; - foreach (var delay in retryDelays) - { - // Copy our current file to the parent directory, overwriting the old app file - var currentExe = Compat.AppCurrentDir.JoinFile(Compat.GetExecutableName()); - var targetExe = parentDir.JoinFile(Compat.GetExecutableName()); - try - { - currentExe.CopyTo(targetExe, true); + if (Compat.AppCurrentDir.Parent is not { } parentDir) + return; - // Ensure permissions are set for unix - if (Compat.IsUnix) - { - File.SetUnixFileMode( - targetExe, // 0755 - UnixFileMode.UserRead - | UnixFileMode.UserWrite - | UnixFileMode.UserExecute - | UnixFileMode.GroupRead - | UnixFileMode.GroupExecute - | UnixFileMode.OtherRead - | UnixFileMode.OtherExecute - ); - } + // Copy our current file to the parent directory, overwriting the old app file + var currentExe = Compat.AppCurrentDir.JoinFile(Compat.GetExecutableName()); + var targetExe = parentDir.JoinFile(Compat.GetExecutableName()); - // Start the new app - Process.Start(targetExe); + var isCopied = false; - // Shutdown the current app - Environment.Exit(0); - } - catch (Exception) - { - Thread.Sleep(delay); - } + foreach ( + var delay in Backoff.DecorrelatedJitterBackoffV2( + TimeSpan.FromMilliseconds(300), + retryCount: 6, + fastFirst: true + ) + ) + { + try + { + currentExe.CopyTo(targetExe, true); + isCopied = true; + break; + } + catch (Exception) + { + Thread.Sleep(delay); } } + if (!isCopied) + { + Logger.Error("Failed to copy current executable to parent directory"); + Environment.Exit(1); + } + + // Ensure permissions are set for unix + if (Compat.IsUnix) + { + File.SetUnixFileMode( + targetExe, // 0755 + UnixFileMode.UserRead + | UnixFileMode.UserWrite + | UnixFileMode.UserExecute + | UnixFileMode.GroupRead + | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead + | UnixFileMode.OtherExecute + ); + } + + // Start the new app while passing our own PID to wait for exit + Process.Start(targetExe, $"--wait-for-exit-pid {Environment.ProcessId}"); + + // Shutdown the current app + Environment.Exit(0); + } + + private static void HandleUpdateCleanup() + { // Delete update folder if it exists in current directory - var updateDir = UpdateHelper.UpdateFolder; - if (updateDir.Exists) + if (UpdateHelper.UpdateFolder is { Exists: true } updateDir) { try { @@ -217,12 +236,42 @@ public static class Program } catch (Exception e) { - var logger = LogManager.GetCurrentClassLogger(); - logger.Error(e, "Failed to delete update file"); + Logger.Error(e, "Failed to delete update folder"); } } } + /// + /// Wait for an external process to exit, + /// ignores if process is not found, already exited, or throws an exception + /// + /// External process PID + /// Timeout to wait for process to exit + private static void WaitForPidExit(int pid, TimeSpan timeout) + { + try + { + var process = Process.GetProcessById(pid); + process + .WaitForExitAsync(new CancellationTokenSource(timeout).Token) + .GetAwaiter() + .GetResult(); + } + catch (OperationCanceledException) + { + Logger.Warn("Timed out ({Timeout:g}) waiting for process {Pid} to exit", timeout, pid); + } + catch (SystemException e) + { + Logger.Warn(e, "Failed to wait for process {Pid} to exit", pid); + } + catch (Exception e) + { + Logger.Error(e, "Unexpected error during WaitForPidExit"); + throw; + } + } + private static void ConfigureSentry() { SentrySdk.Init(o => diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs index 14b8a751..f7dc273e 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using AsyncAwaitBestPractices; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; using Semver; using StabilityMatrix.Avalonia.Languages; using StabilityMatrix.Avalonia.ViewModels.Base; @@ -27,6 +28,7 @@ namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; [Singleton] public partial class UpdateViewModel : ContentDialogViewModelBase { + private readonly ILogger logger; private readonly ISettingsManager settingsManager; private readonly IHttpClientFactory httpClientFactory; private readonly IUpdateHelper updateHelper; @@ -66,11 +68,13 @@ public partial class UpdateViewModel : ContentDialogViewModelBase private static partial Regex RegexChangelog(); public UpdateViewModel( + ILogger logger, ISettingsManager settingsManager, IHttpClientFactory httpClientFactory, IUpdateHelper updateHelper ) { + this.logger = logger; this.settingsManager = settingsManager; this.httpClientFactory = httpClientFactory; this.updateHelper = updateHelper; @@ -127,14 +131,29 @@ public partial class UpdateViewModel : ContentDialogViewModelBase Resources.Label_StabilityMatrix ); - await updateHelper.DownloadUpdate( - UpdateInfo, - new Progress(report => - { - ProgressValue = Convert.ToInt32(report.Percentage); - IsProgressIndeterminate = report.IsIndeterminate; - }) - ); + try + { + await updateHelper.DownloadUpdate( + UpdateInfo, + new Progress(report => + { + ProgressValue = Convert.ToInt32(report.Percentage); + IsProgressIndeterminate = report.IsIndeterminate; + }) + ); + } + catch (Exception e) + { + logger.LogWarning(e, "Failed to download update"); + + var dialog = DialogHelper.CreateMarkdownDialog( + $"{e.GetType().Name}: {e.Message}", + Resources.Label_UnexpectedErrorOccurred + ); + + await dialog.ShowAsync(); + return; + } // On unix, we need to set the executable bit if (Compat.IsUnix) @@ -151,14 +170,23 @@ public partial class UpdateViewModel : ContentDialogViewModelBase ); } + UpdateText = "Getting a few things ready..."; + await using (new MinimumDelay(500, 1000)) + { + Process.Start( + UpdateHelper.ExecutablePath, + $"--wait-for-exit-pid {Environment.ProcessId}" + ); + } + UpdateText = "Update complete. Restarting Stability Matrix in 3 seconds..."; await Task.Delay(1000); UpdateText = "Update complete. Restarting Stability Matrix in 2 seconds..."; await Task.Delay(1000); UpdateText = "Update complete. Restarting Stability Matrix in 1 second..."; await Task.Delay(1000); + UpdateText = "Update complete. Restarting Stability Matrix..."; - Process.Start(UpdateHelper.ExecutablePath); App.Shutdown(); } From b56715cecd07af9f9be15aeb315518072df42d45 Mon Sep 17 00:00:00 2001 From: Ionite Date: Tue, 21 Nov 2023 16:54:15 -0500 Subject: [PATCH 089/144] Add fix changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9fe9ca4..1e466539 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 ### Fixed - Fixed InvalidOperation errors when signing into accounts shortly after signing out, while the previous account update is still running - Fixed Outputs page reverting back to Shared Output Folder every time the page is reloaded +- Potentially fixed updates sometimes clearing settings or launching in the wrong directory ## v2.7.0-dev.1 ### Added From 4c83ec6f689f73626eee084bf6ae1d3676681a72 Mon Sep 17 00:00:00 2001 From: Ionite Date: Tue, 21 Nov 2023 17:01:51 -0500 Subject: [PATCH 090/144] Fix redundant dispose --- StabilityMatrix.Core/Services/SettingsManager.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/StabilityMatrix.Core/Services/SettingsManager.cs b/StabilityMatrix.Core/Services/SettingsManager.cs index 47efd00c..64f6e3a5 100644 --- a/StabilityMatrix.Core/Services/SettingsManager.cs +++ b/StabilityMatrix.Core/Services/SettingsManager.cs @@ -130,7 +130,6 @@ public class SettingsManager : ISettingsManager } using var transaction = BeginTransaction(); func(transaction.Settings); - transaction.Dispose(); } /// From 71068fc80ec8410066be9016d040d24baf523087 Mon Sep 17 00:00:00 2001 From: Ionite Date: Tue, 21 Nov 2023 17:07:04 -0500 Subject: [PATCH 091/144] Nullability fix --- StabilityMatrix.Core/Services/SettingsManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/StabilityMatrix.Core/Services/SettingsManager.cs b/StabilityMatrix.Core/Services/SettingsManager.cs index 64f6e3a5..f1dfb5db 100644 --- a/StabilityMatrix.Core/Services/SettingsManager.cs +++ b/StabilityMatrix.Core/Services/SettingsManager.cs @@ -297,7 +297,7 @@ public class SettingsManager : ISettingsManager if ( !string.IsNullOrWhiteSpace(librarySettings?.LibraryPath) - && Directory.Exists(librarySettings?.LibraryPath) + && Directory.Exists(librarySettings.LibraryPath) ) { LibraryDir = librarySettings.LibraryPath; From ac8c7a0e9f74aa94b1d4060a83b808b77d7bd058 Mon Sep 17 00:00:00 2001 From: Ionite Date: Tue, 21 Nov 2023 17:08:24 -0500 Subject: [PATCH 092/144] Add AppDataHome and LibraryDir overrides from args --- StabilityMatrix.Avalonia/App.axaml.cs | 2 ++ StabilityMatrix.Avalonia/Models/AppArgs.cs | 8 ++++---- StabilityMatrix.Avalonia/Program.cs | 5 +++++ StabilityMatrix.Core/Helper/Compat.cs | 14 ++------------ .../Services/ISettingsManager.cs | 1 + .../Services/SettingsManager.cs | 17 +++++++++++++---- 6 files changed, 27 insertions(+), 20 deletions(-) diff --git a/StabilityMatrix.Avalonia/App.axaml.cs b/StabilityMatrix.Avalonia/App.axaml.cs index 30a6b229..9b01279a 100644 --- a/StabilityMatrix.Avalonia/App.axaml.cs +++ b/StabilityMatrix.Avalonia/App.axaml.cs @@ -258,6 +258,8 @@ public sealed class App : Application var settingsManager = Services.GetRequiredService(); + settingsManager.LibraryDirOverride = Program.Args.DataDirectoryOverride; + if (settingsManager.TryFindLibrary()) { Cultures.SetSupportedCultureOrDefault(settingsManager.Settings.Language); diff --git a/StabilityMatrix.Avalonia/Models/AppArgs.cs b/StabilityMatrix.Avalonia/Models/AppArgs.cs index 03ff8b5a..f1624103 100644 --- a/StabilityMatrix.Avalonia/Models/AppArgs.cs +++ b/StabilityMatrix.Avalonia/Models/AppArgs.cs @@ -58,18 +58,18 @@ public class AppArgs public bool DisableGpuRendering { get; set; } /// - /// Override global app data directory + /// Override global app home directory /// Defaults to (%APPDATA%|~/.config)/StabilityMatrix /// - [Option("global-dir", HelpText = "Override global app data directory")] - public string? GlobalDirectory { get; set; } + [Option("home-dir", HelpText = "Override global app home directory")] + public string? HomeDirectoryOverride { get; set; } /// /// Override data directory /// This takes precedence over relative portable directory and global directory /// [Option("data-dir", HelpText = "Override data directory")] - public string? DataDirectory { get; set; } + public string? DataDirectoryOverride { get; set; } /// /// Custom Uri protocol handler diff --git a/StabilityMatrix.Avalonia/Program.cs b/StabilityMatrix.Avalonia/Program.cs index 16a667f2..616b6346 100644 --- a/StabilityMatrix.Avalonia/Program.cs +++ b/StabilityMatrix.Avalonia/Program.cs @@ -65,6 +65,11 @@ public static class Program Args = parseResult.Value; + if (Args.HomeDirectoryOverride is { } homeDir) + { + Compat.SetAppDataHome(homeDir); + } + // Launched for custom URI scheme, handle and exit if (Args.Uri is { } uriArg) { diff --git a/StabilityMatrix.Core/Helper/Compat.cs b/StabilityMatrix.Core/Helper/Compat.cs index 9ddb79f9..9a014424 100644 --- a/StabilityMatrix.Core/Helper/Compat.cs +++ b/StabilityMatrix.Core/Helper/Compat.cs @@ -51,7 +51,7 @@ public static class Compat /// /// Set AppDataHome to a custom path. Used for testing. /// - internal static void SetAppDataHome(string path) + public static void SetAppDataHome(string path) { AppDataHome = path; } @@ -136,17 +136,7 @@ public static class Compat AppData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); - if ( - Environment.GetEnvironmentVariable("STABILITY_MATRIX_APPDATAHOME") is - { } appDataOverride - ) - { - AppDataHome = appDataOverride; - } - else - { - AppDataHome = AppData + AppName; - } + AppDataHome = AppData + AppName; } /// diff --git a/StabilityMatrix.Core/Services/ISettingsManager.cs b/StabilityMatrix.Core/Services/ISettingsManager.cs index 4b8a92d3..52e9c3e8 100644 --- a/StabilityMatrix.Core/Services/ISettingsManager.cs +++ b/StabilityMatrix.Core/Services/ISettingsManager.cs @@ -9,6 +9,7 @@ namespace StabilityMatrix.Core.Services; public interface ISettingsManager { bool IsPortableMode { get; } + string? LibraryDirOverride { set; } string LibraryDir { get; } bool IsLibraryDirSet { get; } string DatabasePath { get; } diff --git a/StabilityMatrix.Core/Services/SettingsManager.cs b/StabilityMatrix.Core/Services/SettingsManager.cs index f1dfb5db..0d96f8e8 100644 --- a/StabilityMatrix.Core/Services/SettingsManager.cs +++ b/StabilityMatrix.Core/Services/SettingsManager.cs @@ -22,10 +22,9 @@ public class SettingsManager : ISettingsManager private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private static readonly ReaderWriterLockSlim FileLock = new(); - private static readonly string GlobalSettingsPath = Path.Combine( - Compat.AppDataHome, - "global.json" - ); + private static string GlobalSettingsPath => Path.Combine(Compat.AppDataHome, "global.json"); + + public string? LibraryDirOverride { private get; set; } private readonly string? originalEnvPath = Environment.GetEnvironmentVariable( "PATH", @@ -274,6 +273,16 @@ public class SettingsManager : ISettingsManager if (IsLibraryDirSet && !forceReload) return true; + // 0. Check Override + if (!string.IsNullOrEmpty(LibraryDirOverride)) + { + Logger.Info("Using library override path: {Path}", LibraryDirOverride); + LibraryDir = LibraryDirOverride; + SetStaticLibraryPaths(); + LoadSettings(); + return true; + } + // 1. Check portable mode var appDir = Compat.AppCurrentDir; IsPortableMode = File.Exists(Path.Combine(appDir, "Data", ".sm-portable")); From b1259b4a9ba74aa701efc863760a4d971ccc2b2b Mon Sep 17 00:00:00 2001 From: Ionite Date: Wed, 22 Nov 2023 23:10:22 -0500 Subject: [PATCH 093/144] Add Kilo and Mega prefix formatters for model browser --- .../Converters/KiloFormatter.cs | 50 ++ .../KiloFormatterStringConverter.cs | 25 + .../Views/CheckpointBrowserPage.axaml | 428 +++++++++--------- 3 files changed, 297 insertions(+), 206 deletions(-) create mode 100644 StabilityMatrix.Avalonia/Converters/KiloFormatter.cs create mode 100644 StabilityMatrix.Avalonia/Converters/KiloFormatterStringConverter.cs diff --git a/StabilityMatrix.Avalonia/Converters/KiloFormatter.cs b/StabilityMatrix.Avalonia/Converters/KiloFormatter.cs new file mode 100644 index 00000000..32bd2241 --- /dev/null +++ b/StabilityMatrix.Avalonia/Converters/KiloFormatter.cs @@ -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"); + } +} diff --git a/StabilityMatrix.Avalonia/Converters/KiloFormatterStringConverter.cs b/StabilityMatrix.Avalonia/Converters/KiloFormatterStringConverter.cs new file mode 100644 index 00000000..eaa58eea --- /dev/null +++ b/StabilityMatrix.Avalonia/Converters/KiloFormatterStringConverter.cs @@ -0,0 +1,25 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; + +namespace StabilityMatrix.Avalonia.Converters; + +public class KiloFormatterStringConverter : IValueConverter +{ + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value is null ? null : string.Format(new KiloFormatter(), "{0:K}", value); + } + + /// + public object? ConvertBack( + object? value, + Type targetType, + object? parameter, + CultureInfo culture + ) + { + return value is null ? null : throw new NotImplementedException(); + } +} diff --git a/StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml b/StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml index 688ac03f..2bba66bf 100644 --- a/StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml +++ b/StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml @@ -1,20 +1,24 @@ - + - - - + + + + + From 52346ed3e8951c43fd39088ef61ca7d8313d39d5 Mon Sep 17 00:00:00 2001 From: Ionite Date: Thu, 23 Nov 2023 01:43:51 -0500 Subject: [PATCH 095/144] Add model favorite api --- StabilityMatrix.Core/Api/ICivitTRPCApi.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/StabilityMatrix.Core/Api/ICivitTRPCApi.cs b/StabilityMatrix.Core/Api/ICivitTRPCApi.cs index d764fcc2..28f39377 100644 --- a/StabilityMatrix.Core/Api/ICivitTRPCApi.cs +++ b/StabilityMatrix.Core/Api/ICivitTRPCApi.cs @@ -45,4 +45,11 @@ public interface ICivitTRPCApi [Authorize] string bearerToken, CancellationToken cancellationToken = default ); + + [Post("/api/trpc/user.toggleFavoriteModel")] + Task ToggleFavoriteModel( + [Body] CivitUserToggleFavoriteModelRequest request, + [Authorize] string bearerToken, + CancellationToken cancellationToken = default + ); } From 29865e38d25c94524ee9e7969ca9500fc992bd3d Mon Sep 17 00:00:00 2001 From: Ionite Date: Thu, 23 Nov 2023 01:43:57 -0500 Subject: [PATCH 096/144] Create CivitUserToggleFavoriteModelRequest.cs --- .../CivitUserToggleFavoriteModelRequest.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 StabilityMatrix.Core/Models/Api/CivitTRPC/CivitUserToggleFavoriteModelRequest.cs diff --git a/StabilityMatrix.Core/Models/Api/CivitTRPC/CivitUserToggleFavoriteModelRequest.cs b/StabilityMatrix.Core/Models/Api/CivitTRPC/CivitUserToggleFavoriteModelRequest.cs new file mode 100644 index 00000000..8cc912fd --- /dev/null +++ b/StabilityMatrix.Core/Models/Api/CivitTRPC/CivitUserToggleFavoriteModelRequest.cs @@ -0,0 +1,19 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace StabilityMatrix.Core.Models.Api.CivitTRPC; + +public record CivitUserToggleFavoriteModelRequest : IFormattable +{ + [JsonPropertyName("modelId")] + public required int ModelId { get; set; } + + [JsonPropertyName("authed")] + public bool Authed { get; set; } = true; + + /// + public string ToString(string? format, IFormatProvider? formatProvider) + { + return JsonSerializer.Serialize(new { json = this }); + } +} From 594b402110d8e63f8fd1a472882d8c2b9a431f35 Mon Sep 17 00:00:00 2001 From: JT Date: Thu, 23 Nov 2023 12:39:25 -0800 Subject: [PATCH 097/144] Downgraded Avalonia from preview build back to latest stable to fix title bar appearing off screen --- StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj index 6bcb2cb3..91a8008a 100644 --- a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj +++ b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj @@ -24,11 +24,11 @@ - - - + + + - + From c5db3d8c7c23f2170f60c0bec3e4f629800dbcd5 Mon Sep 17 00:00:00 2001 From: Ionite Date: Thu, 23 Nov 2023 22:34:29 -0500 Subject: [PATCH 098/144] Add design time image preview card for checkpoint browser --- .../DesignData/DesignData.cs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/StabilityMatrix.Avalonia/DesignData/DesignData.cs b/StabilityMatrix.Avalonia/DesignData/DesignData.cs index 2addd46f..828a56fe 100644 --- a/StabilityMatrix.Avalonia/DesignData/DesignData.cs +++ b/StabilityMatrix.Avalonia/DesignData/DesignData.cs @@ -311,6 +311,30 @@ public static class DesignData new() { Name = "v1.2.2-Inpainting" } ] }; + }), + dialogFactory.Get(vm => + { + vm.CivitModel = new CivitModel + { + Name = "Another Model", + Description = "A mix of example", + Stats = new CivitModelStats { Rating = 5, RatingCount = 3500 }, + ModelVersions = [ + new() + { + Name = "v1.2.2-Inpainting", + Images = new List + { + new() + { + Nsfw = "None", + Url = "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/" + + "78fd2a0a-42b6-42b0-9815-81cb11bb3d05/00009-2423234823.jpeg" + } + } + } + ] + }; }) }; From 77219cf917955742213f66b39afae70cfd63f351 Mon Sep 17 00:00:00 2001 From: Ionite Date: Thu, 23 Nov 2023 22:34:46 -0500 Subject: [PATCH 099/144] Add image zoom effect on browser card hover --- .../Views/CheckpointBrowserPage.axaml | 89 ++++++++++++------- 1 file changed, 55 insertions(+), 34 deletions(-) diff --git a/StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml b/StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml index 2bba66bf..19d9e90d 100644 --- a/StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml +++ b/StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml @@ -13,35 +13,59 @@ xmlns:viewModels="clr-namespace:StabilityMatrix.Avalonia.ViewModels" xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels.CheckpointManager" xmlns:converters="clr-namespace:StabilityMatrix.Avalonia.Converters" + xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia" d:DataContext="{x:Static designData:DesignData.CheckpointBrowserViewModel}" d:DesignHeight="700" d:DesignWidth="800" x:CompileBindings="True" x:DataType="viewModels:CheckpointBrowserViewModel" mc:Ignorable="d"> - + - - - + + + + + + + + - - - - - - + + - - + + + + Text="{x:Static lang:Resources.Label_Prompt}" /> + - - - - - + Margin="8,0" + Header="{x:Static lang:Resources.Label_AutoCompletion}"> + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Core/Helper/HardwareHelper.cs b/StabilityMatrix.Core/Helper/HardwareHelper.cs deleted file mode 100644 index b50fe42d..00000000 --- a/StabilityMatrix.Core/Helper/HardwareHelper.cs +++ /dev/null @@ -1,172 +0,0 @@ -using System.Diagnostics; -using System.Runtime.Versioning; -using System.Text.RegularExpressions; -using Microsoft.Win32; - -namespace StabilityMatrix.Core.Helper; - -public static partial class HardwareHelper -{ - private static IReadOnlyList? cachedGpuInfos; - - private static string RunBashCommand(string command) - { - var processInfo = new ProcessStartInfo("bash", "-c \"" + command + "\"") - { - UseShellExecute = false, - RedirectStandardOutput = true - }; - - var process = Process.Start(processInfo); - - process.WaitForExit(); - - var output = process.StandardOutput.ReadToEnd(); - - return output; - } - - [SupportedOSPlatform("windows")] - private static IEnumerable IterGpuInfoWindows() - { - const string gpuRegistryKeyPath = - @"SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}"; - - using var baseKey = Registry.LocalMachine.OpenSubKey(gpuRegistryKeyPath); - - if (baseKey == null) yield break; - - foreach (var subKeyName in baseKey.GetSubKeyNames().Where(k => k.StartsWith("0"))) - { - using var subKey = baseKey.OpenSubKey(subKeyName); - if (subKey != null) - { - yield return new GpuInfo - { - Name = subKey.GetValue("DriverDesc")?.ToString(), - MemoryBytes = Convert.ToUInt64(subKey.GetValue("HardwareInformation.qwMemorySize")), - }; - } - } - } - - [SupportedOSPlatform("linux")] - private static IEnumerable IterGpuInfoLinux() - { - var output = RunBashCommand("lspci | grep VGA"); - var gpuLines = output.Split("\n"); - - foreach (var line in gpuLines) - { - if (string.IsNullOrWhiteSpace(line)) continue; - - var gpuId = line.Split(' ')[0]; // The GPU ID is the first part of the line - var gpuOutput = RunBashCommand($"lspci -v -s {gpuId}"); - - ulong memoryBytes = 0; - string? name = null; - - // Parse output with regex - var match = Regex.Match(gpuOutput, @"VGA compatible controller: ([^\n]*)"); - if (match.Success) - { - name = match.Groups[1].Value.Trim(); - } - - match = Regex.Match(gpuOutput, @"prefetchable\) \[size=(\\d+)M\]"); - if (match.Success) - { - memoryBytes = ulong.Parse(match.Groups[1].Value) * 1024 * 1024; - } - - yield return new GpuInfo { Name = name, MemoryBytes = memoryBytes }; - } - } - - /// - /// Yields GpuInfo for each GPU in the system. - /// - public static IEnumerable IterGpuInfo() - { - if (Compat.IsWindows) - { - return IterGpuInfoWindows(); - } - else if (Compat.IsLinux) - { - // Since this requires shell commands, fetch cached value if available. - if (cachedGpuInfos is not null) - { - return cachedGpuInfos; - } - - // No cache, fetch and cache. - cachedGpuInfos = IterGpuInfoLinux().ToList(); - return cachedGpuInfos; - } - // TODO: Implement for macOS - return Enumerable.Empty(); - } - - /// - /// Return true if the system has at least one Nvidia GPU. - /// - public static bool HasNvidiaGpu() - { - return IterGpuInfo().Any(gpu => gpu.IsNvidia); - } - - /// - /// Return true if the system has at least one AMD GPU. - /// - public static bool HasAmdGpu() - { - return IterGpuInfo().Any(gpu => gpu.IsAmd); - } - - // Set ROCm for default if AMD and Linux - public static bool PreferRocm() => !HasNvidiaGpu() - && HasAmdGpu() - && Compat.IsLinux; - - // Set DirectML for default if AMD and Windows - public static bool PreferDirectML() => !HasNvidiaGpu() - && HasAmdGpu() - && Compat.IsWindows; -} - -public enum Level -{ - Unknown, - Low, - Medium, - High -} - -public record GpuInfo -{ - public string? Name { get; init; } = string.Empty; - public ulong MemoryBytes { get; init; } - public Level? MemoryLevel => MemoryBytes switch - { - <= 0 => Level.Unknown, - < 4 * Size.GiB => Level.Low, - < 8 * Size.GiB => Level.Medium, - _ => Level.High - }; - - public bool IsNvidia - { - get - { - var name = Name?.ToLowerInvariant(); - - if (string.IsNullOrEmpty(name)) return false; - - return name.Contains("nvidia") - || name.Contains("tesla"); - } - } - - public bool IsAmd => Name?.ToLowerInvariant().Contains("amd") ?? false; -} diff --git a/StabilityMatrix.Core/Helper/HardwareInfo/CpuInfo.cs b/StabilityMatrix.Core/Helper/HardwareInfo/CpuInfo.cs new file mode 100644 index 00000000..7ea9b2b6 --- /dev/null +++ b/StabilityMatrix.Core/Helper/HardwareInfo/CpuInfo.cs @@ -0,0 +1,7 @@ +namespace StabilityMatrix.Core.Helper.HardwareInfo; + +public readonly record struct CpuInfo +{ + public string ProcessorCaption { get; init; } + public string ProcessorName { get; init; } +} diff --git a/StabilityMatrix.Core/Helper/HardwareInfo/GpuInfo.cs b/StabilityMatrix.Core/Helper/HardwareInfo/GpuInfo.cs new file mode 100644 index 00000000..faabd558 --- /dev/null +++ b/StabilityMatrix.Core/Helper/HardwareInfo/GpuInfo.cs @@ -0,0 +1,31 @@ +namespace StabilityMatrix.Core.Helper.HardwareInfo; + +public record GpuInfo +{ + public int Index { get; init; } + public string? Name { get; init; } = string.Empty; + public ulong MemoryBytes { get; init; } + public MemoryLevel? MemoryLevel => + MemoryBytes switch + { + <= 0 => HardwareInfo.MemoryLevel.Unknown, + < 4 * Size.GiB => HardwareInfo.MemoryLevel.Low, + < 8 * Size.GiB => HardwareInfo.MemoryLevel.Medium, + _ => HardwareInfo.MemoryLevel.High + }; + + public bool IsNvidia + { + get + { + var name = Name?.ToLowerInvariant(); + + if (string.IsNullOrEmpty(name)) + return false; + + return name.Contains("nvidia") || name.Contains("tesla"); + } + } + + public bool IsAmd => Name?.Contains("amd", StringComparison.OrdinalIgnoreCase) ?? false; +} diff --git a/StabilityMatrix.Core/Helper/HardwareInfo/HardwareHelper.cs b/StabilityMatrix.Core/Helper/HardwareInfo/HardwareHelper.cs new file mode 100644 index 00000000..b86216a5 --- /dev/null +++ b/StabilityMatrix.Core/Helper/HardwareInfo/HardwareHelper.cs @@ -0,0 +1,241 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Text.RegularExpressions; +using Hardware.Info; +using Microsoft.Win32; + +namespace StabilityMatrix.Core.Helper.HardwareInfo; + +public static partial class HardwareHelper +{ + private static IReadOnlyList? cachedGpuInfos; + + private static readonly Lazy HardwareInfoLazy = + new(() => new Hardware.Info.HardwareInfo()); + + public static IHardwareInfo HardwareInfo => HardwareInfoLazy.Value; + + private static string RunBashCommand(string command) + { + var processInfo = new ProcessStartInfo("bash", "-c \"" + command + "\"") + { + UseShellExecute = false, + RedirectStandardOutput = true + }; + + var process = Process.Start(processInfo); + + process.WaitForExit(); + + var output = process.StandardOutput.ReadToEnd(); + + return output; + } + + [SupportedOSPlatform("windows")] + private static IEnumerable IterGpuInfoWindows() + { + const string gpuRegistryKeyPath = + @"SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}"; + + using var baseKey = Registry.LocalMachine.OpenSubKey(gpuRegistryKeyPath); + + if (baseKey == null) + yield break; + + var gpuIndex = 0; + + foreach (var subKeyName in baseKey.GetSubKeyNames().Where(k => k.StartsWith("0"))) + { + using var subKey = baseKey.OpenSubKey(subKeyName); + if (subKey != null) + { + yield return new GpuInfo + { + Index = gpuIndex++, + Name = subKey.GetValue("DriverDesc")?.ToString(), + MemoryBytes = Convert.ToUInt64( + subKey.GetValue("HardwareInformation.qwMemorySize") + ), + }; + } + } + } + + [SupportedOSPlatform("linux")] + private static IEnumerable IterGpuInfoLinux() + { + var output = RunBashCommand("lspci | grep VGA"); + var gpuLines = output.Split("\n"); + + var gpuIndex = 0; + + foreach (var line in gpuLines) + { + if (string.IsNullOrWhiteSpace(line)) + continue; + + var gpuId = line.Split(' ')[0]; // The GPU ID is the first part of the line + var gpuOutput = RunBashCommand($"lspci -v -s {gpuId}"); + + ulong memoryBytes = 0; + string? name = null; + + // Parse output with regex + var match = Regex.Match(gpuOutput, @"VGA compatible controller: ([^\n]*)"); + if (match.Success) + { + name = match.Groups[1].Value.Trim(); + } + + match = Regex.Match(gpuOutput, @"prefetchable\) \[size=(\\d+)M\]"); + if (match.Success) + { + memoryBytes = ulong.Parse(match.Groups[1].Value) * 1024 * 1024; + } + + yield return new GpuInfo + { + Index = gpuIndex++, + Name = name, + MemoryBytes = memoryBytes + }; + } + } + + /// + /// Yields GpuInfo for each GPU in the system. + /// + public static IEnumerable IterGpuInfo() + { + if (Compat.IsWindows) + { + return IterGpuInfoWindows(); + } + else if (Compat.IsLinux) + { + // Since this requires shell commands, fetch cached value if available. + if (cachedGpuInfos is not null) + { + return cachedGpuInfos; + } + + // No cache, fetch and cache. + cachedGpuInfos = IterGpuInfoLinux().ToList(); + return cachedGpuInfos; + } + // TODO: Implement for macOS + return Enumerable.Empty(); + } + + /// + /// Return true if the system has at least one Nvidia GPU. + /// + public static bool HasNvidiaGpu() + { + return IterGpuInfo().Any(gpu => gpu.IsNvidia); + } + + /// + /// Return true if the system has at least one AMD GPU. + /// + public static bool HasAmdGpu() + { + return IterGpuInfo().Any(gpu => gpu.IsAmd); + } + + // Set ROCm for default if AMD and Linux + public static bool PreferRocm() => !HasNvidiaGpu() && HasAmdGpu() && Compat.IsLinux; + + // Set DirectML for default if AMD and Windows + public static bool PreferDirectML() => !HasNvidiaGpu() && HasAmdGpu() && Compat.IsWindows; + + /// + /// Gets the total and available physical memory in bytes. + /// + public static MemoryInfo GetMemoryInfo() => + Compat.IsWindows ? GetMemoryInfoImplWindows() : GetMemoryInfoImplGeneric(); + + [SupportedOSPlatform("windows")] + private static MemoryInfo GetMemoryInfoImplWindows() + { + var memoryStatus = new Win32MemoryStatusEx(); + + if (!GlobalMemoryStatusEx(ref memoryStatus)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + if (!GetPhysicallyInstalledSystemMemory(out var installedMemoryKb)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + return new MemoryInfo + { + TotalInstalledBytes = (ulong)installedMemoryKb * 1024, + TotalPhysicalBytes = memoryStatus.UllTotalPhys, + AvailablePhysicalBytes = memoryStatus.UllAvailPhys + }; + } + + private static MemoryInfo GetMemoryInfoImplGeneric() + { + HardwareInfo.RefreshMemoryList(); + + return new MemoryInfo + { + TotalPhysicalBytes = HardwareInfo.MemoryStatus.TotalPhysical, + AvailablePhysicalBytes = HardwareInfo.MemoryStatus.AvailablePhysical + }; + } + + /// + /// Gets cpu info + /// + public static Task GetCpuInfoAsync() => + Compat.IsWindows ? Task.FromResult(GetCpuInfoImplWindows()) : GetCpuInfoImplGenericAsync(); + + [SupportedOSPlatform("windows")] + private static CpuInfo GetCpuInfoImplWindows() + { + var info = new CpuInfo(); + + using var processorKey = Registry.LocalMachine.OpenSubKey( + @"Hardware\Description\System\CentralProcessor\0", + RegistryKeyPermissionCheck.ReadSubTree + ); + + if (processorKey?.GetValue("ProcessorNameString") is string processorName) + { + info = info with { ProcessorCaption = processorName.Trim() }; + } + + return info; + } + + private static Task GetCpuInfoImplGenericAsync() + { + return Task.Run(() => + { + HardwareInfo.RefreshCPUList(); + + return new CpuInfo + { + ProcessorCaption = HardwareInfo.CpuList.FirstOrDefault()?.Caption.Trim() ?? "" + }; + }); + } + + [SupportedOSPlatform("windows")] + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool GetPhysicallyInstalledSystemMemory(out long totalMemoryInKilobytes); + + [SupportedOSPlatform("windows")] + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool GlobalMemoryStatusEx(ref Win32MemoryStatusEx lpBuffer); +} diff --git a/StabilityMatrix.Core/Helper/HardwareInfo/MemoryInfo.cs b/StabilityMatrix.Core/Helper/HardwareInfo/MemoryInfo.cs new file mode 100644 index 00000000..821f35fd --- /dev/null +++ b/StabilityMatrix.Core/Helper/HardwareInfo/MemoryInfo.cs @@ -0,0 +1,10 @@ +namespace StabilityMatrix.Core.Helper.HardwareInfo; + +public readonly record struct MemoryInfo +{ + public ulong TotalInstalledBytes { get; init; } + + public ulong TotalPhysicalBytes { get; init; } + + public ulong AvailablePhysicalBytes { get; init; } +} diff --git a/StabilityMatrix.Core/Helper/HardwareInfo/MemoryLevel.cs b/StabilityMatrix.Core/Helper/HardwareInfo/MemoryLevel.cs new file mode 100644 index 00000000..4f66acbf --- /dev/null +++ b/StabilityMatrix.Core/Helper/HardwareInfo/MemoryLevel.cs @@ -0,0 +1,9 @@ +namespace StabilityMatrix.Core.Helper.HardwareInfo; + +public enum MemoryLevel +{ + Unknown, + Low, + Medium, + High +} diff --git a/StabilityMatrix.Core/Helper/HardwareInfo/Win32MemoryStatusEx.cs b/StabilityMatrix.Core/Helper/HardwareInfo/Win32MemoryStatusEx.cs new file mode 100644 index 00000000..43e399b9 --- /dev/null +++ b/StabilityMatrix.Core/Helper/HardwareInfo/Win32MemoryStatusEx.cs @@ -0,0 +1,17 @@ +using System.Runtime.InteropServices; + +namespace StabilityMatrix.Core.Helper.HardwareInfo; + +[StructLayout(LayoutKind.Sequential)] +public struct Win32MemoryStatusEx +{ + public uint DwLength = (uint)Marshal.SizeOf(typeof(Win32MemoryStatusEx)); + public uint DwMemoryLoad = 0; + public ulong UllTotalPhys = 0; + public ulong UllAvailPhys = 0; + public ulong UllTotalPageFile = 0; + public ulong UllAvailPageFile = 0; + public ulong UllTotalVirtual = 0; + public ulong UllAvailVirtual = 0; + public ulong UllAvailExtendedVirtual = 0; +} diff --git a/StabilityMatrix.Core/Helper/Size.cs b/StabilityMatrix.Core/Helper/Size.cs index 4f31ba60..e1249b7f 100644 --- a/StabilityMatrix.Core/Helper/Size.cs +++ b/StabilityMatrix.Core/Helper/Size.cs @@ -9,25 +9,60 @@ public static class Size public const ulong MiB = KiB * 1024; public const ulong GiB = MiB * 1024; - public static string FormatBytes(ulong bytes) + private static string TrimZero(string value) + { + return value.TrimEnd('0').TrimEnd('.'); + } + + public static string FormatBytes(ulong bytes, bool trimZero = false) { return bytes switch { - < KiB => $"{bytes} B", - < MiB => $"{bytes / (double)KiB:0.0} KiB", - < GiB => $"{bytes / (double)MiB:0.0} MiB", - _ => $"{bytes / (double)GiB:0.0} GiB" + < KiB => $"{bytes:0} Bytes", + < MiB + => ( + trimZero + ? $"{bytes / (double)KiB:0.0}".TrimEnd('0').TrimEnd('.') + : $"{bytes / (double)KiB:0.0}" + ) + " KiB", + < GiB + => ( + trimZero + ? $"{bytes / (double)MiB:0.0}".TrimEnd('0').TrimEnd('.') + : $"{bytes / (double)MiB:0.0}" + ) + " MiB", + _ + => ( + trimZero + ? $"{bytes / (double)GiB:0.0}".TrimEnd('0').TrimEnd('.') + : $"{bytes / (double)GiB:0.0}" + ) + " GiB" }; } - public static string FormatBase10Bytes(ulong bytes) + public static string FormatBase10Bytes(ulong bytes, bool trimZero = false) { return bytes switch { - < KiB => $"{bytes} Bytes", - < MiB => $"{bytes / (double)KiB:0.0} KB", - < GiB => $"{bytes / (double)MiB:0.0} MB", - _ => $"{bytes / (double)GiB:0.00} GB" + < KiB => $"{bytes:0} Bytes", + < MiB + => ( + trimZero + ? $"{bytes / (double)KiB:0.0}".TrimEnd('0').TrimEnd('.') + : $"{bytes / (double)KiB:0.0}" + ) + " KB", + < GiB + => ( + trimZero + ? $"{bytes / (double)MiB:0.0}".TrimEnd('0').TrimEnd('.') + : $"{bytes / (double)MiB:0.0}" + ) + " MB", + _ + => ( + trimZero + ? $"{bytes / (double)GiB:0.00}".TrimEnd('0').TrimEnd('.') + : $"{bytes / (double)GiB:0.00}" + ) + " GB" }; } diff --git a/StabilityMatrix.Core/Models/Packages/A3WebUI.cs b/StabilityMatrix.Core/Models/Packages/A3WebUI.cs index bad8db64..3d6ce702 100644 --- a/StabilityMatrix.Core/Models/Packages/A3WebUI.cs +++ b/StabilityMatrix.Core/Models/Packages/A3WebUI.cs @@ -5,6 +5,7 @@ using NLog; using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper.Cache; +using StabilityMatrix.Core.Helper.HardwareInfo; using StabilityMatrix.Core.Models.FileInterfaces; using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Processes; @@ -103,8 +104,8 @@ public class A3WebUI : BaseGitPackage .Select(gpu => gpu.MemoryLevel) .Max() switch { - Level.Low => "--lowvram", - Level.Medium => "--medvram", + MemoryLevel.Low => "--lowvram", + MemoryLevel.Medium => "--medvram", _ => null }, Options = new() { "--lowvram", "--medvram", "--medvram-sdxl" } diff --git a/StabilityMatrix.Core/Models/Packages/BasePackage.cs b/StabilityMatrix.Core/Models/Packages/BasePackage.cs index 29ff06b9..ec20e75f 100644 --- a/StabilityMatrix.Core/Models/Packages/BasePackage.cs +++ b/StabilityMatrix.Core/Models/Packages/BasePackage.cs @@ -1,5 +1,6 @@ using Octokit; using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Helper.HardwareInfo; using StabilityMatrix.Core.Models.Database; using StabilityMatrix.Core.Models.FileInterfaces; using StabilityMatrix.Core.Models.Progress; diff --git a/StabilityMatrix.Core/Models/Packages/ComfyUI.cs b/StabilityMatrix.Core/Models/Packages/ComfyUI.cs index c7a824b7..10999218 100644 --- a/StabilityMatrix.Core/Models/Packages/ComfyUI.cs +++ b/StabilityMatrix.Core/Models/Packages/ComfyUI.cs @@ -4,6 +4,7 @@ using NLog; using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper.Cache; +using StabilityMatrix.Core.Helper.HardwareInfo; using StabilityMatrix.Core.Models.FileInterfaces; using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Processes; @@ -92,8 +93,8 @@ public class ComfyUI : BaseGitPackage .Select(gpu => gpu.MemoryLevel) .Max() switch { - Level.Low => "--lowvram", - Level.Medium => "--normalvram", + MemoryLevel.Low => "--lowvram", + MemoryLevel.Medium => "--normalvram", _ => null }, Options = { "--highvram", "--normalvram", "--lowvram", "--novram" } diff --git a/StabilityMatrix.Core/Models/Packages/Fooocus.cs b/StabilityMatrix.Core/Models/Packages/Fooocus.cs index b25e2ed1..d55f4d69 100644 --- a/StabilityMatrix.Core/Models/Packages/Fooocus.cs +++ b/StabilityMatrix.Core/Models/Packages/Fooocus.cs @@ -3,6 +3,7 @@ using System.Text.RegularExpressions; using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper.Cache; +using StabilityMatrix.Core.Helper.HardwareInfo; using StabilityMatrix.Core.Models.FileInterfaces; using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Processes; @@ -84,8 +85,8 @@ public class Fooocus : BaseGitPackage .Select(gpu => gpu.MemoryLevel) .Max() switch { - Level.Low => "--lowvram", - Level.Medium => "--normalvram", + MemoryLevel.Low => "--lowvram", + MemoryLevel.Medium => "--normalvram", _ => null }, Options = { "--highvram", "--normalvram", "--lowvram", "--novram" } diff --git a/StabilityMatrix.Core/Models/Packages/KohyaSs.cs b/StabilityMatrix.Core/Models/Packages/KohyaSs.cs index 99b2a6a3..ca4535fd 100644 --- a/StabilityMatrix.Core/Models/Packages/KohyaSs.cs +++ b/StabilityMatrix.Core/Models/Packages/KohyaSs.cs @@ -4,6 +4,7 @@ using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper.Cache; +using StabilityMatrix.Core.Helper.HardwareInfo; using StabilityMatrix.Core.Models.FileInterfaces; using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Processes; diff --git a/StabilityMatrix.Core/Models/Packages/StableDiffusionUx.cs b/StabilityMatrix.Core/Models/Packages/StableDiffusionUx.cs index 9b4a9b45..7ff9f792 100644 --- a/StabilityMatrix.Core/Models/Packages/StableDiffusionUx.cs +++ b/StabilityMatrix.Core/Models/Packages/StableDiffusionUx.cs @@ -5,6 +5,7 @@ using NLog; using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper.Cache; +using StabilityMatrix.Core.Helper.HardwareInfo; using StabilityMatrix.Core.Models.FileInterfaces; using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Processes; @@ -104,8 +105,8 @@ public class StableDiffusionUx : BaseGitPackage .Select(gpu => gpu.MemoryLevel) .Max() switch { - Level.Low => "--lowvram", - Level.Medium => "--medvram", + MemoryLevel.Low => "--lowvram", + MemoryLevel.Medium => "--medvram", _ => null }, Options = new() { "--lowvram", "--medvram", "--medvram-sdxl" } diff --git a/StabilityMatrix.Core/Models/Packages/VladAutomatic.cs b/StabilityMatrix.Core/Models/Packages/VladAutomatic.cs index 6a5c6308..7a300516 100644 --- a/StabilityMatrix.Core/Models/Packages/VladAutomatic.cs +++ b/StabilityMatrix.Core/Models/Packages/VladAutomatic.cs @@ -7,6 +7,7 @@ using NLog; using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper.Cache; +using StabilityMatrix.Core.Helper.HardwareInfo; using StabilityMatrix.Core.Models.FileInterfaces; using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Processes; @@ -110,8 +111,8 @@ public class VladAutomatic : BaseGitPackage .Select(gpu => gpu.MemoryLevel) .Max() switch { - Level.Low => "--lowvram", - Level.Medium => "--medvram", + MemoryLevel.Low => "--lowvram", + MemoryLevel.Medium => "--medvram", _ => null }, Options = new() { "--lowvram", "--medvram" } diff --git a/StabilityMatrix.Core/StabilityMatrix.Core.csproj b/StabilityMatrix.Core/StabilityMatrix.Core.csproj index 254f8efe..504924d6 100644 --- a/StabilityMatrix.Core/StabilityMatrix.Core.csproj +++ b/StabilityMatrix.Core/StabilityMatrix.Core.csproj @@ -7,6 +7,7 @@ enable true true + true @@ -24,6 +25,7 @@ + From 006b0d60da4ae14c9dcd6d88ce47ce1d72b097b8 Mon Sep 17 00:00:00 2001 From: Ionite Date: Fri, 1 Dec 2023 04:03:50 -0500 Subject: [PATCH 141/144] Add default constructor --- StabilityMatrix.Core/Helper/HardwareInfo/Win32MemoryStatusEx.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/StabilityMatrix.Core/Helper/HardwareInfo/Win32MemoryStatusEx.cs b/StabilityMatrix.Core/Helper/HardwareInfo/Win32MemoryStatusEx.cs index 43e399b9..13811565 100644 --- a/StabilityMatrix.Core/Helper/HardwareInfo/Win32MemoryStatusEx.cs +++ b/StabilityMatrix.Core/Helper/HardwareInfo/Win32MemoryStatusEx.cs @@ -14,4 +14,6 @@ public struct Win32MemoryStatusEx public ulong UllTotalVirtual = 0; public ulong UllAvailVirtual = 0; public ulong UllAvailExtendedVirtual = 0; + + public Win32MemoryStatusEx() { } } From 428c48ab4a712c802af95af14d4fb63db93ed211 Mon Sep 17 00:00:00 2001 From: Ionite Date: Fri, 1 Dec 2023 04:08:22 -0500 Subject: [PATCH 142/144] Cleanup and change system information title --- .../Languages/Resources.Designer.cs | 18 +++++++++--------- .../Languages/Resources.resx | 4 ++-- .../Views/Settings/MainSettingsPage.axaml | 12 +----------- 3 files changed, 12 insertions(+), 22 deletions(-) diff --git a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs index b5468fdf..6cc3bce5 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs +++ b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs @@ -1193,15 +1193,6 @@ namespace StabilityMatrix.Avalonia.Languages { } } - /// - /// Looks up a localized string similar to Information. - /// - public static string Label_Information { - get { - return ResourceManager.GetString("Label_Information", resourceCulture); - } - } - /// /// Looks up a localized string similar to Inner exception. /// @@ -1877,6 +1868,15 @@ namespace StabilityMatrix.Avalonia.Languages { } } + /// + /// Looks up a localized string similar to System Information. + /// + public static string Label_SystemInformation { + get { + return ResourceManager.GetString("Label_SystemInformation", resourceCulture); + } + } + /// /// Looks up a localized string similar to Text to Image. /// diff --git a/StabilityMatrix.Avalonia/Languages/Resources.resx b/StabilityMatrix.Avalonia/Languages/Resources.resx index 2c3ff57f..529fa151 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.resx +++ b/StabilityMatrix.Avalonia/Languages/Resources.resx @@ -843,7 +843,7 @@ Tags file to use for suggesting completions (Supports the a1111-sd-webui-tagcomplete .csv format) - - Information + + System Information diff --git a/StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml b/StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml index e35b1bbc..21332c4a 100644 --- a/StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml +++ b/StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml @@ -301,7 +301,7 @@ - + - - From 19457c6a82f9586182035637c77db75a4df87598 Mon Sep 17 00:00:00 2001 From: Ionite Date: Fri, 1 Dec 2023 04:09:29 -0500 Subject: [PATCH 143/144] Update CHANGELOG.md --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b57bb65..9cb0ee26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2.0.0.html). ## v2.7.0-pre.2 +### Added +- Added System Information section to Settings +### Changed +- Moved Inference Settings to subpage ### Fixed - Fixed crash when loading an empty settings file - Improve Settings save and load performance with .NET 8 Source Generating Serialization From 75809f231b68f885e08885d357bb705cb90cddaa Mon Sep 17 00:00:00 2001 From: Ionite Date: Fri, 1 Dec 2023 04:09:36 -0500 Subject: [PATCH 144/144] Update dotnet-tools.json --- .config/dotnet-tools.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 5b4bb470..f59ebfb8 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -15,10 +15,10 @@ ] }, "csharpier": { - "version": "0.25.0", + "version": "0.26.3", "commands": [ "dotnet-csharpier" ] } } -} +} \ No newline at end of file