Browse Source

Merge pull request #348 from ionite34/updates-selection

pull/324/head
Ionite 1 year ago committed by GitHub
parent
commit
dd4f47f093
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 53
      .github/workflows/release.yml
  2. 12
      CHANGELOG.md
  3. 1
      StabilityMatrix.Avalonia/App.axaml
  4. 30
      StabilityMatrix.Avalonia/Converters/BooleanChoiceMultiConverter.cs
  5. 32
      StabilityMatrix.Avalonia/Converters/EnumStringConverter.cs
  6. 24
      StabilityMatrix.Avalonia/Converters/EnumToBooleanConverter.cs
  7. 35
      StabilityMatrix.Avalonia/DesignData/DesignData.cs
  8. 54
      StabilityMatrix.Avalonia/Languages/Resources.Designer.cs
  9. 18
      StabilityMatrix.Avalonia/Languages/Resources.resx
  10. 70
      StabilityMatrix.Avalonia/Models/UpdateChannelCard.cs
  11. 1
      StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj
  12. 25
      StabilityMatrix.Avalonia/Styles/ControlThemes/ListBoxStyles.axaml
  13. 218
      StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs
  14. 3
      StabilityMatrix.Avalonia/ViewModels/Settings/AccountSettingsViewModel.cs
  15. 3
      StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs
  16. 236
      StabilityMatrix.Avalonia/ViewModels/Settings/UpdateSettingsViewModel.cs
  17. 3
      StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs
  18. 20
      StabilityMatrix.Avalonia/Views/Settings/AccountSettingsPage.axaml
  19. 50
      StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml
  20. 154
      StabilityMatrix.Avalonia/Views/Settings/UpdateSettingsPage.axaml
  21. 41
      StabilityMatrix.Avalonia/Views/Settings/UpdateSettingsPage.axaml.cs
  22. 7
      StabilityMatrix.Core/Api/ILykosAuthApi.cs
  23. 24
      StabilityMatrix.Core/Extensions/SemVersionExtensions.cs
  24. 8
      StabilityMatrix.Core/Models/Api/Lykos/GetDownloadResponse.cs
  25. 20
      StabilityMatrix.Core/Models/Api/Lykos/GetUserResponse.cs
  26. 12
      StabilityMatrix.Core/Models/Api/Lykos/LykosRole.cs
  27. 5
      StabilityMatrix.Core/Models/Settings/Settings.cs
  28. 7
      StabilityMatrix.Core/Updater/IUpdateHelper.cs
  29. 90
      StabilityMatrix.Core/Updater/UpdateHelper.cs
  30. 13
      StabilityMatrix.Core/Updater/UpdateStatusChangedEventArgs.cs

53
.github/workflows/release.yml

@ -82,10 +82,10 @@ jobs:
sudo apt-get -y install libfuse2 sudo apt-get -y install libfuse2
dotnet tool install --framework net6.0 -g KuiperZone.PupNet dotnet tool install --framework net6.0 -g KuiperZone.PupNet
- name: Set up .NET 7 - name: Set up .NET 8
uses: actions/setup-dotnet@v3 uses: actions/setup-dotnet@v3
with: with:
dotnet-version: '7.0.x' dotnet-version: '8.0.x'
- name: PupNet Build - name: PupNet Build
env: env:
@ -143,7 +143,7 @@ jobs:
echo "Using version ${{ github.event.inputs.version }}" echo "Using version ${{ github.event.inputs.version }}"
echo "RELEASE_VERSION=${{ github.event.inputs.version }}" >> $env:GITHUB_ENV 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 uses: actions/setup-dotnet@v3
with: with:
dotnet-version: '8.0.x' dotnet-version: '8.0.x'
@ -197,8 +197,8 @@ jobs:
# Zip each build # Zip each build
- name: Zip Artifacts - name: Zip Artifacts
run: | run: |
zip -r StabilityMatrix-win-x64.zip StabilityMatrix-win-x64/* cd StabilityMatrix-win-x64 && zip -r ../StabilityMatrix-win-x64.zip ./. && cd $OLDPWD
zip -r StabilityMatrix-linux-x64.zip StabilityMatrix-linux-x64/* cd StabilityMatrix-linux-x64 && zip -r ../StabilityMatrix-linux-x64.zip ./. && cd $OLDPWD
- name: Create Github Release - name: Create Github Release
id: create_release id: create_release
@ -235,10 +235,16 @@ jobs:
python-version: '3.11' python-version: '3.11'
- name: Install Python Dependencies - name: Install Python Dependencies
run: pip install stability-matrix-tools~=0.2.7 run: pip install stability-matrix-tools>=0.2.10 --upgrade
- name: Publish Auto-Update Release - name: Publish Auto-Update Release
run: sm-tools updates publish-matrix -v $RELEASE_VERSION -y 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 ${{ github.event.inputs.version }} -y
publish-auto-update-b2: publish-auto-update-b2:
name: Publish Auto-Update Release (B2) name: Publish Auto-Update Release (B2)
@ -253,23 +259,22 @@ jobs:
echo "Using version ${{ github.event.inputs.version }}" echo "Using version ${{ github.event.inputs.version }}"
echo "RELEASE_VERSION=${{ github.event.inputs.version }}" >> $env:GITHUB_ENV echo "RELEASE_VERSION=${{ github.event.inputs.version }}" >> $env:GITHUB_ENV
# 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 - uses: actions/setup-python@v4
with: with:
python-version: '3.11' python-version: '3.11'
- name: Install Python Dependencies - name: Install Python Dependencies
run: pip install stability-matrix-tools~=0.2.7 run: pip install stability-matrix-tools>=0.2.10 --upgrade
- name: Download Changelog
run: >
sm-tools updates download-changelog -v $RELEASE_VERSION -y
--changelog
# 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/*
# Check that the zips and CHANGELOG.md are in the current working directory # Check that the zips and CHANGELOG.md are in the current working directory
- name: Check files - name: Check files
@ -288,10 +293,16 @@ jobs:
fi fi
- name: Publish Auto-Update Release - 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: > 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 }} --channel ${{ github.event.inputs.auto-update-release-channel }}
--changelog CHANGELOG.md --changelog CHANGELOG.md
--win-x64 StabilityMatrix-win-x64.zip --win-x64 StabilityMatrix-win-x64.zip
--linux-x64 StabilityMatrix-linux-x64.zip --linux-x64 StabilityMatrix-linux-x64.zip
--b2-bucket-name ${{ secrets.B2_BUCKET_NAME }} -y

12
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/), 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). 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 ## v2.6.2
### Changed ### Changed
- Backend changes for auto-update schema v3, supporting customizable release channels and faster downloads with zip compression - Backend changes for auto-update schema v3, supporting customizable release channels and faster downloads with zip compression

1
StabilityMatrix.Avalonia/App.axaml

@ -23,6 +23,7 @@
<ResourceInclude Source="Controls/Scroll/BetterScrollViewer.axaml"/> <ResourceInclude Source="Controls/Scroll/BetterScrollViewer.axaml"/>
<ResourceInclude Source="Controls/ImageFolderCard.axaml"/> <ResourceInclude Source="Controls/ImageFolderCard.axaml"/>
<ResourceInclude Source="Styles/ControlThemes/HyperlinkIconButtonStyles.axaml"/> <ResourceInclude Source="Styles/ControlThemes/HyperlinkIconButtonStyles.axaml"/>
<ResourceInclude Source="Styles/ControlThemes/ListBoxStyles.axaml"/>
</ResourceDictionary.MergedDictionaries> </ResourceDictionary.MergedDictionaries>
<idcr:ControlRecycling x:Key="ControlRecyclingKey" /> <idcr:ControlRecycling x:Key="ControlRecyclingKey" />

30
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
{
/// <inheritdoc />
public object? Convert(
IList<object?> values,
Type targetType,
object? parameter,
CultureInfo culture
)
{
if (values.Count < 3)
{
return null;
}
if (values[0] is bool boolValue)
{
return boolValue ? values[1] : values[2];
}
return null;
}
}

32
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
{
/// <inheritdoc />
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not Enum enumValue)
return null;
return enumValue.GetStringValue();
}
/// <inheritdoc />
public object? ConvertBack(
object? value,
Type targetType,
object? parameter,
CultureInfo culture
)
{
if (value is not string stringValue)
return null;
return Enum.Parse(targetType, stringValue);
}
}

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

35
StabilityMatrix.Avalonia/DesignData/DesignData.cs

@ -11,6 +11,7 @@ using DynamicData.Binding;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NSubstitute; using NSubstitute;
using NSubstitute.ReturnsExtensions; using NSubstitute.ReturnsExtensions;
using Semver;
using StabilityMatrix.Avalonia.Controls.CodeCompletion; using StabilityMatrix.Avalonia.Controls.CodeCompletion;
using StabilityMatrix.Avalonia.Models; using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Models.TagCompletion; using StabilityMatrix.Avalonia.Models.TagCompletion;
@ -36,6 +37,7 @@ using StabilityMatrix.Core.Models.Database;
using StabilityMatrix.Core.Models.PackageModification; using StabilityMatrix.Core.Models.PackageModification;
using StabilityMatrix.Core.Models.Packages; using StabilityMatrix.Core.Models.Packages;
using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Models.Update;
using StabilityMatrix.Core.Python; using StabilityMatrix.Core.Python;
using StabilityMatrix.Core.Services; using StabilityMatrix.Core.Services;
using StabilityMatrix.Core.Updater; using StabilityMatrix.Core.Updater;
@ -449,6 +451,37 @@ public static class DesignData
public static AccountSettingsViewModel AccountSettingsViewModel => public static AccountSettingsViewModel AccountSettingsViewModel =>
Services.GetRequiredService<AccountSettingsViewModel>(); Services.GetRequiredService<AccountSettingsViewModel>();
public static UpdateSettingsViewModel UpdateSettingsViewModel
{
get
{
var vm = Services.GetRequiredService<UpdateSettingsViewModel>();
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, UpdateInfo>
{
[UpdateChannel.Stable] = update,
[UpdateChannel.Preview] = update,
[UpdateChannel.Development] = update
},
CheckedAt = DateTimeOffset.UtcNow
};
return vm;
}
}
public static CheckpointBrowserViewModel CheckpointBrowserViewModel => public static CheckpointBrowserViewModel CheckpointBrowserViewModel =>
Services.GetRequiredService<CheckpointBrowserViewModel>(); Services.GetRequiredService<CheckpointBrowserViewModel>();
@ -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 public class Indexer
{ {

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

@ -662,6 +662,15 @@ namespace StabilityMatrix.Avalonia.Languages {
} }
} }
/// <summary>
/// Looks up a localized string similar to Auto Updates.
/// </summary>
public static string Label_AutoUpdates {
get {
return ResourceManager.GetString("Label_AutoUpdates", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Base Model. /// Looks up a localized string similar to Base Model.
/// </summary> /// </summary>
@ -1814,6 +1823,33 @@ namespace StabilityMatrix.Avalonia.Languages {
} }
} }
/// <summary>
/// Looks up a localized string similar to Updates.
/// </summary>
public static string Label_Updates {
get {
return ResourceManager.GetString("Label_Updates", resourceCulture);
}
}
/// <summary>
/// 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..
/// </summary>
public static string Label_UpdatesDevChannelDescription {
get {
return ResourceManager.GetString("Label_UpdatesDevChannelDescription", resourceCulture);
}
}
/// <summary>
/// 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..
/// </summary>
public static string Label_UpdatesPreviewChannelDescription {
get {
return ResourceManager.GetString("Label_UpdatesPreviewChannelDescription", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Upscale. /// Looks up a localized string similar to Upscale.
/// </summary> /// </summary>
@ -1886,6 +1922,15 @@ namespace StabilityMatrix.Avalonia.Languages {
} }
} }
/// <summary>
/// Looks up a localized string similar to You&apos;re up to date.
/// </summary>
public static string Label_YouAreUpToDate {
get {
return ResourceManager.GetString("Label_YouAreUpToDate", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Download complete. /// Looks up a localized string similar to Download complete.
/// </summary> /// </summary>
@ -2048,6 +2093,15 @@ namespace StabilityMatrix.Avalonia.Languages {
} }
} }
/// <summary>
/// Looks up a localized string similar to Last checked: {0}.
/// </summary>
public static string TextTemplate_LastChecked {
get {
return ResourceManager.GetString("TextTemplate_LastChecked", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to {0} has been updated to the latest version. /// Looks up a localized string similar to {0} has been updated to the latest version.
/// </summary> /// </summary>

18
StabilityMatrix.Avalonia/Languages/Resources.resx

@ -786,4 +786,22 @@
<data name="Label_DownloadFailed" xml:space="preserve"> <data name="Label_DownloadFailed" xml:space="preserve">
<value>Download Failed</value> <value>Download Failed</value>
</data> </data>
<data name="Label_AutoUpdates" xml:space="preserve">
<value>Auto Updates</value>
</data>
<data name="Label_UpdatesPreviewChannelDescription" xml:space="preserve">
<value>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.</value>
</data>
<data name="Label_UpdatesDevChannelDescription" xml:space="preserve">
<value>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.</value>
</data>
<data name="Label_Updates" xml:space="preserve">
<value>Updates</value>
</data>
<data name="Label_YouAreUpToDate" xml:space="preserve">
<value>You're up to date</value>
</data>
<data name="TextTemplate_LastChecked" xml:space="preserve">
<value>Last checked: {0}</value>
</data>
</root> </root>

70
StabilityMatrix.Avalonia/Models/UpdateChannelCard.cs

@ -0,0 +1,70 @@
using System;
using CommunityToolkit.Mvvm.ComponentModel;
using Semver;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models.Update;
namespace StabilityMatrix.Avalonia.Models;
public partial class UpdateChannelCard : ObservableObject
{
public UpdateChannel UpdateChannel { get; init; }
public string DisplayName => UpdateChannel.GetStringValue();
public string? Description { get; init; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(LatestVersionString))]
[NotifyPropertyChangedFor(nameof(IsLatestVersionUpdateable))]
private SemVersion? latestVersion;
public string? LatestVersionString =>
LatestVersion is null ? null : $"Latest: v{LatestVersion}";
[ObservableProperty]
private bool isSelectable = true;
/// <summary>
/// Whether the <see cref="LatestVersion"/> is available for update.
/// </summary>
public bool IsLatestVersionUpdateable
{
get
{
if (LatestVersion is null)
{
return false;
}
switch (LatestVersion.ComparePrecedenceTo(Compat.AppVersion))
{
case > 0:
// Newer version available
return true;
case 0:
{
// Same version available, check if we both have commit hash metadata
var updateHash = LatestVersion.Metadata;
var appHash = Compat.AppVersion.Metadata;
// Trim both to the lower length, to a minimum of 7 characters
var minLength = Math.Min(7, Math.Min(updateHash.Length, appHash.Length));
updateHash = updateHash[..minLength];
appHash = appHash[..minLength];
// If different, we can update
if (updateHash != appHash)
{
return true;
}
break;
}
}
return false;
}
}
}

1
StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj

@ -38,6 +38,7 @@
<PackageReference Include="Dock.Model.Avalonia" Version="11.0.0.3" /> <PackageReference Include="Dock.Model.Avalonia" Version="11.0.0.3" />
<PackageReference Include="Dock.Serializer" Version="11.0.0.3" /> <PackageReference Include="Dock.Serializer" Version="11.0.0.3" />
<PackageReference Include="DynamicData" Version="8.1.1" /> <PackageReference Include="DynamicData" Version="8.1.1" />
<PackageReference Include="Exceptionless.DateTimeExtensions" Version="3.4.3" />
<PackageReference Include="FluentAvalonia.BreadcrumbBar" Version="2.0.2" /> <PackageReference Include="FluentAvalonia.BreadcrumbBar" Version="2.0.2" />
<PackageReference Include="FluentAvaloniaUI" Version="2.0.4" /> <PackageReference Include="FluentAvaloniaUI" Version="2.0.4" />
<PackageReference Include="FluentIcons.Avalonia" Version="1.1.222" /> <PackageReference Include="FluentIcons.Avalonia" Version="1.1.222" />

25
StabilityMatrix.Avalonia/Styles/ControlThemes/ListBoxStyles.axaml

@ -0,0 +1,25 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ListBoxItem theme that removes ListBox visuals (i.e. for using with radiobuttons) -->
<ControlTheme x:Key="ListBoxItemBorderlessTheme" TargetType="ListBoxItem">
<Setter Property="Template">
<ControlTemplate>
<Panel>
<ContentPresenter
Name="PART_ContentPresenter"
Margin="2,0"
Padding="{TemplateBinding Padding}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
CornerRadius="{TemplateBinding CornerRadius}" />
</Panel>
</ControlTemplate>
</Setter>
</ControlTheme>
</ResourceDictionary>

218
StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs

@ -13,6 +13,7 @@ using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.Views.Dialogs; using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Models.Update; using StabilityMatrix.Core.Models.Update;
@ -82,6 +83,112 @@ public partial class UpdateViewModel : ContentDialogViewModelBase
updateHelper.StartCheckingForUpdates().SafeFireAndForget(); updateHelper.StartCheckingForUpdates().SafeFireAndForget();
} }
public async Task Preload()
{
if (UpdateInfo is null)
return;
ReleaseNotes = await GetReleaseNotes(UpdateInfo.Changelog.ToString());
}
partial void OnUpdateInfoChanged(UpdateInfo? value)
{
CurrentVersionText = $"v{Compat.AppVersion.ToDisplayString()}";
NewVersionText = $"v{value?.Version.ToDisplayString()}";
}
public override async Task OnLoadedAsync()
{
if (!isLoaded)
{
await Preload();
}
}
/// <inheritdoc />
public override void OnUnloaded()
{
base.OnUnloaded();
isLoaded = false;
}
[RelayCommand]
private async Task InstallUpdate()
{
if (UpdateInfo == null)
{
return;
}
ShowProgressBar = true;
IsProgressIndeterminate = true;
UpdateText = string.Format(
Resources.TextTemplate_UpdatingPackage,
Resources.Label_StabilityMatrix
);
await updateHelper.DownloadUpdate(
UpdateInfo,
new Progress<ProgressReport>(report =>
{
ProgressValue = Convert.ToInt32(report.Percentage);
IsProgressIndeterminate = report.IsIndeterminate;
})
);
// On unix, we need to set the executable bit
if (Compat.IsUnix)
{
File.SetUnixFileMode(
UpdateHelper.ExecutablePath, // 0755
UnixFileMode.UserRead
| UnixFileMode.UserWrite
| UnixFileMode.UserExecute
| UnixFileMode.GroupRead
| UnixFileMode.GroupExecute
| UnixFileMode.OtherRead
| UnixFileMode.OtherExecute
);
}
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);
Process.Start(UpdateHelper.ExecutablePath);
App.Shutdown();
}
internal async Task<string> 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";
}
}
/// <summary> /// <summary>
/// Formats changelog markdown including up to the current version /// Formats changelog markdown including up to the current version
/// </summary> /// </summary>
@ -122,6 +229,15 @@ public partial class UpdateViewModel : ContentDialogViewModelBase
x => x.Version == currentVersion.WithoutMetadata() 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 // Support for previous pre-release without changelogs
if (currentVersionBlock == -1) if (currentVersionBlock == -1)
{ {
@ -159,106 +275,4 @@ public partial class UpdateViewModel : ContentDialogViewModelBase
return string.Join(Environment.NewLine + Environment.NewLine, blocks); return string.Join(Environment.NewLine + Environment.NewLine, blocks);
} }
public async Task Preload()
{
if (UpdateInfo is null)
return;
ReleaseNotes = await GetReleaseNotes(UpdateInfo.Changelog.ToString());
}
internal async Task<string> 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}";
NewVersionText = $"v{value?.Version}";
}
public override async Task OnLoadedAsync()
{
if (!isLoaded)
{
await Preload();
}
}
/// <inheritdoc />
public override void OnUnloaded()
{
base.OnUnloaded();
isLoaded = false;
}
[RelayCommand]
private async Task InstallUpdate()
{
if (UpdateInfo == null)
{
return;
}
ShowProgressBar = true;
UpdateText = string.Format(
Resources.TextTemplate_UpdatingPackage,
Resources.Label_StabilityMatrix
);
await updateHelper.DownloadUpdate(
UpdateInfo,
new Progress<ProgressReport>(report =>
{
ProgressValue = Convert.ToInt32(report.Percentage);
IsProgressIndeterminate = report.IsIndeterminate;
})
);
// On unix, we need to set the executable bit
if (Compat.IsUnix)
{
File.SetUnixFileMode(
UpdateHelper.ExecutablePath, // 0755
UnixFileMode.UserRead
| UnixFileMode.UserWrite
| UnixFileMode.UserExecute
| UnixFileMode.GroupRead
| UnixFileMode.GroupExecute
| UnixFileMode.OtherRead
| UnixFileMode.OtherExecute
);
}
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);
Process.Start(UpdateHelper.ExecutablePath);
App.Shutdown();
}
} }

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

@ -46,6 +46,9 @@ public partial class AccountSettingsViewModel : PageViewModelBase
new SymbolIconSource { Symbol = Symbol.Person, IsFilled = true }; new SymbolIconSource { Symbol = Symbol.Person, IsFilled = true };
[ObservableProperty] [ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ConnectLykosCommand))]
[NotifyCanExecuteChangedFor(nameof(ConnectPatreonCommand))]
[NotifyCanExecuteChangedFor(nameof(ConnectCivitCommand))]
private bool isInitialUpdateFinished; private bool isInitialUpdateFinished;
[ObservableProperty] [ObservableProperty]

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

@ -25,7 +25,6 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using DynamicData.Binding; using DynamicData.Binding;
using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Controls;
using FluentAvalonia.UI.Media.Animation;
using NLog; using NLog;
using SkiaSharp; using SkiaSharp;
using StabilityMatrix.Avalonia.Animations; using StabilityMatrix.Avalonia.Animations;
@ -79,7 +78,7 @@ public partial class MainSettingsViewModel : PageViewModelBase
// ReSharper disable once MemberCanBeMadeStatic.Global // ReSharper disable once MemberCanBeMadeStatic.Global
public string AppVersion => public string AppVersion =>
$"Version {Compat.AppVersion}" + (Program.IsDebugBuild ? " (Debug)" : ""); $"Version {Compat.AppVersion.ToDisplayString()}" + (Program.IsDebugBuild ? " (Debug)" : "");
// Theme section // Theme section
[ObservableProperty] [ObservableProperty]

236
StabilityMatrix.Avalonia/ViewModels/Settings/UpdateSettingsViewModel.cs

@ -0,0 +1,236 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using Avalonia.Controls;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Exceptionless.DateTimeExtensions;
using FluentAvalonia.UI.Controls;
using FluentAvalonia.UI.Media.Animation;
using Semver;
using StabilityMatrix.Avalonia.Languages;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Avalonia.Views.Settings;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Models.Update;
using StabilityMatrix.Core.Processes;
using StabilityMatrix.Core.Services;
using StabilityMatrix.Core.Updater;
using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
namespace StabilityMatrix.Avalonia.ViewModels.Settings;
[View(typeof(UpdateSettingsPage))]
[Singleton, ManagedService]
public partial class UpdateSettingsViewModel : PageViewModelBase
{
private readonly IUpdateHelper updateHelper;
private readonly IAccountsService accountsService;
private readonly INavigationService<SettingsViewModel> settingsNavService;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsUpdateAvailable))]
[NotifyPropertyChangedFor(nameof(HeaderText))]
[NotifyPropertyChangedFor(nameof(SubtitleText))]
private UpdateStatusChangedEventArgs? updateStatus;
public bool IsUpdateAvailable => UpdateStatus?.LatestUpdate != null;
public string HeaderText =>
IsUpdateAvailable ? Resources.Label_UpdateAvailable : Resources.Label_YouAreUpToDate;
public string? SubtitleText =>
UpdateStatus is null
? null
: string.Format(
Resources.TextTemplate_LastChecked,
UpdateStatus.CheckedAt.ToApproximateAgeString()
);
[ObservableProperty]
private bool isAutoCheckUpdatesEnabled = true;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(SelectedUpdateChannelCard))]
private UpdateChannel preferredUpdateChannel = UpdateChannel.Stable;
public UpdateChannelCard? SelectedUpdateChannelCard
{
get => AvailableUpdateChannelCards.First(c => c.UpdateChannel == PreferredUpdateChannel);
set => PreferredUpdateChannel = value?.UpdateChannel ?? UpdateChannel.Stable;
}
public IReadOnlyList<UpdateChannelCard> AvailableUpdateChannelCards { get; } =
new UpdateChannelCard[]
{
new()
{
UpdateChannel = UpdateChannel.Development,
Description = Resources.Label_UpdatesDevChannelDescription
},
new()
{
UpdateChannel = UpdateChannel.Preview,
Description = Resources.Label_UpdatesPreviewChannelDescription
},
new() { UpdateChannel = UpdateChannel.Stable }
};
public UpdateSettingsViewModel(
ISettingsManager settingsManager,
IUpdateHelper updateHelper,
IAccountsService accountsService,
INavigationService<SettingsViewModel> settingsNavService
)
{
this.updateHelper = updateHelper;
this.accountsService = accountsService;
this.settingsNavService = settingsNavService;
settingsManager.RelayPropertyFor(
this,
vm => vm.PreferredUpdateChannel,
settings => settings.PreferredUpdateChannel,
true
);
settingsManager.RelayPropertyFor(
this,
vm => vm.IsAutoCheckUpdatesEnabled,
settings => settings.CheckForUpdates,
true
);
accountsService.LykosAccountStatusUpdate += (_, args) =>
{
var isBetaChannelsEnabled = args.User?.IsActiveSupporter == true;
foreach (
var card in AvailableUpdateChannelCards.Where(
c => c.UpdateChannel > UpdateChannel.Stable
)
)
{
card.IsSelectable = isBetaChannelsEnabled;
}
};
// On update status changed
updateHelper.UpdateStatusChanged += (_, args) =>
{
UpdateStatus = args;
};
}
/// <inheritdoc />
public override async Task OnLoadedAsync()
{
if (UpdateStatus is null)
{
await CheckForUpdates();
}
OnPropertyChanged(nameof(SubtitleText));
}
[RelayCommand]
private async Task CheckForUpdates()
{
if (Design.IsDesignMode)
{
return;
}
await updateHelper.CheckForUpdate();
}
/// <summary>
/// Verify a new channel selection is valid, else returns false.
/// </summary>
/// <param name="card"></param>
/// <returns></returns>
public bool VerifyChannelSelection(UpdateChannelCard card)
{
if (card.UpdateChannel == UpdateChannel.Stable)
{
return true;
}
if (accountsService.LykosStatus?.User?.IsActiveSupporter == true)
{
return true;
}
return false;
}
public void ShowLoginRequiredDialog()
{
Dispatcher.UIThread
.InvokeAsync(async () =>
{
var dialog = DialogHelper.CreateTaskDialog(
"Become a Supporter",
""
+ "Support the Stability Matrix Team and get access to early development builds and be the first to test new features. "
);
dialog.Buttons = new[]
{
new(Resources.Label_Accounts, TaskDialogStandardResult.OK),
TaskDialogButton.CloseButton
};
dialog.Commands = new[]
{
new TaskDialogCommand
{
Text = "Patreon",
Description = "https://patreon.com/StabilityMatrix",
Command = new RelayCommand(() =>
{
ProcessRunner.OpenUrl("https://patreon.com/StabilityMatrix");
})
}
};
if (await dialog.ShowAsync(true) is TaskDialogStandardResult.OK)
{
settingsNavService.NavigateTo<AccountSettingsViewModel>(
new SuppressNavigationTransitionInfo()
);
}
})
.SafeFireAndForget();
}
partial void OnUpdateStatusChanged(UpdateStatusChangedEventArgs? value)
{
// Update the update channel cards
// Use maximum version from platforms equal or lower than current
foreach (var card in AvailableUpdateChannelCards)
{
card.LatestVersion = value?.UpdateChannels
.Where(kv => kv.Key <= card.UpdateChannel)
.Select(kv => kv.Value)
.MaxBy(info => info.Version, SemVersion.PrecedenceComparer)
?.Version;
}
}
partial void OnPreferredUpdateChannelChanged(UpdateChannel value)
{
CheckForUpdatesCommand.ExecuteAsync(null).SafeFireAndForget();
}
/// <inheritdoc />
public override string Title => "Updates";
/// <inheritdoc />
public override IconSource IconSource =>
new SymbolIconSource { Symbol = Symbol.Settings, IsFilled = true };
}

3
StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs

@ -35,7 +35,8 @@ public partial class SettingsViewModel : PageViewModelBase
{ {
vmFactory.Get<MainSettingsViewModel>(), vmFactory.Get<MainSettingsViewModel>(),
vmFactory.Get<InferenceSettingsViewModel>(), vmFactory.Get<InferenceSettingsViewModel>(),
vmFactory.Get<AccountSettingsViewModel>() vmFactory.Get<AccountSettingsViewModel>(),
vmFactory.Get<UpdateSettingsViewModel>()
}; };
CurrentPagePath.AddRange(SubPages); CurrentPagePath.AddRange(SubPages);

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

@ -82,13 +82,21 @@
IsFilled="True" IsFilled="True"
FontSize="64" FontSize="64"
Symbol="Person" /> Symbol="Person" />
<controls:BetterAdvancedImage <ui:HyperlinkButton
HorizontalAlignment="Left" ToolTip.Tip="Edit on Gravatar"
NavigateUri="http://gravatar.com/emails/"
IsVisible="{Binding LykosProfileImageUrl, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" IsVisible="{Binding LykosProfileImageUrl, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Source="{Binding LykosProfileImageUrl}" Padding="2"
Width="64" CornerRadius="8"
Height="64" HorizontalAlignment="Left"
CornerRadius="8" /> Classes="transparent-full">
<controls:BetterAdvancedImage
Source="{Binding LykosProfileImageUrl}"
Width="64"
Height="64"
CornerRadius="8" />
</ui:HyperlinkButton>
<StackPanel> <StackPanel>
<TextBlock <TextBlock

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

@ -11,21 +11,25 @@
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages" xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData" 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:system="clr-namespace:System;assembly=System.Runtime"
xmlns:ui="using:FluentAvalonia.UI.Controls" 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:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:vmSettings="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Settings" 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:DataContext="{x:Static mocks:DesignData.MainSettingsViewModel}"
d:DesignHeight="700" d:DesignHeight="700"
d:DesignWidth="800" d:DesignWidth="800"
x:CompileBindings="True" x:CompileBindings="True"
x:DataType="vmSettings:MainSettingsViewModel" x:DataType="vmSettings:MainSettingsViewModel"
Focusable="True"
mc:Ignorable="d"> mc:Ignorable="d">
<controls:UserControlBase.Resources> <controls:UserControlBase.Resources>
<converters:CultureInfoDisplayConverter x:Key="CultureInfoDisplayConverter" /> <converters:CultureInfoDisplayConverter x:Key="CultureInfoDisplayConverter" />
<converters:EnumStringConverter x:Key="EnumStringConverter" />
<converters:EnumToBooleanConverter x:Key="EnumBoolConverter" />
</controls:UserControlBase.Resources> </controls:UserControlBase.Resources>
<ScrollViewer VerticalScrollBarVisibility="Auto"> <ScrollViewer VerticalScrollBarVisibility="Auto">
@ -63,15 +67,14 @@
</ui:SettingsExpander> </ui:SettingsExpander>
<ui:SettingsExpander <ui:SettingsExpander
Grid.Row="3" Grid.Row="3"
IsVisible="{Binding SharedState.IsDebugMode}"
Margin="8,0,8,4" Margin="8,0,8,4"
IsClickEnabled="True" ActionIconSource="ChevronRight"
Command="{Binding NavigateToSubPageCommand}" Command="{Binding NavigateToSubPageCommand}"
CommandParameter="{x:Type vmSettings:InferenceSettingsViewModel}" CommandParameter="{x:Type vmSettings:InferenceSettingsViewModel}"
Header="Inference (Test)" Header="Inference (Test)"
IconSource="Code" IconSource="Code"
ActionIconSource="ChevronRight"> IsClickEnabled="True"
</ui:SettingsExpander> IsVisible="{Binding SharedState.IsDebugMode}" />
</Grid> </Grid>
<!-- Checkpoints Manager Options --> <!-- Checkpoints Manager Options -->
@ -254,16 +257,16 @@
<ui:SettingsExpander <ui:SettingsExpander
Grid.Row="1" Grid.Row="1"
Margin="8,0" Margin="8,0"
IsClickEnabled="True" ActionIconSource="ChevronRight"
Command="{Binding NavigateToSubPageCommand}" Command="{Binding NavigateToSubPageCommand}"
CommandParameter="{x:Type vmSettings:AccountSettingsViewModel}" CommandParameter="{x:Type vmSettings:AccountSettingsViewModel}"
Header="{x:Static lang:Resources.Label_Accounts}" Header="{x:Static lang:Resources.Label_Accounts}"
ActionIconSource="ChevronRight"> IsClickEnabled="True">
<ui:SettingsExpander.IconSource> <ui:SettingsExpander.IconSource>
<fluentIcons:SymbolIconSource <fluentIcons:SymbolIconSource
FontSize="10" FontSize="10"
IsFilled="True" IsFilled="True"
Symbol="Person"/> Symbol="Person" />
</ui:SettingsExpander.IconSource> </ui:SettingsExpander.IconSource>
</ui:SettingsExpander> </ui:SettingsExpander>
<ui:SettingsExpander <ui:SettingsExpander
@ -280,14 +283,32 @@
</sg:SpacedGrid> </sg:SpacedGrid>
<!-- System Options --> <!-- System Options -->
<Grid RowDefinitions="auto, auto, auto"> <sg:SpacedGrid RowDefinitions="Auto,Auto,Auto,Auto" RowSpacing="4">
<TextBlock <TextBlock
Margin="0,0,0,8" Margin="0,0,0,4"
FontWeight="Medium" FontWeight="Medium"
Text="{x:Static lang:Resources.Label_System}" /> Text="{x:Static lang:Resources.Label_System}" />
<!-- Updates page -->
<ui:SettingsExpander <ui:SettingsExpander
Grid.Row="1" Grid.Row="1"
Margin="8,0,8,4" Margin="8,0"
ActionIconSource="ChevronRight"
Command="{Binding NavigateToSubPageCommand}"
CommandParameter="{x:Type vmSettings:UpdateSettingsViewModel}"
Header="{x:Static lang:Resources.Label_Updates}"
IsClickEnabled="True">
<ui:SettingsExpander.IconSource>
<fluentIcons:SymbolIconSource
FontSize="10"
IsFilled="True"
Symbol="ArrowSync" />
</ui:SettingsExpander.IconSource>
</ui:SettingsExpander>
<ui:SettingsExpander
Grid.Row="2"
Margin="8,0"
Description="{x:Static lang:Resources.Label_AddToStartMenu_Details}" Description="{x:Static lang:Resources.Label_AddToStartMenu_Details}"
Header="{x:Static lang:Resources.Label_AddToStartMenu}" Header="{x:Static lang:Resources.Label_AddToStartMenu}"
IconSource="StarAdd" IconSource="StarAdd"
@ -324,8 +345,9 @@
</StackPanel> </StackPanel>
</ui:SettingsExpander.Footer> </ui:SettingsExpander.Footer>
</ui:SettingsExpander> </ui:SettingsExpander>
<ui:SettingsExpander <ui:SettingsExpander
Grid.Row="2" Grid.Row="3"
Margin="8,0" Margin="8,0"
Description="{x:Static lang:Resources.Label_SelectNewDataDirectory_Details}" Description="{x:Static lang:Resources.Label_SelectNewDataDirectory_Details}"
IconSource="MoveToFolder"> IconSource="MoveToFolder">
@ -354,7 +376,7 @@
</Button> </Button>
</ui:SettingsExpander.Footer> </ui:SettingsExpander.Footer>
</ui:SettingsExpander> </ui:SettingsExpander>
</Grid> </sg:SpacedGrid>
<!-- Debug Options --> <!-- Debug Options -->
<Grid IsVisible="{Binding SharedState.IsDebugMode}" RowDefinitions="auto,*"> <Grid IsVisible="{Binding SharedState.IsDebugMode}" RowDefinitions="auto,*">

154
StabilityMatrix.Avalonia/Views/Settings/UpdateSettingsPage.axaml

@ -0,0 +1,154 @@
<controls:UserControlBase
x:Class="StabilityMatrix.Avalonia.Views.Settings.UpdateSettingsPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:fluentIcons="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia"
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:models="clr-namespace:StabilityMatrix.Avalonia.Models"
xmlns:sg="clr-namespace:SpacedGridControl.Avalonia;assembly=SpacedGridControl.Avalonia"
xmlns:sm="clr-namespace:StabilityMatrix.Avalonia"
xmlns:system="clr-namespace:System;assembly=System.Runtime"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:vmSettings="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Settings"
xmlns:converters="clr-namespace:StabilityMatrix.Avalonia.Converters"
d:DataContext="{x:Static mocks:DesignData.UpdateSettingsViewModel}"
d:DesignHeight="550"
d:DesignWidth="800"
x:DataType="vmSettings:UpdateSettingsViewModel"
mc:Ignorable="d">
<controls:UserControlBase.Resources>
<converters:BooleanChoiceMultiConverter x:Key="BoolChoiceMultiConverter" />
</controls:UserControlBase.Resources>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="16,16,24,16" Spacing="2">
<sg:SpacedGrid Margin="0,4,0,16" ColumnDefinitions="*,Auto">
<StackPanel Orientation="Horizontal" Spacing="16">
<fluentIcons:SymbolIcon
Width="64"
Height="64"
FontSize="64"
IsFilled="True"
Symbol="ArrowSync" />
<StackPanel VerticalAlignment="Center">
<TextBlock
Margin="-1,0,0,0"
HorizontalAlignment="Left"
Text="{Binding HeaderText}"
Theme="{DynamicResource SubtitleTextBlockStyle}" />
<TextBlock
HorizontalAlignment="Left"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
Text="{Binding SubtitleText}" />
</StackPanel>
</StackPanel>
<Button
Grid.Column="1"
Classes="accent"
Content="{x:Static lang:Resources.Action_CheckForUpdates}"
Command="{Binding CheckForUpdatesCommand}"
HorizontalAlignment="Right">
</Button>
</sg:SpacedGrid>
<!-- Auto updates toggle -->
<ui:SettingsExpander
Description="Periodically checks for updates"
Header="Notify when updates are ready to install">
<ui:SettingsExpander.IconSource>
<fluentIcons:SymbolIconSource Symbol="Megaphone" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding IsAutoCheckUpdatesEnabled}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<!-- Channel radio buttons -->
<ui:SettingsExpander Header="Preferred Update Channel" IsExpanded="True">
<ui:SettingsExpander.IconSource>
<fluentIcons:SymbolIconSource Symbol="Branch" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpanderItem>
<ListBox
SelectionChanged="ChannelListBox_OnSelectionChanged"
IsEnabled="{Binding IsAutoCheckUpdatesEnabled}"
ItemContainerTheme="{DynamicResource ListBoxItemBorderlessTheme}"
ItemsSource="{Binding AvailableUpdateChannelCards}"
SelectedItem="{Binding SelectedUpdateChannelCard}">
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type models:UpdateChannelCard}">
<RadioButton
GroupName="UpdateChannel"
IsChecked="{Binding $parent[ListBoxItem].IsSelected}" >
<StackPanel VerticalAlignment="Top" HorizontalAlignment="Left">
<StackPanel Orientation="Horizontal" Spacing="5">
<TextBlock
VerticalAlignment="Center"
Text="{Binding DisplayName}">
<TextBlock.Foreground>
<MultiBinding Converter="{StaticResource BoolChoiceMultiConverter}">
<Binding Path="IsSelectable" />
<DynamicResource ResourceKey="TextFillColorPrimaryBrush"/>
<DynamicResource ResourceKey="TextFillColorTertiaryBrush"/>
</MultiBinding>
</TextBlock.Foreground>
</TextBlock>
<Ellipse
HorizontalAlignment="Left"
VerticalAlignment="Center"
IsVisible="{Binding LatestVersionString, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Width="4"
Height="4"
Fill="{DynamicResource TextFillColorTertiaryBrush}" />
<TextBlock
VerticalAlignment="Center"
FontSize="13"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
IsVisible="{Binding LatestVersionString, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Text="{Binding LatestVersionString}" />
<!-- Update available icon -->
<fluentIcons:SymbolIcon
ToolTip.Tip="{x:Static lang:Resources.Label_UpdateAvailable}"
IsVisible="{Binding IsLatestVersionUpdateable}"
VerticalAlignment="Center"
FontSize="16"
Foreground="#3592c4"
Symbol="ArrowCircleUpRight" />
</StackPanel>
<TextBlock
FontSize="13"
TextAlignment="Start"
Margin="0,0,0,0"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Text="{Binding Description}"
Theme="{DynamicResource CaptionTextBlockStyle}" />
</StackPanel>
</RadioButton>
</DataTemplate>
</ListBox.ItemTemplate>
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="8" />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
</ListBox>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
</StackPanel>
</ScrollViewer>
</controls:UserControlBase>

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

7
StabilityMatrix.Core/Api/ILykosAuthApi.cs

@ -63,4 +63,11 @@ public interface ILykosAuthApi
[Headers("Authorization: Bearer")] [Headers("Authorization: Bearer")]
[Delete("/api/oauth/patreon")] [Delete("/api/oauth/patreon")]
Task DeletePatreonOAuth(CancellationToken cancellationToken = default); Task DeletePatreonOAuth(CancellationToken cancellationToken = default);
[Headers("Authorization: Bearer")]
[Get("/api/files/download")]
Task<GetFilesDownloadResponse> GetFilesDownload(
string path,
CancellationToken cancellationToken = default
);
} }

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

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

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

@ -1,9 +1,15 @@
namespace StabilityMatrix.Core.Models.Api.Lykos; namespace StabilityMatrix.Core.Models.Api.Lykos;
public record GetUserResponse( public record GetUserResponse
string Id, {
LykosAccount Account, public required string Id { get; init; }
int UserLevel, public required LykosAccount Account { get; init; }
string PatreonId, public required HashSet<LykosRole> UserRoles { get; init; }
bool IsEmailVerified public string? PatreonId { get; init; }
); public bool IsEmailVerified { get; init; }
public bool IsActiveSupporter =>
UserRoles.Contains(LykosRole.PatreonSupporter)
|| UserRoles.Contains(LykosRole.Insider)
|| (UserRoles.Contains(LykosRole.Developer) && PatreonId is not null);
}

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

5
StabilityMatrix.Core/Models/Settings/Settings.cs

@ -43,6 +43,11 @@ public class Settings
/// </summary> /// </summary>
public UpdateChannel PreferredUpdateChannel { get; set; } = UpdateChannel.Stable; public UpdateChannel PreferredUpdateChannel { get; set; } = UpdateChannel.Stable;
/// <summary>
/// Whether to check for updates
/// </summary>
public bool CheckForUpdates { get; set; } = true;
/// <summary> /// <summary>
/// The last auto-update version that had a notification dismissed by the user /// The last auto-update version that had a notification dismissed by the user
/// </summary> /// </summary>

7
StabilityMatrix.Core/Updater/IUpdateHelper.cs

@ -5,8 +5,11 @@ namespace StabilityMatrix.Core.Updater;
public interface IUpdateHelper public interface IUpdateHelper
{ {
event EventHandler<UpdateStatusChangedEventArgs>? UpdateStatusChanged;
Task StartCheckingForUpdates(); Task StartCheckingForUpdates();
Task DownloadUpdate(UpdateInfo updateInfo, Task CheckForUpdate();
IProgress<ProgressReport> progress);
Task DownloadUpdate(UpdateInfo updateInfo, IProgress<ProgressReport> progress);
} }

90
StabilityMatrix.Core/Updater/UpdateHelper.cs

@ -1,7 +1,10 @@
using System.Text.Json; using System.Text.Json;
using System.Web;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using StabilityMatrix.Core.Api;
using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models.Configs; using StabilityMatrix.Core.Models.Configs;
using StabilityMatrix.Core.Models.FileInterfaces; using StabilityMatrix.Core.Models.FileInterfaces;
@ -18,6 +21,7 @@ public class UpdateHelper : IUpdateHelper
private readonly IHttpClientFactory httpClientFactory; private readonly IHttpClientFactory httpClientFactory;
private readonly IDownloadService downloadService; private readonly IDownloadService downloadService;
private readonly ISettingsManager settingsManager; private readonly ISettingsManager settingsManager;
private readonly ILykosAuthApi lykosAuthApi;
private readonly DebugOptions debugOptions; private readonly DebugOptions debugOptions;
private readonly System.Timers.Timer timer = new(TimeSpan.FromMinutes(60)); private readonly System.Timers.Timer timer = new(TimeSpan.FromMinutes(60));
@ -29,18 +33,23 @@ public class UpdateHelper : IUpdateHelper
public static FilePath ExecutablePath => UpdateFolder.JoinFile(Compat.GetExecutableName()); public static FilePath ExecutablePath => UpdateFolder.JoinFile(Compat.GetExecutableName());
/// <inheritdoc />
public event EventHandler<UpdateStatusChangedEventArgs>? UpdateStatusChanged;
public UpdateHelper( public UpdateHelper(
ILogger<UpdateHelper> logger, ILogger<UpdateHelper> logger,
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IDownloadService downloadService, IDownloadService downloadService,
IOptions<DebugOptions> debugOptions, IOptions<DebugOptions> debugOptions,
ISettingsManager settingsManager ISettingsManager settingsManager,
ILykosAuthApi lykosAuthApi
) )
{ {
this.logger = logger; this.logger = logger;
this.httpClientFactory = httpClientFactory; this.httpClientFactory = httpClientFactory;
this.downloadService = downloadService; this.downloadService = downloadService;
this.settingsManager = settingsManager; this.settingsManager = settingsManager;
this.lykosAuthApi = lykosAuthApi;
this.debugOptions = debugOptions.Value; this.debugOptions = debugOptions.Value;
timer.Elapsed += async (_, _) => timer.Elapsed += async (_, _) =>
@ -67,10 +76,34 @@ public class UpdateHelper : IUpdateHelper
try try
{ {
// download the file from URL var url = updateInfo.Url.ToString();
// check if need authenticated download
const string authedPathPrefix = "/lykos-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);
path = HttpUtility.UrlDecode(path);
url = (
await lykosAuthApi.GetFilesDownload(path).ConfigureAwait(false)
).DownloadUrl.ToString();
}
// Download update
await downloadService await downloadService
.DownloadToFileAsync( .DownloadToFileAsync(
updateInfo.Url.ToString(), url,
downloadFile, downloadFile,
progress: progress, progress: progress,
httpClientName: "UpdateClient" httpClientName: "UpdateClient"
@ -118,7 +151,7 @@ public class UpdateHelper : IUpdateHelper
} }
} }
private async Task CheckForUpdate() public async Task CheckForUpdate()
{ {
try try
{ {
@ -150,7 +183,11 @@ public class UpdateHelper : IUpdateHelper
foreach ( foreach (
var channel in Enum.GetValues(typeof(UpdateChannel)) var channel in Enum.GetValues(typeof(UpdateChannel))
.Cast<UpdateChannel>() .Cast<UpdateChannel>()
.Where(c => c > UpdateChannel.Unknown) .Where(
c =>
c > UpdateChannel.Unknown
&& c <= settingsManager.Settings.PreferredUpdateChannel
)
) )
{ {
if ( if (
@ -159,12 +196,31 @@ public class UpdateHelper : IUpdateHelper
&& ValidateUpdate(update) && ValidateUpdate(update)
) )
{ {
NotifyUpdateAvailable(update); OnUpdateStatusChanged(
new UpdateStatusChangedEventArgs
{
LatestUpdate = update,
UpdateChannels = updateManifest.Updates.ToDictionary(
kv => kv.Key,
kv => kv.Value.GetInfoForCurrentPlatform()
)!
}
);
return; return;
} }
} }
logger.LogInformation("No update available"); logger.LogInformation("No update available");
OnUpdateStatusChanged(
new UpdateStatusChangedEventArgs
{
UpdateChannels = updateManifest.Updates.ToDictionary(
kv => kv.Key,
kv => kv.Value.GetInfoForCurrentPlatform()
)!
}
);
} }
catch (Exception e) catch (Exception e)
{ {
@ -202,6 +258,12 @@ public class UpdateHelper : IUpdateHelper
// Same version available, check if we both have commit hash metadata // Same version available, check if we both have commit hash metadata
var updateHash = update.Version.Metadata; var updateHash = update.Version.Metadata;
var appHash = Compat.AppVersion.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 different, we can update
if (updateHash != appHash) if (updateHash != appHash)
{ {
@ -215,6 +277,22 @@ public class UpdateHelper : IUpdateHelper
return false; 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) private void NotifyUpdateAvailable(UpdateInfo update)
{ {
logger.LogInformation( logger.LogInformation(

13
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<UpdateChannel, UpdateInfo> UpdateChannels { get; init; } =
new Dictionary<UpdateChannel, UpdateInfo>();
public DateTimeOffset CheckedAt { get; init; } = DateTimeOffset.UtcNow;
}
Loading…
Cancel
Save