Browse Source

Merge pull request #153 from ionite34/avalonia

pull/55/head v2.0.0
Ionite 1 year ago committed by GitHub
parent
commit
de59a796f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 162
      .github/workflows/release.yml
  2. 8
      Jenkinsfile
  3. 42
      Jenkinsfile-linux
  4. 292
      StabilityMatrix.Avalonia.pupnet.conf
  5. 31
      StabilityMatrix.Avalonia/App.axaml
  6. 521
      StabilityMatrix.Avalonia/App.axaml.cs
  7. 80
      StabilityMatrix.Avalonia/Assets.cs
  8. BIN
      StabilityMatrix.Avalonia/Assets/Icon.512x512.png
  9. BIN
      StabilityMatrix.Avalonia/Assets/Icon.ico
  10. BIN
      StabilityMatrix.Avalonia/Assets/Icon.png
  11. BIN
      StabilityMatrix.Avalonia/Assets/avalonia-logo.ico
  12. 1
      StabilityMatrix.Avalonia/Assets/licenses.json
  13. BIN
      StabilityMatrix.Avalonia/Assets/linux-x64/7zzs
  14. 88
      StabilityMatrix.Avalonia/Assets/linux-x64/7zzs - LICENSE.txt
  15. BIN
      StabilityMatrix.Avalonia/Assets/noimage.png
  16. 49
      StabilityMatrix.Avalonia/Assets/sitecustomize.py
  17. 43
      StabilityMatrix.Avalonia/Assets/win-x64/7za - LICENSE.txt
  18. BIN
      StabilityMatrix.Avalonia/Assets/win-x64/7za.exe
  19. BIN
      StabilityMatrix.Avalonia/Assets/win-x64/get-pip.pyc
  20. 525
      StabilityMatrix.Avalonia/Assets/win-x64/venv/__init__.py
  21. 10
      StabilityMatrix.Avalonia/Assets/win-x64/venv/__main__.py
  22. 247
      StabilityMatrix.Avalonia/Assets/win-x64/venv/scripts/common/Activate.ps1
  23. 69
      StabilityMatrix.Avalonia/Assets/win-x64/venv/scripts/common/activate
  24. 34
      StabilityMatrix.Avalonia/Assets/win-x64/venv/scripts/nt/activate.bat
  25. 22
      StabilityMatrix.Avalonia/Assets/win-x64/venv/scripts/nt/deactivate.bat
  26. 26
      StabilityMatrix.Avalonia/Assets/win-x64/venv/scripts/posix/activate.csh
  27. 69
      StabilityMatrix.Avalonia/Assets/win-x64/venv/scripts/posix/activate.fish
  28. 89
      StabilityMatrix.Avalonia/Controls/AppWindowBase.cs
  29. 133
      StabilityMatrix.Avalonia/Controls/BetterAdvancedImage.cs
  30. 254
      StabilityMatrix.Avalonia/Controls/BetterContentDialog.cs
  31. 40
      StabilityMatrix.Avalonia/Controls/BetterFlyout.cs
  32. 140
      StabilityMatrix.Avalonia/Controls/BetterImage.cs
  33. 15
      StabilityMatrix.Avalonia/Controls/Card.cs
  34. 88
      StabilityMatrix.Avalonia/Controls/FASymbolIconSource.cs
  35. 32
      StabilityMatrix.Avalonia/Controls/LaunchOptionCardTemplateSelector.cs
  36. 130
      StabilityMatrix.Avalonia/Controls/ProgressRing.cs
  37. 41
      StabilityMatrix.Avalonia/Controls/RefreshBadge.axaml
  38. 16
      StabilityMatrix.Avalonia/Controls/RefreshBadge.axaml.cs
  39. 51
      StabilityMatrix.Avalonia/Controls/UserControlBase.cs
  40. 20
      StabilityMatrix.Avalonia/Converters/FitSquarelyWithinAspectRatioConverter.cs
  41. 46
      StabilityMatrix.Avalonia/Converters/LaunchOptionConverter.cs
  42. 34
      StabilityMatrix.Avalonia/Converters/LaunchOptionIntDoubleConverter.cs
  43. 20
      StabilityMatrix.Avalonia/Converters/ValueConverterGroup.cs
  44. 268
      StabilityMatrix.Avalonia/DesignData/DesignData.cs
  45. 21
      StabilityMatrix.Avalonia/DesignData/MockDownloadService.cs
  46. 12
      StabilityMatrix.Avalonia/DesignData/MockHttpClientFactory.cs
  47. 51
      StabilityMatrix.Avalonia/DesignData/MockLiteDbContext.cs
  48. 35
      StabilityMatrix.Avalonia/DesignData/MockNotificationService.cs
  49. 9
      StabilityMatrix.Avalonia/DesignData/MockSettingsManager.cs
  50. 22
      StabilityMatrix.Avalonia/DesignData/MockSharedFolders.cs
  51. 191
      StabilityMatrix.Avalonia/DialogHelper.cs
  52. 222
      StabilityMatrix.Avalonia/Helpers/UnixPrerequisiteHelper.cs
  53. 290
      StabilityMatrix.Avalonia/Helpers/WindowsPrerequisiteHelper.cs
  54. 66
      StabilityMatrix.Avalonia/Models/AvaloniaResource.cs
  55. 125
      StabilityMatrix.Avalonia/Models/ObservableDictionary.cs
  56. 33
      StabilityMatrix.Avalonia/Models/RemoteResource.cs
  57. 14
      StabilityMatrix.Avalonia/Models/SharedState.cs
  58. 198
      StabilityMatrix.Avalonia/Program.cs
  59. 46
      StabilityMatrix.Avalonia/Services/INotificationService.cs
  60. 74
      StabilityMatrix.Avalonia/Services/NotificationService.cs
  61. 132
      StabilityMatrix.Avalonia/Services/ServiceManager.cs
  62. 79
      StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj
  63. 4
      StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj.DotSettings
  64. 305
      StabilityMatrix.Avalonia/Styles/ButtonStyles.axaml
  65. 93
      StabilityMatrix.Avalonia/Styles/Card.axaml
  66. 113
      StabilityMatrix.Avalonia/Styles/ProgressRing.axaml
  67. 33
      StabilityMatrix.Avalonia/Styles/ThemeColors.axaml
  68. 10
      StabilityMatrix.Avalonia/Styles/ThemeColors.cs
  69. 47
      StabilityMatrix.Avalonia/ViewLocator.cs
  70. 360
      StabilityMatrix.Avalonia/ViewModels/CheckpointBrowserCardViewModel.cs
  71. 382
      StabilityMatrix.Avalonia/ViewModels/CheckpointBrowserViewModel.cs
  72. 250
      StabilityMatrix.Avalonia/ViewModels/CheckpointFile.cs
  73. 400
      StabilityMatrix.Avalonia/ViewModels/CheckpointFolder.cs
  74. 157
      StabilityMatrix.Avalonia/ViewModels/CheckpointsPageViewModel.cs
  75. 462
      StabilityMatrix.Avalonia/ViewModels/ConsoleViewModel.cs
  76. 20
      StabilityMatrix.Avalonia/ViewModels/Dialogs/CivitFileViewModel.cs
  77. 27
      StabilityMatrix.Avalonia/ViewModels/Dialogs/ContentDialogViewModelBase.cs
  78. 16
      StabilityMatrix.Avalonia/ViewModels/Dialogs/ExceptionViewModel.cs
  79. 461
      StabilityMatrix.Avalonia/ViewModels/Dialogs/InstallerViewModel.cs
  80. 86
      StabilityMatrix.Avalonia/ViewModels/Dialogs/LaunchOptionsViewModel.cs
  81. 30
      StabilityMatrix.Avalonia/ViewModels/Dialogs/ModelVersionViewModel.cs
  82. 178
      StabilityMatrix.Avalonia/ViewModels/Dialogs/OneClickInstallViewModel.cs
  83. 155
      StabilityMatrix.Avalonia/ViewModels/Dialogs/SelectDataDirectoryViewModel.cs
  84. 73
      StabilityMatrix.Avalonia/ViewModels/Dialogs/SelectModelVersionViewModel.cs
  85. 91
      StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs
  86. 61
      StabilityMatrix.Avalonia/ViewModels/FirstLaunchSetupViewModel.cs
  87. 440
      StabilityMatrix.Avalonia/ViewModels/LaunchPageViewModel.cs
  88. 190
      StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs
  89. 375
      StabilityMatrix.Avalonia/ViewModels/PackageManagerViewModel.cs
  90. 22
      StabilityMatrix.Avalonia/ViewModels/PageViewModelBase.cs
  91. 60
      StabilityMatrix.Avalonia/ViewModels/ProgressItemViewModel.cs
  92. 51
      StabilityMatrix.Avalonia/ViewModels/ProgressManagerViewModel.cs
  93. 24
      StabilityMatrix.Avalonia/ViewModels/ProgressViewModel.cs
  94. 96
      StabilityMatrix.Avalonia/ViewModels/RefreshBadgeViewModel.cs
  95. 286
      StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs
  96. 27
      StabilityMatrix.Avalonia/ViewModels/ViewModelBase.cs
  97. 279
      StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml
  98. 17
      StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml.cs
  99. 451
      StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml
  100. 60
      StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml.cs
  101. Some files were not shown because too many files have changed in this diff Show More

162
.github/workflows/release.yml

@ -2,62 +2,79 @@ name: Release
on: on:
workflow_dispatch: workflow_dispatch:
inputs:
version:
type: string
description: Version (Semver without leading v)
sentry-release:
type: boolean
description: Make Sentry Release?
default: false
release: release:
types: [ published ] types: [ published ]
jobs: jobs:
release: release-linux:
if: github.repository == 'ionite34/StabilityMatrix' if: github.repository == 'ionite34/StabilityMatrix' || github.event_name == 'workflow_dispatch'
runs-on: windows-latest name: Release (linux-x64)
env:
platform-id: linux-x64
out-name: StabilityMatrix.AppImage
runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: olegtarasov/get-tag@v2.1.2 - uses: olegtarasov/get-tag@v2.1.2
if: github.event_name == 'release'
id: tag_name id: tag_name
with: with:
tagRegex: "v(.*)" tagRegex: "v(.*)"
- name: Set Tag - name: Set Version from Tag
if: github.event_name == 'release'
run: | run: |
echo "Using tag ${{ env.GIT_TAG_NAME }}" echo "Using tag ${{ env.GIT_TAG_NAME }}"
echo "RELEASE_VERSION=${{ env.GIT_TAG_NAME }}.0" >> $env:GITHUB_ENV echo "RELEASE_VERSION=${{ env.GIT_TAG_NAME }}" >> $GITHUB_ENV
- name: Set Version from manual input
if: github.event_name == 'workflow_dispatch'
run: |
echo "Using version ${{ github.event.inputs.version }}"
echo "RELEASE_VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV
- name: Set up .NET - name: Set up .NET 6 (for PupNet)
uses: actions/setup-dotnet@v3 uses: actions/setup-dotnet@v3
with: with:
dotnet-version: '6.0.x' dotnet-version: '6.0.x'
- name: Install dependencies - name: Install PupNet
run: dotnet restore -p:PublishReadyToRun=true run: |
sudo apt-get -y install libfuse2
dotnet tool install --framework net6.0 -g KuiperZone.PupNet
- name: Build - name: Set up .NET 7
uses: actions/setup-dotnet@v3
with:
dotnet-version: '7.0.x'
- name: PupNet Build
env: env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: > run: pupnet -r linux-x64 -c Release --kind appimage --app-version $RELEASE_VERSION --clean -y
dotnet publish ./StabilityMatrix/StabilityMatrix.csproj # Release/linux-x64/StabilityMatrix.x86_64.AppImage
-o out -c Release -r win-x64
-p:Version=$env:RELEASE_VERSION -p:FileVersion=$env:RELEASE_VERSION -p:AssemblyVersion=$env:RELEASE_VERSION
-p:PublishReadyToRun=true -p:PublishSingleFile=true
-p:SentryOrg=${{ secrets.SENTRY_ORG }} -p:SentryProject=${{ secrets.SENTRY_PROJECT }}
-p:SentryUploadSymbols=true -p:SentryUploadSources=true
--self-contained true
- name: Remove old artifacts - name: Post Build
uses: c-hive/gha-remove-artifacts@v1 run: mv ./Release/linux-x64/StabilityMatrix.x86_64.AppImage ${{ env.out-name }}
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
age: '90 seconds'
skip-tags: false
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v2
with: with:
name: StabilityMatrix name: StabilityMatrix-${{ env.platform-id }}
path: ./out/StabilityMatrix.exe path: ${{ env.out-name }}
- name: Create Sentry release - name: Create Sentry release
if: ${{ env.MAKE_SENTRY_RELEASE == 'true' }} if: ${{ github.event_name == 'release' }}
uses: getsentry/action-release@v1 uses: getsentry/action-release@v1
env: env:
MAKE_SENTRY_RELEASE: ${{ secrets.SENTRY_PROJECT != '' }} MAKE_SENTRY_RELEASE: ${{ secrets.SENTRY_PROJECT != '' }}
@ -68,3 +85,94 @@ jobs:
environment: production environment: production
ignore_missing: true ignore_missing: true
version: StabilityMatrix@${{ env.GIT_TAG_NAME }} version: StabilityMatrix@${{ env.GIT_TAG_NAME }}
- name: Create Sentry release
if: ${{ github.event_name == 'workflow_dispatch' }}
uses: getsentry/action-release@v1
env:
MAKE_SENTRY_RELEASE: ${{ secrets.SENTRY_PROJECT != '' }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
with:
environment: production
ignore_missing: true
version: StabilityMatrix@${{ github.event.inputs.version }}
release-windows:
if: github.repository == 'ionite34/StabilityMatrix' || github.event_name == 'workflow_dispatch'
name: Release (win-x64)
env:
platform-id: win-x64
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- uses: olegtarasov/get-tag@v2.1.2
if: github.event_name == 'release'
id: tag_name
with:
tagRegex: "v(.*)"
- name: Set Version from Tag
if: github.event_name == 'release'
run: |
echo "Using tag ${{ env.GIT_TAG_NAME }}"
echo "RELEASE_VERSION=${{ env.GIT_TAG_NAME }}" >> $env:GITHUB_ENV
- name: Set Version from manual input
if: github.event_name == 'workflow_dispatch'
run: |
echo "Using version ${{ github.event.inputs.version }}"
echo "RELEASE_VERSION=${{ github.event.inputs.version }}" >> $env:GITHUB_ENV
- name: Set up .NET 7
uses: actions/setup-dotnet@v3
with:
dotnet-version: '7.0.x'
- name: Install dependencies
run: dotnet restore
- name: .NET Publish
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: >
dotnet publish ./StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj
-o out -c Release -r ${{ env.platform-id }}
-p:Version=$env:RELEASE_VERSION
-p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true
-p:PublishTrimmed=true
-p:SentryOrg=${{ secrets.SENTRY_ORG }} -p:SentryProject=${{ secrets.SENTRY_PROJECT }}
-p:SentryUploadSymbols=true -p:SentryUploadSources=true
- name: Post Build
run: mv ./out/StabilityMatrix.Avalonia.exe ./out/${{ env.out-name }}
- name: Upload Artifact
uses: actions/upload-artifact@v2
with:
name: StabilityMatrix-${{ env.platform-id }}
path: ./out/${{ env.out-name }}
cleanup:
name: Artifact Cleanup
needs: [release-linux, release-windows]
if: github.repository == 'ionite34/StabilityMatrix'
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Remove old artifacts
uses: c-hive/gha-remove-artifacts@v1
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
age: '1 hour'
skip-recent: 2
skip-tags: false
- name: Output
if: always() && true
run: exit 0

8
Jenkinsfile vendored

@ -20,15 +20,15 @@ node("Windows") {
stage('Set Version') { stage('Set Version') {
script { script {
if (env.TAG_NAME) { if (env.TAG_NAME) {
version = env.TAG_NAME.replaceFirst(/^v/, '') + ".0" version = env.TAG_NAME.replaceFirst(/^v/, '')
} else { } else {
version = VersionNumber projectStartDate: '2023-06-21', versionNumberString: '1.0.${BUILDS_ALL_TIME}.0', versionPrefix: '', worstResultForIncrement: 'SUCCESS' version = VersionNumber projectStartDate: '2023-06-21', versionNumberString: '${BUILDS_ALL_TIME}', worstResultForIncrement: 'SUCCESS'
} }
} }
} }
stage('Publish') { stage('Publish') {
bat "dotnet publish .\\StabilityMatrix\\StabilityMatrix.csproj -c Release -o out -r win-x64 -p:PublishSingleFile=true -p:Version=${version} -p:FileVersion=${version} -p:AssemblyVersion=${version} --self-contained true" bat "dotnet publish .\\StabilityMatrix.Avalonia\\StabilityMatrix.Avalonia.csproj -c Release -o out -r win-x64 -p:PublishSingleFile=true -p:VersionPrefix=2.0.0 -p:VersionSuffix=${version} -p:IncludeNativeLibrariesForSelfExtract=true"
} }
stage ('Archive Artifacts') { stage ('Archive Artifacts') {
@ -36,7 +36,7 @@ node("Windows") {
} }
} else { } else {
stage('Publish') { stage('Publish') {
bat "dotnet publish .\\StabilityMatrix\\StabilityMatrix.csproj -c Release -o out -r win-x64 -p:PublishSingleFile=true --self-contained true" bat "dotnet publish .\\StabilityMatrix.Avalonia\\StabilityMatrix.Avalonia.csproj -c Release -o out -r win-x64 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true"
} }
} }
} }

42
Jenkinsfile-linux

@ -0,0 +1,42 @@
node("Diligence") {
def repoName = "StabilityMatrix"
def author = "ionite34"
def version = ""
stage('Clean') {
deleteDir()
}
stage('Checkout') {
git branch: env.BRANCH_NAME, credentialsId: 'Ionite', url: "https://github.com/${author}/${repoName}.git"
}
// stage('Test') {
// sh "dotnet test StabilityMatrix.Tests"
// }
if (env.BRANCH_NAME == 'main') {
stage('Set Version') {
script {
if (env.TAG_NAME) {
version = env.TAG_NAME.replaceFirst(/^v/, '')
} else {
version = VersionNumber projectStartDate: '2023-06-21', versionNumberString: '${BUILDS_ALL_TIME}', worstResultForIncrement: 'SUCCESS'
}
}
}
stage('Publish') {
sh "/home/jenkins/.dotnet/tools/pupnet --runtime linux-x64 --kind appimage --app-version ${version} --clean -y"
}
stage ('Archive Artifacts') {
archiveArtifacts artifacts: 'out/*.appimage', followSymlinks: false
}
} else {
stage('Publish') {
sh "/home/jenkins/.dotnet/tools/pupnet --runtime linux-x64 --kind appimage --clean -y"
}
}
}

292
StabilityMatrix.Avalonia.pupnet.conf

@ -0,0 +1,292 @@
################################################################################
# PUPNET DEPLOY: 1.4.0
################################################################################
########################################
# APP PREAMBLE
########################################
# Mandatory application base name. This MUST BE the base name of the main executable file. It should NOT
# include any directory part or extension, i.e. do not append '.exe' or '.dll'. It should not contain
# spaces or invalid filename characters.
AppBaseName = StabilityMatrix.Avalonia
# Mandatory application friendly name.
AppFriendlyName = Stability Matrix
# Mandatory application ID in reverse DNS form. This should stay constant for lifetime of the software.
AppId = zone.lykos.stabilitymatrix
# Mandatory application version and package release of form: 'VERSION[RELEASE]'. Use optional square
# brackets to denote package release, i.e. '1.2.3[1]'. Release refers to a change to the deployment
# package, rather the application. If release part is absent (i.e. '1.2.3'), the release value defaults
# to '1'. Note that the version-release value given here may be overridden from the command line.
AppVersionRelease = 2.0.0[1]
# Mandatory single line application short summary description.
AppShortSummary = Package and checkpoint manager for Stable Diffusion.
# Optional multi-line (surround with triple """ quotes) application description which may provide
# longer text than AppShortSummary. Text separated by an empty line will be treated as paragraphs
# (complex formatting should be avoided). The content is used by package builders where supported,
# including RPM and DEB, and may optionally be used to populate the '<description>' element in the
# AppStream metadata through the use of a macro variable.
AppDescription =
# Mandatory application license ID. This should be one of the recognised SPDX license
# identifiers, such as: 'MIT', 'GPL-3.0-or-later' or 'Apache-2.0'. For a proprietary or
# custom license, use 'LicenseRef-Proprietary' or 'LicenseRef-LICENSE'.
AppLicenseId = LicenseRef-Proprietary
# Optional path to application copyright/license text file. If provided, it will be packaged with the
# application and used with package builders where supported.
AppLicenseFile = LICENSE
# Optional path to application changelog file. IMPORTANT. If given, this file should contain version
# information in a predefined format. Namely, it should contain one or more version headings of form:
# '+ VERSION;DATE', under which are to be listed change items of form: '- Change description'. Formatted
# information will be parsed and used to populate AppStream metadata. Additionally, it will be packaged
# with the application and used with package builders where supported. NOTE. Superfluous text in the file
# is ignored, so the file may also contain README information.
# For information: https://github.com/kuiperzone/PupNet-Deploy.
AppChangeFile =
########################################
# PUBLISHER
########################################
# Mandatory publisher, group or creator.
PublisherName = Lykos
# Optional copyright statement.
PublisherCopyright = Copyright (C) Lykos 2023
# Optional publisher or application web-link name. Note that Windows Setup packages
# require both PublisherLinkName and PublisherLinkUrl in order to include the link as
# an item in program menu entries. Do not modify name, as may leave old entries in updated installations.
PublisherLinkName = Home Page
# Optional publisher or application web-link URL.
PublisherLinkUrl = https://lykos.ai
# Publisher or maintainer email contact. Although optional, some package builders (i.e. DEB) require it
# and may warn or fail unless provided.
PublisherEmail = stability-matrix@lykos.ai
########################################
# DESKTOP INTEGRATION
########################################
# Boolean (true or false) which indicates whether the application is hidden on the desktop. It is used to
# populate the 'NoDisplay' field of the .desktop file. The default is false. Setting to true will also
# cause the main application start menu entry to be omitted for Windows Setup.
DesktopNoDisplay = false
# Boolean (true or false) which indicates whether the application runs in the terminal, rather than
# providing a GUI. It is used to populate the 'Terminal' field of the .desktop file.
DesktopTerminal = false
# Optional path to a Linux desktop file. If empty (default), one will be generated automatically from
# the information in this file. Supplying a custom file, however, allows for mime-types and
# internationalisation. If supplied, the file MUST contain the line: 'Exec=${INSTALL_EXEC}'
# in order to use the correct install location. Other macros may be used to help automate the content.
# Note. PupNet Deploy can generate you a desktop file. Use --help and 'pupnet --help macro' for reference.
# See: https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html
DesktopFile =
# Optional command name to start the application from the terminal. If, for example, AppBaseName is
# 'Zone.Kuiper.HelloWorld', the value here may be set to a simpler and/or lower-case variant such as
# 'helloworld'. It must not contain spaces or invalid filename characters. Do not add any extension such
# as '.exe'. If empty, the application will not be in the path and cannot be started from the command line.
# For Windows Setup packages, see also SetupCommandPrompt. StartCommand is not
# supported for all packages kinds (i.e. Flatpak). Default is empty (none).
StartCommand = stabilitymatrix
# Optional category for the application. The value should be one of the recognised Freedesktop top-level
# categories, such as: Audio, Development, Game, Office, Utility etc. Only a single value should be
# provided here which will be used, where supported, to populate metadata. The default is empty.
# See: https://specifications.freedesktop.org/menu-spec/latest/apa.html
PrimeCategory = Utility
# Path to AppStream metadata file. It is optional, but recommended as it is used by software centers.
# Note. The contents of the files may use macro variables. Use 'pupnet --help macro' for reference.
# See: https://docs.appimage.org/packaging-guide/optional/appstream.html
MetaFile =
# Optional icon file paths. The value may include multiple filenames separated with semicolon or given
# in multi-line form. Valid types are SVG, PNG and ICO (ICO ignored on Linux). Note that the inclusion
# of a scalable SVG is preferable on Linux, whereas PNGs must be one of the standard sizes and MUST
# include the size in the filename in the form: name.32x32.png' or 'name.32.png'.
IconFiles = """
StabilityMatrix.Avalonia/Assets/Icon.512x512.png
StabilityMatrix.Avalonia/Assets/Icon.ico
"""
########################################
# DOTNET PUBLISH
########################################
# Optional path relative to this file in which to find the dotnet project (.csproj) or solution (.sln)
# file, or the directory containing it. If empty (default), a single project or solution file is
# expected under the same directory as this file. IMPORTANT. If set to 'NONE', dotnet publish
# is disabled (not called). Instead, only DotnetPostPublish is called.
DotnetProjectPath = StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj
# Optional arguments supplied to 'dotnet publish'. Do NOT include '-r' (runtime), or '-c' (configuration)
# here as they will be added according to command line arguments. Typically you want as a minimum:
# '-p:Version=${APP_VERSION} --self-contained true'. Additional useful arguments include:
# '-p:DebugType=None -p:DebugSymbols=false -p:PublishSingleFile=true -p:PublishReadyToRun=true
# -p:PublishTrimmed=true -p:TrimMode=link'. Note. This value may use macro variables. Use 'pupnet --help macro'
# for reference. See: https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-publish
DotnetPublishArgs = -p:Version=${APP_VERSION} --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=None -p:DebugSymbols=false -p:PublishTrimmed=true
# Post-publish (or standalone build) command on Linux (ignored on Windows). It is called after dotnet
# publish, but before the final output is built. This could, for example, be a script which copies
# additional files into the build directory given by ${BUILD_APP_BIN}. The working directory will be
# the location of this file. This value is optional, but becomes mandatory if DotnetProjectPath equals
# 'NONE'. Note. This value may use macro variables. Additionally, scripts may use these as environment
# variables. Use 'pupnet --help macro' for reference.
DotnetPostPublish =
# Post-publish (or standalone build) command on Windows (ignored on Linux). This should perform
# the equivalent operation, as required, as DotnetPostPublish, but using DOS commands and batch
# scripts. Multiple commands may be specified, separated by semicolon or given in multi-line form.
# Note. This value may use macro variables. Additionally, scripts may use these as environment
# variables. Use 'pupnet --help macro' for reference.
DotnetPostPublishOnWindows =
########################################
# PACKAGE OUTPUT
########################################
# Optional package name (excludes version etc.). If empty, defaults to AppBaseName. However, it is
# used not only to specify the base output filename, but to identify the application in DEB and RPM
# packages. You may wish, therefore, to ensure that the value represents a unique name. Naming
# requirements are strict and must contain only alpha-numeric and '-', '+' and '.' characters.
PackageName = StabilityMatrix
# Output directory, or subdirectory relative to this file. It will be created if it does not exist and
# will contain the final deploy output files. If empty, it defaults to the location of this file.
OutputDirectory = Release/linux-x64
########################################
# APPIMAGE OPTIONS
########################################
# Additional arguments for use with appimagetool. Useful for signing. Default is empty.
AppImageArgs =
# Boolean (true or false) which sets whether to include the application version in the AppImage filename,
# i.e. 'HelloWorld-1.2.3-x86_64.AppImage'. Default is false. It is ignored if the output filename is
# specified at command line.
AppImageVersionOutput = false
########################################
# FLATPAK OPTIONS
########################################
# The runtime platform. Invariably for .NET (inc. Avalonia), this should be 'org.freedesktop.Platform'.
# Refer: https://docs.flatpak.org/en/latest/available-runtimes.html
FlatpakPlatformRuntime = org.freedesktop.Platform
# The platform SDK. Invariably for .NET (inc. Avalonia applications) this should be 'org.freedesktop.Sdk'.
# The SDK must be installed on the build system.
FlatpakPlatformSdk = org.freedesktop.Sdk
# The platform runtime version. The latest available version may change periodically.
# Refer to Flatpak documentation.
FlatpakPlatformVersion = 22.08
# Flatpak manifest 'finish-args' sandbox permissions. Optional, but if empty, the application will have
# extremely limited access to the host environment. This option may be used to grant required
# application permissions. Values here should be prefixed with '--' and separated by semicolon or given
# in multi-line form. Refer: https://docs.flatpak.org/en/latest/sandbox-permissions.html
FlatpakFinishArgs = """
--socket=wayland
--socket=x11
--filesystem=host
--share=network
"""
# Additional arguments for use with flatpak-builder. Useful for signing. Default is empty.
# See flatpak-builder --help.
FlatpakBuilderArgs =
########################################
# RPM OPTIONS
########################################
# Boolean (true or false) which specifies whether to build the RPM package with 'AutoReq' equal to yes or no.
# For dotnet application, the value should typically be false, but see RpmRequires below.
# Refer: https://rpm-software-management.github.io/rpm/manual/spec.html
RpmAutoReq = false
# Boolean (true or false) which specifies whether to build the RPM package with 'AutoProv' equal to yes or no.
# Refer: https://rpm-software-management.github.io/rpm/manual/spec.html
RpmAutoProv = true
# Optional list of RPM dependencies. The list may include multiple values separated with semicolon or given
# in multi-line form. If empty, a self-contained dotnet package will successfully run on many (but not all)
# Linux distros. In some cases, it will be necessary to explicitly specify additional dependencies.
# Default values are recommended for use with dotnet and RPM packages at the time of writing.
# For updated information, see: https://learn.microsoft.com/en-us/dotnet/core/install/linux-rhel#dependencies
RpmRequires = """
krb5-libs
libicu
openssl-libs
zlib
"""
########################################
# DEBIAN OPTIONS
########################################
# Optional list of Debian dependencies. The list may include multiple values separated with semicolon or given
# in multi-line form. If empty, a self-contained dotnet package will successfully run on many (but not all)
# Linux distros. In some cases, it will be necessary to explicitly specify additional dependencies.
# Default values are recommended for use with dotnet and Debian packages at the time of writing.
# For updated information, see: https://learn.microsoft.com/en-us/dotnet/core/install/linux-ubuntu#dependencies
DebianRecommends = """
libc6
libgcc1
libgcc-s1
libgssapi-krb5-2
libicu
libssl
libstdc++6
libunwind
zlib1g
"""
########################################
# WINDOWS SETUP OPTIONS
########################################
# Boolean (true or false) which specifies whether the application is to be installed in administrative
# mode, or per-user. Default is false. See: https://jrsoftware.org/ishelp/topic_admininstallmode.htm
SetupAdminInstall = false
# Optional command prompt title. The Windows installer will NOT add your application to the path. However,
# if your package contains a command-line utility, setting this value will ensure that a 'Command Prompt'
# program menu entry is added (with this title) which, when launched, will open a dedicated command
# window with your application directory in its path. Default is empty. See also StartCommand.
SetupCommandPrompt = Command Prompt
# Mandatory value which specifies minimum version of Windows that your software runs on. Windows 8 = 6.2,
# Windows 10/11 = 10. Default: 10. See: https://jrsoftware.org/ishelp/topic_setup_minversion.htm
SetupMinWindowsVersion = 10
# Optional name and parameters of the Sign Tool to be used to digitally sign: the installer,
# uninstaller, and contained exe and dll files. If empty, files will not be signed.
# See: https://jrsoftware.org/ishelp/topic_setup_signtool.htm
SetupSignTool =
# Optional suffix for the installer output filename. The default is empty, but you may wish set it to:
# 'Setup' or similar. This, for example, will output a file of name: HelloWorldSetup-x86_64.exe
# Ignored if the output filename is specified at command line.
SetupSuffixOutput =
# Boolean (true or false) which sets whether to include the application version in the setup filename,
# i.e. 'HelloWorld-1.2.3-x86_64.exe'. Default is false. Ignored if the output filename is specified
# at command line.
SetupVersionOutput = false

31
StabilityMatrix.Avalonia/App.axaml

@ -0,0 +1,31 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="StabilityMatrix.Avalonia.App"
xmlns:local="using:StabilityMatrix.Avalonia"
xmlns:styling="clr-namespace:FluentAvalonia.Styling;assembly=FluentAvalonia"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceInclude Source="Styles/ThemeColors.axaml"/>
<ResourceInclude Source="Styles/Card.axaml"/>
</ResourceDictionary.MergedDictionaries>
<x:Double x:Key="ContentDialogMaxWidth">700</x:Double>
</ResourceDictionary>
</Application.Resources>
<Application.Styles>
<styling:FluentAvaloniaTheme PreferUserAccentColor="True" UseSystemFontOnWindows="True" />
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml"/>
<StyleInclude Source="avares://AsyncImageLoader.Avalonia/AdvancedImage.axaml" />
<StyleInclude Source="Styles/ProgressRing.axaml"/>
<StyleInclude Source="Styles/ButtonStyles.axaml"/>
</Application.Styles>
</Application>

521
StabilityMatrix.Avalonia/App.axaml.cs

@ -0,0 +1,521 @@
using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Platform.Storage;
using Avalonia.Styling;
using FluentAvalonia.UI.Controls;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NLog;
using NLog.Config;
using NLog.Extensions.Logging;
using NLog.Targets;
using Octokit;
using Polly;
using Polly.Contrib.WaitAndRetry;
using Polly.Extensions.Http;
using Polly.Timeout;
using Refit;
using Sentry;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.DesignData;
using StabilityMatrix.Avalonia.Helpers;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Core.Api;
using StabilityMatrix.Core.Converters.Json;
using StabilityMatrix.Core.Database;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Helper.Cache;
using StabilityMatrix.Core.Helper.Factory;
using StabilityMatrix.Core.Models.Api;
using StabilityMatrix.Core.Models.Configs;
using StabilityMatrix.Core.Models.Packages;
using StabilityMatrix.Core.Models.Settings;
using StabilityMatrix.Core.Python;
using StabilityMatrix.Core.Services;
using StabilityMatrix.Core.Updater;
using Application = Avalonia.Application;
using LogLevel = Microsoft.Extensions.Logging.LogLevel;
namespace StabilityMatrix.Avalonia;
public sealed class App : Application
{
[NotNull] public static IServiceProvider? Services { get; private set; }
[NotNull] public static Visual? VisualRoot { get; private set; }
[NotNull] public static IStorageProvider? StorageProvider { get; private set; }
[NotNull] public static IConfiguration? Config { get; private set; }
// ReSharper disable once MemberCanBePrivate.Global
public IClassicDesktopStyleApplicationLifetime? DesktopLifetime =>
ApplicationLifetime as IClassicDesktopStyleApplicationLifetime;
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
// Set design theme
if (Design.IsDesignMode)
{
RequestedThemeVariant = ThemeVariant.Dark;
}
}
public override void OnFrameworkInitializationCompleted()
{
base.OnFrameworkInitializationCompleted();
if (Design.IsDesignMode)
{
DesignData.DesignData.Initialize();
Services = DesignData.DesignData.Services;
}
else
{
ConfigureServiceProvider();
}
if (DesktopLifetime is not null)
{
DesktopLifetime.ShutdownMode = ShutdownMode.OnExplicitShutdown;
// First time setup if needed
var settingsManager = Services.GetRequiredService<ISettingsManager>();
if (!settingsManager.IsEulaAccepted())
{
var setupWindow = Services.GetRequiredService<FirstLaunchSetupWindow>();
var setupViewModel = Services.GetRequiredService<FirstLaunchSetupViewModel>();
setupWindow.DataContext = setupViewModel;
setupWindow.ShowAsDialog = true;
setupWindow.ShowActivated = true;
setupWindow.ShowAsyncCts = new CancellationTokenSource();
DesktopLifetime.MainWindow = setupWindow;
setupWindow.ShowAsyncCts.Token.Register(() =>
{
if (setupWindow.Result == ContentDialogResult.Primary)
{
settingsManager.SetEulaAccepted();
ShowMainWindow();
DesktopLifetime.MainWindow.Show();
}
else
{
Shutdown();
}
});
}
else
{
ShowMainWindow();
}
}
}
private void ShowMainWindow()
{
if (DesktopLifetime is null) return;
var mainViewModel = Services.GetRequiredService<MainWindowViewModel>();
var notificationService = Services.GetRequiredService<INotificationService>();
var mainWindow = Services.GetRequiredService<MainWindow>();
mainWindow.DataContext = mainViewModel;
mainWindow.NotificationService = notificationService;
var settingsManager = Services.GetRequiredService<ISettingsManager>();
var windowSettings = settingsManager.Settings.WindowSettings;
if (windowSettings != null)
{
mainWindow.Position = new PixelPoint(windowSettings.X, windowSettings.Y);
mainWindow.Width = windowSettings.Width;
mainWindow.Height = windowSettings.Height;
}
else
{
mainWindow.WindowStartupLocation = WindowStartupLocation.CenterScreen;
}
mainWindow.Closing += (_, _) =>
{
settingsManager.Transaction(s =>
{
s.WindowSettings = new WindowSettings(
mainWindow.Width, mainWindow.Height,
mainWindow.Position.X, mainWindow.Position.Y);
}, ignoreMissingLibraryDir: true);
};
mainWindow.Closed += (_, _) => Shutdown();
VisualRoot = mainWindow;
StorageProvider = mainWindow.StorageProvider;
DesktopLifetime.MainWindow = mainWindow;
DesktopLifetime.Exit += OnExit;
}
private static void ConfigureServiceProvider()
{
var services = ConfigureServices();
Services = services.BuildServiceProvider();
var settingsManager = Services.GetRequiredService<ISettingsManager>();
settingsManager.TryFindLibrary();
Services.GetRequiredService<ProgressManagerViewModel>().StartEventListener();
}
internal static void ConfigurePageViewModels(IServiceCollection services)
{
services.AddSingleton<PackageManagerViewModel>()
.AddSingleton<SettingsViewModel>()
.AddSingleton<CheckpointBrowserViewModel>()
.AddSingleton<CheckpointBrowserCardViewModel>()
.AddSingleton<CheckpointsPageViewModel>()
.AddSingleton<LaunchPageViewModel>()
.AddSingleton<ProgressManagerViewModel>();
services.AddSingleton<MainWindowViewModel>(provider =>
new MainWindowViewModel(provider.GetRequiredService<ISettingsManager>(),
provider.GetRequiredService<ServiceManager<ViewModelBase>>())
{
Pages =
{
provider.GetRequiredService<LaunchPageViewModel>(),
provider.GetRequiredService<PackageManagerViewModel>(),
provider.GetRequiredService<CheckpointsPageViewModel>(),
provider.GetRequiredService<CheckpointBrowserViewModel>(),
},
FooterPages =
{
provider.GetRequiredService<SettingsViewModel>()
}
});
// Register disposable view models for shutdown cleanup
services.AddSingleton<IDisposable>(p
=> p.GetRequiredService<LaunchPageViewModel>());
}
internal static void ConfigureDialogViewModels(IServiceCollection services)
{
// Dialog view models (transient)
services.AddTransient<InstallerViewModel>();
services.AddTransient<OneClickInstallViewModel>();
services.AddTransient<SelectModelVersionViewModel>();
services.AddTransient<SelectDataDirectoryViewModel>();
services.AddTransient<LaunchOptionsViewModel>();
services.AddTransient<ExceptionViewModel>();
services.AddSingleton<FirstLaunchSetupViewModel>();
services.AddSingleton<UpdateViewModel>();
// Other transients (usually sub view models)
services.AddTransient<CheckpointFolder>();
services.AddTransient<CheckpointFile>();
// Global progress
services.AddSingleton<ProgressManagerViewModel>();
// Controls
services.AddTransient<RefreshBadgeViewModel>();
// Dialog factory
services.AddSingleton<ServiceManager<ViewModelBase>>(provider =>
new ServiceManager<ViewModelBase>()
.Register(provider.GetRequiredService<InstallerViewModel>)
.Register(provider.GetRequiredService<OneClickInstallViewModel>)
.Register(provider.GetRequiredService<SelectModelVersionViewModel>)
.Register(provider.GetRequiredService<SelectDataDirectoryViewModel>)
.Register(provider.GetRequiredService<LaunchOptionsViewModel>)
.Register(provider.GetRequiredService<UpdateViewModel>)
.Register(provider.GetRequiredService<CheckpointFolder>)
.Register(provider.GetRequiredService<CheckpointFile>)
.Register(provider.GetRequiredService<RefreshBadgeViewModel>)
.Register(provider.GetRequiredService<ExceptionViewModel>)
.Register(provider.GetRequiredService<ProgressManagerViewModel>)
.Register(provider.GetRequiredService<FirstLaunchSetupViewModel>));
}
internal static void ConfigureViews(IServiceCollection services)
{
// Pages
services.AddSingleton<CheckpointsPage>();
services.AddSingleton<LaunchPageView>();
services.AddSingleton<PackageManagerPage>();
services.AddSingleton<SettingsPage>();
services.AddSingleton<CheckpointBrowserPage>();
services.AddSingleton<ProgressManagerPage>();
// Dialogs
services.AddTransient<SelectDataDirectoryDialog>();
services.AddTransient<LaunchOptionsDialog>();
services.AddTransient<UpdateDialog>();
services.AddTransient<ExceptionDialog>();
// Controls
services.AddTransient<RefreshBadge>();
// Windows
services.AddSingleton<MainWindow>();
services.AddSingleton<FirstLaunchSetupWindow>();
}
internal static void ConfigurePackages(IServiceCollection services)
{
services.AddSingleton<BasePackage, A3WebUI>();
services.AddSingleton<BasePackage, VladAutomatic>();
services.AddSingleton<BasePackage, ComfyUI>();
}
private static IServiceCollection ConfigureServices()
{
var services = new ServiceCollection();
services.AddMemoryCache();
ConfigurePageViewModels(services);
ConfigureDialogViewModels(services);
ConfigurePackages(services);
// Other services
services.AddSingleton<ISettingsManager, SettingsManager>();
services.AddSingleton<ISharedFolders, SharedFolders>();
services.AddSingleton<SharedState>();
services.AddSingleton<ModelFinder>();
services.AddSingleton<IPackageFactory, PackageFactory>();
services.AddSingleton<IDownloadService, DownloadService>();
services.AddSingleton<IGithubApiCache, GithubApiCache>();
services.AddSingleton<INotificationService, NotificationService>();
services.AddSingleton<IPyRunner, PyRunner>();
services.AddSingleton<IUpdateHelper, UpdateHelper>();
Config = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.Build();
services.Configure<DebugOptions>(Config.GetSection(nameof(DebugOptions)));
if (Compat.IsWindows)
{
services.AddSingleton<IPrerequisiteHelper, WindowsPrerequisiteHelper>();
}
else if (Compat.IsLinux || Compat.IsMacOS)
{
services.AddSingleton<IPrerequisiteHelper, UnixPrerequisiteHelper>();
}
ConfigureViews(services);
if (Design.IsDesignMode)
{
services.AddSingleton<ILiteDbContext, MockLiteDbContext>();
}
else
{
services.AddSingleton<ILiteDbContext, LiteDbContext>();
services.AddSingleton<IDisposable>(p => p.GetRequiredService<ILiteDbContext>());
}
services.AddTransient<IGitHubClient, GitHubClient>(_ =>
{
var client = new GitHubClient(new ProductHeaderValue("StabilityMatrix"));
// var githubApiKey = Config["GithubApiKey"];
// if (string.IsNullOrWhiteSpace(githubApiKey))
// return client;
//
// client.Credentials = new Credentials(githubApiKey);
return client;
});
// Configure Refit and Polly
var jsonSerializerOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
jsonSerializerOptions.Converters.Add(new ObjectToInferredTypesConverter());
jsonSerializerOptions.Converters.Add(new DefaultUnknownEnumConverter<CivitFileType>());
jsonSerializerOptions.Converters.Add(
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
jsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
var defaultRefitSettings = new RefitSettings
{
ContentSerializer =
new SystemTextJsonContentSerializer(jsonSerializerOptions)
};
// HTTP Policies
var retryStatusCodes = new[]
{
HttpStatusCode.RequestTimeout, // 408
HttpStatusCode.InternalServerError, // 500
HttpStatusCode.BadGateway, // 502
HttpStatusCode.ServiceUnavailable, // 503
HttpStatusCode.GatewayTimeout // 504
};
var delay = Backoff
.DecorrelatedJitterBackoffV2(medianFirstRetryDelay: TimeSpan.FromMilliseconds(80),
retryCount: 5);
var retryPolicy = HttpPolicyExtensions
.HandleTransientHttpError()
.Or<TimeoutRejectedException>()
.OrResult(r => retryStatusCodes.Contains(r.StatusCode))
.WaitAndRetryAsync(delay);
// Shorter timeout for local requests
var localTimeout = Policy.TimeoutAsync<HttpResponseMessage>(TimeSpan.FromSeconds(3));
var localDelay = Backoff
.DecorrelatedJitterBackoffV2(medianFirstRetryDelay: TimeSpan.FromMilliseconds(50),
retryCount: 3);
var localRetryPolicy = HttpPolicyExtensions
.HandleTransientHttpError()
.Or<TimeoutRejectedException>()
.OrResult(r => retryStatusCodes.Contains(r.StatusCode))
.WaitAndRetryAsync(localDelay, onRetryAsync: (_, _) =>
{
Debug.WriteLine("Retrying local request...");
return Task.CompletedTask;
});
// named client for update
services.AddHttpClient("UpdateClient")
.AddPolicyHandler(retryPolicy);
// Add Refit clients
services.AddRefitClient<ICivitApi>(defaultRefitSettings)
.ConfigureHttpClient(c =>
{
c.BaseAddress = new Uri("https://civitai.com");
c.Timeout = TimeSpan.FromSeconds(15);
})
.AddPolicyHandler(retryPolicy);
// Add Refit client managers
services.AddHttpClient("A3Client")
.AddPolicyHandler(localTimeout.WrapAsync(localRetryPolicy));
// Add logging
services.AddLogging(builder =>
{
builder.ClearProviders();
builder.AddFilter("Microsoft.Extensions.Http", LogLevel.Warning)
.AddFilter("Microsoft.Extensions.Http.DefaultHttpClientFactory", LogLevel.Warning)
.AddFilter("Microsoft", LogLevel.Warning)
.AddFilter("System", LogLevel.Warning);
builder.SetMinimumLevel(LogLevel.Debug);
builder.AddNLog(ConfigureLogging());
});
return services;
}
/// <summary>
/// Requests shutdown of the Current Application.
/// </summary>
/// <remarks>This returns asynchronously *without waiting* for Shutdown</remarks>
/// <param name="exitCode">Exit code for the application.</param>
/// <exception cref="NullReferenceException">If Application.Current is null</exception>
public static void Shutdown(int exitCode = 0)
{
if (Current is null) throw new NullReferenceException(
"Current Application was null when Shutdown called");
if (Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime lifetime)
{
lifetime.Shutdown(exitCode);
}
}
private static void OnExit(object? sender, ControlledApplicationLifetimeExitEventArgs args)
{
Debug.WriteLine("Start OnExit");
// Services.GetRequiredService<LaunchViewModel>().OnShutdown();
var settingsManager = Services.GetRequiredService<ISettingsManager>();
// If RemoveFolderLinksOnShutdown is set, delete all package junctions
if (settingsManager is
{
IsLibraryDirSet: true,
Settings.RemoveFolderLinksOnShutdown: true
})
{
var sharedFolders = Services.GetRequiredService<ISharedFolders>();
sharedFolders.RemoveLinksForAllPackages();
}
Debug.WriteLine("Start OnExit: Disposing services");
// Dispose all services
foreach (var disposable in Services.GetServices<IDisposable>())
{
Debug.WriteLine($"Disposing {disposable.GetType().Name}");
disposable.Dispose();
}
Debug.WriteLine("End OnExit");
}
private static LoggingConfiguration ConfigureLogging()
{
var logConfig = new LoggingConfiguration();
// File target
logConfig.AddRule(NLog.LogLevel.Debug, NLog.LogLevel.Fatal,
new FileTarget("logfile")
{
Layout = "${longdate}|${level:uppercase=true}|${logger}|${message:withexception=true}",
ArchiveOldFileOnStartup = true,
FileName = "${specialfolder:folder=ApplicationData}/StabilityMatrix/app.log",
ArchiveFileName = "${specialfolder:folder=ApplicationData}/StabilityMatrix/app.{#}.log",
ArchiveNumbering = ArchiveNumberingMode.Rolling,
MaxArchiveFiles = 2
});
// Debugger Target
logConfig.AddRule(NLog.LogLevel.Trace, NLog.LogLevel.Fatal,
new DebuggerTarget("debugger")
{
Layout = "${message}"
});
// Sentry
if (SentrySdk.IsEnabled)
{
logConfig.AddSentry(o =>
{
o.InitializeSdk = false;
o.Layout = "${message}";
o.ShutdownTimeoutSeconds = 5;
o.IncludeEventDataOnBreadcrumbs = true;
o.BreadcrumbLayout = "${logger}: ${message}";
// Debug and higher are stored as breadcrumbs (default is Info)
o.MinimumBreadcrumbLevel = NLog.LogLevel.Debug;
// Error and higher is sent as event (default is Error)
o.MinimumEventLevel = NLog.LogLevel.Error;
});
}
LogManager.Configuration = logConfig;
return logConfig;
}
}

80
StabilityMatrix.Avalonia/Assets.cs

@ -0,0 +1,80 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.Versioning;
using Avalonia.Platform;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Core.Helper;
namespace StabilityMatrix.Avalonia;
internal static class Assets
{
/// <summary>
/// Fixed image for models with no images.
/// </summary>
public static Uri NoImage { get; } =
new("avares://StabilityMatrix.Avalonia/Assets/noimage.png");
public static AvaloniaResource LicensesJson => new(
"avares://StabilityMatrix.Avalonia/Assets/licenses.json");
[SupportedOSPlatform("windows")]
[SupportedOSPlatform("linux")]
public static AvaloniaResource SevenZipExecutable => Compat.Switch(
(PlatformKind.Windows,
new AvaloniaResource("avares://StabilityMatrix.Avalonia/Assets/win-x64/7za.exe")),
(PlatformKind.Linux | PlatformKind.X64,
new AvaloniaResource("avares://StabilityMatrix.Avalonia/Assets/linux-x64/7zzs", (UnixFileMode) 0x777)));
[SupportedOSPlatform("windows")]
[SupportedOSPlatform("linux")]
public static AvaloniaResource SevenZipLicense => Compat.Switch(
(PlatformKind.Windows, new AvaloniaResource("avares://StabilityMatrix.Avalonia/Assets/win-x64/7za - LICENSE.txt")),
(PlatformKind.Linux | PlatformKind.X64, new AvaloniaResource("avares://StabilityMatrix.Avalonia/Assets/linux-x64/7zzs - LICENSE.txt")));
public static AvaloniaResource PyScriptSiteCustomize => new(
"avares://StabilityMatrix.Avalonia/Assets/sitecustomize.py");
[SupportedOSPlatform("windows")]
public static AvaloniaResource PyScriptGetPip => new(
"avares://StabilityMatrix.Avalonia/Assets/win-x64/get-pip.pyc");
[SupportedOSPlatform("windows")]
public static IEnumerable<(AvaloniaResource resource, string relativePath)> PyModuleVenv =>
FindAssets("win-x64/venv/");
[SupportedOSPlatform("windows")]
[SupportedOSPlatform("linux")]
[SupportedOSPlatform("macos")]
public static RemoteResource PythonDownloadUrl => Compat.Switch(
(PlatformKind.Windows | PlatformKind.X64, new RemoteResource(
new Uri("https://www.python.org/ftp/python/3.10.11/python-3.10.11-embed-amd64.zip"),
"608619f8619075629c9c69f361352a0da6ed7e62f83a0e19c63e0ea32eb7629d")),
(PlatformKind.Linux | PlatformKind.X64, new RemoteResource(
new Uri("https://github.com/indygreg/python-build-standalone/releases/download/20230507/cpython-3.10.11+20230507-x86_64-unknown-linux-gnu-install_only.tar.gz"),
"c5bcaac91bc80bfc29cf510669ecad12d506035ecb3ad85ef213416d54aecd79")),
(PlatformKind.MacOS | PlatformKind.Arm, new RemoteResource(
new Uri("https://github.com/indygreg/python-build-standalone/releases/download/20230507/cpython-3.10.11+20230507-aarch64-apple-darwin-install_only.tar.gz"),
"8348bc3c2311f94ec63751fb71bd0108174be1c4def002773cf519ee1506f96f")));
public static Uri DiscordServerUrl { get; } =
new("https://discord.com/invite/TUrgfECxHz");
public static Uri PatreonUrl { get; } =
new("https://patreon.com/StabilityMatrix");
/// <summary>
/// Yield AvaloniaResources given a relative directory path within the 'Assets' folder.
/// </summary>
public static IEnumerable<(AvaloniaResource resource, string relativePath)> FindAssets(string relativeAssetPath)
{
var baseUri = new Uri("avares://StabilityMatrix.Avalonia/Assets/");
var targetUri = new Uri(baseUri, relativeAssetPath);
var files = AssetLoader.GetAssets(targetUri, null);
foreach (var file in files)
{
yield return (new AvaloniaResource(file), targetUri.MakeRelativeUri(file).ToString());
}
}
}

BIN
StabilityMatrix.Avalonia/Assets/Icon.512x512.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
StabilityMatrix.Avalonia/Assets/Icon.ico

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
StabilityMatrix.Avalonia/Assets/Icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
StabilityMatrix.Avalonia/Assets/avalonia-logo.ico

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

1
StabilityMatrix.Avalonia/Assets/licenses.json

File diff suppressed because one or more lines are too long

BIN
StabilityMatrix.Avalonia/Assets/linux-x64/7zzs

Binary file not shown.

88
StabilityMatrix.Avalonia/Assets/linux-x64/7zzs - LICENSE.txt

@ -0,0 +1,88 @@
7-Zip
~~~~~
License for use and distribution
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
7-Zip Copyright (C) 1999-2023 Igor Pavlov.
The licenses for 7zz and 7zzs files are:
- The "GNU LGPL" as main license for most of the code
- The "GNU LGPL" with "unRAR license restriction" for some code
- The "BSD 3-clause License" for some code
Redistributions in binary form must reproduce related license information from this file.
Note:
You can use 7-Zip on any computer, including a computer in a commercial
organization. You don't need to register or pay for 7-Zip.
GNU LGPL information
--------------------
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You can receive a copy of the GNU Lesser General Public License from
http://www.gnu.org/
BSD 3-clause License
--------------------
The "BSD 3-clause License" is used for the code in 7z.dll that implements LZFSE data decompression.
That code was derived from the code in the "LZFSE compression library" developed by Apple Inc,
that also uses the "BSD 3-clause License":
----
Copyright (c) 2015-2016, Apple Inc. All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder(s) nor the names of any contributors may be used to endorse or promote products derived
from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
----
unRAR license restriction
-------------------------
The decompression engine for RAR archives was developed using source
code of unRAR program.
All copyrights to original unRAR code are owned by Alexander Roshal.
The license for original unRAR code has the following restriction:
The unRAR sources cannot be used to re-create the RAR compression algorithm,
which is proprietary. Distribution of modified unRAR sources in separate form
or as a part of other software is permitted, provided that it is clearly
stated in the documentation and source comments that the code may
not be used to develop a RAR (WinRAR) compatible archiver.
--
Igor Pavlov

BIN
StabilityMatrix.Avalonia/Assets/noimage.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

49
StabilityMatrix.Avalonia/Assets/sitecustomize.py

@ -0,0 +1,49 @@
"""
Startup site customization for Stability Matrix.
Currently this installs an audit hook to notify the parent process when input() is called,
so we can prompt the user to enter something.
"""
import sys
# Application Program Command escape sequence
# This wraps messages sent to the parent process.
esc_apc = "\x9F"
esc_prefix = "[SM;"
esc_st = "\x9C"
def send_apc(msg: str):
"""Send an Application Program Command to the parent process."""
sys.stdout.flush()
sys.stdout.write(esc_apc + esc_prefix + msg + esc_st)
sys.stdout.flush()
def send_apc_input(prompt: str):
"""Apc message for input() prompt."""
send_apc('{"type":"input","data":"' + str(prompt) + '"}')
def audit(event: str, *args):
"""Main audit hook function."""
# https://docs.python.org/3/library/functions.html#input
# input() raises audit event `builtins.input` with args (prompt: str) *before* reading from stdin.
# `builtins.input/result` raised after reading from stdin.
if event == "builtins.input":
try:
prompts = args[0] if args else ()
prompt = "".join(prompts)
send_apc_input(prompt)
except Exception:
pass
# Reconfigure stdout to UTF-8
# noinspection PyUnresolvedReferences
sys.stdout.reconfigure(encoding="utf-8")
# Install the audit hook
sys.addaudithook(audit)

43
StabilityMatrix.Avalonia/Assets/win-x64/7za - LICENSE.txt

@ -0,0 +1,43 @@
7-Zip Extra 18.01
-----------------
7-Zip Extra is package of extra modules of 7-Zip.
7-Zip Copyright (C) 1999-2018 Igor Pavlov.
7-Zip is free software. Read License.txt for more information about license.
Source code of binaries can be found at:
http://www.7-zip.org/
7-Zip Extra
~~~~~~~~~~~
License for use and distribution
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Copyright (C) 1999-2018 Igor Pavlov.
7-Zip Extra files are under the GNU LGPL license.
Notes:
You can use 7-Zip Extra on any computer, including a computer in a commercial
organization. You don't need to register or pay for 7-Zip.
GNU LGPL information
--------------------
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You can receive a copy of the GNU Lesser General Public License from
http://www.gnu.org/

BIN
StabilityMatrix.Avalonia/Assets/win-x64/7za.exe

Binary file not shown.

BIN
StabilityMatrix.Avalonia/Assets/win-x64/get-pip.pyc

Binary file not shown.

525
StabilityMatrix.Avalonia/Assets/win-x64/venv/__init__.py vendored

@ -0,0 +1,525 @@
"""
Virtual environment (venv) package for Python. Based on PEP 405.
Copyright (C) 2011-2014 Vinay Sajip.
Licensed to the PSF under a contributor agreement.
"""
import logging
import os
import shutil
import subprocess
import sys
import sysconfig
import types
CORE_VENV_DEPS = ('pip', 'setuptools')
logger = logging.getLogger(__name__)
class EnvBuilder:
"""
This class exists to allow virtual environment creation to be
customized. The constructor parameters determine the builder's
behaviour when called upon to create a virtual environment.
By default, the builder makes the system (global) site-packages dir
*un*available to the created environment.
If invoked using the Python -m option, the default is to use copying
on Windows platforms but symlinks elsewhere. If instantiated some
other way, the default is to *not* use symlinks.
:param system_site_packages: If True, the system (global) site-packages
dir is available to created environments.
:param clear: If True, delete the contents of the environment directory if
it already exists, before environment creation.
:param symlinks: If True, attempt to symlink rather than copy files into
virtual environment.
:param upgrade: If True, upgrade an existing virtual environment.
:param with_pip: If True, ensure pip is installed in the virtual
environment
:param prompt: Alternative terminal prefix for the environment.
:param upgrade_deps: Update the base venv modules to the latest on PyPI
"""
def __init__(self, system_site_packages=False, clear=False,
symlinks=False, upgrade=False, with_pip=False, prompt=None,
upgrade_deps=False):
self.system_site_packages = system_site_packages
self.clear = clear
self.symlinks = symlinks
self.upgrade = upgrade
self.with_pip = with_pip
if prompt == '.': # see bpo-38901
prompt = os.path.basename(os.getcwd())
self.prompt = prompt
self.upgrade_deps = upgrade_deps
def create(self, env_dir):
"""
Create a virtual environment in a directory.
:param env_dir: The target directory to create an environment in.
"""
env_dir = os.path.abspath(env_dir)
context = self.ensure_directories(env_dir)
# See issue 24875. We need system_site_packages to be False
# until after pip is installed.
true_system_site_packages = self.system_site_packages
self.system_site_packages = False
self.create_configuration(context)
self.setup_python(context)
if self.with_pip:
self._setup_pip(context)
if not self.upgrade:
self.setup_scripts(context)
self.post_setup(context)
if true_system_site_packages:
# We had set it to False before, now
# restore it and rewrite the configuration
self.system_site_packages = True
self.create_configuration(context)
if self.upgrade_deps:
self.upgrade_dependencies(context)
def clear_directory(self, path):
for fn in os.listdir(path):
fn = os.path.join(path, fn)
if os.path.islink(fn) or os.path.isfile(fn):
os.remove(fn)
elif os.path.isdir(fn):
shutil.rmtree(fn)
def ensure_directories(self, env_dir):
"""
Create the directories for the environment.
Returns a context object which holds paths in the environment,
for use by subsequent logic.
"""
def create_if_needed(d):
if not os.path.exists(d):
os.makedirs(d)
elif os.path.islink(d) or os.path.isfile(d):
raise ValueError('Unable to create directory %r' % d)
if os.path.exists(env_dir) and self.clear:
self.clear_directory(env_dir)
context = types.SimpleNamespace()
context.env_dir = env_dir
context.env_name = os.path.split(env_dir)[1]
prompt = self.prompt if self.prompt is not None else context.env_name
context.prompt = '(%s) ' % prompt
create_if_needed(env_dir)
executable = sys._base_executable
if not executable: # see gh-96861
raise ValueError('Unable to determine path to the running '
'Python interpreter. Provide an explicit path or '
'check that your PATH environment variable is '
'correctly set.')
dirname, exename = os.path.split(os.path.abspath(executable))
context.executable = executable
context.python_dir = dirname
context.python_exe = exename
if sys.platform == 'win32':
binname = 'Scripts'
incpath = 'Include'
libpath = os.path.join(env_dir, 'Lib', 'site-packages')
else:
binname = 'bin'
incpath = 'include'
libpath = os.path.join(env_dir, 'lib',
'python%d.%d' % sys.version_info[:2],
'site-packages')
context.inc_path = path = os.path.join(env_dir, incpath)
create_if_needed(path)
create_if_needed(libpath)
# Issue 21197: create lib64 as a symlink to lib on 64-bit non-OS X POSIX
if ((sys.maxsize > 2**32) and (os.name == 'posix') and
(sys.platform != 'darwin')):
link_path = os.path.join(env_dir, 'lib64')
if not os.path.exists(link_path): # Issue #21643
os.symlink('lib', link_path)
context.bin_path = binpath = os.path.join(env_dir, binname)
context.bin_name = binname
context.env_exe = os.path.join(binpath, exename)
create_if_needed(binpath)
# Assign and update the command to use when launching the newly created
# environment, in case it isn't simply the executable script (e.g. bpo-45337)
context.env_exec_cmd = context.env_exe
if sys.platform == 'win32':
# bpo-45337: Fix up env_exec_cmd to account for file system redirections.
# Some redirects only apply to CreateFile and not CreateProcess
real_env_exe = os.path.realpath(context.env_exe)
if os.path.normcase(real_env_exe) != os.path.normcase(context.env_exe):
logger.warning('Actual environment location may have moved due to '
'redirects, links or junctions.\n'
' Requested location: "%s"\n'
' Actual location: "%s"',
context.env_exe, real_env_exe)
context.env_exec_cmd = real_env_exe
return context
def create_configuration(self, context):
"""
Create a configuration file indicating where the environment's Python
was copied from, and whether the system site-packages should be made
available in the environment.
:param context: The information for the environment creation request
being processed.
"""
context.cfg_path = path = os.path.join(context.env_dir, 'pyvenv.cfg')
with open(path, 'w', encoding='utf-8') as f:
f.write('home = %s\n' % context.python_dir)
if self.system_site_packages:
incl = 'true'
else:
incl = 'false'
f.write('include-system-site-packages = %s\n' % incl)
f.write('version = %d.%d.%d\n' % sys.version_info[:3])
if self.prompt is not None:
f.write(f'prompt = {self.prompt!r}\n')
if os.name != 'nt':
def symlink_or_copy(self, src, dst, relative_symlinks_ok=False):
"""
Try symlinking a file, and if that fails, fall back to copying.
"""
force_copy = not self.symlinks
if not force_copy:
try:
if not os.path.islink(dst): # can't link to itself!
if relative_symlinks_ok:
assert os.path.dirname(src) == os.path.dirname(dst)
os.symlink(os.path.basename(src), dst)
else:
os.symlink(src, dst)
except Exception: # may need to use a more specific exception
logger.warning('Unable to symlink %r to %r', src, dst)
force_copy = True
if force_copy:
shutil.copyfile(src, dst)
else:
def symlink_or_copy(self, src, dst, relative_symlinks_ok=False):
"""
Try symlinking a file, and if that fails, fall back to copying.
"""
bad_src = os.path.lexists(src) and not os.path.exists(src)
if self.symlinks and not bad_src and not os.path.islink(dst):
try:
if relative_symlinks_ok:
assert os.path.dirname(src) == os.path.dirname(dst)
os.symlink(os.path.basename(src), dst)
else:
os.symlink(src, dst)
return
except Exception: # may need to use a more specific exception
logger.warning('Unable to symlink %r to %r', src, dst)
# On Windows, we rewrite symlinks to our base python.exe into
# copies of venvlauncher.exe
basename, ext = os.path.splitext(os.path.basename(src))
srcfn = os.path.join(os.path.dirname(__file__),
"scripts",
"nt",
basename + ext)
# Builds or venv's from builds need to remap source file
# locations, as we do not put them into Lib/venv/scripts
if sysconfig.is_python_build(True) or not os.path.isfile(srcfn):
if basename.endswith('_d'):
ext = '_d' + ext
basename = basename[:-2]
if basename == 'python':
basename = 'venvlauncher'
elif basename == 'pythonw':
basename = 'venvwlauncher'
src = os.path.join(os.path.dirname(src), basename + ext)
else:
src = srcfn
if not os.path.exists(src):
if not bad_src:
logger.warning('Unable to copy %r', src)
return
shutil.copyfile(src, dst)
def setup_python(self, context):
"""
Set up a Python executable in the environment.
:param context: The information for the environment creation request
being processed.
"""
binpath = context.bin_path
path = context.env_exe
copier = self.symlink_or_copy
dirname = context.python_dir
if os.name != 'nt':
copier(context.executable, path)
if not os.path.islink(path):
os.chmod(path, 0o755)
for suffix in ('python', 'python3', f'python3.{sys.version_info[1]}'):
path = os.path.join(binpath, suffix)
if not os.path.exists(path):
# Issue 18807: make copies if
# symlinks are not wanted
copier(context.env_exe, path, relative_symlinks_ok=True)
if not os.path.islink(path):
os.chmod(path, 0o755)
else:
if self.symlinks:
# For symlinking, we need a complete copy of the root directory
# If symlinks fail, you'll get unnecessary copies of files, but
# we assume that if you've opted into symlinks on Windows then
# you know what you're doing.
suffixes = [
f for f in os.listdir(dirname) if
os.path.normcase(os.path.splitext(f)[1]) in ('.exe', '.dll')
]
if sysconfig.is_python_build(True):
suffixes = [
f for f in suffixes if
os.path.normcase(f).startswith(('python', 'vcruntime'))
]
else:
suffixes = {'python.exe', 'python_d.exe', 'pythonw.exe', 'pythonw_d.exe'}
base_exe = os.path.basename(context.env_exe)
suffixes.add(base_exe)
for suffix in suffixes:
src = os.path.join(dirname, suffix)
if os.path.lexists(src):
copier(src, os.path.join(binpath, suffix))
if sysconfig.is_python_build(True):
# copy init.tcl
for root, dirs, files in os.walk(context.python_dir):
if 'init.tcl' in files:
tcldir = os.path.basename(root)
tcldir = os.path.join(context.env_dir, 'Lib', tcldir)
if not os.path.exists(tcldir):
os.makedirs(tcldir)
src = os.path.join(root, 'init.tcl')
dst = os.path.join(tcldir, 'init.tcl')
shutil.copyfile(src, dst)
break
def _call_new_python(self, context, *py_args, **kwargs):
"""Executes the newly created Python using safe-ish options"""
# gh-98251: We do not want to just use '-I' because that masks
# legitimate user preferences (such as not writing bytecode). All we
# really need is to ensure that the path variables do not overrule
# normal venv handling.
args = [context.env_exec_cmd, *py_args]
kwargs['env'] = env = os.environ.copy()
env['VIRTUAL_ENV'] = context.env_dir
env.pop('PYTHONHOME', None)
env.pop('PYTHONPATH', None)
kwargs['cwd'] = context.env_dir
kwargs['executable'] = context.env_exec_cmd
subprocess.check_output(args, **kwargs)
def _setup_pip(self, context):
"""Installs or upgrades pip in a virtual environment"""
self._call_new_python(context, '-m', 'ensurepip', '--upgrade',
'--default-pip', stderr=subprocess.STDOUT)
def setup_scripts(self, context):
"""
Set up scripts into the created environment from a directory.
This method installs the default scripts into the environment
being created. You can prevent the default installation by overriding
this method if you really need to, or if you need to specify
a different location for the scripts to install. By default, the
'scripts' directory in the venv package is used as the source of
scripts to install.
"""
path = os.path.abspath(os.path.dirname(__file__))
path = os.path.join(path, 'scripts')
self.install_scripts(context, path)
def post_setup(self, context):
"""
Hook for post-setup modification of the venv. Subclasses may install
additional packages or scripts here, add activation shell scripts, etc.
:param context: The information for the environment creation request
being processed.
"""
pass
def replace_variables(self, text, context):
"""
Replace variable placeholders in script text with context-specific
variables.
Return the text passed in , but with variables replaced.
:param text: The text in which to replace placeholder variables.
:param context: The information for the environment creation request
being processed.
"""
text = text.replace('__VENV_DIR__', context.env_dir)
text = text.replace('__VENV_NAME__', context.env_name)
text = text.replace('__VENV_PROMPT__', context.prompt)
text = text.replace('__VENV_BIN_NAME__', context.bin_name)
text = text.replace('__VENV_PYTHON__', context.env_exe)
return text
def install_scripts(self, context, path):
"""
Install scripts into the created environment from a directory.
:param context: The information for the environment creation request
being processed.
:param path: Absolute pathname of a directory containing script.
Scripts in the 'common' subdirectory of this directory,
and those in the directory named for the platform
being run on, are installed in the created environment.
Placeholder variables are replaced with environment-
specific values.
"""
binpath = context.bin_path
plen = len(path)
for root, dirs, files in os.walk(path):
if root == path: # at top-level, remove irrelevant dirs
for d in dirs[:]:
if d not in ('common', os.name):
dirs.remove(d)
continue # ignore files in top level
for f in files:
if (os.name == 'nt' and f.startswith('python')
and f.endswith(('.exe', '.pdb'))):
continue
srcfile = os.path.join(root, f)
suffix = root[plen:].split(os.sep)[2:]
if not suffix:
dstdir = binpath
else:
dstdir = os.path.join(binpath, *suffix)
if not os.path.exists(dstdir):
os.makedirs(dstdir)
dstfile = os.path.join(dstdir, f)
with open(srcfile, 'rb') as f:
data = f.read()
if not srcfile.endswith(('.exe', '.pdb')):
try:
data = data.decode('utf-8')
data = self.replace_variables(data, context)
data = data.encode('utf-8')
except UnicodeError as e:
data = None
logger.warning('unable to copy script %r, '
'may be binary: %s', srcfile, e)
if data is not None:
with open(dstfile, 'wb') as f:
f.write(data)
shutil.copymode(srcfile, dstfile)
def upgrade_dependencies(self, context):
logger.debug(
f'Upgrading {CORE_VENV_DEPS} packages in {context.bin_path}'
)
self._call_new_python(context, '-m', 'pip', 'install', '--upgrade',
*CORE_VENV_DEPS)
def create(env_dir, system_site_packages=False, clear=False,
symlinks=False, with_pip=False, prompt=None, upgrade_deps=False):
"""Create a virtual environment in a directory."""
builder = EnvBuilder(system_site_packages=system_site_packages,
clear=clear, symlinks=symlinks, with_pip=with_pip,
prompt=prompt, upgrade_deps=upgrade_deps)
builder.create(env_dir)
def main(args=None):
compatible = True
if sys.version_info < (3, 3):
compatible = False
elif not hasattr(sys, 'base_prefix'):
compatible = False
if not compatible:
raise ValueError('This script is only for use with Python >= 3.3')
else:
import argparse
parser = argparse.ArgumentParser(prog=__name__,
description='Creates virtual Python '
'environments in one or '
'more target '
'directories.',
epilog='Once an environment has been '
'created, you may wish to '
'activate it, e.g. by '
'sourcing an activate script '
'in its bin directory.')
parser.add_argument('dirs', metavar='ENV_DIR', nargs='+',
help='A directory to create the environment in.')
parser.add_argument('--system-site-packages', default=False,
action='store_true', dest='system_site',
help='Give the virtual environment access to the '
'system site-packages dir.')
if os.name == 'nt':
use_symlinks = False
else:
use_symlinks = True
group = parser.add_mutually_exclusive_group()
group.add_argument('--symlinks', default=use_symlinks,
action='store_true', dest='symlinks',
help='Try to use symlinks rather than copies, '
'when symlinks are not the default for '
'the platform.')
group.add_argument('--copies', default=not use_symlinks,
action='store_false', dest='symlinks',
help='Try to use copies rather than symlinks, '
'even when symlinks are the default for '
'the platform.')
parser.add_argument('--clear', default=False, action='store_true',
dest='clear', help='Delete the contents of the '
'environment directory if it '
'already exists, before '
'environment creation.')
parser.add_argument('--upgrade', default=False, action='store_true',
dest='upgrade', help='Upgrade the environment '
'directory to use this version '
'of Python, assuming Python '
'has been upgraded in-place.')
parser.add_argument('--without-pip', dest='with_pip',
default=True, action='store_false',
help='Skips installing or upgrading pip in the '
'virtual environment (pip is bootstrapped '
'by default)')
parser.add_argument('--prompt',
help='Provides an alternative prompt prefix for '
'this environment.')
parser.add_argument('--upgrade-deps', default=False, action='store_true',
dest='upgrade_deps',
help='Upgrade core dependencies: {} to the latest '
'version in PyPI'.format(
' '.join(CORE_VENV_DEPS)))
options = parser.parse_args(args)
if options.upgrade and options.clear:
raise ValueError('you cannot supply --upgrade and --clear together.')
builder = EnvBuilder(system_site_packages=options.system_site,
clear=options.clear,
symlinks=options.symlinks,
upgrade=options.upgrade,
with_pip=options.with_pip,
prompt=options.prompt,
upgrade_deps=options.upgrade_deps)
for d in options.dirs:
builder.create(d)
if __name__ == '__main__':
rc = 1
try:
main()
rc = 0
except Exception as e:
print('Error: %s' % e, file=sys.stderr)
sys.exit(rc)

10
StabilityMatrix.Avalonia/Assets/win-x64/venv/__main__.py vendored

@ -0,0 +1,10 @@
import sys
from . import main
rc = 1
try:
main()
rc = 0
except Exception as e:
print('Error: %s' % e, file=sys.stderr)
sys.exit(rc)

247
StabilityMatrix.Avalonia/Assets/win-x64/venv/scripts/common/Activate.ps1 vendored

@ -0,0 +1,247 @@
<#
.Synopsis
Activate a Python virtual environment for the current PowerShell session.
.Description
Pushes the python executable for a virtual environment to the front of the
$Env:PATH environment variable and sets the prompt to signify that you are
in a Python virtual environment. Makes use of the command line switches as
well as the `pyvenv.cfg` file values present in the virtual environment.
.Parameter VenvDir
Path to the directory that contains the virtual environment to activate. The
default value for this is the parent of the directory that the Activate.ps1
script is located within.
.Parameter Prompt
The prompt prefix to display when this virtual environment is activated. By
default, this prompt is the name of the virtual environment folder (VenvDir)
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
.Example
Activate.ps1
Activates the Python virtual environment that contains the Activate.ps1 script.
.Example
Activate.ps1 -Verbose
Activates the Python virtual environment that contains the Activate.ps1 script,
and shows extra information about the activation as it executes.
.Example
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
Activates the Python virtual environment located in the specified location.
.Example
Activate.ps1 -Prompt "MyPython"
Activates the Python virtual environment that contains the Activate.ps1 script,
and prefixes the current prompt with the specified string (surrounded in
parentheses) while the virtual environment is active.
.Notes
On Windows, it may be required to enable this Activate.ps1 script by setting the
execution policy for the user. You can do this by issuing the following PowerShell
command:
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
For more information on Execution Policies:
https://go.microsoft.com/fwlink/?LinkID=135170
#>
Param(
[Parameter(Mandatory = $false)]
[String]
$VenvDir,
[Parameter(Mandatory = $false)]
[String]
$Prompt
)
<# Function declarations --------------------------------------------------- #>
<#
.Synopsis
Remove all shell session elements added by the Activate script, including the
addition of the virtual environment's Python executable from the beginning of
the PATH variable.
.Parameter NonDestructive
If present, do not remove this function from the global namespace for the
session.
#>
function global:deactivate ([switch]$NonDestructive) {
# Revert to original values
# The prior prompt:
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
}
# The prior PYTHONHOME:
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
}
# The prior PATH:
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
}
# Just remove the VIRTUAL_ENV altogether:
if (Test-Path -Path Env:VIRTUAL_ENV) {
Remove-Item -Path env:VIRTUAL_ENV
}
# Just remove VIRTUAL_ENV_PROMPT altogether.
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
}
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
}
# Leave deactivate function in the global namespace if requested:
if (-not $NonDestructive) {
Remove-Item -Path function:deactivate
}
}
<#
.Description
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
given folder, and returns them in a map.
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
two strings separated by `=` (with any amount of whitespace surrounding the =)
then it is considered a `key = value` line. The left hand string is the key,
the right hand is the value.
If the value starts with a `'` or a `"` then the first and last character is
stripped from the value before being captured.
.Parameter ConfigDir
Path to the directory that contains the `pyvenv.cfg` file.
#>
function Get-PyVenvConfig(
[String]
$ConfigDir
) {
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
# An empty map will be returned if no config file is found.
$pyvenvConfig = @{ }
if ($pyvenvConfigPath) {
Write-Verbose "File exists, parse `key = value` lines"
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
$pyvenvConfigContent | ForEach-Object {
$keyval = $PSItem -split "\s*=\s*", 2
if ($keyval[0] -and $keyval[1]) {
$val = $keyval[1]
# Remove extraneous quotations around a string value.
if ("'""".Contains($val.Substring(0, 1))) {
$val = $val.Substring(1, $val.Length - 2)
}
$pyvenvConfig[$keyval[0]] = $val
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
}
}
}
return $pyvenvConfig
}
<# Begin Activate script --------------------------------------------------- #>
# Determine the containing directory of this script
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
$VenvExecDir = Get-Item -Path $VenvExecPath
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
# Set values required in priority: CmdLine, ConfigFile, Default
# First, get the location of the virtual environment, it might not be
# VenvExecDir if specified on the command line.
if ($VenvDir) {
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
}
else {
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
Write-Verbose "VenvDir=$VenvDir"
}
# Next, read the `pyvenv.cfg` file to determine any required value such
# as `prompt`.
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
# Next, set the prompt from the command line, or the config file, or
# just use the name of the virtual environment folder.
if ($Prompt) {
Write-Verbose "Prompt specified as argument, using '$Prompt'"
}
else {
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
$Prompt = $pyvenvCfg['prompt'];
}
else {
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
$Prompt = Split-Path -Path $venvDir -Leaf
}
}
Write-Verbose "Prompt = '$Prompt'"
Write-Verbose "VenvDir='$VenvDir'"
# Deactivate any currently active virtual environment, but leave the
# deactivate function in place.
deactivate -nondestructive
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
# that there is an activated venv.
$env:VIRTUAL_ENV = $VenvDir
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
Write-Verbose "Setting prompt to '$Prompt'"
# Set the prompt to include the env name
# Make sure _OLD_VIRTUAL_PROMPT is global
function global:_OLD_VIRTUAL_PROMPT { "" }
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
function global:prompt {
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
_OLD_VIRTUAL_PROMPT
}
$env:VIRTUAL_ENV_PROMPT = $Prompt
}
# Clear PYTHONHOME
if (Test-Path -Path Env:PYTHONHOME) {
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
Remove-Item -Path Env:PYTHONHOME
}
# Add the venv to the PATH
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"

69
StabilityMatrix.Avalonia/Assets/win-x64/venv/scripts/common/activate vendored

@ -0,0 +1,69 @@
# This file must be used with "source bin/activate" *from bash*
# you cannot run it directly
deactivate () {
# reset old environment variables
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
PATH="${_OLD_VIRTUAL_PATH:-}"
export PATH
unset _OLD_VIRTUAL_PATH
fi
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
export PYTHONHOME
unset _OLD_VIRTUAL_PYTHONHOME
fi
# This should detect bash and zsh, which have a hash command that must
# be called to get it to forget past commands. Without forgetting
# past commands the $PATH changes we made may not be respected
if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then
hash -r 2> /dev/null
fi
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
PS1="${_OLD_VIRTUAL_PS1:-}"
export PS1
unset _OLD_VIRTUAL_PS1
fi
unset VIRTUAL_ENV
unset VIRTUAL_ENV_PROMPT
if [ ! "${1:-}" = "nondestructive" ] ; then
# Self destruct!
unset -f deactivate
fi
}
# unset irrelevant variables
deactivate nondestructive
VIRTUAL_ENV="__VENV_DIR__"
export VIRTUAL_ENV
_OLD_VIRTUAL_PATH="$PATH"
PATH="$VIRTUAL_ENV/__VENV_BIN_NAME__:$PATH"
export PATH
# unset PYTHONHOME if set
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
# could use `if (set -u; : $PYTHONHOME) ;` in bash
if [ -n "${PYTHONHOME:-}" ] ; then
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
unset PYTHONHOME
fi
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
_OLD_VIRTUAL_PS1="${PS1:-}"
PS1="__VENV_PROMPT__${PS1:-}"
export PS1
VIRTUAL_ENV_PROMPT="__VENV_PROMPT__"
export VIRTUAL_ENV_PROMPT
fi
# This should detect bash and zsh, which have a hash command that must
# be called to get it to forget past commands. Without forgetting
# past commands the $PATH changes we made may not be respected
if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then
hash -r 2> /dev/null
fi

34
StabilityMatrix.Avalonia/Assets/win-x64/venv/scripts/nt/activate.bat vendored

@ -0,0 +1,34 @@
@echo off
rem This file is UTF-8 encoded, so we need to update the current code page while executing it
for /f "tokens=2 delims=:." %%a in ('"%SystemRoot%\System32\chcp.com"') do (
set _OLD_CODEPAGE=%%a
)
if defined _OLD_CODEPAGE (
"%SystemRoot%\System32\chcp.com" 65001 > nul
)
set VIRTUAL_ENV=__VENV_DIR__
if not defined PROMPT set PROMPT=$P$G
if defined _OLD_VIRTUAL_PROMPT set PROMPT=%_OLD_VIRTUAL_PROMPT%
if defined _OLD_VIRTUAL_PYTHONHOME set PYTHONHOME=%_OLD_VIRTUAL_PYTHONHOME%
set _OLD_VIRTUAL_PROMPT=%PROMPT%
set PROMPT=__VENV_PROMPT__%PROMPT%
if defined PYTHONHOME set _OLD_VIRTUAL_PYTHONHOME=%PYTHONHOME%
set PYTHONHOME=
if defined _OLD_VIRTUAL_PATH set PATH=%_OLD_VIRTUAL_PATH%
if not defined _OLD_VIRTUAL_PATH set _OLD_VIRTUAL_PATH=%PATH%
set PATH=%VIRTUAL_ENV%\__VENV_BIN_NAME__;%PATH%
set VIRTUAL_ENV_PROMPT=__VENV_PROMPT__
:END
if defined _OLD_CODEPAGE (
"%SystemRoot%\System32\chcp.com" %_OLD_CODEPAGE% > nul
set _OLD_CODEPAGE=
)

22
StabilityMatrix.Avalonia/Assets/win-x64/venv/scripts/nt/deactivate.bat vendored

@ -0,0 +1,22 @@
@echo off
if defined _OLD_VIRTUAL_PROMPT (
set "PROMPT=%_OLD_VIRTUAL_PROMPT%"
)
set _OLD_VIRTUAL_PROMPT=
if defined _OLD_VIRTUAL_PYTHONHOME (
set "PYTHONHOME=%_OLD_VIRTUAL_PYTHONHOME%"
set _OLD_VIRTUAL_PYTHONHOME=
)
if defined _OLD_VIRTUAL_PATH (
set "PATH=%_OLD_VIRTUAL_PATH%"
)
set _OLD_VIRTUAL_PATH=
set VIRTUAL_ENV=
set VIRTUAL_ENV_PROMPT=
:END

26
StabilityMatrix.Avalonia/Assets/win-x64/venv/scripts/posix/activate.csh vendored

@ -0,0 +1,26 @@
# This file must be used with "source bin/activate.csh" *from csh*.
# You cannot run it directly.
# Created by Davide Di Blasi <davidedb@gmail.com>.
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
# Unset irrelevant variables.
deactivate nondestructive
setenv VIRTUAL_ENV "__VENV_DIR__"
set _OLD_VIRTUAL_PATH="$PATH"
setenv PATH "$VIRTUAL_ENV/__VENV_BIN_NAME__:$PATH"
set _OLD_VIRTUAL_PROMPT="$prompt"
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
set prompt = "__VENV_PROMPT__$prompt"
setenv VIRTUAL_ENV_PROMPT "__VENV_PROMPT__"
endif
alias pydoc python -m pydoc
rehash

69
StabilityMatrix.Avalonia/Assets/win-x64/venv/scripts/posix/activate.fish vendored

@ -0,0 +1,69 @@
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
# (https://fishshell.com/); you cannot run it directly.
function deactivate -d "Exit virtual environment and return to normal shell environment"
# reset old environment variables
if test -n "$_OLD_VIRTUAL_PATH"
set -gx PATH $_OLD_VIRTUAL_PATH
set -e _OLD_VIRTUAL_PATH
end
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
set -e _OLD_VIRTUAL_PYTHONHOME
end
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
set -e _OLD_FISH_PROMPT_OVERRIDE
# prevents error when using nested fish instances (Issue #93858)
if functions -q _old_fish_prompt
functions -e fish_prompt
functions -c _old_fish_prompt fish_prompt
functions -e _old_fish_prompt
end
end
set -e VIRTUAL_ENV
set -e VIRTUAL_ENV_PROMPT
if test "$argv[1]" != "nondestructive"
# Self-destruct!
functions -e deactivate
end
end
# Unset irrelevant variables.
deactivate nondestructive
set -gx VIRTUAL_ENV "__VENV_DIR__"
set -gx _OLD_VIRTUAL_PATH $PATH
set -gx PATH "$VIRTUAL_ENV/__VENV_BIN_NAME__" $PATH
# Unset PYTHONHOME if set.
if set -q PYTHONHOME
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
set -e PYTHONHOME
end
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
# fish uses a function instead of an env var to generate the prompt.
# Save the current fish_prompt function as the function _old_fish_prompt.
functions -c fish_prompt _old_fish_prompt
# With the original prompt function renamed, we can override with our own.
function fish_prompt
# Save the return status of the last command.
set -l old_status $status
# Output the venv prompt; color taken from the blue of the Python logo.
printf "%s%s%s" (set_color 4B8BBE) "__VENV_PROMPT__" (set_color normal)
# Restore the return status of the previous command.
echo "exit $old_status" | .
# Output the original/"old" prompt.
_old_fish_prompt
end
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
set -gx VIRTUAL_ENV_PROMPT "__VENV_PROMPT__"
end

89
StabilityMatrix.Avalonia/Controls/AppWindowBase.cs

@ -0,0 +1,89 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using Avalonia.Interactivity;
using Avalonia.Threading;
using FluentAvalonia.UI.Windowing;
using StabilityMatrix.Avalonia.ViewModels;
namespace StabilityMatrix.Avalonia.Controls;
[SuppressMessage("ReSharper", "VirtualMemberNeverOverridden.Global")]
public class AppWindowBase : AppWindow
{
public CancellationTokenSource? ShowAsyncCts { get; set; }
protected AppWindowBase()
{
}
public void ShowWithCts(CancellationTokenSource cts)
{
ShowAsyncCts?.Cancel();
ShowAsyncCts = cts;
Show();
}
public Task ShowAsync()
{
ShowAsyncCts?.Cancel();
ShowAsyncCts = new CancellationTokenSource();
var tcs = new TaskCompletionSource<bool>();
ShowAsyncCts.Token.Register(s =>
{
((TaskCompletionSource<bool>) s!).SetResult(true);
}, tcs);
Show();
return tcs.Task;
}
protected override void OnClosed(EventArgs e)
{
base.OnClosed(e);
if (ShowAsyncCts is not null)
{
ShowAsyncCts.Cancel();
ShowAsyncCts = null;
}
}
protected override void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);
if (DataContext is ViewModelBase viewModel)
{
// Run synchronous load then async load
viewModel.OnLoaded();
// Can't block here so we'll run as async on UI thread
Dispatcher.UIThread.InvokeAsync(async () =>
{
await viewModel.OnLoadedAsync();
}).SafeFireAndForget();
}
}
protected override void OnUnloaded(RoutedEventArgs e)
{
base.OnUnloaded(e);
if (DataContext is not ViewModelBase viewModel)
return;
// Run synchronous load then async unload
viewModel.OnUnloaded();
// Can't block here so we'll run as async on UI thread
Dispatcher.UIThread.InvokeAsync(async () =>
{
await viewModel.OnUnloadedAsync();
}).SafeFireAndForget();
}
}

133
StabilityMatrix.Avalonia/Controls/BetterAdvancedImage.cs

@ -0,0 +1,133 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using AsyncImageLoader;
using Avalonia;
using Avalonia.Layout;
using Avalonia.Media;
namespace StabilityMatrix.Avalonia.Controls;
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
[SuppressMessage("ReSharper", "UnusedMember.Local")]
public class BetterAdvancedImage : AdvancedImage
{
#region Reflection Shenanigans to access private parent fields
[NotNull]
private static readonly FieldInfo? IsCornerRadiusUsedField = typeof(AdvancedImage).GetField(
"_isCornerRadiusUsed",BindingFlags.Instance | BindingFlags.NonPublic);
[NotNull]
private static readonly FieldInfo? CornerRadiusClipField = typeof(AdvancedImage).GetField(
"_cornerRadiusClip",BindingFlags.Instance | BindingFlags.NonPublic);
private bool IsCornerRadiusUsed
{
get => IsCornerRadiusUsedField.GetValue(this) as bool? ?? false;
set => IsCornerRadiusUsedField.SetValue(this, value);
}
private RoundedRect CornerRadiusClip
{
get => (RoundedRect) CornerRadiusClipField.GetValue(this)!;
set => CornerRadiusClipField.SetValue(this, value);
}
static BetterAdvancedImage()
{
if (IsCornerRadiusUsedField is null)
{
throw new NullReferenceException("IsCornerRadiusUsedField was not resolved");
}
if (CornerRadiusClipField is null)
{
throw new NullReferenceException("CornerRadiusClipField was not resolved");
}
}
#endregion
protected override Type StyleKeyOverride { get; } = typeof(AdvancedImage);
public BetterAdvancedImage(Uri? baseUri) : base(baseUri)
{
}
public BetterAdvancedImage(IServiceProvider serviceProvider) : base(serviceProvider)
{
}
/// <exception cref="ArgumentOutOfRangeException"></exception>
/// <inheritdoc />
public override void Render(DrawingContext context)
{
var source = CurrentImage;
if (source != null && Bounds is { Width: > 0, Height: > 0 })
{
var viewPort = new Rect(Bounds.Size);
var sourceSize = source.Size;
var scale = Stretch.CalculateScaling(Bounds.Size, sourceSize, StretchDirection);
var scaledSize = sourceSize * scale;
// Calculate starting points for dest
var destX = HorizontalContentAlignment switch
{
HorizontalAlignment.Left => 0,
HorizontalAlignment.Center => (int) (viewPort.Width - scaledSize.Width) / 2,
HorizontalAlignment.Right => (int) (viewPort.Width - scaledSize.Width),
// Stretch is default, use center
HorizontalAlignment.Stretch => (int) (viewPort.Width - scaledSize.Width) / 2,
_ => throw new ArgumentException(nameof(HorizontalContentAlignment))
};
var destY = VerticalContentAlignment switch
{
VerticalAlignment.Top => 0,
VerticalAlignment.Center => (int) (viewPort.Height - scaledSize.Height) / 2,
VerticalAlignment.Bottom => (int) (viewPort.Height - scaledSize.Height),
VerticalAlignment.Stretch => 0, // Stretch is default, use top
_ => throw new ArgumentException(nameof(VerticalContentAlignment))
};
var destRect = viewPort
.CenterRect(new Rect(scaledSize))
.WithX(destX)
.WithY(destY)
.Intersect(viewPort);
var destRectUnscaledSize = destRect.Size / scale;
// Calculate starting points for source
var sourceX = HorizontalContentAlignment switch
{
HorizontalAlignment.Left => 0,
HorizontalAlignment.Center => (int) (sourceSize - destRectUnscaledSize).Width / 2,
HorizontalAlignment.Right => (int) (sourceSize - destRectUnscaledSize).Width,
// Stretch is default, use center
HorizontalAlignment.Stretch => (int) (sourceSize - destRectUnscaledSize).Width / 2,
_ => throw new ArgumentException(nameof(HorizontalContentAlignment))
};
var sourceY = VerticalContentAlignment switch
{
VerticalAlignment.Top => 0,
VerticalAlignment.Center => (int) (sourceSize - destRectUnscaledSize).Height / 2,
VerticalAlignment.Bottom => (int) (sourceSize - destRectUnscaledSize).Height,
VerticalAlignment.Stretch => 0, // Stretch is default, use top
_ => throw new ArgumentException(nameof(VerticalContentAlignment))
};
var sourceRect = new Rect(sourceSize)
.CenterRect(new Rect(destRect.Size / scale))
.WithX(sourceX)
.WithY(sourceY);
DrawingContext.PushedState? pushedState =
IsCornerRadiusUsed ? context.PushClip(CornerRadiusClip) : null;
context.DrawImage(source, sourceRect, destRect);
pushedState?.Dispose();
}
else
{
base.Render(context);
}
}
}

254
StabilityMatrix.Avalonia/Controls/BetterContentDialog.cs

@ -0,0 +1,254 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using Avalonia.Threading;
using FluentAvalonia.UI.Controls;
using StabilityMatrix.Avalonia.ViewModels;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
namespace StabilityMatrix.Avalonia.Controls;
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
[SuppressMessage("ReSharper", "PropertyCanBeMadeInitOnly.Global")]
public class BetterContentDialog : ContentDialog
{
#region Reflection Shenanigans for setting content dialog result
[NotNull]
protected static readonly FieldInfo? ResultField = typeof(ContentDialog).GetField(
"_result",BindingFlags.Instance | BindingFlags.NonPublic);
protected ContentDialogResult Result
{
get => (ContentDialogResult) ResultField.GetValue(this)!;
set => ResultField.SetValue(this, value);
}
[NotNull]
protected static readonly MethodInfo? HideCoreMethod = typeof(ContentDialog).GetMethod(
"HideCore", BindingFlags.Instance | BindingFlags.NonPublic);
protected void HideCore()
{
HideCoreMethod.Invoke(this, null);
}
// Also get button properties to hide on command execution change
[NotNull]
protected static readonly FieldInfo? PrimaryButtonField = typeof(ContentDialog).GetField(
"_primaryButton", BindingFlags.Instance | BindingFlags.NonPublic);
protected Button? PrimaryButton
{
get => (Button?) PrimaryButtonField.GetValue(this)!;
set => PrimaryButtonField.SetValue(this, value);
}
[NotNull]
protected static readonly FieldInfo? SecondaryButtonField = typeof(ContentDialog).GetField(
"_secondaryButton", BindingFlags.Instance | BindingFlags.NonPublic);
protected Button? SecondaryButton
{
get => (Button?) SecondaryButtonField.GetValue(this)!;
set => SecondaryButtonField.SetValue(this, value);
}
[NotNull]
protected static readonly FieldInfo? CloseButtonField = typeof(ContentDialog).GetField(
"_closeButton", BindingFlags.Instance | BindingFlags.NonPublic);
protected Button? CloseButton
{
get => (Button?) CloseButtonField.GetValue(this)!;
set => CloseButtonField.SetValue(this, value);
}
static BetterContentDialog()
{
if (ResultField is null)
{
throw new NullReferenceException("ResultField was not resolved");
}
if (HideCoreMethod is null)
{
throw new NullReferenceException("HideCoreMethod was not resolved");
}
if (PrimaryButtonField is null || SecondaryButtonField is null || CloseButtonField is null)
{
throw new NullReferenceException("Button fields were not resolved");
}
}
#endregion
protected override Type StyleKeyOverride { get; } = typeof(ContentDialog);
public static readonly StyledProperty<bool> IsFooterVisibleProperty = AvaloniaProperty.Register<BetterContentDialog, bool>(
"IsFooterVisible", true);
public bool IsFooterVisible
{
get => GetValue(IsFooterVisibleProperty);
set => SetValue(IsFooterVisibleProperty, value);
}
public static readonly StyledProperty<ScrollBarVisibility> ContentVerticalScrollBarVisibilityProperty
= AvaloniaProperty.Register<BetterContentDialog, ScrollBarVisibility>(
"ContentScrollBarVisibility", ScrollBarVisibility.Auto);
public ScrollBarVisibility ContentVerticalScrollBarVisibility
{
get => GetValue(ContentVerticalScrollBarVisibilityProperty);
set => SetValue(ContentVerticalScrollBarVisibilityProperty, value);
}
public static readonly StyledProperty<double> MaxDialogWidthProperty = AvaloniaProperty.Register<BetterContentDialog, double>(
"MaxDialogWidth");
public double MaxDialogWidth
{
get => GetValue(MaxDialogWidthProperty);
set => SetValue(MaxDialogWidthProperty, value);
}
public static readonly StyledProperty<double> MaxDialogHeightProperty = AvaloniaProperty.Register<BetterContentDialog, double>(
"MaxDialogHeight");
public double MaxDialogHeight
{
get => GetValue(MaxDialogHeightProperty);
set => SetValue(MaxDialogHeightProperty, value);
}
public BetterContentDialog()
{
AddHandler(LoadedEvent, OnLoaded);
}
private void TryBindButtons()
{
if ((Content as Control)?.DataContext is ContentDialogViewModelBase viewModel)
{
viewModel.PrimaryButtonClick += OnDialogButtonClick;
viewModel.SecondaryButtonClick += OnDialogButtonClick;
viewModel.CloseButtonClick += OnDialogButtonClick;
}
// If commands provided, bind OnCanExecuteChanged to hide buttons
// otherwise link visibility to IsEnabled
if (PrimaryButton is not null)
{
if (PrimaryButtonCommand is not null)
{
PrimaryButtonCommand.CanExecuteChanged += (_, _) =>
PrimaryButton.IsEnabled = PrimaryButtonCommand.CanExecute(null);
// Also set initial state
PrimaryButton.IsEnabled = PrimaryButtonCommand.CanExecute(null);
}
else
{
PrimaryButton.IsVisible = IsPrimaryButtonEnabled && !string.IsNullOrEmpty(PrimaryButtonText);
}
}
if (SecondaryButton is not null)
{
if (SecondaryButtonCommand is not null)
{
SecondaryButtonCommand.CanExecuteChanged += (_, _) =>
SecondaryButton.IsEnabled = SecondaryButtonCommand.CanExecute(null);
// Also set initial state
SecondaryButton.IsEnabled = SecondaryButtonCommand.CanExecute(null);
}
else
{
SecondaryButton.IsVisible = IsSecondaryButtonEnabled && !string.IsNullOrEmpty(SecondaryButtonText);
}
}
if (CloseButton is not null)
{
if (CloseButtonCommand is not null)
{
CloseButtonCommand.CanExecuteChanged += (_, _) =>
CloseButton.IsEnabled = CloseButtonCommand.CanExecute(null);
// Also set initial state
CloseButton.IsEnabled = CloseButtonCommand.CanExecute(null);
}
}
}
protected void OnDialogButtonClick(object? sender, ContentDialogResult e)
{
Result = e;
HideCore();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
TryBindButtons();
}
private void OnLoaded(object? sender, RoutedEventArgs? e)
{
TryBindButtons();
// Find the named grid
// https://github.com/amwx/FluentAvalonia/blob/master/src/FluentAvalonia/Styling/
// ControlThemes/FAControls/ContentDialogStyles.axaml#L96
var border = VisualChildren[0] as Border;
var panel = border?.Child as Panel;
var faBorder = panel?.Children[0] as FABorder;
// Set dialog maximums
if (MaxDialogWidth > 0)
{
faBorder!.MaxWidth = MaxDialogWidth;
}
if (MaxDialogHeight > 0)
{
faBorder!.MaxHeight = MaxDialogHeight;
}
var border2 = faBorder?.Child as Border;
// Named Grid 'DialogSpace'
if (border2?.Child is not Grid dialogSpaceGrid) throw new InvalidOperationException("Could not find DialogSpace grid");
var scrollViewer = dialogSpaceGrid.Children[0] as ScrollViewer;
var actualBorder = dialogSpaceGrid.Children[1] as Border;
// Get the parent border, which is what we want to hide
if (scrollViewer is null || actualBorder is null)
{
throw new InvalidOperationException("Could not find parent border");
}
var subBorder = scrollViewer.Content as Border;
var subGrid = subBorder?.Child as Grid;
if (subGrid is null) throw new InvalidOperationException("Could not find sub grid");
var contentControlTitle = subGrid.Children[0] as ContentControl;
// Hide title if empty
if (Title is null or string {Length: 0})
{
contentControlTitle!.IsVisible = false;
}
// Set footer and scrollbar visibility states
actualBorder.IsVisible = IsFooterVisible;
scrollViewer.VerticalScrollBarVisibility = ContentVerticalScrollBarVisibility;
// Also call the vm's OnLoad
if (Content is Control {DataContext: ViewModelBase viewModel})
{
viewModel.OnLoaded();
Dispatcher.UIThread.InvokeAsync(
async () => await viewModel.OnLoadedAsync());
}
}
}

40
StabilityMatrix.Avalonia/Controls/BetterFlyout.cs

@ -0,0 +1,40 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.VisualTree;
namespace StabilityMatrix.Avalonia.Controls;
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public class BetterFlyout : Flyout
{
public static readonly StyledProperty<ScrollBarVisibility> VerticalScrollBarVisibilityProperty = AvaloniaProperty.Register<BetterFlyout, ScrollBarVisibility>(
"VerticalScrollBarVisibility");
public ScrollBarVisibility VerticalScrollBarVisibility
{
get => GetValue(VerticalScrollBarVisibilityProperty);
set => SetValue(VerticalScrollBarVisibilityProperty, value);
}
public static readonly StyledProperty<ScrollBarVisibility> HorizontalScrollBarVisibilityProperty = AvaloniaProperty.Register<BetterFlyout, ScrollBarVisibility>(
"HorizontalScrollBarVisibility");
public ScrollBarVisibility HorizontalScrollBarVisibility
{
get => GetValue(HorizontalScrollBarVisibilityProperty);
set => SetValue(HorizontalScrollBarVisibilityProperty, value);
}
protected override void OnOpened()
{
base.OnOpened();
var presenter = Popup.Child;
if (presenter.FindDescendantOfType<ScrollViewer>() is { } scrollViewer)
{
scrollViewer.VerticalScrollBarVisibility = VerticalScrollBarVisibility;
scrollViewer.HorizontalScrollBarVisibility = HorizontalScrollBarVisibility;
}
}
}

140
StabilityMatrix.Avalonia/Controls/BetterImage.cs

@ -0,0 +1,140 @@
using Avalonia;
using Avalonia.Automation;
using Avalonia.Automation.Peers;
using Avalonia.Controls;
using Avalonia.Controls.Automation.Peers;
using Avalonia.Media;
using Avalonia.Metadata;
namespace StabilityMatrix.Avalonia.Controls;
public class BetterImage : Control
{
/// <summary>
/// Defines the <see cref="Source"/> property.
/// </summary>
public static readonly StyledProperty<IImage?> SourceProperty =
AvaloniaProperty.Register<BetterImage, IImage?>(nameof(Source));
/// <summary>
/// Defines the <see cref="Stretch"/> property.
/// </summary>
public static readonly StyledProperty<Stretch> StretchProperty =
AvaloniaProperty.Register<BetterImage, Stretch>(nameof(Stretch), Stretch.Uniform);
/// <summary>
/// Defines the <see cref="StretchDirection"/> property.
/// </summary>
public static readonly StyledProperty<StretchDirection> StretchDirectionProperty =
AvaloniaProperty.Register<BetterImage, StretchDirection>(
nameof(StretchDirection),
StretchDirection.Both);
static BetterImage()
{
AffectsRender<BetterImage>(SourceProperty, StretchProperty, StretchDirectionProperty);
AffectsMeasure<BetterImage>(SourceProperty, StretchProperty, StretchDirectionProperty);
AutomationProperties.ControlTypeOverrideProperty.OverrideDefaultValue<BetterImage>(
AutomationControlType.Image);
}
/// <summary>
/// Gets or sets the image that will be displayed.
/// </summary>
[Content]
public IImage? Source
{
get { return GetValue(SourceProperty); }
set { SetValue(SourceProperty, value); }
}
/// <summary>
/// Gets or sets a value controlling how the image will be stretched.
/// </summary>
public Stretch Stretch
{
get { return GetValue(StretchProperty); }
set { SetValue(StretchProperty, value); }
}
/// <summary>
/// Gets or sets a value controlling in what direction the image will be stretched.
/// </summary>
public StretchDirection StretchDirection
{
get { return GetValue(StretchDirectionProperty); }
set { SetValue(StretchDirectionProperty, value); }
}
/// <inheritdoc />
protected override bool BypassFlowDirectionPolicies => true;
/// <summary>
/// Renders the control.
/// </summary>
/// <param name="context">The drawing context.</param>
public sealed override void Render(DrawingContext context)
{
var source = Source;
if (source != null && Bounds.Width > 0 && Bounds.Height > 0)
{
Rect viewPort = new Rect(Bounds.Size);
Size sourceSize = source.Size;
Vector scale = Stretch.CalculateScaling(Bounds.Size, sourceSize, StretchDirection);
Size scaledSize = sourceSize * scale;
Rect destRect = viewPort
.CenterRect(new Rect(scaledSize))
.WithX(0)
.WithY(0)
.Intersect(viewPort);
Rect sourceRect = new Rect(sourceSize)
.CenterRect(new Rect(destRect.Size / scale))
.WithX(0)
.WithY(0);
context.DrawImage(source, sourceRect, destRect);
}
}
/// <summary>
/// Measures the control.
/// </summary>
/// <param name="availableSize">The available size.</param>
/// <returns>The desired size of the control.</returns>
protected override Size MeasureOverride(Size availableSize)
{
var source = Source;
var result = new Size();
if (source != null)
{
result = Stretch.CalculateSize(availableSize, source.Size, StretchDirection);
}
return result;
}
/// <inheritdoc/>
protected override Size ArrangeOverride(Size finalSize)
{
var source = Source;
if (source != null)
{
var sourceSize = source.Size;
var result = Stretch.CalculateSize(finalSize, sourceSize);
return result;
}
else
{
return new Size();
}
}
protected override AutomationPeer OnCreateAutomationPeer()
{
return new ImageAutomationPeer(this);
}
}

15
StabilityMatrix.Avalonia/Controls/Card.cs

@ -0,0 +1,15 @@
using System;
using Avalonia.Controls;
namespace StabilityMatrix.Avalonia.Controls;
public class Card : ContentControl
{
protected override Type StyleKeyOverride => typeof(Card);
public Card()
{
MinHeight = 8;
MinWidth = 8;
}
}

88
StabilityMatrix.Avalonia/Controls/FASymbolIconSource.cs

@ -0,0 +1,88 @@
using System;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Avalonia;
using Avalonia.Controls.Documents;
using Avalonia.Media;
using FluentAvalonia.UI.Controls;
using Projektanker.Icons.Avalonia;
namespace StabilityMatrix.Avalonia.Controls;
[TypeConverter(typeof(FASymbolIconSourceConverter))]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
[SuppressMessage("ReSharper", "PropertyCanBeMadeInitOnly.Global")]
public class FASymbolIconSource : PathIconSource
{
public static readonly StyledProperty<string> SymbolProperty =
AvaloniaProperty.Register<FASymbolIconSource, string>(nameof(Symbol));
public static readonly StyledProperty<double> FontSizeProperty = TextElement.FontSizeProperty.AddOwner<FASymbolIconSource>();
public FASymbolIconSource()
{
Stretch = Stretch.None;
// FontSize = 20; // Override value inherited from visual parents.
InvalidateData();
}
public string Symbol
{
get => GetValue(SymbolProperty);
set => SetValue(SymbolProperty, value);
}
public double FontSize
{
get => GetValue(FontSizeProperty);
set => SetValue(FontSizeProperty, value);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == SymbolProperty || change.Property == FontSizeProperty)
{
InvalidateData();
}
}
private void InvalidateData()
{
var path = IconProvider.Current.GetIconPath(Symbol);
var geometry = Geometry.Parse(path);
var scale = FontSize / 20;
Data = geometry;
// TODO: Scaling not working
Data.Transform = new ScaleTransform(scale, scale);
}
}
public class FASymbolIconSourceConverter : TypeConverter
{
public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
{
if (sourceType == typeof(string))
{
return true;
}
return base.CanConvertFrom(context, sourceType);
}
public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
{
return value switch
{
string val => new FASymbolIconSource
{
Symbol = val,
},
_ => base.ConvertFrom(context, culture, value)
};
}
}

32
StabilityMatrix.Avalonia/Controls/LaunchOptionCardTemplateSelector.cs

@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Avalonia.Metadata;
using StabilityMatrix.Core.Models;
namespace StabilityMatrix.Avalonia.Controls;
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public class LaunchOptionCardTemplateSelector : IDataTemplate
{
// public bool SupportsRecycling => false;
// ReSharper disable once CollectionNeverUpdated.Global
[Content]
public Dictionary<LaunchOptionType, IDataTemplate> Templates { get; } = new();
// Check if we can accept the provided data
public bool Match(object? data)
{
return data is LaunchOptionCard;
}
// Build the DataTemplate here
public Control Build(object? data)
{
if (data is not LaunchOptionCard card) throw new ArgumentException(null, nameof(data));
return Templates[card.Type].Build(card)!;
}
}

130
StabilityMatrix.Avalonia/Controls/ProgressRing.cs

@ -0,0 +1,130 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Metadata;
using Avalonia.Controls.Primitives;
namespace StabilityMatrix.Avalonia.Controls;
/// <summary>
/// A control used to indicate the progress of an operation.
/// </summary>
[PseudoClasses(":preserveaspect", ":indeterminate")]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public class ProgressRing : RangeBase
{
public static readonly StyledProperty<bool> IsIndeterminateProperty =
ProgressBar.IsIndeterminateProperty.AddOwner<ProgressRing>();
public static readonly StyledProperty<bool> PreserveAspectProperty =
AvaloniaProperty.Register<ProgressRing, bool>(nameof(PreserveAspect), true);
public static readonly StyledProperty<double> ValueAngleProperty =
AvaloniaProperty.Register<ProgressRing, double>(nameof(ValueAngle));
public static readonly StyledProperty<double> StartAngleProperty =
AvaloniaProperty.Register<ProgressRing, double>(nameof(StartAngle));
public static readonly StyledProperty<double> EndAngleProperty =
AvaloniaProperty.Register<ProgressRing, double>(nameof(EndAngle), 360);
static ProgressRing()
{
MinimumProperty.Changed.AddClassHandler<ProgressRing>(OnMinimumPropertyChanged);
MaximumProperty.Changed.AddClassHandler<ProgressRing>(OnMaximumPropertyChanged);
ValueProperty.Changed.AddClassHandler<ProgressRing>(OnValuePropertyChanged);
MaximumProperty.Changed.AddClassHandler<ProgressRing>(OnStartAnglePropertyChanged);
MaximumProperty.Changed.AddClassHandler<ProgressRing>(OnEndAnglePropertyChanged);
}
public ProgressRing()
{
UpdatePseudoClasses(IsIndeterminate, PreserveAspect);
}
public bool IsIndeterminate
{
get => GetValue(IsIndeterminateProperty);
set => SetValue(IsIndeterminateProperty, value);
}
public bool PreserveAspect
{
get => GetValue(PreserveAspectProperty);
set => SetValue(PreserveAspectProperty, value);
}
public double ValueAngle
{
get => GetValue(ValueAngleProperty);
private set => SetValue(ValueAngleProperty, value);
}
public double StartAngle
{
get => GetValue(StartAngleProperty);
set => SetValue(StartAngleProperty, value);
}
public double EndAngle
{
get => GetValue(EndAngleProperty);
set => SetValue(EndAngleProperty, value);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
var e = change as AvaloniaPropertyChangedEventArgs<bool>;
if (e is null) return;
if (e.Property == IsIndeterminateProperty)
{
UpdatePseudoClasses(e.NewValue.GetValueOrDefault(), null);
}
else if (e.Property == PreserveAspectProperty)
{
UpdatePseudoClasses(null, e.NewValue.GetValueOrDefault());
}
}
private void UpdatePseudoClasses(
bool? isIndeterminate,
bool? preserveAspect)
{
if (isIndeterminate.HasValue)
{
PseudoClasses.Set(":indeterminate", isIndeterminate.Value);
}
if (preserveAspect.HasValue)
{
PseudoClasses.Set(":preserveaspect", preserveAspect.Value);
}
}
private static void OnMinimumPropertyChanged(ProgressRing sender, AvaloniaPropertyChangedEventArgs e)
{
sender.Minimum = (double) e.NewValue!;
}
private static void OnMaximumPropertyChanged(ProgressRing sender, AvaloniaPropertyChangedEventArgs e)
{
sender.Maximum = (double) e.NewValue!;
}
private static void OnValuePropertyChanged(ProgressRing sender, AvaloniaPropertyChangedEventArgs e)
{
sender.ValueAngle = ((double) e.NewValue! - sender.Minimum) * (sender.EndAngle - sender.StartAngle) / (sender.Maximum - sender.Minimum);
}
private static void OnStartAnglePropertyChanged(ProgressRing sender, AvaloniaPropertyChangedEventArgs e)
{
sender.StartAngle = (double) e.NewValue!;
}
private static void OnEndAnglePropertyChanged(ProgressRing sender, AvaloniaPropertyChangedEventArgs e)
{
sender.EndAngle = (double) e.NewValue!;
}
}

41
StabilityMatrix.Avalonia/Controls/RefreshBadge.axaml

@ -0,0 +1,41 @@
<controls:UserControlBase xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
Background="Transparent"
d:DataContext="{x:Static mocks:DesignData.RefreshBadgeViewModel}"
x:DataType="vm:RefreshBadgeViewModel"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="StabilityMatrix.Avalonia.Controls.RefreshBadge">
<Grid Background="Transparent">
<Button
BorderThickness="0"
Command="{Binding RefreshCommand}"
FontSize="26"
Foreground="{Binding ColorBrush}"
Margin="4"
Padding="2"
Background="Transparent"
HorizontalAlignment="Center"
VerticalAlignment="Center"
IsEnabled="{Binding !IsWorking}">
<ui:SymbolIcon Symbol="{Binding Icon}"/>
</Button>
<controls:ProgressRing
FontSize="14"
Grid.Row="0"
Height="20"
BorderThickness="2"
HorizontalAlignment="Center"
VerticalAlignment="Center"
IsEnabled="{Binding IsWorking}"
IsIndeterminate="True"
IsVisible="{Binding IsWorking}"
Width="20"
ToolTip.Tip="{Binding CurrentToolTip}" />
</Grid>
</controls:UserControlBase>

16
StabilityMatrix.Avalonia/Controls/RefreshBadge.axaml.cs

@ -0,0 +1,16 @@
using Avalonia.Markup.Xaml;
namespace StabilityMatrix.Avalonia.Controls;
public partial class RefreshBadge : UserControlBase
{
public RefreshBadge()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

51
StabilityMatrix.Avalonia/Controls/UserControlBase.cs

@ -0,0 +1,51 @@
using System.Diagnostics.CodeAnalysis;
using AsyncAwaitBestPractices;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Threading;
using StabilityMatrix.Avalonia.ViewModels;
namespace StabilityMatrix.Avalonia.Controls;
[SuppressMessage("ReSharper", "VirtualMemberNeverOverridden.Global")]
public class UserControlBase : UserControl
{
static UserControlBase()
{
LoadedEvent.AddClassHandler<UserControlBase>(
(cls, args) => cls.OnLoadedEvent(args));
UnloadedEvent.AddClassHandler<UserControlBase>(
(cls, args) => cls.OnUnloadedEvent(args));
}
// ReSharper disable once UnusedParameter.Global
protected virtual void OnLoadedEvent(RoutedEventArgs? e)
{
if (DataContext is not ViewModelBase viewModel) return;
// Run synchronous load then async load
viewModel.OnLoaded();
// Can't block here so we'll run as async on UI thread
Dispatcher.UIThread.InvokeAsync(async () =>
{
await viewModel.OnLoadedAsync();
}).SafeFireAndForget();
}
// ReSharper disable once UnusedParameter.Global
protected virtual void OnUnloadedEvent(RoutedEventArgs? e)
{
if (DataContext is not ViewModelBase viewModel) return;
// Run synchronous load then async load
viewModel.OnUnloaded();
// Can't block here so we'll run as async on UI thread
Dispatcher.UIThread.InvokeAsync(async () =>
{
await viewModel.OnUnloadedAsync();
}).SafeFireAndForget();
}
}

20
StabilityMatrix.Avalonia/Converters/FitSquarelyWithinAspectRatioConverter.cs

@ -0,0 +1,20 @@
using System;
using System.Globalization;
using Avalonia;
using Avalonia.Data.Converters;
namespace StabilityMatrix.Avalonia.Converters;
public class FitSquarelyWithinAspectRatioConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
var bounds = value is Rect rect ? rect : default;
return Math.Min(bounds.Width, bounds.Height);
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}

46
StabilityMatrix.Avalonia/Converters/LaunchOptionConverter.cs

@ -0,0 +1,46 @@
using System;
using System.Globalization;
using Avalonia.Data.Converters;
namespace StabilityMatrix.Avalonia.Converters;
public class LaunchOptionConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (targetType == typeof(string))
{
return value?.ToString() ?? "";
}
if (targetType == typeof(bool?))
{
return bool.TryParse(value?.ToString(), out var boolValue) && boolValue;
}
if (targetType == typeof(double?))
{
if (value == null)
{
return null;
}
return double.TryParse(value.ToString(), out var doubleValue) ? doubleValue : 0;
}
if (targetType == typeof(int?))
{
if (value == null)
{
return null;
}
return int.TryParse(value.ToString(), out var intValue) ? intValue : 0;
}
throw new ArgumentException("Unsupported type");
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return value;
}
}

34
StabilityMatrix.Avalonia/Converters/LaunchOptionIntDoubleConverter.cs

@ -0,0 +1,34 @@
using System;
using System.Globalization;
using Avalonia.Data.Converters;
namespace StabilityMatrix.Avalonia.Converters;
public class LaunchOptionIntDoubleConverter : IValueConverter
{
// Convert from int to double
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (targetType == typeof(double?))
{
if (value == null)
{
return null;
}
return System.Convert.ToDouble(value);
}
throw new ArgumentException($"Unsupported type {targetType}");
}
// Convert from double to object int (floor)
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (targetType == typeof(int?) || targetType == typeof(object))
{
return System.Convert.ToInt32(value);
}
throw new ArgumentException($"Unsupported type {targetType}");
}
}

20
StabilityMatrix.Avalonia/Converters/ValueConverterGroup.cs

@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Avalonia.Data.Converters;
namespace StabilityMatrix.Avalonia.Converters;
public class ValueConverterGroup : List<IValueConverter>, IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return this.Aggregate(value, (current, converter) => converter.Convert(current, targetType, parameter, culture));
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}

268
StabilityMatrix.Avalonia/DesignData/DesignData.cs

@ -0,0 +1,268 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Net.Http;
using Microsoft.Extensions.DependencyInjection;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Core.Api;
using StabilityMatrix.Core.Database;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Helper.Cache;
using StabilityMatrix.Core.Helper.Factory;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Api;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Python;
using StabilityMatrix.Core.Services;
using StabilityMatrix.Core.Updater;
namespace StabilityMatrix.Avalonia.DesignData;
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public static class DesignData
{
[NotNull] public static IServiceProvider? Services { get; set; }
private static bool isInitialized;
// This needs to be static method instead of static constructor
// or else Avalonia analyzers won't work.
public static void Initialize()
{
if (isInitialized) throw new InvalidOperationException("DesignData is already initialized.");
var services = new ServiceCollection();
var activePackageId = Guid.NewGuid();
services.AddSingleton<ISettingsManager, MockSettingsManager>(_ => new MockSettingsManager
{
Settings =
{
InstalledPackages = new List<InstalledPackage>
{
new()
{
Id = activePackageId,
DisplayName = "My Installed Package",
PackageName = "stable-diffusion-webui",
PackageVersion = "v1.0.0",
LibraryPath = $"Packages{Environment.NewLine}example-webui",
LastUpdateCheck = DateTimeOffset.Now
}
},
ActiveInstalledPackage = activePackageId
}
});
// General services
services.AddLogging()
.AddSingleton<IPackageFactory, PackageFactory>()
.AddSingleton<IUpdateHelper, UpdateHelper>()
.AddSingleton<ModelFinder>()
.AddSingleton<SharedState>();
// Mock services
services
.AddSingleton<INotificationService, MockNotificationService>()
.AddSingleton<ISharedFolders, MockSharedFolders>()
.AddSingleton<IDownloadService, MockDownloadService>()
.AddSingleton<IHttpClientFactory, MockHttpClientFactory>();
// Placeholder services that nobody should need during design time
services
.AddSingleton<IPyRunner>(_ => null!)
.AddSingleton<ILiteDbContext>(_ => null!)
.AddSingleton<ICivitApi>(_ => null!)
.AddSingleton<IGithubApiCache>(_ => null!)
.AddSingleton<IPrerequisiteHelper>(_ => null!);
// Using some default service implementations from App
App.ConfigurePackages(services);
App.ConfigurePageViewModels(services);
App.ConfigureDialogViewModels(services);
App.ConfigureViews(services);
Services = services.BuildServiceProvider();
var dialogFactory = Services.GetRequiredService<ServiceManager<ViewModelBase>>();
var settingsManager = Services.GetRequiredService<ISettingsManager>();
var downloadService = Services.GetRequiredService<IDownloadService>();
var modelFinder = Services.GetRequiredService<ModelFinder>();
var packageFactory = Services.GetRequiredService<IPackageFactory>();
var notificationService = Services.GetRequiredService<INotificationService>();
// Sample data
var sampleCivitVersions = new List<CivitModelVersion>
{
new()
{
Name = "BB95 Furry Mix",
Description = "v1.0.0",
}
};
var sampleViewModel = new ModelVersionViewModel(settingsManager, sampleCivitVersions[0]);
// Sample data for dialogs
SelectModelVersionViewModel.Versions = new[] {sampleViewModel};
SelectModelVersionViewModel.SelectedVersionViewModel = sampleViewModel;
LaunchOptionsViewModel = Services.GetRequiredService<LaunchOptionsViewModel>();
LaunchOptionsViewModel.Cards = new[]
{
LaunchOptionCard.FromDefinition(new LaunchOptionDefinition
{
Name = "Host",
Type = LaunchOptionType.String,
Description = "The host name for the Web UI",
DefaultValue = "localhost",
Options = { "--host" }
}),
LaunchOptionCard.FromDefinition(new LaunchOptionDefinition
{
Name = "API",
Type = LaunchOptionType.Bool,
Options = { "--api" }
})
};
LaunchOptionsViewModel.UpdateFilterCards();
InstallerViewModel = Services.GetRequiredService<InstallerViewModel>();
InstallerViewModel.AvailablePackages =
packageFactory.GetAllAvailablePackages().ToImmutableArray();
InstallerViewModel.SelectedPackage = InstallerViewModel.AvailablePackages[0];
InstallerViewModel.ReleaseNotes = "## Release Notes\nThis is a test release note.";
// Checkpoints page
CheckpointsPageViewModel.CheckpointFolders = new ObservableCollection<CheckpointFolder>
{
new(settingsManager, downloadService, modelFinder)
{
Title = "Lora",
DirectoryPath = "Packages/lora",
CheckpointFiles = new ObservableCollection<CheckpointFile>
{
new()
{
FilePath = "~/Models/Lora/electricity-light.safetensors",
Title = "Auroral Background",
ConnectedModel = new ConnectedModelInfo
{
VersionName = "Lightning Auroral",
BaseModel = "SD 1.5",
ModelName = "Auroral Background",
ModelType = CivitModelType.LORA,
FileMetadata = new CivitFileMetadata
{
Format = CivitModelFormat.SafeTensor,
Fp = CivitModelFpType.fp16,
Size = CivitModelSize.pruned,
}
}
},
new()
{
FilePath = "~/Models/Lora/model.safetensors",
Title = "Some model"
},
}
},
new(settingsManager, downloadService, modelFinder)
{
Title = "VAE",
DirectoryPath = "Packages/VAE",
CheckpointFiles = new ObservableCollection<CheckpointFile>
{
new()
{
FilePath = "~/Models/VAE/vae_v2.pt",
Title = "VAE v2",
}
}
}
};
CheckpointBrowserViewModel.ModelCards = new
ObservableCollection<CheckpointBrowserCardViewModel>
{
new(new CivitModel
{
Name = "BB95 Furry Mix",
Description = "A furry mix of BB95",
}, downloadService, settingsManager,
dialogFactory, notificationService)
};
ProgressManagerViewModel.ProgressItems = new ObservableCollection<ProgressItemViewModel>
{
new(new ProgressItem(Guid.NewGuid(), "Test File.exe", new ProgressReport(0.5f, "Downloading..."))),
new(new ProgressItem(Guid.NewGuid(), "Test File 2.uwu", new ProgressReport(0.25f, "Extracting...")))
};
UpdateViewModel = Services.GetRequiredService<UpdateViewModel>();
UpdateViewModel.UpdateText =
$"Stability Matrix v2.0.1 is now available! You currently have v2.0.0. Would you like to update now?";
UpdateViewModel.ReleaseNotes = "## v2.0.1\n- Fixed a bug\n- Added a feature\n- Removed a feature";
isInitialized = true;
}
[NotNull] public static InstallerViewModel? InstallerViewModel { get; private set; }
[NotNull] public static LaunchOptionsViewModel? LaunchOptionsViewModel { get; private set; }
[NotNull] public static UpdateViewModel? UpdateViewModel { get; private set; }
public static ServiceManager<ViewModelBase> DialogFactory =>
Services.GetRequiredService<ServiceManager<ViewModelBase>>();
public static MainWindowViewModel MainWindowViewModel =>
Services.GetRequiredService<MainWindowViewModel>();
public static FirstLaunchSetupViewModel FirstLaunchSetupViewModel =>
Services.GetRequiredService<FirstLaunchSetupViewModel>();
public static LaunchPageViewModel LaunchPageViewModel =>
Services.GetRequiredService<LaunchPageViewModel>();
public static PackageManagerViewModel PackageManagerViewModel =>
Services.GetRequiredService<PackageManagerViewModel>();
public static CheckpointsPageViewModel CheckpointsPageViewModel =>
Services.GetRequiredService<CheckpointsPageViewModel>();
public static SettingsViewModel SettingsViewModel =>
Services.GetRequiredService<SettingsViewModel>();
public static CheckpointBrowserViewModel CheckpointBrowserViewModel =>
Services.GetRequiredService<CheckpointBrowserViewModel>();
public static SelectModelVersionViewModel SelectModelVersionViewModel =>
Services.GetRequiredService<SelectModelVersionViewModel>();
public static OneClickInstallViewModel OneClickInstallViewModel =>
Services.GetRequiredService<OneClickInstallViewModel>();
public static SelectDataDirectoryViewModel SelectDataDirectoryViewModel =>
Services.GetRequiredService<SelectDataDirectoryViewModel>();
public static ProgressManagerViewModel ProgressManagerViewModel =>
Services.GetRequiredService<ProgressManagerViewModel>();
public static ExceptionViewModel ExceptionViewModel =>
DialogFactory.Get<ExceptionViewModel>(viewModel =>
{
// Use try-catch to generate traceback information
try
{
try
{
throw new OperationCanceledException("Example");
}
catch (OperationCanceledException e)
{
throw new AggregateException(e);
}
}
catch (AggregateException e)
{
viewModel.Exception = e;
}
});
public static RefreshBadgeViewModel RefreshBadgeViewModel => new()
{
State = ProgressState.Success
};
}

21
StabilityMatrix.Avalonia/DesignData/MockDownloadService.cs

@ -0,0 +1,21 @@
using System;
using System.IO;
using System.Threading.Tasks;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.DesignData;
public class MockDownloadService : IDownloadService
{
public Task DownloadToFileAsync(string downloadUrl, string downloadPath,
IProgress<ProgressReport>? progress = null, string? httpClientName = null)
{
return Task.CompletedTask;
}
public Task<Stream> GetImageStreamFromUrl(string url)
{
return Task.FromResult(new MemoryStream(new byte[24]) as Stream);
}
}

12
StabilityMatrix.Avalonia/DesignData/MockHttpClientFactory.cs

@ -0,0 +1,12 @@
using System;
using System.Net.Http;
namespace StabilityMatrix.Avalonia.DesignData;
public class MockHttpClientFactory : IHttpClientFactory
{
public HttpClient CreateClient(string name)
{
throw new NotImplementedException();
}
}

51
StabilityMatrix.Avalonia/DesignData/MockLiteDbContext.cs

@ -0,0 +1,51 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using LiteDB.Async;
using StabilityMatrix.Core.Database;
using StabilityMatrix.Core.Models.Api;
using StabilityMatrix.Core.Models.Database;
namespace StabilityMatrix.Avalonia.DesignData;
public class MockLiteDbContext : ILiteDbContext
{
public LiteDatabaseAsync Database => throw new NotImplementedException();
public ILiteCollectionAsync<CivitModel> CivitModels => throw new NotImplementedException();
public ILiteCollectionAsync<CivitModelVersion> CivitModelVersions => throw new NotImplementedException();
public ILiteCollectionAsync<CivitModelQueryCacheEntry> CivitModelQueryCache => throw new NotImplementedException();
public Task<(CivitModel?, CivitModelVersion?)> FindCivitModelFromFileHashAsync(string hashBlake3)
{
return Task.FromResult<(CivitModel?, CivitModelVersion?)>((null, null));
}
public Task<bool> UpsertCivitModelAsync(CivitModel civitModel)
{
return Task.FromResult(true);
}
public Task<bool> UpsertCivitModelAsync(IEnumerable<CivitModel> civitModels)
{
return Task.FromResult(true);
}
public Task<bool> UpsertCivitModelQueryCacheEntryAsync(CivitModelQueryCacheEntry entry)
{
return Task.FromResult(true);
}
public Task<GithubCacheEntry?> GetGithubCacheEntry(string cacheKey)
{
return Task.FromResult<GithubCacheEntry>(null);
}
public Task<bool> UpsertGithubCacheEntry(GithubCacheEntry cacheEntry)
{
return Task.FromResult(true);
}
public void Dispose()
{
GC.SuppressFinalize(this);
}
}

35
StabilityMatrix.Avalonia/DesignData/MockNotificationService.cs

@ -0,0 +1,35 @@
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls.Notifications;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Core.Models;
namespace StabilityMatrix.Avalonia.DesignData;
public class MockNotificationService : INotificationService
{
public void Initialize(Visual? visual,
NotificationPosition position = NotificationPosition.BottomRight, int maxItems = 3)
{
}
public void Show(INotification notification)
{
}
public Task<TaskResult<T>> TryAsync<T>(Task<T> task, string title = "Error", string? message = null,
NotificationType appearance = NotificationType.Error)
{
return Task.FromResult(new TaskResult<T>(default!));
}
public Task<TaskResult<bool>> TryAsync(Task task, string title = "Error", string? message = null,
NotificationType appearance = NotificationType.Error)
{
return Task.FromResult(new TaskResult<bool>(true));
}
public void Show(string title, string message, NotificationType appearance = NotificationType.Information)
{
}
}

9
StabilityMatrix.Avalonia/DesignData/MockSettingsManager.cs

@ -0,0 +1,9 @@
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.DesignData;
public class MockSettingsManager : SettingsManager
{
protected override void LoadSettings() {}
protected override void SaveSettings() {}
}

22
StabilityMatrix.Avalonia/DesignData/MockSharedFolders.cs

@ -0,0 +1,22 @@
using System.Threading.Tasks;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.Packages;
namespace StabilityMatrix.Avalonia.DesignData;
public class MockSharedFolders : ISharedFolders
{
public void SetupLinksForPackage(BasePackage basePackage, DirectoryPath installDirectory)
{
}
public Task UpdateLinksForPackage(BasePackage basePackage, DirectoryPath installDirectory)
{
return Task.CompletedTask;
}
public void RemoveLinksForAllPackages()
{
}
}

191
StabilityMatrix.Avalonia/DialogHelper.cs

@ -0,0 +1,191 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Linq;
using System.Runtime.CompilerServices;
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls;
using Markdown.Avalonia;
using StabilityMatrix.Avalonia.Controls;
namespace StabilityMatrix.Avalonia;
public static class DialogHelper
{
/// <summary>
/// Create a generic textbox entry content dialog.
/// </summary>
public static BetterContentDialog CreateTextEntryDialog(
string title,
string description,
IReadOnlyList<TextBoxField> textFields)
{
Dispatcher.UIThread.VerifyAccess();
var stackPanel = new StackPanel();
var grid = new Grid
{
RowDefinitions =
{
new RowDefinition(GridLength.Auto),
new RowDefinition(GridLength.Star)
},
Children =
{
new TextBlock
{
Text = description
},
stackPanel
}
};
grid.Loaded += (_, _) =>
{
// Focus first textbox
var firstTextBox = stackPanel.Children.OfType<TextBox>().First();
firstTextBox.Focus();
firstTextBox.CaretIndex = firstTextBox.Text?.LastIndexOf('.') ?? 0;
};
// Disable primary button if any textboxes are invalid
var primaryCommand = new RelayCommand(delegate { },
() =>
{
var invalidCount = textFields.Count(field => !field.IsValid);
Debug.WriteLine($"Checking can execute: {invalidCount} invalid fields");
return invalidCount == 0;
});
// Create textboxes
foreach (var field in textFields)
{
var label = new TextBlock
{
Text = field.Label
};
stackPanel.Children.Add(label);
var textBox = new TextBox
{
[!TextBox.TextProperty] = new Binding("TextProperty"),
Watermark = field.Watermark,
DataContext = field,
};
stackPanel.Children.Add(textBox);
// When IsValid property changes, update invalid count and primary button
field.PropertyChanged += (_, args) =>
{
if (args.PropertyName == nameof(TextBoxField.IsValid))
{
primaryCommand.NotifyCanExecuteChanged();
}
};
// Set initial value
textBox.Text = field.Text;
// See if initial value is valid
try
{
field.Validator?.Invoke(field.Text);
}
catch (Exception)
{
field.IsValid = false;
}
}
return new BetterContentDialog
{
Title = title,
Content = grid,
PrimaryButtonText = "OK",
CloseButtonText = "Cancel",
IsPrimaryButtonEnabled = true,
PrimaryButtonCommand = primaryCommand,
DefaultButton = ContentDialogButton.Primary
};
}
/// <summary>
/// Create a generic dialog for showing a markdown document
/// </summary>
public static BetterContentDialog CreateMarkdownDialog(string markdown, string? title = null)
{
Dispatcher.UIThread.VerifyAccess();
var viewer = new MarkdownScrollViewer
{
Markdown = markdown
};
return new BetterContentDialog
{
Title = title,
Content = viewer,
CloseButtonText = "Close",
IsPrimaryButtonEnabled = false,
};
}
}
// Text fields
public sealed class TextBoxField : INotifyPropertyChanged
{
// Label above the textbox
public string Label { get; init; } = string.Empty;
// Actual text value
public string Text { get; set; } = string.Empty;
// Watermark text
public string Watermark { get; init; } = string.Empty;
/// <summary>
/// Validation action on text changes. Throw exception if invalid.
/// </summary>
public Action<string>? Validator { get; init; }
public string TextProperty
{
get => Text;
[DebuggerStepThrough]
set
{
try
{
Validator?.Invoke(value);
}
catch (Exception e)
{
IsValid = false;
throw new DataValidationException(e.Message);
}
Text = value;
IsValid = true;
OnPropertyChanged();
}
}
// Default to true if no validator is provided
private bool isValid;
public bool IsValid
{
get => Validator == null || isValid;
set
{
isValid = value;
OnPropertyChanged();
}
}
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}

222
StabilityMatrix.Avalonia/Helpers/UnixPrerequisiteHelper.cs

@ -0,0 +1,222 @@
using System;
using System.IO;
using System.Linq;
using System.Runtime.Versioning;
using System.Threading.Tasks;
using Avalonia.Controls;
using FluentAvalonia.UI.Controls;
using NLog;
using StabilityMatrix.Core.Exceptions;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Processes;
using StabilityMatrix.Core.Python;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.Helpers;
[SupportedOSPlatform("macos")]
[SupportedOSPlatform("linux")]
public class UnixPrerequisiteHelper : IPrerequisiteHelper
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private readonly IDownloadService downloadService;
private readonly ISettingsManager settingsManager;
private readonly IPyRunner pyRunner;
private DirectoryPath HomeDir => settingsManager.LibraryDir;
private DirectoryPath AssetsDir => HomeDir + "Assets";
private DirectoryPath PythonDir => AssetsDir + "Python310";
private FilePath PythonDllPath => PythonDir + "python310.dll";
public bool IsPythonInstalled => PythonDllPath.Exists;
private DirectoryPath PortableGitInstallDir => HomeDir + "PortableGit";
public string GitBinPath => PortableGitInstallDir + "bin";
// Cached store of whether or not git is installed
private bool? isGitInstalled;
public UnixPrerequisiteHelper(
IDownloadService downloadService,
ISettingsManager settingsManager,
IPyRunner pyRunner)
{
this.downloadService = downloadService;
this.settingsManager = settingsManager;
this.pyRunner = pyRunner;
}
private async Task<bool> CheckIsGitInstalled()
{
var result = await ProcessRunner.RunBashCommand("git --version");
isGitInstalled = result.ExitCode == 0;
return isGitInstalled == true;
}
public async Task InstallAllIfNecessary(IProgress<ProgressReport>? progress = null)
{
await UnpackResourcesIfNecessary(progress);
await InstallPythonIfNecessary(progress);
}
public async Task UnpackResourcesIfNecessary(IProgress<ProgressReport>? progress = null)
{
// Array of (asset_uri, extract_to)
var assets = new[]
{
(Assets.SevenZipExecutable, AssetsDir),
(Assets.SevenZipLicense, AssetsDir),
};
progress?.Report(new ProgressReport(0, message: "Unpacking resources", isIndeterminate: true));
Directory.CreateDirectory(AssetsDir);
foreach (var (asset, extractDir) in assets)
{
await asset.ExtractToDir(extractDir);
}
progress?.Report(new ProgressReport(1, message: "Unpacking resources", isIndeterminate: false));
}
public async Task InstallGitIfNecessary(IProgress<ProgressReport>? progress = null)
{
if (isGitInstalled == true || (isGitInstalled == null && await CheckIsGitInstalled())) return;
// Show prompt to install git
var dialog = new ContentDialog
{
Title = "Git not found",
Content = new StackPanel
{
Children =
{
new TextBlock
{
Text = "The current operation requires Git. Please install it to continue."
},
new SelectableTextBlock
{
Text = "$ sudo apt install git"
},
}
},
PrimaryButtonText = "Retry",
CloseButtonText = "Close",
DefaultButton = ContentDialogButton.Primary,
};
while (true)
{
// Return if installed
if (await CheckIsGitInstalled()) return;
if (await dialog.ShowAsync() == ContentDialogResult.None)
{
// Cancel
throw new OperationCanceledException("Git installation canceled");
}
// Otherwise continue to retry indefinitely
}
}
public async Task RunGit(string? workingDirectory = null, params string[] args)
{
var command = args.Length == 0 ? "git" :
"git " + string.Join(" ", args.Select(ProcessRunner.Quote));
var result = await ProcessRunner.RunBashCommand(command, workingDirectory ?? "");
if (result.ExitCode != 0)
{
Logger.Error("Git command [{Command}] failed with exit code " +
"{ExitCode}:\n{StdOut}\n{StdErr}",
command, result.ExitCode, result.StandardOutput, result.StandardError);
throw new ProcessException($"Git command [{command}] failed with exit code" +
$" {result.ExitCode}:\n{result.StandardOutput}\n{result.StandardError}");
}
}
public async Task InstallPythonIfNecessary(IProgress<ProgressReport>? progress = null)
{
if (IsPythonInstalled) return;
Directory.CreateDirectory(AssetsDir);
// Download
var remote = Assets.PythonDownloadUrl;
var url = remote.Url;
var hashSha256 = remote.HashSha256;
var fileName = Path.GetFileName(url.LocalPath);
var downloadPath = Path.Combine(AssetsDir, fileName);
Logger.Info($"Downloading Python from {url} to {downloadPath}");
try
{
await downloadService.DownloadToFileAsync(url.ToString(), downloadPath, progress);
// Verify hash
var actualHash = await FileHash.GetSha256Async(downloadPath);
Logger.Info($"Verifying Python hash: (expected: {hashSha256}, actual: {actualHash})");
if (actualHash != hashSha256)
{
throw new Exception($"Python download hash mismatch: expected {hashSha256}, actual {actualHash}");
}
// Extract
Logger.Info($"Extracting Python Zip: {downloadPath} to {PythonDir}");
if (PythonDir.Exists)
{
await PythonDir.DeleteAsync(true);
}
progress?.Report(new ProgressReport(0, "Installing Python", isIndeterminate: true));
await ArchiveHelper.Extract7ZAuto(downloadPath, PythonDir);
// For Linux, move the inner 'python' folder up to the root PythonDir
if (Compat.IsLinux)
{
var innerPythonDir = PythonDir.JoinDir("python");
if (!innerPythonDir.Exists)
{
throw new Exception($"Python download did not contain expected inner 'python' folder: {innerPythonDir}");
}
foreach (var folder in Directory.EnumerateDirectories(innerPythonDir))
{
var folderName = Path.GetFileName(folder);
var dest = Path.Combine(PythonDir, folderName);
Directory.Move(folder, dest);
}
Directory.Delete(innerPythonDir);
}
}
finally
{
// Cleanup download file
if (File.Exists(downloadPath))
{
File.Delete(downloadPath);
}
}
// Initialize pyrunner and install virtualenv
await pyRunner.Initialize();
await pyRunner.InstallPackage("virtualenv");
progress?.Report(new ProgressReport(1, "Installing Python", isIndeterminate: false));
}
public Task<string> GetGitOutput(string? workingDirectory = null, params string[] args)
{
throw new NotImplementedException();
}
[UnsupportedOSPlatform("Linux")]
[UnsupportedOSPlatform("macOS")]
public Task InstallVcRedistIfNecessary(IProgress<ProgressReport>? progress = null)
{
throw new PlatformNotSupportedException();
}
}

290
StabilityMatrix.Avalonia/Helpers/WindowsPrerequisiteHelper.cs

@ -0,0 +1,290 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.Versioning;
using System.Threading.Tasks;
using Microsoft.Win32;
using NLog;
using Octokit;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Processes;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.Helpers;
[SupportedOSPlatform("windows")]
public class WindowsPrerequisiteHelper : IPrerequisiteHelper
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private readonly IGitHubClient gitHubClient;
private readonly IDownloadService downloadService;
private readonly ISettingsManager settingsManager;
private const string VcRedistDownloadUrl = "https://aka.ms/vs/16/release/vc_redist.x64.exe";
private string HomeDir => settingsManager.LibraryDir;
private string VcRedistDownloadPath => Path.Combine(HomeDir, "vcredist.x64.exe");
private string AssetsDir => Path.Combine(HomeDir, "Assets");
private string SevenZipPath => Path.Combine(AssetsDir, "7za.exe");
private string PythonDownloadPath => Path.Combine(AssetsDir, "python-3.10.11-embed-amd64.zip");
private string PythonDir => Path.Combine(AssetsDir, "Python310");
private string PythonDllPath => Path.Combine(PythonDir, "python310.dll");
private string PythonLibraryZipPath => Path.Combine(PythonDir, "python310.zip");
private string GetPipPath => Path.Combine(PythonDir, "get-pip.pyc");
// Temporary directory to extract venv to during python install
private string VenvTempDir => Path.Combine(PythonDir, "venv");
private string PortableGitInstallDir => Path.Combine(HomeDir, "PortableGit");
private string PortableGitDownloadPath => Path.Combine(HomeDir, "PortableGit.7z.exe");
private string GitExePath => Path.Combine(PortableGitInstallDir, "bin", "git.exe");
public string GitBinPath => Path.Combine(PortableGitInstallDir, "bin");
public bool IsPythonInstalled => File.Exists(PythonDllPath);
public WindowsPrerequisiteHelper(
IGitHubClient gitHubClient,
IDownloadService downloadService,
ISettingsManager settingsManager)
{
this.gitHubClient = gitHubClient;
this.downloadService = downloadService;
this.settingsManager = settingsManager;
}
public async Task RunGit(string? workingDirectory = null, params string[] args)
{
var process = ProcessRunner.StartAnsiProcess(GitExePath, args,
workingDirectory: workingDirectory,
environmentVariables: new Dictionary<string, string>
{
{"PATH", Compat.GetEnvPathWithExtensions(GitBinPath)}
});
await ProcessRunner.WaitForExitConditionAsync(process);
}
public async Task<string> GetGitOutput(string? workingDirectory = null, params string[] args)
{
var process = await ProcessRunner.GetProcessOutputAsync(
GitExePath, string.Join(" ", args),
workingDirectory: workingDirectory,
environmentVariables: new Dictionary<string, string>
{
{"PATH", Compat.GetEnvPathWithExtensions(GitBinPath)}
});
return process;
}
public async Task InstallAllIfNecessary(IProgress<ProgressReport>? progress = null)
{
await InstallVcRedistIfNecessary(progress);
await UnpackResourcesIfNecessary(progress);
await InstallPythonIfNecessary(progress);
await InstallGitIfNecessary(progress);
}
public async Task UnpackResourcesIfNecessary(IProgress<ProgressReport>? progress = null)
{
// Array of (asset_uri, extract_to)
var assets = new[]
{
(Assets.SevenZipExecutable, AssetsDir),
(Assets.SevenZipLicense, AssetsDir),
};
progress?.Report(new ProgressReport(0, message: "Unpacking resources", isIndeterminate: true));
Directory.CreateDirectory(AssetsDir);
foreach (var (asset, extractDir) in assets)
{
await asset.ExtractToDir(extractDir);
}
progress?.Report(new ProgressReport(1, message: "Unpacking resources", isIndeterminate: false));
}
public async Task InstallPythonIfNecessary(IProgress<ProgressReport>? progress = null)
{
if (File.Exists(PythonDllPath))
{
Logger.Debug("Python already installed at {PythonDllPath}", PythonDllPath);
return;
}
Logger.Info("Python not found at {PythonDllPath}, downloading...", PythonDllPath);
Directory.CreateDirectory(AssetsDir);
// Delete existing python zip if it exists
if (File.Exists(PythonLibraryZipPath))
{
File.Delete(PythonLibraryZipPath);
}
var remote = Assets.PythonDownloadUrl;
var url = remote.Url.ToString();
Logger.Info($"Downloading Python from {url} to {PythonLibraryZipPath}");
// Cleanup to remove zip if download fails
try
{
// Download python zip
await downloadService.DownloadToFileAsync(url, PythonDownloadPath, progress: progress);
// Verify python hash
var downloadHash = await FileHash.GetSha256Async(PythonDownloadPath, progress);
if (downloadHash != remote.HashSha256)
{
var fileExists = File.Exists(PythonDownloadPath);
var fileSize = new FileInfo(PythonDownloadPath).Length;
var msg = $"Python download hash mismatch: {downloadHash} != {remote.HashSha256} " +
$"(file exists: {fileExists}, size: {fileSize})";
throw new Exception(msg);
}
progress?.Report(new ProgressReport(progress: 1f, message: "Python download complete"));
progress?.Report(new ProgressReport(-1, "Installing Python...", isIndeterminate: true));
// We also need 7z if it's not already unpacked
if (!File.Exists(SevenZipPath))
{
await Assets.SevenZipExecutable.ExtractToDir(AssetsDir);
await Assets.SevenZipLicense.ExtractToDir(AssetsDir);
}
// Delete existing python dir
if (Directory.Exists(PythonDir))
{
Directory.Delete(PythonDir, true);
}
// Unzip python
await ArchiveHelper.Extract7Z(PythonDownloadPath, PythonDir);
try
{
// Extract embedded venv folder
Directory.CreateDirectory(VenvTempDir);
foreach (var (resource, relativePath) in Assets.PyModuleVenv)
{
var path = Path.Combine(VenvTempDir, relativePath);
// Create missing directories
var dir = Path.GetDirectoryName(path);
if (dir != null)
{
Directory.CreateDirectory(dir);
}
await resource.ExtractTo(path);
}
// Add venv to python's library zip
await ArchiveHelper.AddToArchive7Z(PythonLibraryZipPath, VenvTempDir);
}
finally
{
// Remove venv
if (Directory.Exists(VenvTempDir))
{
Directory.Delete(VenvTempDir, true);
}
}
// Extract get-pip.pyc
await Assets.PyScriptGetPip.ExtractToDir(PythonDir);
// We need to uncomment the #import site line in python310._pth for pip to work
var pythonPthPath = Path.Combine(PythonDir, "python310._pth");
var pythonPthContent = await File.ReadAllTextAsync(pythonPthPath);
pythonPthContent = pythonPthContent.Replace("#import site", "import site");
await File.WriteAllTextAsync(pythonPthPath, pythonPthContent);
progress?.Report(new ProgressReport(1f, "Python install complete"));
}
finally
{
// Always delete zip after download
if (File.Exists(PythonDownloadPath))
{
File.Delete(PythonDownloadPath);
}
}
}
public async Task InstallGitIfNecessary(IProgress<ProgressReport>? progress = null)
{
if (File.Exists(GitExePath))
{
Logger.Debug("Git already installed at {GitExePath}", GitExePath);
return;
}
Logger.Info("Git not found at {GitExePath}, downloading...", GitExePath);
var portableGitUrl =
"https://github.com/git-for-windows/git/releases/download/v2.41.0.windows.1/PortableGit-2.41.0-64-bit.7z.exe";
if (!File.Exists(PortableGitDownloadPath))
{
await downloadService.DownloadToFileAsync(portableGitUrl, PortableGitDownloadPath, progress: progress);
progress?.Report(new ProgressReport(progress: 1f, message: "Git download complete"));
}
await UnzipGit(progress);
}
[SupportedOSPlatform("windows")]
public async Task InstallVcRedistIfNecessary(IProgress<ProgressReport>? progress = null)
{
var registry = Registry.LocalMachine;
var key = registry.OpenSubKey(
@"SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\X64", false);
if (key != null)
{
var buildId = Convert.ToUInt32(key.GetValue("Bld"));
if (buildId >= 30139)
{
return;
}
}
Logger.Info("Downloading VC Redist");
await downloadService.DownloadToFileAsync(VcRedistDownloadUrl, VcRedistDownloadPath, progress: progress);
progress?.Report(new ProgressReport(progress: 1f, message: "Visual C++ download complete",
type: ProgressType.Download));
Logger.Info("Installing VC Redist");
progress?.Report(new ProgressReport(progress: 0.5f, isIndeterminate: true, type: ProgressType.Generic, message: "Installing prerequisites..."));
var process = ProcessRunner.StartAnsiProcess(VcRedistDownloadPath, "/install /quiet /norestart");
await process.WaitForExitAsync();
progress?.Report(new ProgressReport(progress: 1f, message: "Visual C++ install complete",
type: ProgressType.Generic));
File.Delete(VcRedistDownloadPath);
}
private async Task UnzipGit(IProgress<ProgressReport>? progress = null)
{
if (progress == null)
{
await ArchiveHelper.Extract7Z(PortableGitDownloadPath, PortableGitInstallDir);
}
else
{
await ArchiveHelper.Extract7Z(PortableGitDownloadPath, PortableGitInstallDir, progress);
}
Logger.Info("Extracted Git");
File.Delete(PortableGitDownloadPath);
}
}

66
StabilityMatrix.Avalonia/Models/AvaloniaResource.cs

@ -0,0 +1,66 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Threading.Tasks;
using Avalonia.Platform;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models.FileInterfaces;
namespace StabilityMatrix.Avalonia.Models;
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public readonly record struct AvaloniaResource(
Uri UriPath,
UnixFileMode WriteUnixFileMode = UnixFileMode.None)
{
/// <summary>
/// File name component of the Uri path.
/// </summary>
public string FileName => Path.GetFileName(UriPath.ToString());
/// <summary>
/// File path relative to the 'Assets' folder.
/// </summary>
public Uri RelativeAssetPath =>
new Uri("avares://StabilityMatrix.Avalonia/Assets/").MakeRelativeUri(UriPath);
public AvaloniaResource(string uriPath, UnixFileMode writeUnixFileMode = UnixFileMode.None)
: this(new Uri(uriPath), writeUnixFileMode)
{
}
/// <summary>
/// Opens a stream to this resource.
/// </summary>
public Stream Open() => AssetLoader.Open(UriPath);
/// <summary>
/// Extracts this resource to a target file path.
/// </summary>
public async Task ExtractTo(FilePath outputPath, bool overwrite = true)
{
if (outputPath.Exists)
{
// Skip if not overwriting
if (!overwrite) return;
// Otherwise delete the file
outputPath.Delete();
}
var stream = AssetLoader.Open(UriPath);
await using var fileStream = File.Create(outputPath);
await stream.CopyToAsync(fileStream);
// Write permissions
if (!Compat.IsWindows && Compat.IsUnix && WriteUnixFileMode != UnixFileMode.None)
{
File.SetUnixFileMode(outputPath, WriteUnixFileMode);
}
}
/// <summary>
/// Extracts this resource to the output directory.
/// </summary>
public Task ExtractToDir(DirectoryPath outputDir, bool overwrite = true)
{
return ExtractTo(outputDir.JoinFile(FileName), overwrite);
}
}

125
StabilityMatrix.Avalonia/Models/ObservableDictionary.cs

@ -0,0 +1,125 @@
using System.Collections;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
namespace StabilityMatrix.Avalonia.Models;
public class ObservableDictionary<TKey, TValue> : IDictionary<TKey, TValue>,
INotifyCollectionChanged, INotifyPropertyChanged where TKey : notnull
{
private readonly IDictionary<TKey, TValue> dictionary;
public event NotifyCollectionChangedEventHandler? CollectionChanged;
public event PropertyChangedEventHandler? PropertyChanged;
public IEnumerator<KeyValuePair<TKey, TValue>> GetEnumerator() => dictionary.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
public ObservableDictionary()
{
dictionary = new Dictionary<TKey, TValue>();
}
public ObservableDictionary(Dictionary<TKey, TValue> dictionary)
{
this.dictionary = dictionary;
}
public void Add(KeyValuePair<TKey, TValue> item)
{
dictionary.Add(item);
CollectionChanged?.Invoke(this,
new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Count)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Keys)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Values)));
}
public void Clear()
{
dictionary.Clear();
CollectionChanged?.Invoke(this,
new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Count)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Keys)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Values)));
}
public bool Contains(KeyValuePair<TKey, TValue> item) => dictionary.Contains(item);
public void CopyTo(KeyValuePair<TKey, TValue>[] array, int arrayIndex)
{
dictionary.CopyTo(array, arrayIndex);
}
public bool Remove(KeyValuePair<TKey, TValue> item)
{
var success = dictionary.Remove(item);
if (!success) return false;
CollectionChanged?.Invoke(this,
new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, item));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Count)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Keys)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Values)));
return success;
}
public int Count => dictionary.Count;
public bool IsReadOnly => dictionary.IsReadOnly;
public void Add(TKey key, TValue value)
{
dictionary.Add(key, value);
CollectionChanged?.Invoke(this,
new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add,
new KeyValuePair<TKey, TValue>(key, value)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Count)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Keys)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Values)));
}
public bool ContainsKey(TKey key) => dictionary.ContainsKey(key);
public bool Remove(TKey key)
{
var success = dictionary.TryGetValue(key, out var value) && dictionary.Remove(key);
if (!success) return false;
CollectionChanged?.Invoke(this,
new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove,
new KeyValuePair<TKey, TValue>(key, value!)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Count)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Keys)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Values)));
return success;
}
public bool TryGetValue([NotNull] TKey key, [MaybeNullWhen(false)] out TValue value)
=> dictionary.TryGetValue(key, out value);
public TValue this[TKey key]
{
get => dictionary[key];
set
{
var exists = dictionary.ContainsKey(key);
var action = exists
? NotifyCollectionChangedAction.Replace
: NotifyCollectionChangedAction.Add;
dictionary[key] = value;
CollectionChanged?.Invoke(this,
new NotifyCollectionChangedEventArgs(action, dictionary[key]));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Count)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Keys)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Values)));
}
}
public ICollection<TKey> Keys => dictionary.Keys;
public ICollection<TValue> Values => dictionary.Values;
}

33
StabilityMatrix.Avalonia/Models/RemoteResource.cs

@ -0,0 +1,33 @@
using System;
namespace StabilityMatrix.Avalonia.Models;
/// <summary>
/// Defines a remote downloadable resource.
/// </summary>
public readonly record struct RemoteResource
{
public Uri Url { get; }
public Uri[]? FallbackUrls { get; }
public string HashSha256 { get; }
public RemoteResource(Uri url, string hashSha256)
{
Url = url;
HashSha256 = hashSha256;
}
public RemoteResource(Uri[] urls, string hashSha256)
{
if (urls.Length == 0)
{
throw new ArgumentException("Must have at least one url.", nameof(urls));
}
Url = urls[0];
FallbackUrls = urls.Length > 1 ? urls[1..] : null;
HashSha256 = hashSha256;
}
}

14
StabilityMatrix.Avalonia/Models/SharedState.cs

@ -0,0 +1,14 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace StabilityMatrix.Avalonia.Models;
/// <summary>
/// Singleton DI service for observable shared UI state.
/// </summary>
public partial class SharedState : ObservableObject
{
/// <summary>
/// Whether debug mode enabled from settings page version tap.
/// </summary>
[ObservableProperty] private bool isDebugMode;
}

198
StabilityMatrix.Avalonia/Program.cs

@ -0,0 +1,198 @@
using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using NLog;
using Polly.Contrib.WaitAndRetry;
using Projektanker.Icons.Avalonia;
using Projektanker.Icons.Avalonia.FontAwesome;
using Semver;
using Sentry;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Updater;
namespace StabilityMatrix.Avalonia;
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public class Program
{
private static bool isExceptionDialogEnabled;
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args)
{
HandleUpdateReplacement();
var infoVersion = Assembly.GetExecutingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
Compat.AppVersion = SemVersion.Parse(infoVersion ?? "0.0.0", SemVersionStyles.Strict);
// Configure exception dialog for unhandled exceptions
if (!Debugger.IsAttached || args.Contains("--debug-exception-dialog"))
{
isExceptionDialogEnabled = true;
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
}
// Configure Sentry
if ((Debugger.IsAttached && args.Contains("--debug-sentry")) || !args.Contains("--no-sentry"))
{
ConfigureSentry();
}
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
{
IconProvider.Current.Register<FontAwesomeIconProvider>();
return AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}
private static void HandleUpdateReplacement()
{
// Check if we're in the named update folder
if (Compat.AppCurrentDir.Parent is {Name: UpdateHelper.UpdateFolderName} parentDir)
{
var retryDelays = Backoff.DecorrelatedJitterBackoffV2(
TimeSpan.FromMilliseconds(350), retryCount: 5);
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);
// Start the new app
Process.Start(targetExe);
// Shutdown the current app
Environment.Exit(0);
}
catch (Exception)
{
Thread.Sleep(delay);
}
}
}
// Delete update folder if it exists in current directory
var updateDir = UpdateHelper.UpdateFolder;
if (updateDir.Exists)
{
try
{
updateDir.Delete(true);
}
catch (Exception e)
{
var logger = LogManager.GetCurrentClassLogger();
logger.Error(e, "Failed to delete update file");
}
}
}
private static void ConfigureSentry()
{
SentrySdk.Init(o =>
{
o.Dsn = "https://eac7a5ea065d44cf9a8565e0f1817da2@o4505314753380352.ingest.sentry.io/4505314756067328";
o.StackTraceMode = StackTraceMode.Enhanced;
o.TracesSampleRate = 1.0;
o.IsGlobalModeEnabled = true;
// Enables Sentry's "Release Health" feature.
o.AutoSessionTracking = true;
// 1.0 to capture 100% of transactions for performance monitoring.
o.TracesSampleRate = 1.0;
#if DEBUG
o.Environment = "Development";
#endif
});
}
private static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
{
if (e.ExceptionObject is not Exception ex) return;
var logger = LogManager.GetCurrentClassLogger();
logger.Fatal(ex, "Unhandled {Type}: {Message}", ex.GetType().Name, ex.Message);
if (SentrySdk.IsEnabled)
{
SentrySdk.CaptureException(ex);
}
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime lifetime)
{
var dialog = new ExceptionDialog
{
DataContext = new ExceptionViewModel
{
Exception = ex
}
};
var mainWindow = lifetime.MainWindow;
// We can only show dialog if main window exists, and is visible
if (mainWindow is {PlatformImpl: not null, IsVisible: true})
{
// Configure for dialog mode
dialog.ShowAsDialog = true;
dialog.WindowStartupLocation = WindowStartupLocation.CenterOwner;
// Show synchronously without blocking UI thread
// https://github.com/AvaloniaUI/Avalonia/issues/4810#issuecomment-704259221
var cts = new CancellationTokenSource();
dialog.ShowDialog(mainWindow).ContinueWith(_ =>
{
cts.Cancel();
ExitWithException(ex);
}, TaskScheduler.FromCurrentSynchronizationContext());
Dispatcher.UIThread.MainLoop(cts.Token);
}
else
{
// No parent window available
var cts = new CancellationTokenSource();
// Exit on token cancellation
cts.Token.Register(() => ExitWithException(ex));
dialog.ShowWithCts(cts);
Dispatcher.UIThread.MainLoop(cts.Token);
}
}
}
[DoesNotReturn]
private static void ExitWithException(Exception exception)
{
App.Shutdown(1);
Dispatcher.UIThread.InvokeShutdown();
Environment.Exit(Marshal.GetHRForException(exception));
}
}

46
StabilityMatrix.Avalonia/Services/INotificationService.cs

@ -0,0 +1,46 @@
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls.Notifications;
using StabilityMatrix.Core.Models;
namespace StabilityMatrix.Avalonia.Services;
public interface INotificationService
{
public void Initialize(
Visual? visual,
NotificationPosition position = NotificationPosition.BottomRight,
int maxItems = 3);
public void Show(INotification notification);
/// <summary>
/// Attempt to run the given task, showing a generic error notification if it fails.
/// </summary>
/// <param name="task">The task to run.</param>
/// <param name="title">The title to show in the notification.</param>
/// <param name="message">The message to show, default to exception.Message</param>
/// <param name="appearance">The appearance of the notification.</param>
Task<TaskResult<T>> TryAsync<T>(
Task<T> task,
string title = "Error",
string? message = null,
NotificationType appearance = NotificationType.Error);
/// <summary>
/// Attempt to run the given void task, showing a generic error notification if it fails.
/// Return a TaskResult with true if the task succeeded, false if it failed.
/// </summary>
/// <param name="task">The task to run.</param>
/// <param name="title">The title to show in the notification.</param>
/// <param name="message">The message to show, default to exception.Message</param>
/// <param name="appearance">The appearance of the notification.</param>
Task<TaskResult<bool>> TryAsync(
Task task,
string title = "Error",
string? message = null,
NotificationType appearance = NotificationType.Error);
void Show(string title, string message,
NotificationType appearance = NotificationType.Information);
}

74
StabilityMatrix.Avalonia/Services/NotificationService.cs

@ -0,0 +1,74 @@
using System;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Notifications;
using StabilityMatrix.Core.Models;
namespace StabilityMatrix.Avalonia.Services;
public class NotificationService : INotificationService
{
private WindowNotificationManager? notificationManager;
public void Initialize(
Visual? visual,
NotificationPosition position = NotificationPosition.BottomRight,
int maxItems = 3)
{
if (notificationManager is not null) return;
notificationManager = new WindowNotificationManager(TopLevel.GetTopLevel(visual))
{
Position = position,
MaxItems = maxItems
};
}
public void Show(INotification notification)
{
notificationManager?.Show(notification);
}
public void Show(string title, string message,
NotificationType appearance = NotificationType.Information)
{
Show(new Notification(title, message, appearance));
}
/// <inheritdoc />
public async Task<TaskResult<T>> TryAsync<T>(
Task<T> task,
string title = "Error",
string? message = null,
NotificationType appearance = NotificationType.Error)
{
try
{
return new TaskResult<T>(await task);
}
catch (Exception e)
{
Show(new Notification(title, message ?? e.Message, appearance));
return TaskResult<T>.FromException(e);
}
}
/// <inheritdoc />
public async Task<TaskResult<bool>> TryAsync(
Task task,
string title = "Error",
string? message = null,
NotificationType appearance = NotificationType.Error)
{
try
{
await task;
return new TaskResult<bool>(true);
}
catch (Exception e)
{
Show(new Notification(title, message ?? e.Message, appearance));
return new TaskResult<bool>(false, e);
}
}
}

132
StabilityMatrix.Avalonia/Services/ServiceManager.cs

@ -0,0 +1,132 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
namespace StabilityMatrix.Avalonia.Services;
public class ServiceManager<T>
{
// Holds providers
private readonly Dictionary<Type, Func<T>> providers = new();
// Holds singleton instances
private readonly Dictionary<Type, T> instances = new();
/// <summary>
/// Register a new dialog view model (singleton instance)
/// </summary>
public ServiceManager<T> Register<TService>(TService instance) where TService : T
{
lock (instances)
{
if (instances.ContainsKey(typeof(TService)) || providers.ContainsKey(typeof(TService)))
{
throw new ArgumentException(
$"Service of type {typeof(TService)} is already registered for {typeof(T)}");
}
instances[typeof(TService)] = instance;
}
return this;
}
/// <summary>
/// Register a new dialog view model provider action (called on each dialog creation)
/// </summary>
public ServiceManager<T> Register<TService>(Func<TService> provider) where TService : T
{
lock (providers)
{
if (instances.ContainsKey(typeof(TService)) || providers.ContainsKey(typeof(TService)))
{
throw new ArgumentException(
$"Service of type {typeof(TService)} is already registered for {typeof(T)}");
}
// Return type is wrong during build with method group syntax
// ReSharper disable once RedundantCast
providers[typeof(TService)] = () => (TService) provider();
}
return this;
}
/// <summary>
/// Register a new dialog view model instance using a service provider
/// Equal to Register[TService](serviceProvider.GetRequiredService[TService])
/// </summary>
public ServiceManager<T> RegisterProvider<TService>(IServiceProvider provider) where TService : notnull, T
{
lock (providers)
{
if (instances.ContainsKey(typeof(TService)) || providers.ContainsKey(typeof(TService)))
{
throw new ArgumentException(
$"Service of type {typeof(TService)} is already registered for {typeof(T)}");
}
// Return type is wrong during build with method group syntax
// ReSharper disable once RedundantCast
providers[typeof(TService)] = () => (TService) provider.GetRequiredService<TService>();
}
return this;
}
/// <summary>
/// Get a view model instance
/// </summary>
[SuppressMessage("ReSharper", "InconsistentlySynchronizedField")]
public TService Get<TService>() where TService : T
{
if (instances.TryGetValue(typeof(TService), out var instance))
{
if (instance is null)
{
throw new ArgumentException(
$"Service of type {typeof(TService)} was registered as null");
}
return (TService) instance;
}
if (providers.TryGetValue(typeof(TService), out var provider))
{
if (provider is null)
{
throw new ArgumentException(
$"Service of type {typeof(TService)} was registered as null");
}
var result = provider();
if (result is null)
{
throw new ArgumentException(
$"Service provider for type {typeof(TService)} returned null");
}
return (TService) result;
}
throw new ArgumentException(
$"Service of type {typeof(TService)} is not registered for {typeof(T)}");
}
/// <summary>
/// Get a view model instance with an initializer parameter
/// </summary>
public TService Get<TService>(Func<TService, TService> initializer) where TService : T
{
var instance = Get<TService>();
return initializer(instance);
}
/// <summary>
/// Get a view model instance with an initializer for a mutable instance
/// </summary>
public TService Get<TService>(Action<TService> initializer) where TService : T
{
var instance = Get<TService>();
initializer(instance);
return instance;
}
}

79
StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj

@ -0,0 +1,79 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<RuntimeIdentifiers>win-x64;linux-x64;osx-x64;osx-arm64</RuntimeIdentifiers>
<Nullable>enable</Nullable>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<TrimMode>partial</TrimMode>
<PublishTrimmed>true</PublishTrimmed>
<ApplicationIcon>./Assets/Icon.ico</ApplicationIcon>
<Version>2.0.0-dev.1</Version>
<InformationalVersion>$(Version)</InformationalVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AsyncImageLoader.Avalonia" Version="3.0.0-avalonia11-preview6" />
<PackageReference Include="Avalonia" Version="11.0.0" />
<PackageReference Include="Avalonia.AvaloniaEdit" Version="11.0.0" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.0" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.0" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.0" />
<PackageReference Include="Avalonia.Xaml.Behaviors" Version="11.0.0.1" />
<PackageReference Include="AvaloniaEdit.TextMate" Version="11.0.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
<PackageReference Include="FluentAvaloniaUI" Version="2.0.0" />
<PackageReference Include="FluentIcons.Avalonia" Version="1.1.207" />
<PackageReference Include="FluentIcons.FluentAvalonia" Version="1.1.207" />
<PackageReference Include="FuzzySharp" Version="2.0.2" />
<PackageReference Include="Markdown.Avalonia" Version="11.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="7.0.8" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="7.0.0" />
<PackageReference Include="Nito.AsyncEx" Version="5.1.2" />
<PackageReference Include="NLog" Version="5.2.2" />
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.2" />
<PackageReference Include="Polly" Version="7.2.4" />
<PackageReference Include="Polly.Contrib.WaitAndRetry" Version="1.1.1" />
<PackageReference Include="Polly.Extensions.Http" Version="3.0.0" />
<PackageReference Include="Projektanker.Icons.Avalonia.FontAwesome" Version="6.6.0" />
<PackageReference Include="Sentry" Version="3.33.1" />
<PackageReference Include="Sentry.NLog" Version="3.33.1" />
<PackageReference Include="TextMateSharp.Grammars" Version="1.0.55" />
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\Icon.ico" />
<AvaloniaResource Include="Assets\Icon.png" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StabilityMatrix.Core\StabilityMatrix.Core.csproj" />
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\noimage.png" />
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\licenses.json" />
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\sitecustomize.py" />
</ItemGroup>
<ItemGroup>
<!-- Only for win-64 -->
<AvaloniaResource Include="Assets\win-x64\**" Condition="'$(RuntimeIdentifier)' == 'win-x64'" />
<!-- Only for linux-64 -->
<AvaloniaResource Include="Assets\linux-x64\**" Condition="'$(RuntimeIdentifier)' == 'linux-x64'" />
</ItemGroup>
</Project>

4
StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj.DotSettings

@ -0,0 +1,4 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:s="clr-namespace:System;assembly=mscorlib"
xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/Daemon/ConfigureAwaitAnalysisMode/@EntryValue">UI</s:String></wpf:ResourceDictionary>

305
StabilityMatrix.Avalonia/Styles/ButtonStyles.axaml

@ -0,0 +1,305 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia">
<Design.PreviewWith>
<Border Padding="20">
<StackPanel>
<Button Classes="success" Content="Success Button" Margin="8" HorizontalAlignment="Center" />
<Button Classes="accent" Content="FA Accent Button" Margin="8" HorizontalAlignment="Center" />
<Button Classes="systemaccent" Content="System Accent Button" Margin="8" HorizontalAlignment="Center" />
<Button Classes="danger" Content="Danger Button" Margin="8" HorizontalAlignment="Center" />
<Button Classes="info" Content="Info Button" Margin="8" HorizontalAlignment="Center" />
<Button Classes="transparent-info" Content="Semi-Transparent Info Button" Margin="8" HorizontalAlignment="Center" />
<Button Classes="transparent" Content="Transparent Button" Margin="8" HorizontalAlignment="Center" />
<Button Content="Disabled Button" Margin="8" IsEnabled="False" HorizontalAlignment="Center" />
</StackPanel>
</Border>
</Design.PreviewWith>
<!-- Success -->
<Style Selector="Button.success">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeGreenColor}" />
<Setter Property="BorderBrush" Value="{DynamicResource ThemeGreenColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" />
</Style>
<Style Selector="^:pointerover">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeDarkGreenColor}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ThemeDarkGreenColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" />
</Style>
</Style>
<Style Selector="^:pressed">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeDarkDarkGreenColor}" />
<Setter Property="BorderBrush" Value="{DynamicResource ThemeDarkDarkGreenColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" />
</Style>
</Style>
<Style Selector="^:disabled">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" />
</Style>
</Style>
</Style>
<!-- Danger -->
<Style Selector="Button.danger">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeRedColor}" />
<Setter Property="BorderBrush" Value="{DynamicResource ThemeRedColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" />
</Style>
<Style Selector="^:pointerover">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeDarkRedColor}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ThemeDarkRedColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" />
</Style>
</Style>
<Style Selector="^:pressed">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeDarkDarkRedColor}" />
<Setter Property="BorderBrush" Value="{DynamicResource ThemeDarkDarkRedColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" />
</Style>
</Style>
<Style Selector="^:disabled">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" />
</Style>
</Style>
</Style>
<!-- Info -->
<Style Selector="Button.info">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeLightBlueColor}" />
<Setter Property="BorderBrush" Value="{DynamicResource ThemeLightBlueColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" />
</Style>
<Style Selector="^:pointerover">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeBlueColor}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ThemeBlueColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" />
</Style>
</Style>
<Style Selector="^:pressed">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeDarkBlueColor}" />
<Setter Property="BorderBrush" Value="{DynamicResource ThemeDarkBlueColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" />
</Style>
</Style>
<Style Selector="^:disabled">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" />
</Style>
</Style>
</Style>
<!--Accent Button-->
<Style Selector="Button.accent">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource AccentButtonBackground}" />
<Setter Property="BorderBrush" Value="{DynamicResource AccentButtonBorderBrush}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource AccentButtonForeground}" />
</Style>
<Style Selector="^:pointerover">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource AccentButtonBackgroundPointerOver}" />
<Setter Property="BorderBrush" Value="{DynamicResource AccentButtonBorderBrushPointerOver}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource AccentButtonForegroundPointerOver}" />
</Style>
</Style>
<Style Selector="^:pressed">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource AccentButtonBackgroundPressed}" />
<Setter Property="BorderBrush" Value="{DynamicResource AccentButtonBorderBrushPressed}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource AccentButtonForegroundPressed}" />
</Style>
</Style>
<Style Selector="^:disabled">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource AccentButtonBackgroundDisabled}" />
<Setter Property="BorderBrush" Value="{DynamicResource AccentButtonBorderBrushDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource AccentButtonForegroundDisabled}" />
</Style>
</Style>
</Style>
<!-- SystemAccent -->
<Style Selector="Button.systemaccent">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource SystemAccentColor}" />
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" />
</Style>
<Style Selector="^:pointerover">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource SystemAccentColorDark1}"/>
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColorDark1}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" />
</Style>
</Style>
<Style Selector="^:pressed">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource SystemAccentColorDark2}" />
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColorDark2}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" />
</Style>
</Style>
<Style Selector="^:disabled">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" />
</Style>
</Style>
</Style>
<!-- Transparent -->
<Style Selector="Button.transparent">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource SystemControlTransparentBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrush}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" />
</Style>
<Style Selector="^:pointerover">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBackgroundDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" />
</Style>
</Style>
<Style Selector="^:pressed">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundPressed}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBackgroundPressed}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" />
</Style>
</Style>
<Style Selector="^:disabled">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" />
</Style>
</Style>
</Style>
<!-- Semi-Transparent Info -->
<Style Selector="Button.transparent-info">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeLightBlueColorTransparent}" />
<Setter Property="BorderBrush" Value="{DynamicResource ThemeLightBlueColorTransparent}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" />
</Style>
<Style Selector="^:pointerover">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeBlueColorTransparent}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ThemeBlueColorTransparent}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPointerOver}" />
</Style>
</Style>
<Style Selector="^:pressed">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ThemeDarkBlueColorTransparent}" />
<Setter Property="BorderBrush" Value="{DynamicResource ThemeDarkBlueColorTransparent}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundPressed}" />
</Style>
</Style>
<Style Selector="^:disabled">
<Style Selector="^ /template/ ui|FABorder#Root">
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundDisabled}" />
<Setter Property="BorderBrush" Value="{DynamicResource ButtonBorderBrushDisabled}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundDisabled}" />
</Style>
</Style>
</Style>
</Styles>

93
StabilityMatrix.Avalonia/Styles/Card.axaml

@ -0,0 +1,93 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls">
<Design.PreviewWith>
<Border Padding="20">
<StackPanel>
<controls:Card
Margin="8"
MaxHeight="450"
Name="ModelCard"
Width="330">
<TextBlock Text="Normal Card" />
</controls:Card>
<controls:Card
Margin="8"
MaxHeight="450"
Classes="success"
Width="330">
<TextBlock Text="Success Card" />
</controls:Card>
<controls:Card
Margin="8"
MaxHeight="450"
Classes="info"
Width="330">
<TextBlock Text="Info Card" />
</controls:Card>
</StackPanel>
</Border>
</Design.PreviewWith>
<ControlTheme x:Key="{x:Type controls:Card}" TargetType="controls:Card">
<Setter Property="Background" Value="{DynamicResource ComboBoxBackground}"/>
<Setter Property="BorderBrush" Value="{DynamicResource ComboBoxBorderBrush}" />
<Setter Property="Foreground" Value="{DynamicResource ComboBoxForeground}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="14,16,14,16" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="FontSize" Value="{DynamicResource ControlContentThemeFontSize}" />
<Setter Property="FontWeight" Value="Normal" />
<Setter Property="Border.CornerRadius" Value="{DynamicResource ControlCornerRadius}" />
<Setter Property="Focusable" Value="False" />
<Setter Property="KeyboardNavigation.IsTabStop" Value="False" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate>
<Grid>
<Border
x:Name="ContentBorder"
Padding="{TemplateBinding Padding}"
HorizontalAlignment="{TemplateBinding HorizontalAlignment}"
VerticalAlignment="{TemplateBinding VerticalAlignment}"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding Border.CornerRadius}">
<ContentPresenter
x:Name="ContentPresenter"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
Content="{TemplateBinding Content}" />
</Border>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
<Styles>
<!-- Success -->
<Style Selector="controls|Card.success">
<Setter Property="Background" Value="{DynamicResource ThemeGreenColorTransparent}" />
<Setter Property="BorderBrush" Value="{DynamicResource ThemeGreenColorTransparent}" />
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" />
</Style>
</Style>
<!-- Info -->
<Style Selector="controls|Card.info">
<Setter Property="Background" Value="{DynamicResource ThemeDarkBlueColorTransparent}" />
<Setter Property="BorderBrush" Value="{DynamicResource ThemeDarkBlueColorTransparent}" />
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource ButtonForeground}" />
</Style>
</Style>
</Styles>
</ControlTheme>
</ResourceDictionary>

113
StabilityMatrix.Avalonia/Styles/ProgressRing.axaml

@ -0,0 +1,113 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:StabilityMatrix.Avalonia.Converters"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
x:CompileBindings="True">
<Design.PreviewWith>
<StackPanel>
<Border Padding="20">
<controls:ProgressRing StartAngle="90" EndAngle="270" Value="50" Foreground="Red" BorderThickness="5" Width="100" Height="100" />
</Border>
<Border Padding="20">
<controls:ProgressRing IsIndeterminate="True" BorderThickness="10" Width="100" Height="100" />
</Border>
</StackPanel>
</Design.PreviewWith>
<Styles.Resources>
<converters:FitSquarelyWithinAspectRatioConverter x:Key="FitSquarelyWithinAspectRatioConverter"/>
</Styles.Resources>
<Style Selector="controls|ProgressRing">
<Setter Property="Foreground" Value="{DynamicResource SystemAccentColor}"/>
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundBaseLowBrush}" />
<Setter Property="BorderThickness" Value="9" />
<Setter Property="MinHeight" Value="16" />
<Setter Property="MinWidth" Value="16" />
<Setter Property="Template">
<ControlTemplate>
<Panel x:Name="FluentRingRoot">
<Ellipse x:Name="Track"
Stroke="{TemplateBinding Background}"
StrokeThickness="{Binding BorderThickness.Left, RelativeSource={RelativeSource Mode=TemplatedParent}}" />
<Arc x:Name="Fill"
Stroke="{TemplateBinding Foreground}"
StrokeThickness="{Binding BorderThickness.Left, RelativeSource={RelativeSource Mode=TemplatedParent}}"
StrokeLineCap="Round" />
</Panel>
</ControlTemplate>
</Setter>
</Style>
<Style Selector="controls|ProgressRing:not(:indeterminate) /template/ Arc#Fill">
<Setter Property="StartAngle" Value="{Binding StartAngle, RelativeSource={RelativeSource Mode=TemplatedParent}}"/>
<Setter Property="SweepAngle" Value="{Binding ValueAngle, RelativeSource={RelativeSource Mode=TemplatedParent}}"/>
</Style>
<Style Selector="controls|ProgressRing:preserveaspect">
<Setter Property="MinWidth" Value="32" />
<Setter Property="MinHeight" Value="32" />
</Style>
<Style Selector="controls|ProgressRing:preserveaspect /template/ Panel#FluentRingRoot">
<Setter Property="Width" Value="{TemplateBinding Bounds, Converter={StaticResource FitSquarelyWithinAspectRatioConverter}}"/>
<Setter Property="Height" Value="{Binding Width, RelativeSource={RelativeSource Mode=Self}}"/>
</Style>
<Style Selector="controls|ProgressRing[IsEnabled=True]:indeterminate /template/ Arc#Fill">
<Style.Animations>
<Animation Duration="0:0:5" Easing="LinearEasing" IterationCount="INFINITE" FillMode="Both">
<KeyFrame Cue="0%">
<Setter Property="StartAngle" Value="-720"/>
<Setter Property="SweepAngle" Value="0"/>
</KeyFrame>
<KeyFrame Cue="12.5%">
<Setter Property="StartAngle" Value="-540"/>
<Setter Property="SweepAngle" Value="50"/>
</KeyFrame>
<KeyFrame Cue="25%">
<Setter Property="StartAngle" Value="-360"/>
<Setter Property="SweepAngle" Value="100"/>
</KeyFrame>
<KeyFrame Cue="32.5%">
<Setter Property="StartAngle" Value="-180"/>
<Setter Property="SweepAngle" Value="50"/>
</KeyFrame>
<KeyFrame Cue="50%">
<Setter Property="StartAngle" Value="0"/>
<Setter Property="SweepAngle" Value="5"/>
</KeyFrame>
<KeyFrame Cue="62.5%">
<Setter Property="StartAngle" Value="180"/>
<Setter Property="SweepAngle" Value="50"/>
</KeyFrame>
<KeyFrame Cue="75%">
<Setter Property="StartAngle" Value="360"/>
<Setter Property="SweepAngle" Value="100"/>
</KeyFrame>
<KeyFrame Cue="87.5%">
<Setter Property="StartAngle" Value="540"/>
<Setter Property="SweepAngle" Value="50"/>
</KeyFrame>
<KeyFrame Cue="100%">
<Setter Property="StartAngle" Value="720"/>
<Setter Property="SweepAngle" Value="0"/>
</KeyFrame>
</Animation>
</Style.Animations>
</Style>
<Style Selector="controls|ProgressRing[IsEnabled=True] /template/ Ellipse#Track">
<Style.Animations>
<Animation Duration="0:0:1" IterationCount="INFINITE">
<KeyFrame Cue="0%">
<Setter Property="Opacity" Value="0.99"/>
</KeyFrame>
<KeyFrame Cue="100%">
<Setter Property="Opacity" Value="1"/>
</KeyFrame>
</Animation>
</Style.Animations>
</Style>
</Styles>

33
StabilityMatrix.Avalonia/Styles/ThemeColors.axaml

@ -0,0 +1,33 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Add Resources Here -->
<Color x:Key="ThemePrimaryColor">#333333</Color>
<Color x:Key="ThemeDarkDarkRedColor">#952923</Color>
<Color x:Key="ThemeDarkRedColor">#C2362E</Color>
<Color x:Key="ThemeRedColor">#F44336</Color>
<Color x:Key="ThemePinkColor">#E91E63</Color>
<Color x:Key="ThemePurpleColor">#9C27B0</Color>
<Color x:Key="ThemeDeepPurpleColor">#673AB7</Color>
<Color x:Key="ThemeIndigoColor">#3F51B5</Color>
<Color x:Key="ThemeDarkBlueColor">#1A72BD</Color>
<Color x:Key="ThemeDarkBlueColorTransparent">#AA1A72BD</Color>
<Color x:Key="ThemeBlueColor">#2196F3</Color>
<Color x:Key="ThemeBlueColorTransparent">#AA2196F3</Color>
<Color x:Key="ThemeLightBlueColor">#03A9F4</Color>
<Color x:Key="ThemeLightBlueColorTransparent">#AA03A9F4</Color>
<Color x:Key="ThemeCyanColor">#00BCD4</Color>
<Color x:Key="ThemeTealColor">#009688</Color>
<Color x:Key="ThemeDarkDarkGreenColor">#2C582C</Color>
<Color x:Key="ThemeDarkGreenColor">#3A783C</Color>
<Color x:Key="ThemeGreenColor">#4BA04F</Color>
<Color x:Key="ThemeGreenColorTransparent">#AA4BA04F</Color>
<Color x:Key="ThemeLightGreenColor">#8BC34A</Color>
<Color x:Key="ThemeLimeColor">#CDDC39</Color>
<Color x:Key="ThemeYellowColor">#FFEB3B</Color>
<Color x:Key="ThemeAmberColor">#FFC107</Color>
<Color x:Key="ThemeOrangeColor">#FF9800</Color>
<Color x:Key="ThemeDeepOrangeColor">#FF5722</Color>
<Color x:Key="ThemeBrownColor">#795548</Color>
<Color x:Key="ThemeGreyColor">#9E9E9E</Color>
<Color x:Key="ThemeBlueGreyColor">#607D8B</Color>
</ResourceDictionary>

10
StabilityMatrix.Avalonia/Styles/ThemeColors.cs

@ -0,0 +1,10 @@
using Avalonia.Media;
namespace StabilityMatrix.Avalonia.Styles;
public static class ThemeColors
{
public static readonly SolidColorBrush ThemeGreen = SolidColorBrush.Parse("#4caf50");
public static readonly SolidColorBrush ThemeRed = SolidColorBrush.Parse("#f44336");
public static readonly SolidColorBrush ThemeYellow = SolidColorBrush.Parse("#ffeb3b");
}

47
StabilityMatrix.Avalonia/ViewLocator.cs

@ -0,0 +1,47 @@
using System;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using StabilityMatrix.Avalonia.ViewModels;
using StabilityMatrix.Core.Attributes;
namespace StabilityMatrix.Avalonia;
public class ViewLocator : IDataTemplate
{
public Control Build(object? data)
{
if (data is null) throw new ArgumentNullException(nameof(data));
var type = data.GetType();
if (type is null) throw new InvalidOperationException("Type is null");
if (Attribute.GetCustomAttribute(type, typeof(ViewAttribute)) is ViewAttribute viewAttr)
{
var viewType = viewAttr.GetViewType();
#pragma warning disable IL2072
// In design mode, just create a new instance of the view
if (Design.IsDesignMode)
{
return (Control) Activator.CreateInstance(viewType)!;
}
#pragma warning restore IL2072
// Otherwise get from the service provider
if (App.Services.GetService(viewType) is Control view)
{
return view;
}
}
return new TextBlock
{
Text = "Not Found: " + data.GetType().FullName
};
}
public bool Match(object? data)
{
return data is ViewModelBase;
}
}

360
StabilityMatrix.Avalonia/ViewModels/CheckpointBrowserCardViewModel.cs

@ -0,0 +1,360 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Controls.Notifications;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls;
using NLog;
using Octokit;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Api;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Processes;
using StabilityMatrix.Core.Services;
using Notification = Avalonia.Controls.Notifications.Notification;
namespace StabilityMatrix.Avalonia.ViewModels;
public partial class CheckpointBrowserCardViewModel : ProgressViewModel
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private readonly IDownloadService downloadService;
private readonly ISettingsManager settingsManager;
private readonly ServiceManager<ViewModelBase> dialogFactory;
private readonly INotificationService notificationService;
public CivitModel CivitModel { get; init; }
public override bool IsTextVisible => Value > 0;
[ObservableProperty] private Uri? cardImage;
[ObservableProperty] private bool isImporting;
[ObservableProperty] private string updateCardText = string.Empty;
[ObservableProperty] private bool showUpdateCard;
public CheckpointBrowserCardViewModel(
CivitModel civitModel,
IDownloadService downloadService,
ISettingsManager settingsManager,
ServiceManager<ViewModelBase> dialogFactory,
INotificationService notificationService)
{
this.downloadService = downloadService;
this.settingsManager = settingsManager;
this.dialogFactory = dialogFactory;
this.notificationService = notificationService;
CivitModel = civitModel;
UpdateImage();
CheckIfInstalled();
// Update image when nsfw setting changes
settingsManager.RegisterPropertyChangedHandler(
s => s.ModelBrowserNsfwEnabled,
_ => Dispatcher.UIThread.Post(UpdateImage));
}
private void CheckIfInstalled()
{
if (Design.IsDesignMode)
{
UpdateCardText = "Installed";
ShowUpdateCard = true;
return;
}
if (CivitModel.ModelVersions == null) return;
var installedModels = settingsManager.Settings.InstalledModelHashes;
if (!installedModels.Any()) return;
// check if latest version is installed
var latestVersion = CivitModel.ModelVersions[0];
var latestVersionInstalled = latestVersion.Files != null && latestVersion.Files.Any(file =>
file is {Type: CivitFileType.Model, Hashes.BLAKE3: not null} &&
installedModels.Contains(file.Hashes.BLAKE3));
// check if any of the ModelVersion.Files.Hashes.BLAKE3 hashes are in the installedModels list
var anyVersionInstalled = latestVersionInstalled || CivitModel.ModelVersions.Any(version =>
version.Files != null && version.Files.Any(file =>
file is {Type: CivitFileType.Model, Hashes.BLAKE3: not null} &&
installedModels.Contains(file.Hashes.BLAKE3)));
UpdateCardText = latestVersionInstalled ? "Installed" :
anyVersionInstalled ? "Update Available" : string.Empty;
ShowUpdateCard = anyVersionInstalled;
}
// Choose and load image based on nsfw setting
private void UpdateImage()
{
var nsfwEnabled = settingsManager.Settings.ModelBrowserNsfwEnabled;
var version = CivitModel.ModelVersions?.FirstOrDefault();
var images = version?.Images;
// Try to find a valid image
var image = images?.FirstOrDefault(image => nsfwEnabled || image.Nsfw == "None");
if (image != null)
{
// var imageStream = await downloadService.GetImageStreamFromUrl(image.Url);
// Dispatcher.UIThread.Post(() => { CardImage = new Bitmap(imageStream); });
CardImage = new Uri(image.Url);
return;
}
// If no valid image found, use no image
CardImage = Assets.NoImage;
// var assetStream = AssetLoader.Open(new Uri("avares://StabilityMatrix.Avalonia/Assets/noimage.png"));
// Otherwise Default image
// Dispatcher.UIThread.Post(() => { CardImage = new Bitmap(assetStream); });
}
[RelayCommand]
private void OpenModel()
{
ProcessRunner.OpenUrl($"https://civitai.com/models/{CivitModel.Id}");
}
[RelayCommand]
private async Task Import(CivitModel model)
{
await DoImport(model);
CheckIfInstalled();
}
[RelayCommand]
private async Task ShowVersionDialog(CivitModel model)
{
var versions = model.ModelVersions;
if (versions is null || versions.Count == 0)
{
notificationService.Show(new Notification("Model has no versions available",
"This model has no versions available for download", NotificationType.Warning));
return;
}
var dialog = new BetterContentDialog
{
Title = model.Name,
IsPrimaryButtonEnabled = false,
IsSecondaryButtonEnabled = false,
IsFooterVisible = false,
};
var viewModel = dialogFactory.Get<SelectModelVersionViewModel>();
viewModel.Dialog = dialog;
viewModel.Versions = versions.Select(v => new ModelVersionViewModel(settingsManager, v))
.ToImmutableArray();
viewModel.SelectedVersionViewModel = viewModel.Versions[0];
dialog.Content = new SelectModelVersionDialog
{
DataContext = viewModel
};
var result = await dialog.ShowAsync();
if (result != ContentDialogResult.Primary)
{
return;
}
var selectedVersion = viewModel?.SelectedVersionViewModel?.ModelVersion;
var selectedFile = viewModel?.SelectedFile?.CivitFile;
await Task.Delay(100);
await DoImport(model, selectedVersion, selectedFile);
}
private async Task DoImport(CivitModel model, CivitModelVersion? selectedVersion = null,
CivitFile? selectedFile = null)
{
IsImporting = true;
Text = "Downloading...";
// Holds files to be deleted on errors
var filesForCleanup = new HashSet<FilePath>();
// Set Text when exiting, finally block will set 100 and delay clear progress
try
{
// Get latest version
var modelVersion = selectedVersion ?? model.ModelVersions?.FirstOrDefault();
if (modelVersion is null)
{
notificationService.Show(new Notification("Model has no versions available",
"This model has no versions available for download", NotificationType.Warning));
Text = "Unable to Download";
return;
}
// Get latest version file
var modelFile = selectedFile ??
modelVersion.Files?.FirstOrDefault(x => x.Type == CivitFileType.Model);
if (modelFile is null)
{
notificationService.Show(new Notification("Model has no files available",
"This model has no files available for download", NotificationType.Warning));
Text = "Unable to Download";
return;
}
var downloadFolder = Path.Combine(settingsManager.ModelsDirectory,
model.Type.ConvertTo<SharedFolderType>().GetStringValue());
// Folders might be missing if user didn't install any packages yet
Directory.CreateDirectory(downloadFolder);
var downloadPath = Path.GetFullPath(Path.Combine(downloadFolder, modelFile.Name));
filesForCleanup.Add(downloadPath);
// Do the download
var progressId = Guid.NewGuid();
var downloadTask = downloadService.DownloadToFileAsync(modelFile.DownloadUrl,
downloadPath,
new Progress<ProgressReport>(report =>
{
if (Math.Abs(report.Percentage - Value) > 0.1)
{
Dispatcher.UIThread.Post(() =>
{
Value = report.Percentage;
Text = $"Downloading... {report.Percentage}%";
});
EventManager.Instance.OnProgressChanged(new ProgressItem(progressId,
modelFile.Name, report));
}
}));
var downloadResult =
await notificationService.TryAsync(downloadTask, "Could not download file");
// Failed download handling
if (downloadResult.Exception is not null)
{
// For exceptions other than ApiException or TaskCanceledException, log error
var logLevel = downloadResult.Exception switch
{
HttpRequestException or ApiException or TaskCanceledException => LogLevel.Warn,
_ => LogLevel.Error
};
Logger.Log(logLevel, downloadResult.Exception, "Error during model download");
Text = "Download Failed";
EventManager.Instance.OnProgressChanged(new ProgressItem(progressId,
modelFile.Name, new ProgressReport(0f), true));
return;
}
// When sha256 is available, validate the downloaded file
var fileExpectedSha256 = modelFile.Hashes.SHA256;
if (!string.IsNullOrEmpty(fileExpectedSha256))
{
var hashProgress = new Progress<ProgressReport>(progress =>
{
Value = progress.Percentage;
Text = $"Validating... {progress.Percentage}%";
EventManager.Instance.OnProgressChanged(new ProgressItem(progressId,
modelFile.Name, progress));
});
var sha256 = await FileHash.GetSha256Async(downloadPath, hashProgress);
if (sha256 != fileExpectedSha256.ToLowerInvariant())
{
Text = "Import Failed!";
DelayedClearProgress(TimeSpan.FromMilliseconds(800));
notificationService.Show(new Notification("Download failed hash validation",
"This may be caused by network or server issues from CivitAI, please try again in a few minutes.",
NotificationType.Error));
Text = "Download Failed";
EventManager.Instance.OnProgressChanged(new ProgressItem(progressId,
modelFile.Name, new ProgressReport(0f), true));
return;
}
settingsManager.Transaction(
s => s.InstalledModelHashes.Add(modelFile.Hashes.BLAKE3));
notificationService.Show(new Notification("Import complete",
$"{model.Type} {model.Name} imported successfully!", NotificationType.Success));
}
IsIndeterminate = true;
// Save connected model info
var modelFileName = Path.GetFileNameWithoutExtension(modelFile.Name);
var modelInfo =
new ConnectedModelInfo(CivitModel, modelVersion, modelFile, DateTime.UtcNow);
var modelInfoPath = Path.GetFullPath(Path.Combine(
downloadFolder, modelFileName + ConnectedModelInfo.FileExtension));
filesForCleanup.Add(modelInfoPath);
await modelInfo.SaveJsonToDirectory(downloadFolder, modelFileName);
// If available, save a model image
if (modelVersion.Images != null && modelVersion.Images.Any())
{
var image = modelVersion.Images[0];
var imageExtension = Path.GetExtension(image.Url).TrimStart('.');
if (imageExtension is "jpg" or "jpeg" or "png")
{
var imageDownloadPath = Path.GetFullPath(Path.Combine(downloadFolder,
$"{modelFileName}.preview.{imageExtension}"));
filesForCleanup.Add(imageDownloadPath);
var imageTask =
downloadService.DownloadToFileAsync(image.Url, imageDownloadPath);
await notificationService.TryAsync(imageTask, "Could not download preview image");
}
}
// Successful - clear cleanup list
filesForCleanup.Clear();
EventManager.Instance.OnProgressChanged(new ProgressItem(progressId,
modelFile.Name, new ProgressReport(1f, "Import complete")));
Text = "Import complete!";
}
catch (Exception e)
{
Debug.WriteLine(e);
}
finally
{
foreach (var file in filesForCleanup.Where(file => file.Exists))
{
file.Delete();
Logger.Info($"Download cleanup: Deleted file {file}");
}
IsIndeterminate = false;
Value = 100;
CheckIfInstalled();
DelayedClearProgress(TimeSpan.FromMilliseconds(800));
}
}
private void DelayedClearProgress(TimeSpan delay)
{
Task.Delay(delay).ContinueWith(_ =>
{
Text = string.Empty;
Value = 0;
IsImporting = false;
});
}
}

382
StabilityMatrix.Avalonia/ViewModels/CheckpointBrowserViewModel.cs

@ -0,0 +1,382 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Controls.Notifications;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls;
using NLog;
using Refit;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Core.Api;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Database;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Api;
using StabilityMatrix.Core.Models.Settings;
using StabilityMatrix.Core.Services;
using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
namespace StabilityMatrix.Avalonia.ViewModels;
[View(typeof(CheckpointBrowserPage))]
public partial class CheckpointBrowserViewModel : PageViewModelBase
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private readonly ICivitApi civitApi;
private readonly IDownloadService downloadService;
private readonly ISettingsManager settingsManager;
private readonly ServiceManager<ViewModelBase> dialogFactory;
private readonly ILiteDbContext liteDbContext;
private readonly INotificationService notificationService;
private const int MaxModelsPerPage = 14;
[ObservableProperty] private ObservableCollection<CheckpointBrowserCardViewModel>? modelCards;
[ObservableProperty] private DataGridCollectionView? modelCardsView;
[ObservableProperty] private string searchQuery = string.Empty;
[ObservableProperty] private bool showNsfw;
[ObservableProperty] private bool showMainLoadingSpinner;
[ObservableProperty] private CivitPeriod selectedPeriod = CivitPeriod.Month;
[ObservableProperty] private CivitSortMode sortMode = CivitSortMode.HighestRated;
[ObservableProperty] private CivitModelType selectedModelType = CivitModelType.Checkpoint;
[ObservableProperty] private int currentPageNumber;
[ObservableProperty] private int totalPages;
[ObservableProperty] private bool hasSearched;
[ObservableProperty] private bool canGoToNextPage;
[ObservableProperty] private bool canGoToPreviousPage;
[ObservableProperty] private bool isIndeterminate;
[ObservableProperty] private bool noResultsFound;
[ObservableProperty] private string noResultsText = string.Empty;
private List<CheckpointBrowserCardViewModel> allModelCards = new();
public IEnumerable<CivitPeriod> AllCivitPeriods => Enum.GetValues(typeof(CivitPeriod)).Cast<CivitPeriod>();
public IEnumerable<CivitSortMode> AllSortModes => Enum.GetValues(typeof(CivitSortMode)).Cast<CivitSortMode>();
public IEnumerable<CivitModelType> AllModelTypes => Enum.GetValues(typeof(CivitModelType))
.Cast<CivitModelType>()
.Where(t => t == CivitModelType.All || t.ConvertTo<SharedFolderType>() > 0)
.OrderBy(t => t.ToString());
public CheckpointBrowserViewModel(
ICivitApi civitApi,
IDownloadService downloadService,
ISettingsManager settingsManager,
ServiceManager<ViewModelBase> dialogFactory,
ILiteDbContext liteDbContext,
INotificationService notificationService)
{
this.civitApi = civitApi;
this.downloadService = downloadService;
this.settingsManager = settingsManager;
this.dialogFactory = dialogFactory;
this.liteDbContext = liteDbContext;
this.notificationService = notificationService;
CurrentPageNumber = 1;
CanGoToNextPage = true;
}
public override void OnLoaded()
{
if (Design.IsDesignMode) return;
var searchOptions = settingsManager.Settings.ModelSearchOptions;
SelectedPeriod = searchOptions?.SelectedPeriod ?? CivitPeriod.Month;
SortMode = searchOptions?.SortMode ?? CivitSortMode.HighestRated;
SelectedModelType = searchOptions?.SelectedModelType ?? CivitModelType.Checkpoint;
ShowNsfw = settingsManager.Settings.ModelBrowserNsfwEnabled;
settingsManager.RelayPropertyFor(this, model => model.ShowNsfw,
settings => settings.ModelBrowserNsfwEnabled);
}
/// <summary>
/// Filter predicate for model cards
/// </summary>
private bool FilterModelCardsPredicate(object? item)
{
if (item is not CheckpointBrowserCardViewModel card) return false;
return !card.CivitModel.Nsfw || ShowNsfw;
}
/// <summary>
/// Background update task
/// </summary>
private async Task CivitModelQuery(CivitModelsRequest request)
{
var timer = Stopwatch.StartNew();
var queryText = request.Query;
try
{
var modelsResponse = await civitApi.GetModels(request);
var models = modelsResponse.Items;
if (models is null)
{
Logger.Debug("CivitAI Query {Text} returned no results (in {Elapsed:F1} s)",
queryText, timer.Elapsed.TotalSeconds);
return;
}
Logger.Debug("CivitAI Query {Text} returned {Results} results (in {Elapsed:F1} s)",
queryText, models.Count, timer.Elapsed.TotalSeconds);
var unknown = models.Where(m => m.Type == CivitModelType.Unknown).ToList();
if (unknown.Any())
{
var names = unknown.Select(m => m.Name).ToList();
Logger.Warn("Excluded {Unknown} unknown model types: {Models}", unknown.Count,
names);
}
// Filter out unknown model types and archived/taken-down models
models = models.Where(m => m.Type.ConvertTo<SharedFolderType>() > 0)
.Where(m => m.Mode == null).ToList();
// Database update calls will invoke `OnModelsUpdated`
// Add to database
await liteDbContext.UpsertCivitModelAsync(models);
// Add as cache entry
var cacheNew = await liteDbContext.UpsertCivitModelQueryCacheEntryAsync(new()
{
Id = ObjectHash.GetMd5Guid(request),
InsertedAt = DateTimeOffset.UtcNow,
Request = request,
Items = models,
Metadata = modelsResponse.Metadata
});
if (cacheNew)
{
Logger.Debug("New cache entry, updating model cards");
UpdateModelCards(models, modelsResponse.Metadata);
}
else
{
Logger.Debug("Cache entry already exists, not updating model cards");
}
}
catch (OperationCanceledException)
{
notificationService.Show(new Notification("Request to CivitAI timed out",
"Please try again in a few minutes"));
Logger.Warn($"CivitAI query timed out ({request})");
}
catch (HttpRequestException e)
{
notificationService.Show(new Notification("CivitAI can't be reached right now",
"Please try again in a few minutes"));
Logger.Warn(e, $"CivitAI query HttpRequestException ({request})");
}
catch (ApiException e)
{
notificationService.Show(new Notification("CivitAI can't be reached right now",
"Please try again in a few minutes"));
Logger.Warn(e, $"CivitAI query ApiException ({request})");
}
catch (Exception e)
{
notificationService.Show(new Notification("CivitAI can't be reached right now",
$"Unknown exception during CivitAI query: {e.GetType().Name}"));
Logger.Error(e, $"CivitAI query unknown exception ({request})");
}
finally
{
ShowMainLoadingSpinner = false;
UpdateResultsText();
}
}
/// <summary>
/// Updates model cards using api response object.
/// </summary>
private void UpdateModelCards(IEnumerable<CivitModel>? models, CivitMetadata? metadata)
{
if (models is null)
{
ModelCards?.Clear();
}
else
{
var updateCards = models
.Select(model => new CheckpointBrowserCardViewModel(model,
downloadService, settingsManager, dialogFactory, notificationService)).ToList();
allModelCards = updateCards;
ModelCards =
new ObservableCollection<CheckpointBrowserCardViewModel>(
updateCards.Where(FilterModelCardsPredicate));
}
TotalPages = metadata?.TotalPages ?? 1;
CanGoToPreviousPage = CurrentPageNumber > 1;
CanGoToNextPage = CurrentPageNumber < TotalPages;
// Status update
ShowMainLoadingSpinner = false;
IsIndeterminate = false;
HasSearched = true;
}
private string previousSearchQuery = string.Empty;
[RelayCommand]
private async Task SearchModels()
{
var timer = Stopwatch.StartNew();
if (SearchQuery != previousSearchQuery)
{
// Reset page number
CurrentPageNumber = 1;
previousSearchQuery = SearchQuery;
}
// Build request
var modelRequest = new CivitModelsRequest
{
Limit = MaxModelsPerPage,
Nsfw = "true", // Handled by local view filter
Sort = SortMode,
Period = SelectedPeriod,
Page = CurrentPageNumber
};
if (SearchQuery.StartsWith("#"))
{
modelRequest.Tag = SearchQuery[1..];
}
else if (SearchQuery.StartsWith("@"))
{
modelRequest.Username = SearchQuery[1..];
}
else
{
modelRequest.Query = SearchQuery;
}
if (SelectedModelType != CivitModelType.All)
{
modelRequest.Types = new[] {SelectedModelType};
}
// See if query is cached
var cachedQuery = await liteDbContext.CivitModelQueryCache
.IncludeAll()
.FindByIdAsync(ObjectHash.GetMd5Guid(modelRequest));
// If cached, update model cards
if (cachedQuery is not null)
{
var elapsed = timer.Elapsed;
Logger.Debug("Using cached query for {Text} [{RequestHash}] (in {Elapsed:F1} s)",
SearchQuery, modelRequest.GetHashCode(), elapsed.TotalSeconds);
UpdateModelCards(cachedQuery.Items, cachedQuery.Metadata);
// Start remote query (background mode)
// Skip when last query was less than 2 min ago
var timeSinceCache = DateTimeOffset.UtcNow - cachedQuery.InsertedAt;
if (timeSinceCache?.TotalMinutes >= 2)
{
CivitModelQuery(modelRequest).SafeFireAndForget();
Logger.Debug(
"Cached query was more than 2 minutes ago ({Seconds:F0} s), updating cache with remote query",
timeSinceCache.Value.TotalSeconds);
}
}
else
{
// Not cached, wait for remote query
ShowMainLoadingSpinner = true;
await CivitModelQuery(modelRequest);
}
UpdateResultsText();
}
[RelayCommand]
private async Task PreviousPage()
{
if (CurrentPageNumber == 1) return;
CurrentPageNumber--;
await TrySearchAgain(false);
}
[RelayCommand]
private async Task NextPage()
{
CurrentPageNumber++;
await TrySearchAgain(false);
}
partial void OnShowNsfwChanged(bool value)
{
settingsManager.Transaction(s => s.ModelBrowserNsfwEnabled, value);
// ModelCardsView?.Refresh();
var updateCards = allModelCards
.Where(FilterModelCardsPredicate);
ModelCards = new ObservableCollection<CheckpointBrowserCardViewModel>(updateCards);
if (!HasSearched) return;
UpdateResultsText();
}
partial void OnSelectedPeriodChanged(CivitPeriod value)
{
TrySearchAgain().SafeFireAndForget();
settingsManager.Transaction(s => s.ModelSearchOptions = new ModelSearchOptions(
value, SortMode, SelectedModelType));
}
partial void OnSortModeChanged(CivitSortMode value)
{
TrySearchAgain().SafeFireAndForget();
settingsManager.Transaction(s => s.ModelSearchOptions = new ModelSearchOptions(
SelectedPeriod, value, SelectedModelType));
}
partial void OnSelectedModelTypeChanged(CivitModelType value)
{
TrySearchAgain().SafeFireAndForget();
settingsManager.Transaction(s => s.ModelSearchOptions = new ModelSearchOptions(
SelectedPeriod, SortMode, value));
}
private async Task TrySearchAgain(bool shouldUpdatePageNumber = true)
{
if (!HasSearched) return;
ModelCards?.Clear();
if (shouldUpdatePageNumber)
{
CurrentPageNumber = 1;
}
// execute command instead of calling method directly so that the IsRunning property gets updated
await SearchModelsCommand.ExecuteAsync(null);
}
private void UpdateResultsText()
{
NoResultsFound = ModelCards?.Count <= 0;
NoResultsText = allModelCards.Count > 0
? $"{allModelCards.Count} results hidden by filters"
: "No results found";
}
public override string Title => "Model Browser";
public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.BrainCircuit, IsFilled = true };
}

250
StabilityMatrix.Avalonia/ViewModels/CheckpointFile.cs

@ -0,0 +1,250 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using Avalonia.Data;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls;
using NLog;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Processes;
namespace StabilityMatrix.Avalonia.ViewModels;
public partial class CheckpointFile : ViewModelBase
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
// Event for when this file is deleted
public event EventHandler<CheckpointFile>? Deleted;
/// <summary>
/// Absolute path to the checkpoint file.
/// </summary>
[ObservableProperty, NotifyPropertyChangedFor(nameof(FileName))]
private string filePath = string.Empty;
/// <summary>
/// Custom title for UI.
/// </summary>
[ObservableProperty]
private string title = string.Empty;
public string? PreviewImagePath { get; set; }
public Bitmap? PreviewImage { get; set; }
public bool IsPreviewImageLoaded => PreviewImage != null;
[ObservableProperty]
private ConnectedModelInfo? connectedModel;
public bool IsConnectedModel => ConnectedModel != null;
[ObservableProperty] private bool isLoading;
public string FileName => Path.GetFileName(FilePath);
public ObservableCollection<string> Badges { get; set; } = new();
private static readonly string[] SupportedCheckpointExtensions = { ".safetensors", ".pt", ".ckpt", ".pth", "bin" };
private static readonly string[] SupportedImageExtensions = { ".png", ".jpg", ".jpeg" };
private static readonly string[] SupportedMetadataExtensions = { ".json" };
partial void OnConnectedModelChanged(ConnectedModelInfo? value)
{
// Update title, first check user defined, then connected model name
Title = value?.UserTitle ?? value?.ModelName ?? string.Empty;
// Update badges
Badges.Clear();
var fpType = value?.FileMetadata.Fp?.GetStringValue().ToUpperInvariant();
if (fpType != null)
{
Badges.Add(fpType);
}
if (!string.IsNullOrWhiteSpace(value?.BaseModel))
{
Badges.Add(value.BaseModel);
}
}
[RelayCommand]
private async Task DeleteAsync()
{
if (File.Exists(FilePath))
{
IsLoading = true;
try
{
await using var delay = new MinimumDelay(200, 500);
await Task.Run(() => File.Delete(FilePath));
if (PreviewImagePath != null && File.Exists(PreviewImagePath))
{
await Task.Run(() => File.Delete(PreviewImagePath));
}
}
catch (IOException ex)
{
Logger.Warn($"Failed to delete checkpoint file {FilePath}: {ex.Message}");
return; // Don't delete from collection
}
finally
{
IsLoading = false;
}
}
Deleted?.Invoke(this, this);
}
[RelayCommand]
private async Task RenameAsync()
{
// Parent folder path
var parentPath = Path.GetDirectoryName(FilePath) ?? "";
var textFields = new TextBoxField[]
{
new()
{
Label = "File name",
Validator = text =>
{
if (string.IsNullOrWhiteSpace(text)) throw new
DataValidationException("File name is required");
if (File.Exists(Path.Combine(parentPath, text))) throw new
DataValidationException("File name already exists");
},
Text = FileName
}
};
var dialog = DialogHelper.CreateTextEntryDialog("Rename Model", "", textFields);
if (await dialog.ShowAsync() == ContentDialogResult.Primary)
{
var name = textFields[0].Text;
var nameNoExt = Path.GetFileNameWithoutExtension(name);
var originalNameNoExt = Path.GetFileNameWithoutExtension(FileName);
// Rename file in OS
try
{
var newFilePath = Path.Combine(parentPath, name);
File.Move(FilePath, newFilePath);
FilePath = newFilePath;
// If preview image exists, rename it too
if (PreviewImagePath != null && File.Exists(PreviewImagePath))
{
var newPreviewImagePath = Path.Combine(parentPath, $"{nameNoExt}.preview{Path.GetExtension(PreviewImagePath)}");
File.Move(PreviewImagePath, newPreviewImagePath);
PreviewImagePath = newPreviewImagePath;
}
// If connected model info exists, rename it too (<name>.cm-info.json)
if (ConnectedModel != null)
{
var cmInfoPath = Path.Combine(parentPath, $"{originalNameNoExt}.cm-info.json");
if (File.Exists(cmInfoPath))
{
File.Move(cmInfoPath, Path.Combine(parentPath, $"{nameNoExt}.cm-info.json"));
}
}
}
catch (Exception e)
{
Logger.Warn(e, $"Failed to rename checkpoint file {FilePath}");
}
}
}
[RelayCommand]
private void OpenOnCivitAi()
{
if (ConnectedModel?.ModelId == null) return;
ProcessRunner.OpenUrl($"https://civitai.com/models/{ConnectedModel.ModelId}");
}
// Loads image from path
private async Task LoadPreviewImage()
{
if (PreviewImagePath == null) return;
await Dispatcher.UIThread.InvokeAsync(() =>
{
PreviewImage = new Bitmap(File.OpenRead(PreviewImagePath));
});
}
/// <summary>
/// Indexes directory and yields all checkpoint files.
/// First we match all files with supported extensions.
/// If found, we also look for
/// - {filename}.preview.{image-extensions} (preview image)
/// - {filename}.cm-info.json (connected model info)
/// </summary>
public static IEnumerable<CheckpointFile> FromDirectoryIndex(string directory, SearchOption searchOption = SearchOption.TopDirectoryOnly)
{
// Get all files with supported extensions
var allExtensions = SupportedCheckpointExtensions
.Concat(SupportedImageExtensions)
.Concat(SupportedMetadataExtensions);
var files = allExtensions.AsParallel()
.SelectMany(pattern => Directory.EnumerateFiles(directory, $"*{pattern}", searchOption)).ToDictionary<string, string>(Path.GetFileName);
foreach (var file in files.Keys.Where(k => SupportedCheckpointExtensions.Contains(Path.GetExtension(k))))
{
var checkpointFile = new CheckpointFile()
{
Title = Path.GetFileNameWithoutExtension(file),
FilePath = Path.Combine(directory, file),
};
// Check for connected model info
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file);
var cmInfoPath = $"{fileNameWithoutExtension}.cm-info.json";
if (files.TryGetValue(cmInfoPath, out var jsonPath))
{
try
{
var jsonData = File.ReadAllText(jsonPath);
checkpointFile.ConnectedModel = ConnectedModelInfo.FromJson(jsonData);
}
catch (IOException e)
{
Debug.WriteLine($"Failed to parse {cmInfoPath}: {e}");
}
}
// Check for preview image
var previewImage = SupportedImageExtensions.Select(ext => $"{fileNameWithoutExtension}.preview{ext}").FirstOrDefault(files.ContainsKey);
if (previewImage != null)
{
checkpointFile.PreviewImagePath = files[previewImage];
checkpointFile.LoadPreviewImage().SafeFireAndForget();
}
yield return checkpointFile;
}
}
/// <summary>
/// Index with progress reporting.
/// </summary>
public static IEnumerable<CheckpointFile> FromDirectoryIndex(string directory, IProgress<ProgressReport> progress,
SearchOption searchOption = SearchOption.TopDirectoryOnly)
{
var current = 0ul;
foreach (var checkpointFile in FromDirectoryIndex(directory, searchOption))
{
current++;
progress.Report(new ProgressReport(current, "Indexing", checkpointFile.FileName));
yield return checkpointFile;
}
}
}

400
StabilityMatrix.Avalonia/ViewModels/CheckpointFolder.cs

@ -0,0 +1,400 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using AvaloniaEdit.Utils;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Processes;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.ViewModels;
public partial class CheckpointFolder : ViewModelBase
{
private readonly ISettingsManager settingsManager;
private readonly IDownloadService downloadService;
private readonly ModelFinder modelFinder;
// ReSharper disable once FieldCanBeMadeReadOnly.Local
private bool useCategoryVisibility;
/// <summary>
/// Absolute path to the folder.
/// </summary>
public string DirectoryPath { get; init; } = string.Empty;
/// <summary>
/// Custom title for UI.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FolderType))]
[NotifyPropertyChangedFor(nameof(TitleWithFilesCount))]
private string title = string.Empty;
[ObservableProperty]
private SharedFolderType folderType;
/// <summary>
/// True if the category is enabled for the manager page.
/// </summary>
[ObservableProperty] private bool isCategoryEnabled = true;
/// <summary>
/// True if currently expanded in the UI.
/// </summary>
[ObservableProperty] private bool isExpanded = true;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsDragBlurEnabled))]
private bool isCurrentDragTarget;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsDragBlurEnabled))]
private bool isImportInProgress;
[ObservableProperty] private string searchFilter = string.Empty;
public bool IsDragBlurEnabled => IsCurrentDragTarget || IsImportInProgress;
public string TitleWithFilesCount =>
CheckpointFiles.Any() || SubFolders.Any(f => f.CheckpointFiles.Any())
? $"{Title} ({CheckpointFiles.Count + SubFolders.Sum(folder => folder.CheckpointFiles.Count)})"
: Title;
public ProgressViewModel Progress { get; } = new();
public CheckpointFolder? ParentFolder { get; init; }
public ObservableCollection<CheckpointFolder> SubFolders { get; init; } = new();
public ObservableCollection<CheckpointFile> CheckpointFiles { get; init; } = new();
public ObservableCollection<CheckpointFile> DisplayedCheckpointFiles { get; set; }
public CheckpointFolder(
ISettingsManager settingsManager,
IDownloadService downloadService,
ModelFinder modelFinder,
bool useCategoryVisibility = true)
{
this.settingsManager = settingsManager;
this.downloadService = downloadService;
this.modelFinder = modelFinder;
this.useCategoryVisibility = useCategoryVisibility;
CheckpointFiles.CollectionChanged += OnCheckpointFilesChanged;
DisplayedCheckpointFiles = CheckpointFiles;
}
/// <summary>
/// When title is set, set the category enabled state from settings.
/// </summary>
// ReSharper disable once UnusedParameterInPartialMethod
partial void OnTitleChanged(string value)
{
if (!useCategoryVisibility) return;
// Update folder type
var result = Enum.TryParse(Title, out SharedFolderType type);
FolderType = result ? type : new SharedFolderType();
IsCategoryEnabled = settingsManager.IsSharedFolderCategoryVisible(FolderType);
}
partial void OnSearchFilterChanged(string value)
{
if (string.IsNullOrEmpty(value))
{
DisplayedCheckpointFiles = CheckpointFiles;
}
else
{
var filteredFiles = CheckpointFiles.Where(y =>
y.FileName.Contains(value, StringComparison.OrdinalIgnoreCase));
DisplayedCheckpointFiles = new ObservableCollection<CheckpointFile>(filteredFiles);
}
}
/// <summary>
/// When toggling the category enabled state, save it to settings.
/// </summary>
partial void OnIsCategoryEnabledChanged(bool value)
{
if (!useCategoryVisibility) return;
if (value != settingsManager.IsSharedFolderCategoryVisible(FolderType))
{
settingsManager.SetSharedFolderCategoryVisible(FolderType, value);
}
}
// On collection changes
private void OnCheckpointFilesChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
OnPropertyChanged(nameof(TitleWithFilesCount));
if (e.NewItems == null) return;
// On new added items, add event handler for deletion
foreach (CheckpointFile item in e.NewItems)
{
item.Deleted += OnCheckpointFileDelete;
}
}
/// <summary>
/// Handler for CheckpointFile requesting to be deleted from the collection.
/// </summary>
/// <param name="sender"></param>
/// <param name="file"></param>
private void OnCheckpointFileDelete(object? sender, CheckpointFile file)
{
Dispatcher.UIThread.Post(() =>
{
CheckpointFiles.Remove(file);
DisplayedCheckpointFiles.Remove(file);
});
}
public async Task OnDrop(DragEventArgs e)
{
IsImportInProgress = true;
IsCurrentDragTarget = false;
try
{
// {System.Linq.Enumerable.WhereEnumerableIterator<Avalonia.Platform.Storage.IStorageItem>}
if (e.Data.Get(DataFormats.Files) is IEnumerable<IStorageItem> files)
{
var paths = files.Select(f => f.Path.LocalPath).ToArray();
await ImportFilesAsync(paths, settingsManager.Settings.IsImportAsConnected);
}
}
catch (Exception)
{
// If no exception this will be handled by DelayedClearProgress()
IsImportInProgress = false;
}
}
[RelayCommand]
private async Task ShowInExplorer(string path)
{
await ProcessRunner.OpenFolderBrowser(path);
}
[RelayCommand]
private async Task CreateSubFolder()
{
Dispatcher.UIThread.VerifyAccess();
var textBox = new TextBox();
var dialog = new ContentDialog
{
Content = textBox,
DefaultButton = ContentDialogButton.Primary,
PrimaryButtonText = "OK",
IsPrimaryButtonEnabled = true,
};
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
var targetName = textBox.Text;
if (string.IsNullOrWhiteSpace(targetName)) return;
var subFolderPath = Path.Combine(DirectoryPath, targetName);
Directory.CreateDirectory(subFolderPath);
SubFolders.Add(new CheckpointFolder(settingsManager,
downloadService, modelFinder,
useCategoryVisibility: false)
{
Title = Path.GetFileName(subFolderPath),
DirectoryPath = subFolderPath,
FolderType = FolderType,
ParentFolder = this,
IsExpanded = false,
});
}
}
/// <summary>
/// Imports files to the folder. Reports progress to instance properties.
/// </summary>
public async Task ImportFilesAsync(IEnumerable<string> files, bool convertToConnected = false)
{
Progress.Value = 0;
var copyPaths = files.ToDictionary(k => k, v => Path.Combine(DirectoryPath, Path.GetFileName(v)));
var progress = new Progress<ProgressReport>(report =>
{
Progress.IsIndeterminate = false;
Progress.Value = report.Percentage;
// For multiple files, add count
Progress.Text = copyPaths.Count > 1 ? $"Importing {report.Title} ({report.Message})" : $"Importing {report.Title}";
});
await FileTransfers.CopyFiles(copyPaths, progress);
// Hash files and convert them to connected model if found
if (convertToConnected)
{
var modelFilesCount = copyPaths.Count;
var modelFiles = copyPaths.Values
.Select(path => new FilePath(path));
// Holds tasks for model queries after hash
var modelQueryTasks = new List<Task<bool>>();
foreach (var (i, modelFile) in modelFiles.Enumerate())
{
var hashProgress = new Progress<ProgressReport>(report =>
{
Progress.IsIndeterminate = false;
Progress.Value = report.Percentage;
Progress.Text = modelFilesCount > 1 ?
$"Computing metadata for {modelFile.Info.Name} ({i}/{modelFilesCount})" :
$"Computing metadata for {report.Title}";
});
var hashBlake3 = await FileHash.GetBlake3Async(modelFile, hashProgress);
// Start a task to query the model in background
var queryTask = Task.Run(async () =>
{
var result = await modelFinder.LocalFindModel(hashBlake3);
result ??= await modelFinder.RemoteFindModel(hashBlake3);
if (result is null) return false; // Not found
var (model, version, file) = result.Value;
// Save connected model info json
var modelFileName = Path.GetFileNameWithoutExtension(modelFile.Info.Name);
var modelInfo = new ConnectedModelInfo(
model, version, file, DateTimeOffset.UtcNow);
await modelInfo.SaveJsonToDirectory(DirectoryPath, modelFileName);
// If available, save thumbnail
var image = version.Images?.FirstOrDefault();
if (image != null)
{
var imageExt = Path.GetExtension(image.Url).TrimStart('.');
if (imageExt is "jpg" or "jpeg" or "png")
{
var imageDownloadPath = Path.GetFullPath(
Path.Combine(DirectoryPath, $"{modelFileName}.preview.{imageExt}"));
await downloadService.DownloadToFileAsync(image.Url, imageDownloadPath);
}
}
return true;
});
modelQueryTasks.Add(queryTask);
}
// Set progress to indeterminate
Progress.IsIndeterminate = true;
Progress.Text = "Checking connected model information";
// Wait for all model queries to finish
var modelQueryResults = await Task.WhenAll(modelQueryTasks);
var successCount = modelQueryResults.Count(r => r);
var totalCount = modelQueryResults.Length;
var failCount = totalCount - successCount;
await IndexAsync();
Progress.Value = 100;
Progress.Text = successCount switch
{
0 when failCount > 0 =>
"Import complete. No connected data found.",
> 0 when failCount > 0 =>
$"Import complete. Found connected data for {successCount} of {totalCount} models.",
_ => $"Import complete. Found connected data for all {totalCount} models."
};
DelayedClearProgress(TimeSpan.FromSeconds(1.5));
}
else
{
Progress.Text = "Import complete";
Progress.Value = 100;
await IndexAsync();
DelayedClearProgress(TimeSpan.FromSeconds(1.5));
}
}
/// <summary>
/// Clears progress after a delay.
/// </summary>
private void DelayedClearProgress(TimeSpan delay)
{
Task.Delay(delay).ContinueWith(_ =>
{
IsImportInProgress = false;
Progress.Value = 0;
Progress.Text = string.Empty;
});
}
/// <summary>
/// Gets checkpoint files from folder index
/// </summary>
private async Task<List<CheckpointFile>> GetCheckpointFilesAsync(IProgress<ProgressReport>? progress = default)
{
if (!Directory.Exists(DirectoryPath))
{
return new List<CheckpointFile>();
}
return await (progress switch
{
null => Task.Run(() =>
CheckpointFile.FromDirectoryIndex(DirectoryPath).ToList()),
_ => Task.Run(() =>
CheckpointFile.FromDirectoryIndex(DirectoryPath, progress).ToList())
});
}
/// <summary>
/// Indexes the folder for checkpoint files and refreshes the CheckPointFiles collection.
/// </summary>
public async Task IndexAsync(IProgress<ProgressReport>? progress = default)
{
SubFolders.Clear();
// Get subfolders
foreach (var folder in Directory.GetDirectories(DirectoryPath))
{
// Create subfolder
var subFolder = new CheckpointFolder(settingsManager,
downloadService, modelFinder,
useCategoryVisibility: false)
{
Title = Path.GetFileName(folder),
DirectoryPath = folder,
FolderType = FolderType, // Inherit our folder type
ParentFolder = this,
IsExpanded = false, // Subfolders are collapsed by default
};
await subFolder.IndexAsync(progress);
SubFolders.Add(subFolder);
}
CheckpointFiles.Clear();
CheckpointFiles.AddRange(await GetCheckpointFilesAsync());
}
}

157
StabilityMatrix.Avalonia/ViewModels/CheckpointsPageViewModel.cs

@ -0,0 +1,157 @@
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls;
using NLog;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Processes;
using StabilityMatrix.Core.Services;
using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
namespace StabilityMatrix.Avalonia.ViewModels;
[View(typeof(CheckpointsPage))]
public partial class CheckpointsPageViewModel : PageViewModelBase
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private readonly ISharedFolders sharedFolders;
private readonly ISettingsManager settingsManager;
private readonly ModelFinder modelFinder;
private readonly IDownloadService downloadService;
public override string Title => "Checkpoints";
public override IconSource IconSource => new SymbolIconSource
{Symbol = Symbol.Notebook, IsFilled = true};
// Toggle button for auto hashing new drag-and-dropped files for connected upgrade
[ObservableProperty] private bool isImportAsConnected;
[ObservableProperty] private bool isLoading;
[ObservableProperty] private bool isIndexing;
[ObservableProperty] private string searchFilter;
partial void OnIsImportAsConnectedChanged(bool value)
{
if (settingsManager.IsLibraryDirSet &&
value != settingsManager.Settings.IsImportAsConnected)
{
settingsManager.Transaction(s => s.IsImportAsConnected = value);
}
}
[ObservableProperty] private ObservableCollection<CheckpointFolder> checkpointFolders = new();
[ObservableProperty]
private ObservableCollection<CheckpointFolder> displayedCheckpointFolders = new();
public CheckpointsPageViewModel(
ISharedFolders sharedFolders,
ISettingsManager settingsManager,
IDownloadService downloadService,
ModelFinder modelFinder)
{
this.sharedFolders = sharedFolders;
this.settingsManager = settingsManager;
this.downloadService = downloadService;
this.modelFinder = modelFinder;
}
public override async Task OnLoadedAsync()
{
DisplayedCheckpointFolders = CheckpointFolders;
if (Design.IsDesignMode) return;
// Set UI states
IsImportAsConnected = settingsManager.Settings.IsImportAsConnected;
SearchFilter = string.Empty;
IsLoading = CheckpointFolders.Count == 0;
IsIndexing = CheckpointFolders.Count > 0;
await IndexFolders();
IsLoading = false;
IsIndexing = false;
}
partial void OnSearchFilterChanged(string value)
{
var filteredFolders = CheckpointFolders
.Where(ContainsSearchFilter).ToList();
foreach (var folder in filteredFolders)
{
folder.SearchFilter = SearchFilter;
}
DisplayedCheckpointFolders = new ObservableCollection<CheckpointFolder>(filteredFolders);
}
private bool ContainsSearchFilter(CheckpointFolder folder)
{
if (folder == null)
throw new ArgumentNullException(nameof(folder));
// Check files in the current folder
return folder.CheckpointFiles.Any(x =>
x.FileName.Contains(SearchFilter, StringComparison.OrdinalIgnoreCase)) ||
// If no matching files were found in the current folder, check in all subfolders
folder.SubFolders.Any(subFolder => ContainsSearchFilter(subFolder));
}
private async Task IndexFolders()
{
var modelsDirectory = settingsManager.ModelsDirectory;
// Get all folders within the shared folder root
if (string.IsNullOrWhiteSpace(modelsDirectory))
{
CheckpointFolders.Clear();
return;
}
// Skip if the shared folder root doesn't exist
if (!Directory.Exists(modelsDirectory))
{
Logger.Debug($"Skipped shared folder index - {modelsDirectory} doesn't exist");
CheckpointFolders.Clear();
return;
}
var folders = Directory.GetDirectories(modelsDirectory);
// Index all folders
var indexTasks = folders.Select(f => Task.Run(async () =>
{
var checkpointFolder =
new CheckpointFolder(settingsManager, downloadService, modelFinder)
{
Title = Path.GetFileName(f),
DirectoryPath = f,
IsExpanded = true, // Top level folders expanded by default
};
await checkpointFolder.IndexAsync();
return checkpointFolder;
})).ToList();
await Task.WhenAll(indexTasks);
// Set new observable collection, ordered by alphabetical order
CheckpointFolders =
new ObservableCollection<CheckpointFolder>(indexTasks
.Select(t => t.Result)
.OrderBy(f => f.Title));
DisplayedCheckpointFolders = new ObservableCollection<CheckpointFolder>(CheckpointFolders
.Where(x => x.CheckpointFiles.Any(y => y.FileName.Contains(SearchFilter))));
}
[RelayCommand]
private async Task OpenModelsFolder()
{
await ProcessRunner.OpenFolderBrowser(settingsManager.ModelsDirectory);
}
}

462
StabilityMatrix.Avalonia/ViewModels/ConsoleViewModel.cs

@ -0,0 +1,462 @@
using System;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Threading.Tasks.Dataflow;
using Avalonia.Threading;
using AvaloniaEdit.Document;
using CommunityToolkit.Mvvm.ComponentModel;
using Nito.AsyncEx;
using NLog;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Processes;
namespace StabilityMatrix.Avalonia.ViewModels;
public partial class ConsoleViewModel : ObservableObject, IDisposable
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
// Queue for console updates
private BufferBlock<ProcessOutput> buffer = new();
// Task that updates the console (runs on UI thread)
private Task? updateTask;
// Cancellation token source for updateTask
private CancellationTokenSource? updateCts;
public bool IsUpdatesRunning => updateTask?.IsCompleted == false;
[ObservableProperty] private TextDocument document = new();
/// <summary>
/// Current offset for write operations.
/// </summary>
private int writeCursor;
/// <summary>
/// Lock for accessing <see cref="writeCursor"/>
/// </summary>
private readonly AsyncLock writeCursorLock = new();
/// <summary>
/// Timeout for acquiring locks on <see cref="writeCursor"/>
/// </summary>
// ReSharper disable once MemberCanBePrivate.Global
public TimeSpan WriteCursorLockTimeout { get; init; } = TimeSpan.FromMilliseconds(100);
/// <summary>
/// Gets a cancellation token using the cursor lock timeout
/// </summary>
private CancellationToken WriteCursorLockTimeoutToken =>
new CancellationTokenSource(WriteCursorLockTimeout).Token;
/// <summary>
/// Event invoked when an ApcMessage of type Input is received.
/// </summary>
public event EventHandler<ApcMessage>? ApcInput;
/// <summary>
/// Starts update task for processing Post messages.
/// </summary>
/// <exception cref="InvalidOperationException">If update task is already running</exception>
public void StartUpdates()
{
if (updateTask is not null)
{
throw new InvalidOperationException("Update task is already running");
}
updateCts = new CancellationTokenSource();
updateTask = Dispatcher.UIThread.InvokeAsync(ConsoleUpdateLoop, DispatcherPriority.Render);
}
/// <summary>
/// Cancels the update task and waits for it to complete.
/// </summary>
public async Task StopUpdatesAsync()
{
Logger.Trace($"Stopping console updates, current buffer items: {buffer.Count}");
// First complete the buffer
buffer.Complete();
// Wait for buffer to complete, max 3 seconds
var completionCts = new CancellationTokenSource(3000);
try
{
await buffer.Completion.WaitAsync(completionCts.Token);
}
catch (TaskCanceledException e)
{
// We can still continue since this just means we lose
// some remaining output
Logger.Warn("Buffer completion timed out: " + e.Message);
}
// Cancel update task
updateCts?.Cancel();
updateCts = null;
// Wait for update task
if (updateTask is not null)
{
await updateTask;
updateTask = null;
}
Logger.Trace($"Stopped console updates with {buffer.Count} buffer items remaining");
}
/// <summary>
/// Clears the console and sets a new buffer.
/// This also resets the write cursor to 0.
/// </summary>
public async Task Clear()
{
// Clear document
Document.Text = string.Empty;
// Reset write cursor
await ResetWriteCursor();
// Clear buffer and create new one
buffer.Complete();
buffer = new BufferBlock<ProcessOutput>();
}
/// <summary>
/// Resets the write cursor to be equal to the document length.
/// </summary>
public async Task ResetWriteCursor()
{
using (await writeCursorLock.LockAsync(WriteCursorLockTimeoutToken))
{
Debug.WriteLine($"Reset cursor to end: ({writeCursor} -> {Document.TextLength})");
writeCursor = Document.TextLength;
}
DebugPrintDocument();
}
private async Task ConsoleUpdateLoop()
{
// This must be run in the UI thread
Dispatcher.UIThread.VerifyAccess();
// Get cancellation token
var ct = updateCts?.Token
?? throw new InvalidOperationException("Update cancellation token must be set");
try
{
while (!ct.IsCancellationRequested)
{
ProcessOutput output;
try
{
output = await buffer.ReceiveAsync(ct);
}
catch (InvalidOperationException e)
{
// Thrown when buffer is completed, convert to OperationCanceledException
throw new OperationCanceledException("Update buffer completed", e);
}
var outputType = output.IsStdErr ? "stderr" : "stdout";
Debug.WriteLine($"Processing: [{outputType}] (Text = {output.Text.ToRepr()}, " +
$"Raw = {output.RawText?.ToRepr()}, " +
$"CarriageReturn = {output.CarriageReturn}, " +
$"CursorUp = {output.CursorUp}, " +
$"AnsiCommand = {output.AnsiCommand})");
// Link the cancellation token to the write cursor lock timeout
var linkedCt = CancellationTokenSource
.CreateLinkedTokenSource(ct, WriteCursorLockTimeoutToken).Token;
using (await writeCursorLock.LockAsync(linkedCt))
{
ConsoleUpdateOne(output);
}
}
}
catch (OperationCanceledException e)
{
Logger.Debug($"Console update loop canceled: {e.Message}");
}
catch (Exception e)
{
// Log other errors and continue here to not crash the UI thread
Logger.Error(e, $"Unexpected error in console update loop: {e.GetType().Name} {e.Message}");
}
}
/// <summary>
/// Handle one instance of ProcessOutput.
/// Calls to this function must be synchronized with <see cref="writeCursorLock"/>
/// </summary>
/// <remarks>Not checked, but must be run in the UI thread.</remarks>
private void ConsoleUpdateOne(ProcessOutput output)
{
Debug.Assert(Dispatcher.UIThread.CheckAccess());
// Check for Apc messages
if (output.ApcMessage is not null)
{
// Handle Apc message, for now just input audit events
var message = output.ApcMessage.Value;
if (message.Type == ApcType.Input)
{
ApcInput?.Invoke(this, message);
}
// Ignore further processing
return;
}
// If we have a carriage return,
// start current write at the beginning of the current line
if (output.CarriageReturn > 0)
{
var currentLine = Document.GetLineByOffset(writeCursor);
// Get the start of current line as new write cursor
var lineStartOffset = currentLine.Offset;
// See if we need to move the cursor
if (lineStartOffset == writeCursor)
{
Debug.WriteLine($"Cursor already at start for carriage return " +
$"(offset = {lineStartOffset}, line = {currentLine.LineNumber})");
}
else
{
// Also remove everything on current line
// We'll temporarily do this for now to fix progress
var lineEndOffset = currentLine.EndOffset;
var lineLength = lineEndOffset - lineStartOffset;
Document.Remove(lineStartOffset, lineLength);
Debug.WriteLine($"Moving cursor to start for carriage return " +
$"({writeCursor} -> {lineStartOffset})");
writeCursor = lineStartOffset;
}
}
// Write new text
if (!string.IsNullOrEmpty(output.Text))
{
DirectWriteLinesToConsole(output.Text);
}
// Handle cursor movements
if (output.CursorUp > 0)
{
// Get the line and column of the current cursor
var currentLocation = Document.GetLocation(writeCursor);
if (currentLocation.Line == 1)
{
// We are already on the first line, ignore
Debug.WriteLine($"Cursor up: Already on first line");
}
else
{
// We want to move up one line
var targetLocation = new TextLocation(currentLocation.Line - 1, currentLocation.Column);
var targetOffset = Document.GetOffset(targetLocation);
// Update cursor to target offset
Debug.WriteLine($"Cursor up: Moving (line {currentLocation.Line}, {writeCursor})" +
$" -> (line {targetLocation.Line}, {targetOffset})");
writeCursor = targetOffset;
}
}
// Handle erase commands, different to cursor move as they don't move the cursor
// We'll insert blank spaces instead
if (output.AnsiCommand.HasFlag(AnsiCommand.EraseLine))
{
// Get the current line, we'll insert spaces from start to end
var currentLine = Document.GetLineByOffset(writeCursor);
// Make some spaces to insert
var spaces = new string(' ', currentLine.Length);
// Insert the text
Debug.WriteLine($"Erasing line {currentLine.LineNumber}: (length = {currentLine.Length})");
using (Document.RunUpdate())
{
Document.Replace(currentLine.Offset, currentLine.Length, spaces);
}
}
DebugPrintDocument();
}
/// <summary>
/// Write text potentially containing line breaks to the console.
/// <remarks>This call will hold a upgradeable read lock</remarks>
/// </summary>
private void DirectWriteLinesToConsole(string text)
{
// When our cursor is not at end, newlines should be interpreted as commands to
// move cursor forward to the next linebreak instead of inserting a newline.
// If text contains no newlines, we can just call DirectWriteToConsole
// Also if cursor is equal to document length
if (!text.Contains(Environment.NewLine) || writeCursor == Document.TextLength)
{
DirectWriteToConsole(text);
return;
}
// Otherwise we need to handle how linebreaks are treated
// Split text into lines
var lines = text.Split(Environment.NewLine).ToList();
foreach (var lineText in lines.SkipLast(1))
{
// Insert text
DirectWriteToConsole(lineText);
// Set cursor to start of next line, if we're not already there
var currentLine = Document.GetLineByOffset(writeCursor);
// If next line is available, move cursor to start of next line
if (currentLine.LineNumber < Document.LineCount)
{
var nextLine = Document.GetLineByNumber(currentLine.LineNumber + 1);
Debug.WriteLine($"Moving cursor to start of next line " +
$"({writeCursor} -> {nextLine.Offset})");
writeCursor = nextLine.Offset;
}
else
{
// Otherwise move to end of current line, and direct insert a newline
var lineEndOffset = currentLine.EndOffset;
Debug.WriteLine($"Moving cursor to end of current line " +
$"({writeCursor} -> {lineEndOffset})");
writeCursor = lineEndOffset;
DirectWriteToConsole(Environment.NewLine);
}
}
}
/// <summary>
/// Write text to the console, does not handle newlines.
/// This should probably only be used by <see cref="DirectWriteLinesToConsole"/>
/// <remarks>This call will hold a upgradeable read lock</remarks>
/// </summary>
private void DirectWriteToConsole(string text)
{
using (Document.RunUpdate())
{
// Need to replace text first if cursor lower than document length
var replaceLength = Math.Min(Document.TextLength - writeCursor, text.Length);
if (replaceLength > 0)
{
var newText = text[..replaceLength];
Debug.WriteLine(
$"Replacing: (cursor = {writeCursor}, length = {replaceLength}, " +
$"text = {Document.GetText(writeCursor, replaceLength).ToRepr()} " +
$"-> {newText.ToRepr()})");
Document.Replace(writeCursor, replaceLength, newText);
writeCursor += replaceLength;
}
// If we replaced less than content.Length, we need to insert the rest
var remainingLength = text.Length - replaceLength;
if (remainingLength > 0)
{
var textToInsert = text[replaceLength..];
Debug.WriteLine($"Inserting: (cursor = {writeCursor}, " +
$"text = {textToInsert.ToRepr()})");
Document.Insert(writeCursor, textToInsert);
writeCursor += textToInsert.Length;
}
}
}
/// <summary>
/// Debug function to print the current document to the console.
/// Includes formatted cursor position.
/// </summary>
[Conditional("DEBUG")]
private void DebugPrintDocument()
{
var text = Document.Text;
// Add a number for each line
// Add an arrow line for where the cursor is, for example (cursor on offset 3):
//
// 1 | This is the first line
// ~~~~~~~^ (3)
// 2 | This is the second line
//
var lines = text.Split(Environment.NewLine).ToList();
var numberPadding = lines.Count.ToString().Length;
for (var i = 0; i < lines.Count; i++)
{
lines[i] = $"{(i + 1).ToString().PadLeft(numberPadding)} | {lines[i]}";
}
var cursorLine = Document.GetLineByOffset(writeCursor);
var cursorLineOffset = writeCursor - cursorLine.Offset;
// Need to account for padding + line number + space + cursor line offset
var linePadding = numberPadding + 3 + cursorLineOffset;
var cursorLineArrow = new string('~', linePadding) + $"^ ({writeCursor})";
// If more than line count, append to end
if (cursorLine.LineNumber >= lines.Count)
{
lines.Add(cursorLineArrow);
}
else
{
lines.Insert(cursorLine.LineNumber, cursorLineArrow);
}
var textWithCursor = string.Join(Environment.NewLine, lines);
Debug.WriteLine("[Current Document]");
Debug.WriteLine(textWithCursor);
}
/// <summary>
/// Posts an update to the console
/// <remarks>Safe to call on non-UI threads</remarks>
/// </summary>
public void Post(ProcessOutput output)
{
// If update task is running, send to buffer
if (updateTask != null)
{
buffer.Post(output);
return;
}
// Otherwise, use manual update one
Dispatcher.UIThread.Post(() => ConsoleUpdateOne(output));
}
/// <summary>
/// Posts an update to the console.
/// Helper for calling Post(ProcessOutput) with strings
/// </summary>
public void Post(string text)
{
Post(new ProcessOutput { Text = text });
}
/// <summary>
/// Posts an update to the console.
/// Equivalent to Post(text + Environment.NewLine)
/// </summary>
public void PostLine(string text)
{
Post(new ProcessOutput { Text = text + Environment.NewLine });
}
public void Dispose()
{
updateCts?.Cancel();
updateTask?.Dispose();
updateCts?.Dispose();
GC.SuppressFinalize(this);
}
}

20
StabilityMatrix.Avalonia/ViewModels/Dialogs/CivitFileViewModel.cs

@ -0,0 +1,20 @@
using CommunityToolkit.Mvvm.ComponentModel;
using StabilityMatrix.Core.Models.Api;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
public partial class CivitFileViewModel : ObservableObject
{
[ObservableProperty] private CivitFile civitFile;
[ObservableProperty] private bool isInstalled;
public CivitFileViewModel(ISettingsManager settingsManager, CivitFile civitFile)
{
CivitFile = civitFile;
var installedModelHashes = settingsManager.Settings.InstalledModelHashes;
IsInstalled = CivitFile is {Type: CivitFileType.Model, Hashes.BLAKE3: not null} &&
installedModelHashes.Contains(CivitFile.Hashes.BLAKE3);
}
}

27
StabilityMatrix.Avalonia/ViewModels/Dialogs/ContentDialogViewModelBase.cs

@ -0,0 +1,27 @@
using System;
using FluentAvalonia.UI.Controls;
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
public class ContentDialogViewModelBase : ViewModelBase
{
// Events for button clicks
public event EventHandler<ContentDialogResult>? PrimaryButtonClick;
public event EventHandler<ContentDialogResult>? SecondaryButtonClick;
public event EventHandler<ContentDialogResult>? CloseButtonClick;
public virtual void OnPrimaryButtonClick()
{
PrimaryButtonClick?.Invoke(this, ContentDialogResult.Primary);
}
public virtual void OnSecondaryButtonClick()
{
SecondaryButtonClick?.Invoke(this, ContentDialogResult.Secondary);
}
public virtual void OnCloseButtonClick()
{
CloseButtonClick?.Invoke(this, ContentDialogResult.None);
}
}

16
StabilityMatrix.Avalonia/ViewModels/Dialogs/ExceptionViewModel.cs

@ -0,0 +1,16 @@
using System;
using CommunityToolkit.Mvvm.ComponentModel;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Core.Attributes;
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
[View(typeof(ExceptionDialog))]
public partial class ExceptionViewModel : ViewModelBase
{
public Exception? Exception { get; set; }
public string? Message => Exception?.Message;
public string? ExceptionType => Exception?.GetType().Name ?? "";
}

461
StabilityMatrix.Avalonia/ViewModels/Dialogs/InstallerViewModel.cs

@ -0,0 +1,461 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using Avalonia.Controls;
using Avalonia.Controls.Notifications;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls;
using NLog;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Helper.Factory;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Database;
using StabilityMatrix.Core.Models.Packages;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Processes;
using StabilityMatrix.Core.Python;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
public partial class InstallerViewModel : ContentDialogViewModelBase
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private readonly ISettingsManager settingsManager;
private readonly IPyRunner pyRunner;
private readonly IDownloadService downloadService;
private readonly INotificationService notificationService;
private readonly ISharedFolders sharedFolders;
private readonly IPrerequisiteHelper prerequisiteHelper;
[ObservableProperty] private BasePackage selectedPackage;
[ObservableProperty] private PackageVersion? selectedVersion;
[ObservableProperty] private IReadOnlyList<BasePackage>? availablePackages;
[ObservableProperty] private ObservableCollection<GitCommit>? availableCommits;
[ObservableProperty] private ObservableCollection<PackageVersion>? availableVersions;
[ObservableProperty] private GitCommit? selectedCommit;
[ObservableProperty] private string? releaseNotes;
// Version types (release or commit)
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ReleaseLabelText),
nameof(IsReleaseMode), nameof(SelectedVersion))]
private PackageVersionType selectedVersionType = PackageVersionType.Commit;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsReleaseModeAvailable))]
private PackageVersionType availableVersionTypes =
PackageVersionType.GithubRelease | PackageVersionType.Commit;
public string ReleaseLabelText => IsReleaseMode ? "Version" : "Branch";
public bool IsReleaseMode
{
get => SelectedVersionType == PackageVersionType.GithubRelease;
set => SelectedVersionType = value ? PackageVersionType.GithubRelease : PackageVersionType.Commit;
}
public bool IsReleaseModeAvailable => AvailableVersionTypes.HasFlag(PackageVersionType.GithubRelease);
[ObservableProperty] private bool showDuplicateWarning;
[ObservableProperty] private string? installName;
public ProgressViewModel InstallProgress { get; } = new();
public InstallerViewModel(
ISettingsManager settingsManager,
IPackageFactory packageFactory,
IPyRunner pyRunner,
IDownloadService downloadService, INotificationService notificationService,
ISharedFolders sharedFolders,
IPrerequisiteHelper prerequisiteHelper)
{
this.settingsManager = settingsManager;
this.pyRunner = pyRunner;
this.downloadService = downloadService;
this.notificationService = notificationService;
this.sharedFolders = sharedFolders;
this.prerequisiteHelper = prerequisiteHelper;
// AvailablePackages and SelectedPackage
AvailablePackages = new ObservableCollection<BasePackage>(packageFactory.GetAllAvailablePackages());
SelectedPackage = AvailablePackages[0];
}
public override void OnLoaded()
{
if (AvailablePackages == null) return;
SelectedPackage = AvailablePackages[0];
IsReleaseMode = !SelectedPackage.ShouldIgnoreReleases;
}
public override async Task OnLoadedAsync()
{
if (Design.IsDesignMode) return;
// Check for updates
try
{
if (IsReleaseMode)
{
var versions = (await SelectedPackage.GetAllVersions()).ToList();
AvailableVersions = new ObservableCollection<PackageVersion>(versions);
if (!AvailableVersions.Any()) return;
SelectedVersion = AvailableVersions[0];
}
else
{
var branches = (await SelectedPackage.GetAllBranches()).ToList();
AvailableVersions = new ObservableCollection<PackageVersion>(branches.Select(b =>
new PackageVersion
{
TagName = b.Name,
ReleaseNotesMarkdown = b.Commit.Label
}));
UpdateSelectedVersionToLatestMain();
}
ReleaseNotes = SelectedVersion?.ReleaseNotesMarkdown;
}
catch (Exception e)
{
Logger.Warn("Error getting versions: {Exception}", e.ToString());
}
}
[RelayCommand]
private async Task Install()
{
var result = await notificationService.TryAsync(ActuallyInstall(), "Could not install package");
if (result.IsSuccessful)
{
notificationService.Show(new Notification(
$"Package {SelectedPackage.Name} installed successfully!",
"Success", NotificationType.Success));
OnPrimaryButtonClick();
}
else
{
var ex = result.Exception!;
Logger.Error(ex, $"Error installing package: {ex}");
var dialog = new BetterContentDialog
{
Title = "Error installing package",
Content = ex.ToString(),
CloseButtonText = "Close"
};
await dialog.ShowAsync();
}
}
private async Task ActuallyInstall()
{
if (string.IsNullOrWhiteSpace(InstallName))
{
notificationService.Show(new Notification("Package name is empty",
"Please enter a name for the package", NotificationType.Error));
return;
}
try
{
await InstallGitIfNecessary();
SelectedPackage.InstallLocation = Path.Combine(
settingsManager.LibraryDir, "Packages", InstallName);
if (!PyRunner.PipInstalled || !PyRunner.VenvInstalled)
{
InstallProgress.Text = "Installing dependencies...";
InstallProgress.IsIndeterminate = true;
await pyRunner.Initialize();
if (!PyRunner.PipInstalled)
{
await pyRunner.SetupPip();
}
if (!PyRunner.VenvInstalled)
{
await pyRunner.InstallPackage("virtualenv");
}
}
string version;
if (IsReleaseMode)
{
version = SelectedVersion?.TagName ??
throw new NullReferenceException("Selected version is null");
await DownloadPackage(version, false);
}
else
{
version = SelectedCommit?.Sha ??
throw new NullReferenceException("Selected commit is null");
await DownloadPackage(version, true);
}
await InstallPackage();
InstallProgress.Text = "Setting up shared folder links...";
sharedFolders.SetupLinksForPackage(SelectedPackage, SelectedPackage.InstallLocation);
InstallProgress.Text = "Done";
InstallProgress.IsIndeterminate = false;
InstallProgress.Value = 100;
EventManager.Instance.OnGlobalProgressChanged(100);
var branch = SelectedVersionType == PackageVersionType.GithubRelease ?
null : SelectedVersion!.TagName;
var package = new InstalledPackage
{
DisplayName = InstallName,
LibraryPath = Path.Combine("Packages", InstallName),
Id = Guid.NewGuid(),
PackageName = SelectedPackage.Name,
PackageVersion = version,
DisplayVersion = GetDisplayVersion(version, branch),
InstalledBranch = branch,
LaunchCommand = SelectedPackage.LaunchCommand,
LastUpdateCheck = DateTimeOffset.Now
};
await using var st = settingsManager.BeginTransaction();
st.Settings.InstalledPackages.Add(package);
st.Settings.ActiveInstalledPackage = package.Id;
}
finally
{
InstallProgress.Value = 0;
InstallProgress.IsIndeterminate = false;
}
}
private void UpdateSelectedVersionToLatestMain()
{
if (AvailableVersions is null)
{
SelectedVersion = null;
}
else
{
// First try to find master
var version = AvailableVersions.FirstOrDefault(x => x.TagName == "master");
// If not found, try main
version ??= AvailableVersions.FirstOrDefault(x => x.TagName == "main");
// If still not found, just use the first one
version ??= AvailableVersions[0];
SelectedVersion = version;
}
}
private static string GetDisplayVersion(string version, string? branch)
{
return branch == null ? version : $"{branch}@{version[..7]}";
}
private Task<string> DownloadPackage(string version, bool isCommitHash)
{
InstallProgress.Text = "Downloading package...";
var progress = new Progress<ProgressReport>(progress =>
{
InstallProgress.IsIndeterminate = progress.IsIndeterminate;
InstallProgress.Value = progress.Percentage;
EventManager.Instance.OnGlobalProgressChanged((int) progress.Percentage);
});
return SelectedPackage.DownloadPackage(version, isCommitHash, progress);
}
private async Task InstallPackage()
{
InstallProgress.Text = "Installing package...";
SelectedPackage.ConsoleOutput += SelectedPackageOnConsoleOutput;
try
{
var progress = new Progress<ProgressReport>(progress =>
{
InstallProgress.IsIndeterminate = progress.IsIndeterminate;
InstallProgress.Value = progress.Percentage;
EventManager.Instance.OnGlobalProgressChanged((int) progress.Percentage);
});
await SelectedPackage.InstallPackage(progress);
}
finally
{
SelectedPackage.ConsoleOutput -= SelectedPackageOnConsoleOutput;
}
}
private void SelectedPackageOnConsoleOutput(object? sender, ProcessOutput e)
{
InstallProgress.Description = e.Text;
}
[RelayCommand]
private async Task ShowPreview()
{
var url = SelectedPackage.PreviewImageUri.ToString();
var imageStream = await downloadService.GetImageStreamFromUrl(url);
var bitmap = new Bitmap(imageStream);
var dialog = new ContentDialog
{
Title = "Test title",
PrimaryButtonText = "Open in Browser",
CloseButtonText = "Close",
Content = new Image
{
Source = bitmap,
Stretch = Stretch.Uniform,
MaxHeight = 500,
HorizontalAlignment = HorizontalAlignment.Center
}
};
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
ProcessRunner.OpenUrl(url);
}
}
// When available version types change, reset selected version type if not compatible
partial void OnAvailableVersionTypesChanged(PackageVersionType value)
{
if (!value.HasFlag(SelectedVersionType))
{
SelectedVersionType = value;
}
}
// When changing branch / release modes, refresh
// ReSharper disable once UnusedParameterInPartialMethod
partial void OnSelectedVersionTypeChanged(PackageVersionType value) => OnSelectedPackageChanged(SelectedPackage);
partial void OnSelectedPackageChanged(BasePackage value)
{
ReleaseNotes = string.Empty;
AvailableVersions?.Clear();
AvailableCommits?.Clear();
AvailableVersionTypes = SelectedPackage.ShouldIgnoreReleases
? PackageVersionType.Commit
: PackageVersionType.GithubRelease | PackageVersionType.Commit;
if (Design.IsDesignMode) return;
Dispatcher.UIThread.InvokeAsync(async () =>
{
Logger.Debug($"Release mode: {IsReleaseMode}");
var versions = (await value.GetAllVersions(IsReleaseMode)).ToList();
if (!versions.Any()) return;
AvailableVersions = new ObservableCollection<PackageVersion>(versions);
Logger.Debug($"Available versions: {string.Join(", ", AvailableVersions)}");
SelectedVersion = AvailableVersions[0];
ReleaseNotes = versions.First().ReleaseNotesMarkdown;
Logger.Debug($"Loaded release notes for {ReleaseNotes}");
if (!IsReleaseMode)
{
var commits = (await value.GetAllCommits(SelectedVersion.TagName))?.ToList();
if (commits is null || commits.Count == 0) return;
AvailableCommits = new ObservableCollection<GitCommit>(commits);
SelectedCommit = AvailableCommits[0];
UpdateSelectedVersionToLatestMain();
}
InstallName = SelectedPackage.DisplayName;
}).SafeFireAndForget();
}
private async Task InstallGitIfNecessary()
{
var progressHandler = new Progress<ProgressReport>(progress =>
{
if (progress.Message != null && progress.Message.Contains("Downloading"))
{
InstallProgress.Text = $"Downloading prerequisites... {progress.Percentage:N0}%";
}
else if (progress.Type == ProgressType.Extract)
{
InstallProgress.Text = $"Installing git... {progress.Percentage:N0}%";
}
else if (progress.Title != null && progress.Title.Contains("Unpacking"))
{
InstallProgress.Text = $"Unpacking resources... {progress.Percentage:N0}%";
}
else
{
InstallProgress.Text = progress.Message;
}
InstallProgress.IsIndeterminate = progress.IsIndeterminate;
InstallProgress.Value = Convert.ToInt32(progress.Percentage);
});
await prerequisiteHelper.InstallAllIfNecessary(progressHandler);
}
partial void OnInstallNameChanged(string? value)
{
ShowDuplicateWarning =
settingsManager.Settings.InstalledPackages.Any(p =>
p.LibraryPath == $"Packages{Path.DirectorySeparatorChar}{value}");
}
partial void OnSelectedVersionChanged(PackageVersion? value)
{
ReleaseNotes = value?.ReleaseNotesMarkdown ?? string.Empty;
if (value == null) return;
SelectedCommit = null;
AvailableCommits?.Clear();
if (!IsReleaseMode)
{
Task.Run(async () =>
{
try
{
var hashes = await SelectedPackage.GetAllCommits(value.TagName);
if (hashes is null) throw new Exception("No commits found");
Dispatcher.UIThread.Post(() =>
{
AvailableCommits = new ObservableCollection<GitCommit>(hashes);
SelectedCommit = AvailableCommits[0];
});
}
catch (Exception e)
{
Logger.Warn($"Error getting commits: {e.Message}");
}
}).SafeFireAndForget();
}
}
}

86
StabilityMatrix.Avalonia/ViewModels/Dialogs/LaunchOptionsViewModel.cs

@ -0,0 +1,86 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using FuzzySharp;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper.Cache;
using StabilityMatrix.Core.Models;
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
[View(typeof(LaunchOptionsDialog))]
public partial class LaunchOptionsViewModel : ContentDialogViewModelBase
{
private readonly LRUCache<string, ImmutableList<LaunchOptionCard>> cache = new(100);
[ObservableProperty] private string title = "Launch Options";
[ObservableProperty] private bool isSearchBoxEnabled = true;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FilteredCards))]
private string searchText = string.Empty;
[ObservableProperty]
private IReadOnlyList<LaunchOptionCard>? filteredCards;
public IReadOnlyList<LaunchOptionCard>? Cards { get; set; }
/// <summary>
/// Return cards that match the search text
/// </summary>
private IReadOnlyList<LaunchOptionCard>? GetFilteredCards()
{
var text = SearchText;
if (string.IsNullOrWhiteSpace(text) || text.Length < 2)
{
return Cards;
}
// Try cache
if (cache.Get(text, out var cachedCards))
{
return cachedCards!;
}
var searchCard = new LaunchOptionCard
{
Title = text.ToLowerInvariant(),
Type = LaunchOptionType.Bool,
Options = Array.Empty<LaunchOption>()
};
var extracted = Process
.ExtractTop(searchCard, Cards, c => c.Title.ToLowerInvariant());
var results = extracted
.Where(r => r.Score > 40)
.Select(r => r.Value)
.ToImmutableList();
cache.Add(text, results);
return results;
}
public void UpdateFilterCards() => FilteredCards = GetFilteredCards();
public override void OnLoaded()
{
base.OnLoaded();
UpdateFilterCards();
}
/// <summary>
/// Export the current cards options to a list of strings
/// </summary>
public List<LaunchOption> AsLaunchArgs()
{
var launchArgs = new List<LaunchOption>();
if (Cards is null) return launchArgs;
foreach (var card in Cards)
{
launchArgs.AddRange(card.Options);
}
return launchArgs;
}
}

30
StabilityMatrix.Avalonia/ViewModels/Dialogs/ModelVersionViewModel.cs

@ -0,0 +1,30 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using StabilityMatrix.Core.Models.Api;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
public partial class ModelVersionViewModel : ObservableObject
{
[ObservableProperty] private CivitModelVersion modelVersion;
[ObservableProperty] private ObservableCollection<CivitFileViewModel> civitFileViewModels;
[ObservableProperty] private bool isInstalled;
public ModelVersionViewModel(ISettingsManager settingsManager, CivitModelVersion modelVersion)
{
ModelVersion = modelVersion;
var installedModelHashes = settingsManager.Settings.InstalledModelHashes;
IsInstalled = ModelVersion.Files?.Any(file =>
file is {Type: CivitFileType.Model, Hashes.BLAKE3: not null} &&
installedModelHashes.Contains(file.Hashes.BLAKE3)) ??
false;
CivitFileViewModels = new ObservableCollection<CivitFileViewModel>(
ModelVersion.Files?.Select(file => new CivitFileViewModel(settingsManager, file)) ??
new List<CivitFileViewModel>());
}
}

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

@ -0,0 +1,178 @@
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Helper.Factory;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Packages;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Python;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
public partial class OneClickInstallViewModel : ViewModelBase
{
private readonly ISettingsManager settingsManager;
private readonly IPackageFactory packageFactory;
private readonly IPrerequisiteHelper prerequisiteHelper;
private readonly ILogger<OneClickInstallViewModel> logger;
private readonly IPyRunner pyRunner;
private readonly ISharedFolders sharedFolders;
private const string DefaultPackageName = "stable-diffusion-webui";
[ObservableProperty] private string headerText;
[ObservableProperty] private string subHeaderText;
[ObservableProperty] private string subSubHeaderText = string.Empty;
[ObservableProperty] private bool showInstallButton;
[ObservableProperty] private bool isIndeterminate;
[ObservableProperty] private ObservableCollection<BasePackage> allPackages;
[ObservableProperty] private BasePackage selectedPackage;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsProgressBarVisible))]
private int oneClickInstallProgress;
public bool IsProgressBarVisible => OneClickInstallProgress > 0 || IsIndeterminate;
public OneClickInstallViewModel(ISettingsManager settingsManager, IPackageFactory packageFactory,
IPrerequisiteHelper prerequisiteHelper, ILogger<OneClickInstallViewModel> logger, IPyRunner pyRunner,
ISharedFolders sharedFolders)
{
this.settingsManager = settingsManager;
this.packageFactory = packageFactory;
this.prerequisiteHelper = prerequisiteHelper;
this.logger = logger;
this.pyRunner = pyRunner;
this.sharedFolders = sharedFolders;
HeaderText = "Welcome to Stability Matrix!";
SubHeaderText = "Choose your preferred interface and click Install to get started!";
ShowInstallButton = true;
AllPackages =
new ObservableCollection<BasePackage>(this.packageFactory.GetAllAvailablePackages());
SelectedPackage = AllPackages[0];
}
[RelayCommand]
private async Task Install()
{
ShowInstallButton = false;
await DoInstall();
ShowInstallButton = true;
}
[RelayCommand]
private Task ToggleAdvancedMode()
{
EventManager.Instance.OnOneClickInstallFinished(true);
return Task.CompletedTask;
}
private async Task DoInstall()
{
HeaderText = $"Installing {SelectedPackage.DisplayName}";
var progressHandler = new Progress<ProgressReport>(progress =>
{
SubHeaderText = $"{progress.Title} {progress.Percentage:N0}%";
IsIndeterminate = progress.IsIndeterminate;
OneClickInstallProgress = Convert.ToInt32(progress.Percentage);
});
await prerequisiteHelper.InstallAllIfNecessary(progressHandler);
SubHeaderText = "Installing prerequisites...";
IsIndeterminate = true;
if (!PyRunner.PipInstalled)
{
await pyRunner.SetupPip();
}
if (!PyRunner.VenvInstalled)
{
await pyRunner.InstallPackage("virtualenv");
}
IsIndeterminate = false;
var libraryDir = settingsManager.LibraryDir;
// get latest version & download & install
SubHeaderText = "Getting latest version...";
var latestVersion = await SelectedPackage.GetLatestVersion();
SelectedPackage.InstallLocation =
Path.Combine(libraryDir, "Packages", SelectedPackage.DisplayName);
SelectedPackage.ConsoleOutput += (_, output) => SubSubHeaderText = output.Text;
await DownloadPackage(latestVersion);
await InstallPackage();
SubHeaderText = "Setting up shared folder links...";
sharedFolders.SetupLinksForPackage(SelectedPackage, SelectedPackage.InstallLocation);
var installedPackage = new InstalledPackage
{
DisplayName = SelectedPackage.DisplayName,
LibraryPath = Path.Combine("Packages", SelectedPackage.DisplayName),
Id = Guid.NewGuid(),
PackageName = SelectedPackage.Name,
PackageVersion = latestVersion,
DisplayVersion = latestVersion,
LaunchCommand = SelectedPackage.LaunchCommand,
LastUpdateCheck = DateTimeOffset.Now
};
await using var st = settingsManager.BeginTransaction();
st.Settings.InstalledPackages.Add(installedPackage);
st.Settings.ActiveInstalledPackage = installedPackage.Id;
EventManager.Instance.OnInstalledPackagesChanged();
HeaderText = "Installation complete!";
SubSubHeaderText = string.Empty;
OneClickInstallProgress = 100;
SubHeaderText = "Proceeding to Launch page in 3 seconds...";
await Task.Delay(1000);
SubHeaderText = "Proceeding to Launch page in 2 seconds...";
await Task.Delay(1000);
SubHeaderText = "Proceeding to Launch page in 1 second...";
await Task.Delay(1000);
// should close dialog
EventManager.Instance.OnOneClickInstallFinished(false);
}
private async Task DownloadPackage(string version)
{
SubHeaderText = "Downloading package...";
var progress = new Progress<ProgressReport>(progress =>
{
IsIndeterminate = progress.IsIndeterminate;
OneClickInstallProgress = Convert.ToInt32(progress.Percentage);
EventManager.Instance.OnGlobalProgressChanged(OneClickInstallProgress);
});
await SelectedPackage.DownloadPackage(version, false, progress);
SubHeaderText = "Download Complete";
OneClickInstallProgress = 100;
}
private async Task InstallPackage()
{
SelectedPackage.ConsoleOutput += (_, output) => SubSubHeaderText = output.Text;
SubHeaderText = "Downloading and installing package requirements...";
var progress = new Progress<ProgressReport>(progress =>
{
IsIndeterminate = progress.IsIndeterminate;
OneClickInstallProgress = Convert.ToInt32(progress.Percentage);
EventManager.Instance.OnGlobalProgressChanged(OneClickInstallProgress);
});
await SelectedPackage.InstallPackage(progress);
}
}

155
StabilityMatrix.Avalonia/ViewModels/Dialogs/SelectDataDirectoryViewModel.cs

@ -0,0 +1,155 @@
using System;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using Avalonia.Platform.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using NLog;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Models.Settings;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
[View(typeof(SelectDataDirectoryDialog))]
public partial class SelectDataDirectoryViewModel : ContentDialogViewModelBase
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
public static string DefaultInstallLocation => Compat.AppDataHome;
private readonly ISettingsManager settingsManager;
private const string ValidExistingDirectoryText = "Valid existing data directory found";
private const string InvalidDirectoryText =
"Directory must be empty or have a valid settings.json file";
private const string NotEnoughFreeSpaceText = "Not enough free space on the selected drive";
[ObservableProperty] private string dataDirectory = DefaultInstallLocation;
[ObservableProperty] private bool isPortableMode;
[ObservableProperty] private string directoryStatusText = string.Empty;
[ObservableProperty] private bool isStatusBadgeVisible;
[ObservableProperty] private bool isDirectoryValid;
public RefreshBadgeViewModel ValidatorRefreshBadge { get; } = new()
{
State = ProgressState.Inactive,
SuccessToolTipText = ValidExistingDirectoryText,
FailToolTipText = InvalidDirectoryText
};
public bool HasOldData => settingsManager.GetOldInstalledPackages().Any();
public SelectDataDirectoryViewModel(ISettingsManager settingsManager)
{
this.settingsManager = settingsManager;
ValidatorRefreshBadge.RefreshFunc = ValidateDataDirectory;
}
public override void OnLoaded()
{
ValidatorRefreshBadge.RefreshCommand.ExecuteAsync(null).SafeFireAndForget();
}
// Revalidate on data directory change
partial void OnDataDirectoryChanged(string value)
{
ValidatorRefreshBadge.RefreshCommand.ExecuteAsync(null).SafeFireAndForget();
}
private async Task<bool> ValidateDataDirectory()
{
await using var delay = new MinimumDelay(100, 200);
// Doesn't exist, this is fine as a new install, hide badge
if (!Directory.Exists(DataDirectory))
{
IsStatusBadgeVisible = false;
IsDirectoryValid = true;
return true;
}
// Otherwise check that a settings.json exists
var settingsPath = Path.Combine(DataDirectory, "settings.json");
// settings.json exists: Try deserializing it
if (File.Exists(settingsPath))
{
try
{
var jsonText = await File.ReadAllTextAsync(settingsPath);
var _ = JsonSerializer.Deserialize<Settings>(jsonText, new JsonSerializerOptions
{
Converters = { new JsonStringEnumConverter() }
});
// If successful, show existing badge
IsStatusBadgeVisible = true;
IsDirectoryValid = true;
DirectoryStatusText = ValidExistingDirectoryText;
return true;
}
catch (Exception e)
{
Logger.Info("Failed to deserialize settings.json: {Msg}", e.Message);
// If not, show error badge, and set directory to invalid to prevent continuing
IsStatusBadgeVisible = true;
IsDirectoryValid = false;
DirectoryStatusText = InvalidDirectoryText;
return false;
}
}
// No settings.json
// Check if the directory is %APPDATA%\StabilityMatrix: hide badge and set directory valid
if (DataDirectory == DefaultInstallLocation)
{
IsStatusBadgeVisible = false;
IsDirectoryValid = true;
return true;
}
// Check if the directory is empty: hide badge and set directory to valid
var isEmpty = !Directory.EnumerateFileSystemEntries(DataDirectory).Any();
if (isEmpty)
{
IsStatusBadgeVisible = false;
IsDirectoryValid = true;
return true;
}
// Not empty and not appdata: show error badge, and set directory to invalid
IsStatusBadgeVisible = true;
IsDirectoryValid = false;
DirectoryStatusText = InvalidDirectoryText;
return false;
}
private bool CanPickFolder => App.StorageProvider.CanPickFolder;
[RelayCommand(CanExecute = nameof(CanPickFolder))]
private async Task ShowFolderBrowserDialog()
{
var provider = App.StorageProvider;
var result = await provider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Select Data Folder",
AllowMultiple = false
});
if (result.Count != 1) return;
DataDirectory = result[0].Path.LocalPath;
}
partial void OnIsPortableModeChanged(bool value)
{
DataDirectory = value ? Compat.AppCurrentDir + "Data" : DefaultInstallLocation;
}
}

73
StabilityMatrix.Avalonia/ViewModels/Dialogs/SelectModelVersionViewModel.cs

@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using FluentAvalonia.UI.Controls;
using StabilityMatrix.Core.Models.Api;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
public partial class SelectModelVersionViewModel : ContentDialogViewModelBase
{
private readonly ISettingsManager settingsManager;
private readonly IDownloadService downloadService;
public required ContentDialog Dialog { get; set; }
public required IReadOnlyList<ModelVersionViewModel> Versions { get; set; }
[ObservableProperty] private Bitmap? previewImage;
[ObservableProperty] private ModelVersionViewModel? selectedVersionViewModel;
[ObservableProperty] private CivitFileViewModel? selectedFile;
[ObservableProperty] private bool isImportEnabled;
public SelectModelVersionViewModel(ISettingsManager settingsManager,
IDownloadService downloadService)
{
this.settingsManager = settingsManager;
this.downloadService = downloadService;
}
public override void OnLoaded()
{
SelectedVersionViewModel = Versions[0];
}
partial void OnSelectedVersionViewModelChanged(ModelVersionViewModel? value)
{
var nsfwEnabled = settingsManager.Settings.ModelBrowserNsfwEnabled;
var firstImageUrl = value?.ModelVersion?.Images?.FirstOrDefault(
img => nsfwEnabled || img.Nsfw == "None")?.Url;
Dispatcher.UIThread.InvokeAsync(async
() => await UpdateImage(firstImageUrl));
}
partial void OnSelectedFileChanged(CivitFileViewModel? value)
{
IsImportEnabled = value?.CivitFile != null;
}
private async Task UpdateImage(string? url = null)
{
var assetStream = string.IsNullOrWhiteSpace(url)
? AssetLoader.Open(new Uri("avares://StabilityMatrix.Avalonia/Assets/noimage.png"))
: await downloadService.GetImageStreamFromUrl(url);
PreviewImage = new Bitmap(assetStream);
}
public void Cancel()
{
Dialog.Hide(ContentDialogResult.Secondary);
}
public void Import()
{
Dialog.Hide(ContentDialogResult.Primary);
}
}

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

@ -0,0 +1,91 @@
using System;
using System.Diagnostics;
using System.Net.Http;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Models.Update;
using StabilityMatrix.Core.Services;
using StabilityMatrix.Core.Updater;
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
[View(typeof(UpdateDialog))]
public partial class UpdateViewModel : ContentDialogViewModelBase
{
private readonly ISettingsManager settingsManager;
private readonly IHttpClientFactory httpClientFactory;
private readonly IUpdateHelper updateHelper;
[ObservableProperty] private bool isUpdateAvailable;
[ObservableProperty] private UpdateInfo? updateInfo;
[ObservableProperty] private string? releaseNotes;
[ObservableProperty] private string? updateText;
[ObservableProperty] private int progressValue;
[ObservableProperty] private bool showProgressBar;
public UpdateViewModel(
ISettingsManager settingsManager,
IHttpClientFactory httpClientFactory,
IUpdateHelper updateHelper)
{
this.settingsManager = settingsManager;
this.httpClientFactory = httpClientFactory;
this.updateHelper = updateHelper;
EventManager.Instance.UpdateAvailable += (_, info) =>
{
IsUpdateAvailable = true;
UpdateInfo = info;
};
updateHelper.StartCheckingForUpdates().SafeFireAndForget();
}
public override async Task OnLoadedAsync()
{
UpdateText = $"Stability Matrix v{UpdateInfo?.Version} is now available! You currently have v{Utilities.GetAppVersion()}. Would you like to update now?";
var client = httpClientFactory.CreateClient();
var response = await client.GetAsync(UpdateInfo?.ChangelogUrl);
if (response.IsSuccessStatusCode)
{
ReleaseNotes = await response.Content.ReadAsStringAsync();
}
else
{
ReleaseNotes = "## Unable to load release notes";
}
}
[RelayCommand]
private async Task InstallUpdate()
{
if (UpdateInfo == null)
{
return;
}
ShowProgressBar = true;
UpdateText = $"Downloading update v{UpdateInfo.Version}...";
await updateHelper.DownloadUpdate(UpdateInfo, new Progress<ProgressReport>(report =>
{
ProgressValue = Convert.ToInt32(report.Percentage);
}));
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();
}
}

61
StabilityMatrix.Avalonia/ViewModels/FirstLaunchSetupViewModel.cs

@ -0,0 +1,61 @@
using System.Linq;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using CommunityToolkit.Mvvm.ComponentModel;
using StabilityMatrix.Avalonia.Styles;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper;
namespace StabilityMatrix.Avalonia.ViewModels;
[View(typeof(FirstLaunchSetupWindow))]
public partial class FirstLaunchSetupViewModel : ViewModelBase
{
[ObservableProperty]
private bool eulaAccepted;
[ObservableProperty]
private string gpuInfoText = string.Empty;
[ObservableProperty]
private RefreshBadgeViewModel checkHardwareBadge = new()
{
WorkingToolTipText = "We're checking some hardware specifications to determine compatibility.",
SuccessToolTipText = "Everything looks good!",
FailToolTipText = "We recommend a GPU with CUDA support for the best experience. " +
"You can continue without one, but some packages may not work, and inference may be slower.",
FailColorBrush = ThemeColors.ThemeYellow,
};
public FirstLaunchSetupViewModel()
{
CheckHardwareBadge.RefreshFunc = SetGpuInfo;
}
private async Task<bool> SetGpuInfo()
{
GpuInfo[] gpuInfo;
await using (new MinimumDelay(800, 1200))
{
// Query GPU info
gpuInfo = await Task.Run(() => HardwareHelper.IterGpuInfo().ToArray());
}
// First Nvidia GPU
var activeGpu = gpuInfo.FirstOrDefault(gpu => gpu.Name?.ToLowerInvariant().Contains("nvidia") ?? false);
var isNvidia = activeGpu is not null;
// Otherwise first GPU
activeGpu ??= gpuInfo.FirstOrDefault();
GpuInfoText = activeGpu is null
? "No GPU detected"
: $"{activeGpu.Name} ({Size.FormatBytes(activeGpu.MemoryBytes)})";
return isNvidia;
}
public override void OnLoaded()
{
base.OnLoaded();
CheckHardwareBadge.RefreshCommand.ExecuteAsync(null).SafeFireAndForget();
}
}

440
StabilityMatrix.Avalonia/ViewModels/LaunchPageViewModel.cs

@ -0,0 +1,440 @@
using System;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using Avalonia;
using Avalonia.Controls.Documents;
using Avalonia.Controls.Notifications;
using Avalonia.Controls.Primitives;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls;
using Microsoft.Extensions.Logging;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Helper.Factory;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.Packages;
using StabilityMatrix.Core.Processes;
using StabilityMatrix.Core.Python;
using StabilityMatrix.Core.Services;
using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
namespace StabilityMatrix.Avalonia.ViewModels;
[View(typeof(LaunchPageView))]
public partial class LaunchPageViewModel : PageViewModelBase, IDisposable
{
private readonly ILogger<LaunchPageViewModel> logger;
private readonly ISettingsManager settingsManager;
private readonly IPackageFactory packageFactory;
private readonly IPyRunner pyRunner;
private readonly INotificationService notificationService;
private readonly ISharedFolders sharedFolders;
private readonly ServiceManager<ViewModelBase> dialogFactory;
// Regex to match if input contains a yes/no prompt,
// i.e "Y/n", "yes/no". Case insensitive.
// Separated by / or |.
[GeneratedRegex(@"y(/|\|)n|yes(/|\|)no", RegexOptions.IgnoreCase)]
private static partial Regex InputYesNoRegex();
public override string Title => "Launch";
public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.Rocket, IsFilled = true};
public ConsoleViewModel Console { get; } = new();
[ObservableProperty] private bool launchButtonVisibility;
[ObservableProperty] private bool stopButtonVisibility;
[ObservableProperty] private bool isLaunchTeachingTipsOpen;
[ObservableProperty] private bool showWebUiButton;
[ObservableProperty] private InstalledPackage? selectedPackage;
[ObservableProperty] private ObservableCollection<InstalledPackage> installedPackages = new();
[ObservableProperty] private BasePackage? runningPackage;
// private bool clearingPackages;
private string webUiUrl = string.Empty;
// Input info-bars
[ObservableProperty] private bool showManualInputPrompt;
[ObservableProperty] private bool showConfirmInputPrompt;
public LaunchPageViewModel(
ILogger<LaunchPageViewModel> logger,
ISettingsManager settingsManager,
IPackageFactory packageFactory,
IPyRunner pyRunner,
INotificationService notificationService,
ISharedFolders sharedFolders,
ServiceManager<ViewModelBase> dialogFactory)
{
this.logger = logger;
this.settingsManager = settingsManager;
this.packageFactory = packageFactory;
this.pyRunner = pyRunner;
this.notificationService = notificationService;
this.sharedFolders = sharedFolders;
this.dialogFactory = dialogFactory;
EventManager.Instance.PackageLaunchRequested += OnPackageLaunchRequested;
EventManager.Instance.OneClickInstallFinished += OnOneClickInstallFinished;
EventManager.Instance.InstalledPackagesChanged += OnInstalledPackagesChanged;
EventManager.Instance.TeachingTooltipNeeded += OnTeachingTooltipNeeded;
// Handler for console input
Console.ApcInput += (_, message) =>
{
if (InputYesNoRegex().IsMatch(message.Data))
{
ShowConfirmInputPrompt = true;
}
};
}
private void OnTeachingTooltipNeeded(object? sender, EventArgs e) =>
IsLaunchTeachingTipsOpen = true;
private void OnInstalledPackagesChanged(object? sender, EventArgs e) => OnLoaded();
private void OnPackageLaunchRequested(object? sender, Guid e)
{
if (RunningPackage is not null)
{
notificationService.Show("A package is already running",
"Please stop the current package before launching another.",
NotificationType.Error);
return;
}
OnLoaded();
if (SelectedPackage is null) return;
LaunchAsync().SafeFireAndForget();
}
public override void OnLoaded()
{
// Ensure active package either exists or is null
settingsManager.Transaction(s =>
{
s.UpdateActiveInstalledPackage();
}, ignoreMissingLibraryDir: true);
// Load installed packages
InstalledPackages =
new ObservableCollection<InstalledPackage>(settingsManager.Settings.InstalledPackages);
// Load active package
SelectedPackage = settingsManager.Settings.GetActiveInstalledPackage();
}
[RelayCommand]
private async Task LaunchAsync()
{
IsLaunchTeachingTipsOpen = false;
var activeInstall = SelectedPackage;
if (activeInstall == null)
{
// No selected package: error notification
notificationService.Show(new Notification(
message: "You must install and select a package before launching",
title: "No package selected",
type: NotificationType.Error));
return;
}
var activeInstallName = activeInstall.PackageName;
var basePackage = string.IsNullOrWhiteSpace(activeInstallName)
? null
: packageFactory.FindPackageByName(activeInstallName);
if (basePackage == null)
{
logger.LogWarning(
"During launch, package name '{PackageName}' did not match a definition",
activeInstallName);
notificationService.Show(new Notification("Package name invalid",
"Install package name did not match a definition. Please reinstall and let us know about this issue.",
NotificationType.Error));
return;
}
// If this is the first launch (LaunchArgs is null),
// load and save a launch options dialog vm
// so that dynamic initial values are saved.
if (activeInstall.LaunchArgs == null)
{
var definitions = basePackage.LaunchOptions;
// Open a config page and save it
var viewModel = dialogFactory.Get<LaunchOptionsViewModel>();
viewModel.Cards = LaunchOptionCard
.FromDefinitions(definitions, Array.Empty<LaunchOption>())
.ToImmutableArray();
var args = viewModel.AsLaunchArgs();
logger.LogDebug("Setting initial launch args: {Args}",
string.Join(", ", args.Select(o => o.ToArgString()?.ToRepr())));
settingsManager.SaveLaunchArgs(activeInstall.Id, args);
}
await pyRunner.Initialize();
// Get path from package
var packagePath = new DirectoryPath(settingsManager.LibraryDir, activeInstall.LibraryPath!);
// Unpack sitecustomize.py to venv
await UnpackSiteCustomize(packagePath.JoinDir("venv"));
basePackage.ConsoleOutput += OnProcessOutputReceived;
basePackage.Exited += OnProcessExited;
basePackage.StartupComplete += RunningPackageOnStartupComplete;
// Clear console and start update processing
await Console.StopUpdatesAsync();
await Console.Clear();
Console.StartUpdates();
// Update shared folder links (in case library paths changed)
await sharedFolders.UpdateLinksForPackage(basePackage, packagePath);
// Load user launch args from settings and convert to string
var userArgs = settingsManager.GetLaunchArgs(activeInstall.Id);
var userArgsString = string.Join(" ", userArgs.Select(opt => opt.ToArgString()));
// Join with extras, if any
userArgsString = string.Join(" ", userArgsString, basePackage.ExtraLaunchArguments);
await basePackage.RunPackage(packagePath, userArgsString);
RunningPackage = basePackage;
}
// Unpacks sitecustomize.py to the target venv
private static async Task UnpackSiteCustomize(DirectoryPath venvPath)
{
if (Compat.IsWindows)
{
var file = venvPath.JoinFile("Lib", "site-packages", "sitecustomize.py");
await Assets.PyScriptSiteCustomize.ExtractTo(file, true);
}
else
{
var file = venvPath.JoinFile("lib", "python3.10", "site-packages", "sitecustomize.py");
await Assets.PyScriptSiteCustomize.ExtractTo(file, true);
}
}
[RelayCommand]
private async Task Config()
{
var activeInstall = SelectedPackage;
var name = activeInstall?.PackageName;
if (name == null || activeInstall == null)
{
logger.LogWarning($"Selected package is null");
return;
}
var package = packageFactory.FindPackageByName(name);
if (package == null)
{
logger.LogWarning("Package {Name} not found", name);
return;
}
var definitions = package.LaunchOptions;
// Check if package supports IArgParsable
// Use dynamic parsed args over static
/*if (package is IArgParsable parsable)
{
var rootPath = activeInstall.FullPath!;
var moduleName = parsable.RelativeArgsDefinitionScriptPath;
var parser = new ArgParser(pyRunner, rootPath, moduleName);
definitions = await parser.GetArgsAsync();
}*/
// Open a config page
var userLaunchArgs = settingsManager.GetLaunchArgs(activeInstall.Id);
var viewModel = dialogFactory.Get<LaunchOptionsViewModel>();
viewModel.Cards = LaunchOptionCard.FromDefinitions(definitions, userLaunchArgs)
.ToImmutableArray();
logger.LogDebug("Launching config dialog with cards: {CardsCount}",
viewModel.Cards.Count);
var dialog = new BetterContentDialog
{
ContentVerticalScrollBarVisibility = ScrollBarVisibility.Disabled,
IsPrimaryButtonEnabled = true,
PrimaryButtonText = "Save",
CloseButtonText = "Cancel",
DefaultButton = ContentDialogButton.Primary,
Padding = new Thickness(0, 16),
Content = new LaunchOptionsDialog
{
DataContext = viewModel,
}
};
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
// Save config
var args = viewModel.AsLaunchArgs();
settingsManager.SaveLaunchArgs(activeInstall.Id, args);
}
}
// Send user input to running package
public async Task SendInput(string input)
{
if (RunningPackage is BaseGitPackage package)
{
var venv = package.VenvRunner;
var process = venv?.Process;
if (process is not null)
{
await process.StandardInput.WriteLineAsync(input);
}
else
{
logger.LogWarning("Attempted to write input but Process is null");
}
}
}
[RelayCommand]
private async Task SendConfirmInput(bool value)
{
// This must be on the UI thread
Dispatcher.UIThread.CheckAccess();
// Also send input to our own console
if (value)
{
Console.Post("y\n");
await SendInput("y\n");
}
else
{
Console.Post("n\n");
await SendInput("n\n");
}
ShowConfirmInputPrompt = false;
}
[RelayCommand]
private async Task SendManualInput(string input)
{
// Also send input to our own console
Console.PostLine(input);
await SendInput(input);
}
public async Task Stop()
{
if (RunningPackage is null) return;
await RunningPackage.Shutdown();
RunningPackage = null;
ShowWebUiButton = false;
Console.PostLine($"{Environment.NewLine}Stopped process at {DateTimeOffset.Now}");
}
public void OpenWebUi()
{
if (string.IsNullOrEmpty(webUiUrl)) return;
notificationService.TryAsync(Task.Run(() => ProcessRunner.OpenUrl(webUiUrl)),
"Failed to open URL", $"{webUiUrl}");
}
private void OnProcessExited(object? sender, int exitCode)
{
Dispatcher.UIThread.InvokeAsync(async () =>
{
logger.LogTrace("Process exited ({Code}) at {Time:g}",
exitCode, DateTimeOffset.Now);
// Need to wait for streams to finish before detaching handlers
if (RunningPackage is BaseGitPackage {VenvRunner: not null} package)
{
var process = package.VenvRunner.Process;
if (process is not null)
{
// Max 5 seconds
var ct = new CancellationTokenSource(5000).Token;
try
{
await process.WaitUntilOutputEOF(ct);
}
catch (OperationCanceledException e)
{
logger.LogWarning("Waiting for process EOF timed out: {Message}", e.Message);
}
}
}
// Detach handlers
if (sender is BasePackage basePackage)
{
basePackage.ConsoleOutput -= OnProcessOutputReceived;
basePackage.Exited -= OnProcessExited;
basePackage.StartupComplete -= RunningPackageOnStartupComplete;
}
RunningPackage = null;
ShowWebUiButton = false;
await Console.StopUpdatesAsync();
// Need to reset cursor in case its in some weird position
// from progress bars
await Console.ResetWriteCursor();
Console.PostLine($"{Environment.NewLine}Process finished with exit code {exitCode}");
}).SafeFireAndForget();
}
// Callback for processes
private void OnProcessOutputReceived(object? sender, ProcessOutput output)
{
Console.Post(output);
EventManager.Instance.OnScrollToBottomRequested();
}
private void OnOneClickInstallFinished(object? sender, bool e)
{
OnLoaded();
}
private void RunningPackageOnStartupComplete(object? sender, string e)
{
webUiUrl = e;
ShowWebUiButton = !string.IsNullOrWhiteSpace(webUiUrl);
}
public void Dispose()
{
Console.Dispose();
RunningPackage?.Shutdown();
GC.SuppressFinalize(this);
}
}

190
StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs

@ -0,0 +1,190 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls.Primitives;
using CommunityToolkit.Mvvm.ComponentModel;
using FluentAvalonia.UI.Controls;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.ViewModels;
[View(typeof(MainWindow))]
public partial class MainWindowViewModel : ViewModelBase
{
private readonly ISettingsManager settingsManager;
private readonly ServiceManager<ViewModelBase> dialogFactory;
public string Greeting => "Welcome to Avalonia!";
[ObservableProperty]
private PageViewModelBase? currentPage;
[ObservableProperty]
private object? selectedCategory;
[ObservableProperty]
private List<PageViewModelBase> pages = new();
[ObservableProperty]
private List<PageViewModelBase> footerPages = new();
public ProgressManagerViewModel ProgressManagerViewModel { get; init; }
public UpdateViewModel UpdateViewModel { get; init; }
public MainWindowViewModel(ISettingsManager settingsManager, ServiceManager<ViewModelBase> dialogFactory)
{
this.settingsManager = settingsManager;
this.dialogFactory = dialogFactory;
ProgressManagerViewModel = dialogFactory.Get<ProgressManagerViewModel>();
UpdateViewModel = dialogFactory.Get<UpdateViewModel>();
}
public override async Task OnLoadedAsync()
{
// Set only if null, since this may be called again when content dialogs open
CurrentPage ??= Pages.FirstOrDefault();
SelectedCategory ??= Pages.FirstOrDefault();
EventManager.Instance.PageChangeRequested += OnPageChangeRequested;
if (!await EnsureDataDirectory())
{
// False if user exited dialog, shutdown app
App.Shutdown();
return;
}
// Index checkpoints if we dont have
settingsManager.IndexCheckpoints();
if (!settingsManager.Settings.InstalledPackages.Any())
{
var viewModel = dialogFactory.Get<OneClickInstallViewModel>();
var dialog = new BetterContentDialog
{
IsPrimaryButtonEnabled = false,
IsSecondaryButtonEnabled = false,
IsFooterVisible = false,
Content = new OneClickInstallDialog
{
DataContext = viewModel
},
};
EventManager.Instance.OneClickInstallFinished += (_, skipped) =>
{
dialog.Hide();
if (skipped) return;
EventManager.Instance.OnTeachingTooltipNeeded();
};
await dialog.ShowAsync();
}
}
/// <summary>
/// Check if the data directory exists, if not, show the select data directory dialog.
/// </summary>
private async Task<bool> EnsureDataDirectory()
{
// If we can't find library, show selection dialog
if (!settingsManager.TryFindLibrary())
{
var result = await ShowSelectDataDirectoryDialog();
if (!result) return false;
}
// Try to find library again, should be found now
if (!settingsManager.TryFindLibrary())
{
throw new Exception("Could not find library after setting path");
}
// Tell LaunchPage to load any packages if they selected an existing directory
EventManager.Instance.OnInstalledPackagesChanged();
// Check if there are old packages, if so show migration dialog
// TODO: Migration dialog
return true;
}
/// <summary>
/// Shows the select data directory dialog.
/// </summary>
/// <returns>true if path set successfully, false if user exited dialog.</returns>
private async Task<bool> ShowSelectDataDirectoryDialog()
{
var viewModel = dialogFactory.Get<SelectDataDirectoryViewModel>();
var dialog = new BetterContentDialog
{
IsPrimaryButtonEnabled = false,
IsSecondaryButtonEnabled = false,
IsFooterVisible = false,
Content = new SelectDataDirectoryDialog
{
DataContext = viewModel
}
};
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
// 1. For portable mode, call settings.SetPortableMode()
if (viewModel.IsPortableMode)
{
settingsManager.SetPortableMode();
}
// 2. For custom path, call settings.SetLibraryPath(path)
else
{
settingsManager.SetLibraryPath(viewModel.DataDirectory);
}
// Indicate success
return true;
}
return false;
}
public async Task ShowUpdateDialog()
{
var viewModel = dialogFactory.Get<UpdateViewModel>();
var dialog = new BetterContentDialog
{
ContentVerticalScrollBarVisibility = ScrollBarVisibility.Disabled,
DefaultButton = ContentDialogButton.Close,
IsPrimaryButtonEnabled = false,
IsSecondaryButtonEnabled = false,
IsFooterVisible = false,
Content = new UpdateDialog
{
DataContext = viewModel
}
};
await dialog.ShowAsync();
}
private void OnPageChangeRequested(object? sender, Type e)
{
CurrentPage = Pages.FirstOrDefault(p => p.GetType() == e);
SelectedCategory = Pages.FirstOrDefault(p => p.GetType() == e);
}
partial void OnSelectedCategoryChanged(object? value)
{
if (value is PageViewModelBase page)
{
CurrentPage = page;
}
}
}

375
StabilityMatrix.Avalonia/ViewModels/PackageManagerViewModel.cs

@ -0,0 +1,375 @@
using System;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using Avalonia.Controls;
using Avalonia.Controls.Notifications;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls;
using Microsoft.Extensions.Logging;
using Polly;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Helper.Factory;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.Packages;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Services;
using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
namespace StabilityMatrix.Avalonia.ViewModels;
/// <summary>
/// This is our ViewModel for the second page
/// </summary>
[View(typeof(PackageManagerPage))]
public partial class PackageManagerViewModel : PageViewModelBase
{
private readonly ILogger<PackageManagerViewModel> logger;
private readonly ISettingsManager settingsManager;
private readonly IPackageFactory packageFactory;
private readonly INotificationService notificationService;
private readonly ServiceManager<ViewModelBase> dialogFactory;
private const int MinutesToWaitForUpdateCheck = 60;
public PackageManagerViewModel(ILogger<PackageManagerViewModel> logger,
ISettingsManager settingsManager, IPackageFactory packageFactory,
INotificationService notificationService, ServiceManager<ViewModelBase> dialogFactory)
{
this.logger = logger;
this.settingsManager = settingsManager;
this.packageFactory = packageFactory;
this.notificationService = notificationService;
this.dialogFactory = dialogFactory;
Packages =
new ObservableCollection<InstalledPackage>(settingsManager.Settings.InstalledPackages);
SelectedPackage = Packages.FirstOrDefault();
}
[ObservableProperty]
private InstalledPackage? selectedPackage;
[ObservableProperty, NotifyPropertyChangedFor(nameof(ProgressBarVisibility))]
private int progressValue;
[ObservableProperty]
private string progressText = string.Empty;
[ObservableProperty, NotifyPropertyChangedFor(nameof(ProgressBarVisibility))]
private bool isIndeterminate;
[ObservableProperty]
private bool isUninstalling;
[ObservableProperty, NotifyPropertyChangedFor(nameof(SelectedPackage))]
private bool updateAvailable;
[ObservableProperty] private string installButtonText;
[ObservableProperty] private bool installButtonEnabled;
public bool ProgressBarVisibility => ProgressValue > 0 || IsIndeterminate;
public ObservableCollection<InstalledPackage> Packages { get; }
public override bool CanNavigateNext { get; protected set; } = true;
public override bool CanNavigatePrevious { get; protected set; }
public override string Title => "Packages";
public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.Box, IsFilled = true};
public override async Task OnLoadedAsync()
{
if (!Design.IsDesignMode)
{
await CheckUpdates();
}
var installedPackages = settingsManager.Settings.InstalledPackages;
SelectedPackage = installedPackages.FirstOrDefault(x =>
x.Id == settingsManager.Settings.ActiveInstalledPackage);
SelectedPackage ??= installedPackages.FirstOrDefault();
InstallButtonEnabled = SelectedPackage != null;
}
private async Task CheckUpdates()
{
var installedPackages = settingsManager.Settings.InstalledPackages;
Packages.Clear();
foreach (var packageToUpdate in installedPackages)
{
var basePackage = packageFactory.FindPackageByName(packageToUpdate.PackageName);
if (basePackage == null) continue;
var canCheckUpdate = packageToUpdate.LastUpdateCheck == null ||
packageToUpdate.LastUpdateCheck.Value.AddMinutes(MinutesToWaitForUpdateCheck) <
DateTimeOffset.Now;
if (canCheckUpdate)
{
var hasUpdate = await basePackage.CheckForUpdates(packageToUpdate);
packageToUpdate.UpdateAvailable = hasUpdate;
packageToUpdate.LastUpdateCheck = DateTimeOffset.Now;
settingsManager.SetLastUpdateCheck(packageToUpdate);
}
Packages.Add(packageToUpdate);
}
}
public async Task Launch()
{
if (SelectedPackage == null) return;
if (InstallButtonText == "Launch")
{
settingsManager.Transaction(s => s.ActiveInstalledPackage = SelectedPackage.Id);
EventManager.Instance.RequestPageChange(typeof(LaunchPageViewModel));
EventManager.Instance.OnPackageLaunchRequested(SelectedPackage.Id);
}
else
{
await UpdateSelectedPackage();
}
}
public async Task Uninstall()
{
// In design mode, just remove the package from the list
if (Design.IsDesignMode)
{
if (SelectedPackage != null)
{
Packages.Remove(SelectedPackage);
}
return;
}
if (SelectedPackage?.LibraryPath == null)
{
logger.LogError("No package selected to uninstall");
return;
}
var dialog = new ContentDialog
{
Title = "Are you sure?",
Content = "This will delete all folders in the package directory, including any generated images in that directory as well as any files you may have added.",
PrimaryButtonText = "Yes, delete it",
CloseButtonText = "No, keep it",
DefaultButton = ContentDialogButton.Primary
};
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
IsUninstalling = true;
InstallButtonEnabled = false;
var deleteTask = DeleteDirectoryAsync(Path.Combine(settingsManager.LibraryDir,
SelectedPackage.LibraryPath));
var taskResult = await notificationService.TryAsync(deleteTask,
"Some files could not be deleted. Please close any open files in the package directory and try again.");
if (taskResult.IsSuccessful)
{
notificationService.Show(new Notification("Success",
$"Package {SelectedPackage.DisplayName} uninstalled",
NotificationType.Success));
settingsManager.Transaction(settings =>
{
settings.RemoveInstalledPackageAndUpdateActive(SelectedPackage);
});
}
IsUninstalling = false;
InstallButtonEnabled = true;
await OnLoadedAsync();
}
}
partial void OnSelectedPackageChanged(InstalledPackage? value)
{
if (value is null) return;
UpdateAvailable = value.UpdateAvailable;
InstallButtonText = value.UpdateAvailable ? "Update" : "Launch";
}
/// <summary>
/// Deletes a directory and all of its contents recursively.
/// Uses Polly to retry the deletion if it fails, up to 5 times with an exponential backoff.
/// </summary>
/// <param name="targetDirectory"></param>
private Task DeleteDirectoryAsync(string targetDirectory)
{
var policy = Policy.Handle<IOException>()
.WaitAndRetryAsync(3, attempt => TimeSpan.FromMilliseconds(50 * Math.Pow(2, attempt)),
onRetry: (exception, calculatedWaitDuration) =>
{
logger.LogWarning(
exception,
"Deletion of {TargetDirectory} failed. Retrying in {CalculatedWaitDuration}",
targetDirectory, calculatedWaitDuration);
});
return policy.ExecuteAsync(async () =>
{
await Task.Run(() =>
{
DeleteDirectory(targetDirectory);
});
});
}
private void DeleteDirectory(string targetDirectory)
{
// Skip if directory does not exist
if (!Directory.Exists(targetDirectory))
{
return;
}
// For junction points, delete with recursive false
if (new DirectoryInfo(targetDirectory).LinkTarget != null)
{
logger.LogInformation("Removing junction point {TargetDirectory}", targetDirectory);
try
{
Directory.Delete(targetDirectory, false);
return;
}
catch (IOException ex)
{
throw new IOException($"Failed to delete junction point {targetDirectory}", ex);
}
}
// Recursively delete all subdirectories
var subdirectoryEntries = Directory.GetDirectories(targetDirectory);
foreach (var subdirectoryPath in subdirectoryEntries)
{
DeleteDirectory(subdirectoryPath);
}
// Delete all files in the directory
var fileEntries = Directory.GetFiles(targetDirectory);
foreach (var filePath in fileEntries)
{
try
{
File.SetAttributes(filePath, FileAttributes.Normal);
File.Delete(filePath);
}
catch (IOException ex)
{
throw new IOException($"Failed to delete file {filePath}", ex);
}
}
// Delete the target directory itself
try
{
Directory.Delete(targetDirectory, false);
}
catch (IOException ex)
{
throw new IOException($"Failed to delete directory {targetDirectory}", ex);
}
}
private async Task UpdateSelectedPackage()
{
if (SelectedPackage == null) return;
var package = packageFactory.FindPackageByName(SelectedPackage.PackageName);
if (package == null)
{
logger.LogError("Could not find package {SelectedPackagePackageName}",
SelectedPackage.PackageName);
return;
}
ProgressText = $"Updating {SelectedPackage.DisplayName} to latest version...";
package.InstallLocation = SelectedPackage.FullPath!;
var progress = new Progress<ProgressReport>(progress =>
{
var percent = Convert.ToInt32(progress.Percentage);
ProgressValue = percent;
IsIndeterminate = progress.IsIndeterminate;
ProgressText = $"Updating {SelectedPackage.DisplayName} to latest version... {percent:N0}%";
EventManager.Instance.OnGlobalProgressChanged(percent);
});
var updateResult = await package.Update(SelectedPackage, progress);
if (string.IsNullOrWhiteSpace(updateResult))
{
var errorMsg =
$"There was an error updating {SelectedPackage.DisplayName}. Please try again later.";
if (SelectedPackage.PackageName == "automatic")
{
errorMsg = errorMsg.Replace("Please try again later.",
"Please stash any changes before updating, or manually update the package.");
}
// there was an error
notificationService.Show(new Notification("Error updating package",
errorMsg, NotificationType.Error));
}
settingsManager.UpdatePackageVersionNumber(SelectedPackage.Id, updateResult);
notificationService.Show("Update complete",
$"{SelectedPackage.DisplayName} has been updated to the latest version.",
NotificationType.Success);
await OnLoadedAsync();
await DelayedClearProgress(TimeSpan.FromSeconds(3));
}
[RelayCommand]
private async Task ShowInstallDialog()
{
var viewModel = dialogFactory.Get<InstallerViewModel>();
viewModel.AvailablePackages = packageFactory.GetAllAvailablePackages().ToImmutableArray();
viewModel.SelectedPackage = viewModel.AvailablePackages[0];
var dialog = new BetterContentDialog
{
MaxDialogWidth = 1100,
DefaultButton = ContentDialogButton.Close,
IsPrimaryButtonEnabled = false,
IsSecondaryButtonEnabled = false,
IsFooterVisible = false,
Content = new InstallerDialog
{
DataContext = viewModel
}
};
await dialog.ShowAsync();
await OnLoadedAsync();
}
private async Task DelayedClearProgress(TimeSpan delay)
{
await Task.Delay(delay);
ProgressText = string.Empty;
ProgressValue = 0;
IsIndeterminate = false;
SelectedPackage.UpdateAvailable = false;
UpdateAvailable = false;
}
}

22
StabilityMatrix.Avalonia/ViewModels/PageViewModelBase.cs

@ -0,0 +1,22 @@
using FluentAvalonia.UI.Controls;
namespace StabilityMatrix.Avalonia.ViewModels;
/// <summary>
/// An abstract class for enabling page navigation.
/// </summary>
public abstract class PageViewModelBase : ViewModelBase
{
/// <summary>
/// Gets if the user can navigate to the next page
/// </summary>
public virtual bool CanNavigateNext { get; protected set; }
/// <summary>
/// Gets if the user can navigate to the previous page
/// </summary>
public virtual bool CanNavigatePrevious { get; protected set; }
public abstract string Title { get; }
public abstract IconSource IconSource { get; }
}

60
StabilityMatrix.Avalonia/ViewModels/ProgressItemViewModel.cs

@ -0,0 +1,60 @@
using System;
using CommunityToolkit.Mvvm.ComponentModel;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models.Progress;
namespace StabilityMatrix.Avalonia.ViewModels;
public partial class ProgressItemViewModel : ViewModelBase
{
[ObservableProperty] private Guid id;
[ObservableProperty] private string name;
[ObservableProperty] private ProgressReport progress;
[ObservableProperty] private bool failed;
[ObservableProperty] private string? progressText;
public ProgressItemViewModel(ProgressItem progressItem)
{
Id = progressItem.ProgressId;
Name = progressItem.Name;
Progress = progressItem.Progress;
Failed = progressItem.Failed;
ProgressText = GetProgressText(Progress);
EventManager.Instance.ProgressChanged += OnProgressChanged;
}
private void OnProgressChanged(object? sender, ProgressItem e)
{
if (e.ProgressId != Id)
return;
Progress = e.Progress;
Failed = e.Failed;
ProgressText = GetProgressText(Progress);
}
private string GetProgressText(ProgressReport report)
{
switch (report.Type)
{
case ProgressType.Generic:
break;
case ProgressType.Download:
return Failed ? "Download Failed" : "Downloading...";
case ProgressType.Extract:
return Failed ? "Extraction Failed" : "Extracting...";
}
if (Failed)
{
return "Failed";
}
return string.IsNullOrWhiteSpace(report.Message)
? string.IsNullOrWhiteSpace(report.Title)
? string.Empty
: report.Title
: report.Message;
}
}

51
StabilityMatrix.Avalonia/ViewModels/ProgressManagerViewModel.cs

@ -0,0 +1,51 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using FluentAvalonia.UI.Controls;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models.Progress;
using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
namespace StabilityMatrix.Avalonia.ViewModels;
[View(typeof(ProgressManagerPage))]
public partial class ProgressManagerViewModel : PageViewModelBase
{
public override string Title => "Download Manager";
public override IconSource IconSource => new SymbolIconSource {Symbol = Symbol.ArrowCircleDown, IsFilled = true};
[ObservableProperty]
private ObservableCollection<ProgressItemViewModel> progressItems;
public ProgressManagerViewModel()
{
ProgressItems = new ObservableCollection<ProgressItemViewModel>();
}
public void StartEventListener()
{
EventManager.Instance.ProgressChanged += OnProgressChanged;
}
public void ClearDownloads()
{
if (!ProgressItems.Any(p => Math.Abs(p.Progress.Percentage - 100) < 0.01f))
return;
var itemsInProgress = ProgressItems
.Where(p => p.Progress.Percentage < 100).ToList();
ProgressItems = new ObservableCollection<ProgressItemViewModel>(itemsInProgress);
}
private void OnProgressChanged(object? sender, ProgressItem e)
{
if (ProgressItems.Any(x => x.Id == e.ProgressId))
return;
ProgressItems.Add(new ProgressItemViewModel(e));
}
}

24
StabilityMatrix.Avalonia/ViewModels/ProgressViewModel.cs

@ -0,0 +1,24 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace StabilityMatrix.Avalonia.ViewModels;
/// <summary>
/// Generic view model for progress reporting.
/// </summary>
public partial class ProgressViewModel : ObservableObject
{
[ObservableProperty, NotifyPropertyChangedFor(nameof(IsTextVisible))]
private string? text;
[ObservableProperty]
private string? description;
[ObservableProperty, NotifyPropertyChangedFor(nameof(IsProgressVisible))]
private double value;
[ObservableProperty, NotifyPropertyChangedFor(nameof(IsProgressVisible))]
private bool isIndeterminate;
public virtual bool IsProgressVisible => Value > 0 || IsIndeterminate;
public virtual bool IsTextVisible => !string.IsNullOrWhiteSpace(Text);
}

96
StabilityMatrix.Avalonia/ViewModels/RefreshBadgeViewModel.cs

@ -0,0 +1,96 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using Avalonia.Media;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls;
using NLog;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.Styles;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Models.Progress;
namespace StabilityMatrix.Avalonia.ViewModels;
[View(typeof(RefreshBadge))]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public partial class RefreshBadgeViewModel : ViewModelBase
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
public string WorkingToolTipText { get; set; } = "Loading...";
public string SuccessToolTipText { get; set; } = "Success";
public string InactiveToolTipText { get; set; } = "";
public string FailToolTipText { get; set; } = "Failed";
public Symbol InactiveIcon { get; set; } = Symbol.Clear;
public Symbol SuccessIcon { get; set; } = Symbol.Checkmark;
public Symbol FailIcon { get; set; } = Symbol.AlertUrgent;
public IBrush SuccessColorBrush { get; set; } = ThemeColors.ThemeGreen;
public IBrush InactiveColorBrush { get; set; } = ThemeColors.ThemeYellow;
public IBrush FailColorBrush { get; set; } = ThemeColors.ThemeYellow;
public Func<Task<bool>>? RefreshFunc { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsWorking))]
[NotifyPropertyChangedFor(nameof(ColorBrush))]
[NotifyPropertyChangedFor(nameof(CurrentToolTip))]
[NotifyPropertyChangedFor(nameof(Icon))]
private ProgressState state;
public bool IsWorking => State == ProgressState.Working;
/*public ControlAppearance Appearance => State switch
{
ProgressState.Working => ControlAppearance.Info,
ProgressState.Success => ControlAppearance.Success,
ProgressState.Failed => ControlAppearance.Danger,
_ => ControlAppearance.Secondary
};*/
public IBrush ColorBrush => State switch
{
ProgressState.Success => SuccessColorBrush,
ProgressState.Inactive => InactiveColorBrush,
ProgressState.Failed => FailColorBrush,
_ => Brushes.Gray
};
public string CurrentToolTip => State switch
{
ProgressState.Working => WorkingToolTipText,
ProgressState.Success => SuccessToolTipText,
ProgressState.Inactive => InactiveToolTipText,
ProgressState.Failed => FailToolTipText,
_ => ""
};
public Symbol Icon => State switch
{
ProgressState.Success => SuccessIcon,
ProgressState.Failed => FailIcon,
_ => InactiveIcon
};
[RelayCommand]
private async Task Refresh()
{
Logger.Info("Running refresh command...");
if (RefreshFunc == null) return;
State = ProgressState.Working;
try
{
var result = await RefreshFunc.Invoke();
State = result ? ProgressState.Success : ProgressState.Failed;
}
catch (Exception ex)
{
State = ProgressState.Failed;
Logger.Error(ex, "Refresh command failed: {Ex}", ex.Message);
}
}
}

286
StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs

@ -0,0 +1,286 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls.Notifications;
using Avalonia.Styling;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls;
using NLog;
using StabilityMatrix.Avalonia.Models;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Python;
using StabilityMatrix.Core.Services;
using Symbol = FluentIcons.Common.Symbol;
using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource;
namespace StabilityMatrix.Avalonia.ViewModels;
[View(typeof(SettingsPage))]
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;
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}";
// Theme section
[ObservableProperty] private string? selectedTheme;
public IReadOnlyList<string> AvailableThemes { get; } = new[]
{
"Light",
"Dark",
"System",
};
// Shared folder options
[ObservableProperty] private bool removeSymlinksOnShutdown;
// 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 SettingsViewModel(
INotificationService notificationService,
ISettingsManager settingsManager,
IPrerequisiteHelper prerequisiteHelper,
IPyRunner pyRunner,
SharedState sharedState)
{
this.notificationService = notificationService;
this.settingsManager = settingsManager;
this.prerequisiteHelper = prerequisiteHelper;
this.pyRunner = pyRunner;
SharedState = sharedState;
SelectedTheme = AvailableThemes[1];
RemoveSymlinksOnShutdown = settingsManager.Settings.RemoveFolderLinksOnShutdown;
}
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 OnRemoveSymlinksOnShutdownChanged(bool value)
{
settingsManager.Transaction(s => s.RemoveFolderLinksOnShutdown = value);
}
[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 = "Python version info",
Content = result,
PrimaryButtonText = "Ok",
IsPrimaryButtonEnabled = true
};
dialog.Title = "Python version info";
dialog.Content = result;
dialog.PrimaryButtonText = "Ok";
await dialog.ShowAsync();
}
#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 = "OK",
CloseButtonText = "Close"
};
var result = await dialog.ShowAsync();
notificationService.Show(new Notification("Content dialog closed",
$"Result: {result}"));
}
[RelayCommand]
private void DebugThrowException()
{
// Use try-catch to generate traceback information
throw new OperationCanceledException("Example Message");
}
#endregion
#region Info Section
public void OnVersionClick()
{
// Ignore if already enabled
if (SharedState.IsDebugMode) return;
VersionTapCount++;
switch (VersionTapCount)
{
// Reached required threshold
case >= VersionTapCountThreshold:
{
IsVersionTapTeachingTipOpen = false;
// Enable debug options
SharedState.IsDebugMode = true;
notificationService.Show(
"Debug options enabled", "Warning: Improper use may corrupt application state or cause loss of data.");
VersionTapCount = 0;
break;
}
// Open teaching tip above 3rd click
case >= 3:
IsVersionTapTeachingTipOpen = true;
break;
}
}
[RelayCommand]
private async Task ShowLicensesDialog()
{
try
{
var markdown = GetLicensesMarkdown();
var dialog = DialogHelper.CreateMarkdownDialog(markdown, "Licenses");
dialog.MaxDialogHeight = 600;
await dialog.ShowAsync();
}
catch (Exception e)
{
notificationService.Show("Failed to read licenses information",
$"{e}", NotificationType.Error);
}
}
private static string GetLicensesMarkdown()
{
// Read licenses.json
using var reader = new StreamReader(Assets.LicensesJson.Open());
var licenses = JsonSerializer
.Deserialize<IReadOnlyList<LicenseInfo>>(reader.ReadToEnd()) ??
throw new InvalidOperationException("Failed to read licenses.json");
// Generate markdown
var builder = new StringBuilder();
foreach (var license in licenses)
{
builder.AppendLine($"## [{license.PackageName}]({license.PackageUrl}) by {string.Join(", ", license.Authors)}");
builder.AppendLine();
builder.AppendLine(license.Description);
builder.AppendLine();
builder.AppendLine($"[{license.LicenseUrl}]({license.LicenseUrl})");
builder.AppendLine();
}
return builder.ToString();
}
#endregion
}

27
StabilityMatrix.Avalonia/ViewModels/ViewModelBase.cs

@ -0,0 +1,27 @@
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
namespace StabilityMatrix.Avalonia.ViewModels;
public class ViewModelBase : ObservableValidator
{
public virtual void OnLoaded()
{
}
public virtual Task OnLoadedAsync()
{
return Task.CompletedTask;
}
public virtual void OnUnloaded()
{
}
public virtual Task OnUnloadedAsync()
{
return Task.CompletedTask;
}
}

279
StabilityMatrix.Avalonia/Views/CheckpointBrowserPage.axaml

@ -0,0 +1,279 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:viewModels="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:designData="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="600"
x:DataType="viewModels:CheckpointBrowserViewModel"
d:DataContext="{x:Static designData:DesignData.CheckpointBrowserViewModel}"
x:CompileBindings="True"
x:Class="StabilityMatrix.Avalonia.Views.CheckpointBrowserPage">
<UserControl.Resources>
<DataTemplate DataType="{x:Type viewModels:CheckpointBrowserCardViewModel}" x:Key="CivitModelTemplate">
<controls:Card
Margin="8"
MaxHeight="450"
CornerRadius="8"
Name="ModelCard"
Width="330">
<StackPanel Orientation="Vertical">
<TextBlock
Foreground="{DynamicResource TextControlForeground}"
Margin="0,0,0,0"
Text="{Binding CivitModel.Name}"
VerticalAlignment="Center" />
<TextBlock
FontSize="11"
Foreground="{DynamicResource TextControlForeground}"
Margin="0,2,0,0"
Text="{Binding CivitModel.ModelVersions[0].Name, FallbackValue=''}"
VerticalAlignment="Center" />
<Grid>
<controls:BetterAdvancedImage
Margin="0,8,0,8"
Height="300"
StretchDirection="Both"
CornerRadius="4"
VerticalContentAlignment="Top"
HorizontalContentAlignment="Center"
Source="{Binding CardImage}"
Stretch="UniformToFill" />
<Button
Command="{Binding OpenModelCommand}"
CommandParameter="{Binding CivitModel}"
HorizontalAlignment="Right"
Classes="transparent-info"
Margin="0,16,8,0"
VerticalAlignment="Top">
<Button.Content>
<ui:SymbolIcon Symbol="Open" />
</Button.Content>
</Button>
<StackPanel Orientation="Horizontal">
<controls:Card
Classes="info"
Height="24"
HorizontalAlignment="Left"
Padding="4"
Margin="4,16,0,0"
VerticalAlignment="Top">
<TextBlock
FontSize="10"
FontWeight="Medium"
HorizontalAlignment="Center"
Text="{Binding CivitModel.Type}"
VerticalAlignment="Center" />
</controls:Card>
<controls:Card
Classes="success"
Height="24"
HorizontalAlignment="Left"
Padding="4"
IsVisible="{Binding ShowUpdateCard}"
Margin="4,16,0,0"
VerticalAlignment="Top">
<TextBlock
FontSize="10"
FontWeight="Medium"
HorizontalAlignment="Center"
Text="{Binding UpdateCardText}"
VerticalAlignment="Center" />
</controls:Card>
</StackPanel>
<Rectangle
Fill="#DD000000"
HorizontalAlignment="Stretch"
Margin="0,8,0,8"
VerticalAlignment="Stretch"
IsVisible="{Binding IsImporting}" />
<StackPanel
HorizontalAlignment="Stretch"
Orientation="Vertical"
VerticalAlignment="Center"
IsVisible="{Binding IsImporting}">
<controls:ProgressRing
HorizontalAlignment="Center"
IsIndeterminate="False"
Width="120"
Height="120"
StartAngle="90"
EndAngle="450"
Value="{Binding Value}"
VerticalAlignment="Center" />
<TextBlock
HorizontalAlignment="Center"
Margin="0,8,0,0"
Text="{Binding Text, TargetNullValue=Importing...}"
VerticalAlignment="Center" />
</StackPanel>
</Grid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Button
Classes="accent"
Command="{Binding ImportCommand}"
CommandParameter="{Binding CivitModel}"
IsEnabled="{Binding !IsImporting}"
HorizontalAlignment="Stretch"
Margin="0,8,0,0">
<Button.Content>
<StackPanel Orientation="Horizontal">
<TextBlock Text="Import Latest -" />
<TextBlock Margin="4,0,0,0"
Text="{Binding CivitModel.FullFilesSize}" />
</StackPanel>
</Button.Content>
</Button>
<Button Grid.Column="1"
Margin="8,8,0,0"
Classes="accent"
IsEnabled="{Binding !IsImporting}"
Command="{Binding ShowVersionDialogCommand}"
CommandParameter="{Binding CivitModel}"
Content="All Versions" />
</Grid>
</StackPanel>
</controls:Card>
</DataTemplate>
</UserControl.Resources>
<Grid RowDefinitions="Auto,*,Auto" Margin="0,8,0,0">
<StackPanel Margin="8" Orientation="Vertical">
<Grid ColumnDefinitions="*,Auto">
<TextBox
HorizontalAlignment="Stretch"
Margin="8,0,0,0"
Watermark="Search models, #tags, or @users"
Text="{Binding SearchQuery, Mode=TwoWay}" />
<Button
Classes="accent"
Command="{Binding SearchModelsCommand}"
Grid.Column="1"
IsDefault="True"
Margin="8,0,8,0"
VerticalAlignment="Stretch"
Width="80">
<Grid>
<controls:ProgressRing
MinHeight="16"
IsIndeterminate="True"
VerticalAlignment="Center"
BorderThickness="4"
IsVisible="{Binding SearchModelsCommand.IsRunning}"
MinWidth="16" />
<TextBlock
Text="Search"
VerticalAlignment="Center"
IsVisible="{Binding !SearchModelsCommand.IsRunning}" />
</Grid>
</Button>
</Grid>
<DockPanel>
<StackPanel Margin="8" Orientation="Vertical">
<Label Content="Sort" />
<ComboBox
ItemsSource="{Binding AllSortModes}"
MinWidth="100"
SelectedItem="{Binding SortMode}" />
</StackPanel>
<StackPanel Margin="0,8" Orientation="Vertical">
<Label Content="Period" />
<ComboBox
ItemsSource="{Binding AllCivitPeriods}"
MinWidth="100"
SelectedItem="{Binding SelectedPeriod}" />
</StackPanel>
<StackPanel Margin="8" Orientation="Vertical">
<Label Content="Model Type" />
<ComboBox
ItemsSource="{Binding AllModelTypes}"
MinWidth="100"
SelectedItem="{Binding SelectedModelType}" />
</StackPanel>
<CheckBox
Content="Show NSFW Content"
HorizontalAlignment="Right"
IsChecked="{Binding ShowNsfw, Mode=TwoWay}"
Margin="8,8,8,0" />
</DockPanel>
</StackPanel>
<ScrollViewer Grid.Row="1">
<ItemsRepeater ItemTemplate="{StaticResource CivitModelTemplate}"
ItemsSource="{Binding ModelCards}">
<ItemsRepeater.Layout>
<UniformGridLayout Orientation="Horizontal" />
</ItemsRepeater.Layout>
</ItemsRepeater>
</ScrollViewer>
<TextBlock
Grid.Row="2"
Margin="16,8"
Text="Data provided by CivitAI"
VerticalAlignment="Bottom" />
<StackPanel
Grid.Row="2"
HorizontalAlignment="Center"
Margin="8"
Orientation="Vertical"
IsVisible="{Binding HasSearched}">
<TextBlock Margin="0,0,4,4" TextAlignment="Center">
<Run Text="Page" />
<Run Text="{Binding CurrentPageNumber, FallbackValue=1}" />
<Run Text="/" />
<Run Text="{Binding TotalPages, FallbackValue=5}" />
</TextBlock>
<StackPanel HorizontalAlignment="Center" Orientation="Horizontal">
<Button
Command="{Binding PreviousPageCommand}"
IsEnabled="{Binding CanGoToPreviousPage}"
Margin="0,0,8,0">
<ui:SymbolIcon Symbol="PreviousFilled" />
</Button>
<Button Command="{Binding NextPageCommand}" IsEnabled="{Binding CanGoToNextPage}">
<ui:SymbolIcon Symbol="NextFilled" />
</Button>
</StackPanel>
</StackPanel>
<TextBlock
Grid.Row="0"
Grid.RowSpan="3"
FontSize="20"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Text="{Binding NoResultsText, FallbackValue=No results found}"
IsVisible="{Binding NoResultsFound}" />
<controls:ProgressRing
Grid.Row="1"
IsIndeterminate="True"
Width="128"
Height="128"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
IsVisible="{Binding ShowMainLoadingSpinner, FallbackValue=False}" />
</Grid>
</UserControl>

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

@ -0,0 +1,17 @@
using Avalonia.Markup.Xaml;
using StabilityMatrix.Avalonia.Controls;
namespace StabilityMatrix.Avalonia.Views;
public partial class CheckpointBrowserPage : UserControlBase
{
public CheckpointBrowserPage()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

451
StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml

@ -0,0 +1,451 @@
<controls:UserControlBase xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
xmlns:system="clr-namespace:System;assembly=System.Runtime"
d:DataContext="{x:Static mocks:DesignData.CheckpointsPageViewModel}"
x:CompileBindings="True"
x:DataType="vm:CheckpointsPageViewModel"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="StabilityMatrix.Avalonia.Views.CheckpointsPage">
<controls:UserControlBase.DataTemplates>
<!-- Template for dropdown category checkbox item -->
<DataTemplate DataType="{x:Type vm:CheckpointFolder}">
<ui:ToggleMenuFlyoutItem
Text="{Binding TitleWithFilesCount}"
IsChecked="{Binding IsCategoryEnabled, Mode=TwoWay}"/>
</DataTemplate>
</controls:UserControlBase.DataTemplates>
<controls:UserControlBase.Resources>
<!--Direction="0"
ShadowDepth="0"-->
<DropShadowEffect
BlurRadius="8"
Color="#FF000000"
Opacity="0.2"
x:Key="TextDropShadowEffect" />
<!-- Template for a single badge -->
<DataTemplate DataType="{x:Type system:String}" x:Key="BadgeTemplate">
<controls:Card
Height="18"
Margin="4,0,0,0"
Padding="3"
Width="40">
<TextBlock
FontSize="10"
FontWeight="Medium"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
HorizontalAlignment="Center"
Text="{Binding}"
VerticalAlignment="Center" />
</controls:Card>
</DataTemplate>
<!-- Checkpoint File Card -->
<DataTemplate DataType="{x:Type vm:CheckpointFile}" x:Key="CheckpointFileDataTemplate">
<Border
DragDrop.AllowDrop="False"
Background="Transparent"
BorderThickness="2"
Margin="8">
<controls:Card Width="260">
<!-- Right click menu for a checkpoint file -->
<controls:Card.ContextFlyout>
<ui:FAMenuFlyout>
<ui:MenuFlyoutItem Command="{Binding RenameCommand}"
Text="Rename" IconSource="Rename" />
<ui:MenuFlyoutItem Command="{Binding DeleteCommand}"
Text="Delete" IconSource="Delete" />
<ui:MenuFlyoutItem Command="{Binding OpenOnCivitAiCommand}"
Text="Open on CivitAI" IconSource="Link"
IsVisible="{Binding IsConnectedModel}"/>
</ui:FAMenuFlyout>
</controls:Card.ContextFlyout>
<Grid>
<!-- Main contents, hidden when IsLoading is true -->
<StackPanel MinHeight="70">
<Grid ColumnDefinitions="*,*,*,*,*" RowDefinitions="*,0.1*" IsVisible="{Binding !IsLoading}">
<StackPanel
Grid.ColumnSpan="4"
HorizontalAlignment="Left"
IsEnabled="True"
Margin="4"
Orientation="Vertical">
<!-- Tooltip for title and version -->
<ToolTip.Tip>
<StackPanel>
<!-- Title -->
<TextBlock
FontSize="14"
FontWeight="Medium"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
HorizontalAlignment="Left"
Margin="0,0,0,4"
Text="{Binding Title}"
TextWrapping="WrapWithOverflow" />
<!-- Version -->
<TextBlock
FontSize="13"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
HorizontalAlignment="Left"
Margin="0,0,0,4"
Text="{Binding ConnectedModel.VersionName, FallbackValue=''}"
TextWrapping="WrapWithOverflow"
IsVisible="{Binding IsConnectedModel}" />
<!-- Image -->
<Image
Margin="0,0,0,4"
Source="{Binding PreviewImage}"
Stretch="Uniform"
IsVisible="{Binding IsConnectedModel}" />
</StackPanel>
</ToolTip.Tip>
<TextBlock
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
HorizontalAlignment="Left"
IsEnabled="True"
MaxWidth="150"
Text="{Binding Title}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />
<TextBlock
FontSize="13"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
HorizontalAlignment="Left"
IsEnabled="True"
Text="{Binding ConnectedModel.VersionName, FallbackValue=''}"
TextWrapping="WrapWithOverflow"
IsVisible="{Binding IsConnectedModel}" />
</StackPanel>
<Grid Grid.Column="4" Grid.Row="0">
<Button
Background="Transparent"
BorderBrush="Transparent"
FontSize="20"
HorizontalAlignment="Right"
IsEnabled="True"
MaxHeight="48"
MaxWidth="64"
Padding="0"
ToolTip.Tip="Connected Model"
VerticalAlignment="Top"
IsVisible="{Binding IsConnectedModel, Mode=OneWay}">
<ui:SymbolIcon
Foreground="LightGreen"
Symbol="CloudSyncComplete"
ToolTip.Tip="Connected Model" />
</Button>
<Button
Background="Transparent"
BorderBrush="Transparent"
FontSize="20"
HorizontalAlignment="Right"
IsEnabled="True"
MaxHeight="48"
MaxWidth="64"
Padding="0"
ToolTip.Tip="Local Model"
VerticalAlignment="Top"
IsVisible="{Binding !IsConnectedModel, Mode=OneWay}">
<ui:SymbolIcon
Foreground="DimGray"
Symbol="CloudOffline"
ToolTip.Tip="Local Model" />
</Button>
</Grid>
<TextBlock
FontSize="11"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
Grid.Column="0"
Grid.ColumnSpan="3"
Grid.Row="1"
IsEnabled="True"
Margin="4,4"
Text="{Binding FileName}"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding FileName}"
VerticalAlignment="Bottom" />
<ItemsControl
Grid.Column="3"
Grid.ColumnSpan="2"
Grid.Row="1"
ItemTemplate="{StaticResource BadgeTemplate}"
ItemsSource="{Binding Badges}"
VerticalAlignment="Bottom">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel
HorizontalAlignment="Right"
Orientation="Horizontal"
VerticalAlignment="Bottom" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</Grid>
</StackPanel>
<!-- Progress ring -->
<controls:ProgressRing
Grid.Row="0"
Height="32"
Width="32"
Foreground="{DynamicResource ThemeRedColor}"
BorderThickness="4"
HorizontalAlignment="Center"
IsIndeterminate="True"
Padding="0"
VerticalAlignment="Center"
IsEnabled="{Binding IsLoading}"
IsVisible="{Binding IsLoading}" />
</Grid>
</controls:Card>
</Border>
</DataTemplate>
<!-- Checkpoint Folder Expander -->
<DataTemplate DataType="{x:Type vm:CheckpointFolder}" x:Key="CheckpointFolderGridDataTemplate">
<Expander
IsExpanded="{Binding IsExpanded}"
Margin="8"
Padding="8,8,8,8"
IsVisible="{Binding IsCategoryEnabled, FallbackValue=True}">
<!-- Right click menu for a checkpoint folder -->
<Expander.ContextFlyout>
<ui:FAMenuFlyout>
<ui:MenuFlyoutItem Text="Show in Explorer" IconSource="Open"
Command="{Binding ShowInExplorerCommand}"
CommandParameter="{Binding DirectoryPath}"/>
<ui:MenuFlyoutSeparator/>
<ui:MenuFlyoutSubItem Text="New" IconSource="Add">
<ui:MenuFlyoutSubItem.Items>
<ui:MenuFlyoutItem Text="Folder" IconSource="OpenFolder"
Command="{Binding CreateSubFolderCommand}"/>
</ui:MenuFlyoutSubItem.Items>
</ui:MenuFlyoutSubItem>
</ui:FAMenuFlyout>
</Expander.ContextFlyout>
<!-- Editable header -->
<Expander.Header>
<Grid>
<TextBlock Text="{Binding Title}" VerticalAlignment="Center"/>
</Grid>
</Expander.Header>
<StackPanel Orientation="Vertical">
<!-- Subfolders -->
<StackPanel Orientation="Vertical">
<ItemsControl
HorizontalAlignment="Stretch"
ItemTemplate="{DynamicResource CheckpointFolderGridDataTemplate}"
ItemsSource="{Binding SubFolders, Mode=OneWay}"
Margin="8,0,8,8" />
</StackPanel>
<!-- Files Grid -->
<Grid Name="FilesGrid"
Background="Transparent"
DragDrop.AllowDrop="True">
<ItemsRepeater
Classes="ItemsDragAndDrop"
ItemTemplate="{StaticResource CheckpointFileDataTemplate}"
ItemsSource="{Binding DisplayedCheckpointFiles}">
<ItemsRepeater.Layout>
<UniformGridLayout Orientation="Horizontal"/>
</ItemsRepeater.Layout>
<TextBlock Text="Hi"/>
</ItemsRepeater>
<!-- Blurred background for drag and drop -->
<Border
CornerRadius="8"
Grid.RowSpan="4"
IsEnabled="False"
IsVisible="{Binding IsDragBlurEnabled}">
<Border.Background>
<SolidColorBrush Color="#EE202020" />
</Border.Background>
</Border>
<!-- Drag prompt text -->
<TextBlock
Effect="{StaticResource TextDropShadowEffect}"
FontSize="24"
HorizontalAlignment="Center"
Text="Drop a file here to import"
VerticalAlignment="Center"
IsVisible="{Binding IsCurrentDragTarget}" />
<StackPanel
Margin="0,8"
Orientation="Vertical"
DataContext="{Binding Progress}"
VerticalAlignment="Center">
<!-- Import progress -->
<TextBlock
Effect="{StaticResource TextDropShadowEffect}"
FontSize="18"
HorizontalAlignment="Center"
Text="{Binding Text}"
VerticalAlignment="Center"
IsVisible="{Binding IsTextVisible}" />
<ProgressBar
Effect="{StaticResource TextDropShadowEffect}"
HorizontalAlignment="Stretch"
IsIndeterminate="{Binding IsIndeterminate, FallbackValue=False}"
Margin="64,8"
VerticalAlignment="Center"
IsVisible="{Binding IsProgressVisible}"
Value="{Binding Value, FallbackValue=20}" />
</StackPanel>
</Grid>
</StackPanel>
</Expander>
</DataTemplate>
<!-- Template for dropdown box -->
<ControlTemplate x:Key="CategoryDropDownTemplate">
<Grid>
<Border
CornerRadius="{TemplateBinding Border.CornerRadius}"
Margin="{TemplateBinding Margin}"
Name="ContentBorder"
Padding="0"
VerticalAlignment="Stretch">
<Grid>
<Rectangle
Height="16"
HorizontalAlignment="Left"
Margin="0"
RadiusX="2"
RadiusY="2"
VerticalAlignment="Center"
IsVisible="False"
Width="3"
x:Name="ActiveRectangle">
<Rectangle.Fill>
<SolidColorBrush Color="{DynamicResource SystemAccentColorSecondary}" />
</Rectangle.Fill>
</Rectangle>
<Grid ColumnDefinitions="*,Auto">
<!-- Setup grid rows -->
<StackPanel
Grid.Column="0"
Margin="10,0,0,0"
VerticalAlignment="Top">
<!--<CheckBox
Content="{Binding TitleWithFilesCount}"
IsChecked="{Binding IsCategoryEnabled, Mode=TwoWay}" />-->
</StackPanel>
</Grid>
</Grid>
</Border>
</Grid>
</ControlTemplate>
</controls:UserControlBase.Resources>
<Grid ColumnDefinitions="Auto,*" RowDefinitions="0.2*,*" Margin="4, 0">
<!-- Top settings bar -->
<ToggleSwitch
OffContent="Import as Connected"
OnContent="Import as Connected"
Grid.Column="0"
IsChecked="{Binding IsImportAsConnected}"
Margin="24,8"
ToolTip.Tip="Search for connected metadata on new local imports" />
<StackPanel
Grid.Column="1"
Grid.Row="0"
Spacing="8"
HorizontalAlignment="Right"
Margin="16,0"
Orientation="Horizontal">
<TextBox Margin="16, 16"
Watermark="Search"
Height="16"
MinWidth="150"
Text="{Binding SearchFilter, Mode=TwoWay}"/>
<controls:ProgressRing
Width="16"
Height="16"
BorderThickness="4"
IsIndeterminate="True"
IsVisible="{Binding IsIndexing}"/>
<TextBlock Text="Indexing..." FontSize="11"
VerticalAlignment="Center"
IsVisible="{Binding IsIndexing}"/>
<Button
Command="{Binding OpenModelsFolderCommand}">
<TextBlock Text="Models Folder"/>
</Button>
<DropDownButton
Content="Categories"
Margin="8"
VerticalAlignment="Center"
HorizontalAlignment="Right">
<DropDownButton.Flyout>
<ui:FAMenuFlyout
ItemsSource="{Binding DisplayedCheckpointFolders}"/>
</DropDownButton.Flyout>
</DropDownButton>
<!--<ComboBox
VerticalAlignment="Center"
HorizontalAlignment="Right"
ItemsSource="{Binding CheckpointFolders}"
MinWidth="180"
SelectedIndex="0">
<ComboBox.ItemContainerStyle>
<Style TargetType="ComboBoxItem">
<Setter Property="Template" Value="{StaticResource CategoryDropDownStyle}" />
</Style>
</ComboBox.ItemContainerStyle>
<ComboBox.ItemTemplate>
<DataTemplate DataType="{x:Type vm:CheckpointFolder}">
<StackPanel Margin="10,0,0,0" VerticalAlignment="Top">
<TextBlock Margin="0,5,0,5" Text="Categories" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>-->
</StackPanel>
<!-- Main view with model cards -->
<ScrollViewer
Grid.Column="0"
Grid.ColumnSpan="2"
Grid.Row="1"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<Grid>
<controls:ProgressRing
Width="128"
Height="128"
BorderThickness="8"
IsIndeterminate="True"
IsVisible="{Binding IsLoading}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<StackPanel Orientation="Vertical">
<ItemsControl
HorizontalAlignment="Stretch"
ItemTemplate="{StaticResource CheckpointFolderGridDataTemplate}"
ItemsSource="{Binding DisplayedCheckpointFolders, Mode=OneWay}"
Margin="8,0,8,8" />
</StackPanel>
</Grid>
</ScrollViewer>
<!-- Overlay for draggable file panels -->
<Panel Name="OverlayPanel"
Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="2" />
</Grid>
</controls:UserControlBase>

60
StabilityMatrix.Avalonia/Views/CheckpointsPage.axaml.cs

@ -0,0 +1,60 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Markup.Xaml;
using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.ViewModels;
namespace StabilityMatrix.Avalonia.Views;
public partial class CheckpointsPage : UserControlBase
{
public CheckpointsPage()
{
InitializeComponent();
AddHandler(DragDrop.DragEnterEvent, OnDragEnter);
AddHandler(DragDrop.DragLeaveEvent, OnDragExit);
AddHandler(DragDrop.DropEvent, OnDrop);
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
private static async void OnDrop(object? sender, DragEventArgs e)
{
var sourceDataContext = (e.Source as Control)?.DataContext;
if (sourceDataContext is CheckpointFolder folder)
{
await folder.OnDrop(e);
}
}
private static void OnDragExit(object? sender, DragEventArgs e)
{
var sourceDataContext = (e.Source as Control)?.DataContext;
if (sourceDataContext is CheckpointFolder folder)
{
folder.IsCurrentDragTarget = false;
}
}
private static void OnDragEnter(object? sender, DragEventArgs e)
{
// Only allow Copy or Link as Drop Operations.
e.DragEffects &= DragDropEffects.Copy | DragDropEffects.Link;
// Only allow if the dragged data contains text or filenames.
if (!e.Data.Contains(DataFormats.Text) && !e.Data.Contains(DataFormats.Files))
{
e.DragEffects = DragDropEffects.None;
}
// Forward to view model
var sourceDataContext = (e.Source as Control)?.DataContext;
if (sourceDataContext is CheckpointFolder folder)
{
folder.IsCurrentDragTarget = true;
}
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save