using System.ComponentModel; using System.Diagnostics; using System.Linq.Expressions; using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; using AsyncAwaitBestPractices; using NLog; using Refit; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Models; using StabilityMatrix.Core.Models.FileInterfaces; using StabilityMatrix.Core.Models.Settings; using StabilityMatrix.Core.Python; namespace StabilityMatrix.Core.Services; public class SettingsManager : ISettingsManager { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private static readonly ReaderWriterLockSlim FileLock = new(); private static readonly string GlobalSettingsPath = Path.Combine(Compat.AppDataHome, "global.json"); private readonly string? originalEnvPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Process); // Library properties public bool IsPortableMode { get; private set; } private string? libraryDir; public string LibraryDir { get { if (string.IsNullOrWhiteSpace(libraryDir)) { throw new InvalidOperationException("LibraryDir is not set"); } return libraryDir; } private set { libraryDir = value; LibraryDirChanged?.Invoke(this, value); } } public bool IsLibraryDirSet => !string.IsNullOrWhiteSpace(libraryDir); // Dynamic paths from library public string DatabasePath => Path.Combine(LibraryDir, "StabilityMatrix.db"); private string SettingsPath => Path.Combine(LibraryDir, "settings.json"); public string ModelsDirectory => Path.Combine(LibraryDir, "Models"); public DirectoryPath TagsDirectory => new(LibraryDir, "Tags"); public Settings Settings { get; private set; } = new(); public event EventHandler? LibraryDirChanged; public event EventHandler? SettingsPropertyChanged; /// public SettingsTransaction BeginTransaction() { if (!IsLibraryDirSet) { throw new InvalidOperationException("LibraryDir not set when BeginTransaction was called"); } return new SettingsTransaction(this, SaveSettingsAsync); } /// public void Transaction(Action func, bool ignoreMissingLibraryDir = false) { if (!IsLibraryDirSet) { if (ignoreMissingLibraryDir) { func(Settings); return; } throw new InvalidOperationException("LibraryDir not set when Transaction was called"); } using var transaction = BeginTransaction(); func(transaction.Settings); transaction.Dispose(); } /// public void Transaction(Expression> expression, TValue value) { if (expression.Body is not MemberExpression memberExpression) { throw new ArgumentException( $"Expression must be a member expression, not {expression.Body.NodeType}"); } var propertyInfo = memberExpression.Member as PropertyInfo; if (propertyInfo == null) { throw new ArgumentException( $"Expression member must be a property, not {memberExpression.Member.MemberType}"); } var name = propertyInfo.Name; // Set value using var transaction = BeginTransaction(); propertyInfo.SetValue(transaction.Settings, value); // Invoke property changed event SettingsPropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); } /// public void RelayPropertyFor( T source, Expression> sourceProperty, Expression> settingsProperty) where T : INotifyPropertyChanged { var sourceGetter = sourceProperty.Compile(); var (propertyName, assigner) = Expressions.GetAssigner(sourceProperty); var sourceSetter = assigner.Compile(); var settingsGetter = settingsProperty.Compile(); var (targetPropertyName, settingsAssigner) = Expressions.GetAssigner(settingsProperty); var settingsSetter = settingsAssigner.Compile(); var sourceTypeName = source.GetType().Name; // Update source when settings change SettingsPropertyChanged += (_, args) => { if (args.PropertyName != propertyName) return; Logger.Trace( "[RelayPropertyFor] " + "Settings.{TargetProperty:l} -> {SourceType:l}.{SourceProperty:l}", targetPropertyName, sourceTypeName, propertyName); sourceSetter(source, settingsGetter(Settings)); }; // Set and Save settings when source changes source.PropertyChanged += (_, args) => { if (args.PropertyName != propertyName) return; Logger.Trace( "[RelayPropertyFor] " + "{SourceType:l}.{SourceProperty:l} -> Settings.{TargetProperty:l}", sourceTypeName, propertyName, targetPropertyName); settingsSetter(Settings, sourceGetter(source)); SaveSettingsAsync().SafeFireAndForget(); // Invoke property changed event SettingsPropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); }; } /// public void RegisterPropertyChangedHandler( Expression> settingsProperty, Action onPropertyChanged) { var settingsGetter = settingsProperty.Compile(); var (propertyName, _) = Expressions.GetAssigner(settingsProperty); // Invoke handler when settings change SettingsPropertyChanged += (_, args) => { if (args.PropertyName != propertyName) return; onPropertyChanged(settingsGetter(Settings)); }; } /// /// Attempts to locate and set the library path /// Return true if found, false otherwise /// public bool TryFindLibrary() { // 1. Check portable mode var appDir = Compat.AppCurrentDir; IsPortableMode = File.Exists(Path.Combine(appDir, "Data", ".sm-portable")); if (IsPortableMode) { LibraryDir = appDir + "Data"; SetStaticLibraryPaths(); LoadSettings(); return true; } // 2. Check %APPDATA%/StabilityMatrix/library.json FilePath libraryJsonFile = Compat.AppDataHome + "library.json"; if (!libraryJsonFile.Exists) return false; try { var libraryJson = libraryJsonFile.ReadAllText(); var librarySettings = JsonSerializer.Deserialize(libraryJson); if (!string.IsNullOrWhiteSpace(librarySettings?.LibraryPath)) { LibraryDir = librarySettings.LibraryPath; SetStaticLibraryPaths(); LoadSettings(); return true; } } catch (Exception e) { Logger.Warn("Failed to read library.json in AppData: {Message}", e.Message); } return false; } // Set static classes requiring library path private void SetStaticLibraryPaths() { GlobalConfig.LibraryDir = LibraryDir; ArchiveHelper.HomeDir = LibraryDir; PyRunner.HomeDir = LibraryDir; } /// /// Save a new library path to %APPDATA%/StabilityMatrix/library.json /// public void SetLibraryPath(string path) { Compat.AppDataHome.Create(); var libraryJsonFile = Compat.AppDataHome.JoinFile("library.json"); var library = new LibrarySettings { LibraryPath = path }; var libraryJson = JsonSerializer.Serialize(library, new JsonSerializerOptions { WriteIndented = true }); libraryJsonFile.WriteAllText(libraryJson); // actually create the LibraryPath directory Directory.CreateDirectory(path); } /// /// Enable and create settings files for portable mode /// Creates the ./Data directory and the `.sm-portable` marker file /// public void SetPortableMode() { // Get app directory var appDir = Compat.AppCurrentDir; // Create data directory var dataDir = appDir.JoinDir("Data"); dataDir.Create(); // Create marker file dataDir.JoinFile(".sm-portable").Create(); } /// /// Iterable of installed packages using the old absolute path format. /// Can be called with Any() to check if the user needs to migrate. /// public IEnumerable GetOldInstalledPackages() { var oldSettingsPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StabilityMatrix", "settings.json"); if (!File.Exists(oldSettingsPath)) yield break; var oldSettingsJson = File.ReadAllText(oldSettingsPath); var oldSettings = JsonSerializer.Deserialize(oldSettingsJson, new JsonSerializerOptions { Converters = { new JsonStringEnumConverter() } }); // Absolute paths are old formats requiring migration #pragma warning disable CS0618 var oldPackages = oldSettings?.InstalledPackages.Where(package => package.Path != null); #pragma warning restore CS0618 if (oldPackages == null) yield break; foreach (var package in oldPackages) { yield return package; } } public Guid GetOldActivePackageId() { var oldSettingsPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StabilityMatrix", "settings.json"); if (!File.Exists(oldSettingsPath)) return default; var oldSettingsJson = File.ReadAllText(oldSettingsPath); var oldSettings = JsonSerializer.Deserialize(oldSettingsJson, new JsonSerializerOptions { Converters = { new JsonStringEnumConverter() } }); if (oldSettings == null) return default; return oldSettings.ActiveInstalledPackageId ?? default; } public void AddPathExtension(string pathExtension) { Settings.PathExtensions ??= new List(); Settings.PathExtensions.Add(pathExtension); SaveSettings(); } public string GetPathExtensionsAsString() { return string.Join(";", Settings.PathExtensions ?? new List()); } /// /// Insert path extensions to the front of the PATH environment variable /// public void InsertPathExtensions() { if (Settings.PathExtensions == null) return; var toInsert = GetPathExtensionsAsString(); // Append the original path, if any if (originalEnvPath != null) { toInsert += $";{originalEnvPath}"; } Environment.SetEnvironmentVariable("PATH", toInsert, EnvironmentVariableTarget.Process); } public void UpdatePackageVersionNumber(Guid id, string? newVersion) { var package = Settings.InstalledPackages.FirstOrDefault(x => x.Id == id); if (package == null || newVersion == null) { return; } package.PackageVersion = newVersion; package.DisplayVersion = string.IsNullOrWhiteSpace(package.InstalledBranch) ? newVersion : $"{package.InstalledBranch}@{newVersion[..7]}"; SaveSettings(); } public void SetLastUpdateCheck(InstalledPackage package) { var installedPackage = Settings.InstalledPackages.First(p => p.DisplayName == package.DisplayName); installedPackage.LastUpdateCheck = package.LastUpdateCheck; installedPackage.UpdateAvailable = package.UpdateAvailable; SaveSettings(); } public List GetLaunchArgs(Guid packageId) { var packageData = Settings.InstalledPackages.FirstOrDefault(x => x.Id == packageId); return packageData?.LaunchArgs ?? new(); } public void SaveLaunchArgs(Guid packageId, List launchArgs) { var packageData = Settings.InstalledPackages.FirstOrDefault(x => x.Id == packageId); if (packageData == null) { return; } // Only save if not null or default var toSave = launchArgs.Where(opt => !opt.IsEmptyOrDefault()).ToList(); packageData.LaunchArgs = toSave; SaveSettings(); } public string? GetActivePackageHost() { var package = Settings.InstalledPackages.FirstOrDefault(x => x.Id == Settings.ActiveInstalledPackageId); if (package == null) return null; var hostOption = package.LaunchArgs?.FirstOrDefault(x => x.Name.ToLowerInvariant() == "host"); if (hostOption?.OptionValue != null) { return hostOption.OptionValue as string; } return hostOption?.DefaultValue as string; } public string? GetActivePackagePort() { var package = Settings.InstalledPackages.FirstOrDefault(x => x.Id == Settings.ActiveInstalledPackageId); if (package == null) return null; var portOption = package.LaunchArgs?.FirstOrDefault(x => x.Name.ToLowerInvariant() == "port"); if (portOption?.OptionValue != null) { return portOption.OptionValue as string; } return portOption?.DefaultValue as string; } public void SetSharedFolderCategoryVisible(SharedFolderType type, bool visible) { Settings.SharedFolderVisibleCategories ??= new SharedFolderType(); if (visible) { Settings.SharedFolderVisibleCategories |= type; } else { Settings.SharedFolderVisibleCategories &= ~type; } SaveSettings(); } public bool IsSharedFolderCategoryVisible(SharedFolderType type) { // False for default if (type == 0) return false; return Settings.SharedFolderVisibleCategories?.HasFlag(type) ?? false; } public bool IsEulaAccepted() { if (!File.Exists(GlobalSettingsPath)) { Directory.CreateDirectory(Path.GetDirectoryName(GlobalSettingsPath)!); File.Create(GlobalSettingsPath).Close(); File.WriteAllText(GlobalSettingsPath, "{}"); return false; } var json = File.ReadAllText(GlobalSettingsPath); var globalSettings = JsonSerializer.Deserialize(json); return globalSettings?.EulaAccepted ?? false; } public void SetEulaAccepted() { var globalSettings = new GlobalSettings {EulaAccepted = true}; var json = JsonSerializer.Serialize(globalSettings); File.WriteAllText(GlobalSettingsPath, json); } public void IndexCheckpoints() { Settings.InstalledModelHashes ??= new HashSet(); if (Settings.InstalledModelHashes.Any()) return; var sw = new Stopwatch(); sw.Start(); var modelHashes = new HashSet(); var sharedModelDirectory = Path.Combine(LibraryDir, "Models"); if (!Directory.Exists(sharedModelDirectory)) return; var connectedModelJsons = Directory.GetFiles(sharedModelDirectory, "*.cm-info.json", SearchOption.AllDirectories); foreach (var jsonFile in connectedModelJsons) { var json = File.ReadAllText(jsonFile); var connectedModel = JsonSerializer.Deserialize(json); if (connectedModel?.Hashes.BLAKE3 != null) { modelHashes.Add(connectedModel.Hashes.BLAKE3); } } Transaction(s => s.InstalledModelHashes = modelHashes); sw.Stop(); Logger.Info($"Indexed {modelHashes.Count} checkpoints in {sw.ElapsedMilliseconds}ms"); } /// /// Loads settings from the settings file /// If the settings file does not exist, it will be created with default values /// protected virtual void LoadSettings() { FileLock.EnterReadLock(); try { if (!File.Exists(SettingsPath)) { File.Create(SettingsPath).Close(); Settings.Theme = "Dark"; var defaultSettingsJson = JsonSerializer.Serialize(Settings); File.WriteAllText(SettingsPath, defaultSettingsJson); return; } var settingsContent = File.ReadAllText(SettingsPath); var modifiedDefaultSerializerOptions = SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions(); modifiedDefaultSerializerOptions.Converters.Add(new JsonStringEnumConverter()); Settings = JsonSerializer.Deserialize(settingsContent, modifiedDefaultSerializerOptions)!; } finally { FileLock.ExitReadLock(); } } protected virtual void SaveSettings() { FileLock.TryEnterWriteLock(100000); try { if (!File.Exists(SettingsPath)) { File.Create(SettingsPath).Close(); } var json = JsonSerializer.Serialize(Settings, new JsonSerializerOptions { WriteIndented = true, Converters = { new JsonStringEnumConverter() } }); File.WriteAllText(SettingsPath, json); } finally { FileLock.ExitWriteLock(); } } private Task SaveSettingsAsync() { return Task.Run(SaveSettings); } }