using System.ComponentModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; using System.Reflection; using System.Text.Json; using AsyncAwaitBestPractices; using NLog; using StabilityMatrix.Core.Attributes; 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; [Singleton(typeof(ISettingsManager))] public class SettingsManager : ISettingsManager { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private static string GlobalSettingsPath => Path.Combine(Compat.AppDataHome, "global.json"); private readonly SemaphoreSlim fileLock = new(1, 1); private bool isLoaded; private DirectoryPath? libraryDirOverride; // Library properties public bool IsPortableMode { get; private set; } private DirectoryPath? libraryDir; public DirectoryPath LibraryDir { get { if (libraryDir is null) { throw new InvalidOperationException("LibraryDir is not set"); } return libraryDir; } private set { var isChanged = libraryDir != value; libraryDir = value; // Only invoke if different if (isChanged) { LibraryDirChanged?.Invoke(this, value); } } } [MemberNotNullWhen(true, nameof(libraryDir))] public bool IsLibraryDirSet => libraryDir is not null; // Dynamic paths from library private FilePath SettingsFile => LibraryDir.JoinFile("settings.json"); public string ModelsDirectory => Path.Combine(LibraryDir, "Models"); public string DownloadsDirectory => Path.Combine(LibraryDir, ".downloads"); public DirectoryPath TagsDirectory => LibraryDir.JoinDir("Tags"); public DirectoryPath ImagesDirectory => LibraryDir.JoinDir("Images"); public DirectoryPath ImagesInferenceDirectory => ImagesDirectory.JoinDir("Inference"); public DirectoryPath ConsolidatedImagesDirectory => ImagesDirectory.JoinDir("Consolidated"); public Settings Settings { get; private set; } = new(); public List PackageInstallsInProgress { get; set; } = []; /// public event EventHandler? LibraryDirChanged; /// public event EventHandler? SettingsPropertyChanged; /// public event EventHandler? Loaded; /// public void SetLibraryDirOverride(DirectoryPath path) { libraryDirOverride = path; } /// public void RegisterOnLibraryDirSet(Action handler) { if (IsLibraryDirSet) { handler(LibraryDir); return; } LibraryDirChanged += Handler; return; void Handler(object? sender, string dir) { LibraryDirChanged -= Handler; handler(dir); } } /// public SettingsTransaction BeginTransaction() { if (!IsLibraryDirSet) { throw new InvalidOperationException("LibraryDir not set when BeginTransaction was called"); } return new SettingsTransaction(this, () => SaveSettings(), () => 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); } /// 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 RelayPropertyChangedEventArgs(name)); } /// public void RelayPropertyFor( T source, Expression> sourceProperty, Expression> settingsProperty, bool setInitial = false, TimeSpan? delay = null ) 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 += (sender, args) => { if (args.PropertyName != targetPropertyName) return; // Skip if event is relay and the sender is the source, to prevent duplicate if (args.IsRelay && ReferenceEquals(sender, source)) 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 += (sender, args) => { if (args.PropertyName != propertyName) return; Logger.Trace( "[RelayPropertyFor] " + "{SourceType:l}.{SourceProperty:l} -> Settings.{TargetProperty:l}", sourceTypeName, propertyName, targetPropertyName ); settingsSetter(Settings, sourceGetter(source)); if (IsLibraryDirSet) { if (delay != null) { SaveSettingsDelayed(delay.Value).SafeFireAndForget(); } else { SaveSettingsAsync().SafeFireAndForget(); } } else { Logger.Warn("[RelayPropertyFor] LibraryDir not set when saving"); } // Invoke property changed event, passing along sender SettingsPropertyChanged?.Invoke( sender, new RelayPropertyChangedEventArgs(targetPropertyName, true) ); }; // Set initial value if requested if (setInitial) { sourceSetter(source, settingsGetter(Settings)); } } /// 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(bool forceReload = false) { if (IsLibraryDirSet && !forceReload) return true; // 0. Check Override if (libraryDirOverride is not null) { var fullOverridePath = libraryDirOverride.Info.FullName; Logger.Info("Using library override path: {Path}", fullOverridePath); LibraryDir = libraryDirOverride; SetStaticLibraryPaths(); LoadSettings(); return true; } // 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) && Directory.Exists(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(); } public void SaveLaunchArgs(Guid packageId, IEnumerable 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 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); if (string.IsNullOrWhiteSpace(json)) continue; try { var connectedModel = JsonSerializer.Deserialize(json); if (connectedModel?.Hashes.BLAKE3 != null) { modelHashes.Add(connectedModel.Hashes.BLAKE3); } } catch (Exception e) { Logger.Warn(e, "Failed to parse connected model info from {JsonFile}", jsonFile); } } Transaction(s => s.InstalledModelHashes = modelHashes); sw.Stop(); Logger.Info($"Indexed {modelHashes.Count} checkpoints in {sw.ElapsedMilliseconds}ms"); } /// /// Loads settings from the settings file. Continues without loading if the file does not exist or is empty. /// Will set to true when finished in any case. /// protected virtual void LoadSettings(CancellationToken cancellationToken = default) { fileLock.Wait(cancellationToken); try { if (!SettingsFile.Exists) { return; } using var fileStream = SettingsFile.Info.OpenRead(); if (fileStream.Length == 0) { Logger.Warn("Settings file is empty, using default settings"); return; } var loadedSettings = JsonSerializer.Deserialize( fileStream, SettingsSerializerContext.Default.Settings ); if (loadedSettings is not null) { Settings = loadedSettings; } } finally { fileLock.Release(); isLoaded = true; Loaded?.Invoke(this, EventArgs.Empty); } } /// /// Loads settings from the settings file. Continues without loading if the file does not exist or is empty. /// Will set to true when finished in any case. /// protected virtual async Task LoadSettingsAsync(CancellationToken cancellationToken = default) { await fileLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { if (!SettingsFile.Exists) { return; } await using var fileStream = SettingsFile.Info.OpenRead(); if (fileStream.Length == 0) { Logger.Warn("Settings file is empty, using default settings"); return; } var loadedSettings = await JsonSerializer .DeserializeAsync(fileStream, SettingsSerializerContext.Default.Settings, cancellationToken) .ConfigureAwait(false); if (loadedSettings is not null) { Settings = loadedSettings; } Loaded?.Invoke(this, EventArgs.Empty); } finally { fileLock.Release(); isLoaded = true; Loaded?.Invoke(this, EventArgs.Empty); } } protected virtual void SaveSettings(CancellationToken cancellationToken = default) { // Skip saving if not loaded yet if (!isLoaded) return; fileLock.Wait(cancellationToken); try { // Create empty settings file if it doesn't exist if (!SettingsFile.Exists) { SettingsFile.Directory?.Create(); SettingsFile.Create(); } // Check disk space if (SystemInfo.GetDiskFreeSpaceBytes(SettingsFile) is < 1 * SystemInfo.Mebibyte) { Logger.Warn("Not enough disk space to save settings"); return; } var jsonBytes = JsonSerializer.SerializeToUtf8Bytes( Settings, SettingsSerializerContext.Default.Settings ); if (jsonBytes.Length == 0) { Logger.Error("JsonSerializer returned empty bytes for some reason"); return; } using var fs = File.Open(SettingsFile, FileMode.Open); if (fs.CanWrite) { fs.Write(jsonBytes, 0, jsonBytes.Length); fs.Flush(); fs.SetLength(jsonBytes.Length); } } finally { fileLock.Release(); } } protected virtual async Task SaveSettingsAsync(CancellationToken cancellationToken = default) { // Skip saving if not loaded yet if (!isLoaded) return; await fileLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { // Create empty settings file if it doesn't exist if (!SettingsFile.Exists) { SettingsFile.Directory?.Create(); SettingsFile.Create(); } // Check disk space if (SystemInfo.GetDiskFreeSpaceBytes(SettingsFile) is < 1 * SystemInfo.Mebibyte) { Logger.Warn("Not enough disk space to save settings"); return; } var jsonBytes = JsonSerializer.SerializeToUtf8Bytes( Settings, SettingsSerializerContext.Default.Settings ); if (jsonBytes.Length == 0) { Logger.Error("JsonSerializer returned empty bytes for some reason"); return; } await using var fs = File.Open(SettingsFile, FileMode.Open); if (fs.CanWrite) { await fs.WriteAsync(jsonBytes, cancellationToken).ConfigureAwait(false); await fs.FlushAsync(cancellationToken).ConfigureAwait(false); fs.SetLength(jsonBytes.Length); } } finally { fileLock.Release(); } } private volatile CancellationTokenSource? delayedSaveCts; private Task SaveSettingsDelayed(TimeSpan delay) { var cts = new CancellationTokenSource(); var oldCancellationToken = Interlocked.Exchange(ref delayedSaveCts, cts); try { oldCancellationToken?.Cancel(); } catch (ObjectDisposedException) { } return Task.Run( async () => { try { await Task.Delay(delay, cts.Token).ConfigureAwait(false); await SaveSettingsAsync(cts.Token).ConfigureAwait(false); } catch (TaskCanceledException) { } finally { cts.Dispose(); } }, CancellationToken.None ); } }