From ac0e2964f3e3a08bfc41479c344aa153796ef3b8 Mon Sep 17 00:00:00 2001 From: e-nord Date: Fri, 2 Oct 2026 22:27:48 -0700 Subject: [PATCH 1/6] fix: write logs under --home-dir and resolve it to an absolute path The NLog file target was hardcoded to %AppData%/StabilityMatrix/Logs, while the Settings "Logs" shortcut and the exception dialog's log zip read from Compat.AppDataHome/Logs. With --home-dir set, those diverged and the log folder never existed, throwing DirectoryNotFoundException. Also resolve a relative --home-dir to a full path so folder-browser calls and file:// URIs don't receive relative paths. Co-Authored-By: Claude Opus 5.5 --- StabilityMatrix.Avalonia/App.axaml.cs | 7 +++++-- StabilityMatrix.Avalonia/Program.cs | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/StabilityMatrix.Avalonia/App.axaml.cs b/StabilityMatrix.Avalonia/App.axaml.cs index 07a196cf2..1281f9ce9 100644 --- a/StabilityMatrix.Avalonia/App.axaml.cs +++ b/StabilityMatrix.Avalonia/App.axaml.cs @@ -1327,12 +1327,15 @@ private static LoggingConfiguration ConfigureLogging() builder.ForLogger().FilterMinLevel(NLog.LogLevel.Trace).WriteTo(consoleTarget).WithAsync(); // File logging + // Use Compat.AppDataHome so logs follow --home-dir overrides + // (forward slashes to avoid NLog layout escape parsing of backslashes) + var logDir = Compat.AppDataHome.JoinDir("Logs").FullPath.Replace('\\', '/'); var fileTarget = new FileTarget("logfile") { Layout = "${longdate}|${level:uppercase=true}|${logger}|${message:withexception=true}", - FileName = "${specialfolder:folder=ApplicationData}/StabilityMatrix/Logs/app.log", + FileName = $"{logDir}/app.log", ArchiveOldFileOnStartup = true, - ArchiveFileName = "${specialfolder:folder=ApplicationData}/StabilityMatrix/Logs/app.{#}.log", + ArchiveFileName = $"{logDir}/app.{{#}}.log", ArchiveDateFormat = "yyyy-MM-dd HH_mm_ss", ArchiveNumbering = ArchiveNumberingMode.Date, MaxArchiveFiles = 9, diff --git a/StabilityMatrix.Avalonia/Program.cs b/StabilityMatrix.Avalonia/Program.cs index 838b018d0..6c834c055 100644 --- a/StabilityMatrix.Avalonia/Program.cs +++ b/StabilityMatrix.Avalonia/Program.cs @@ -87,8 +87,10 @@ x.Tag is ErrorType.HelpRequestedError or ErrorType.VersionRequestedError Args = parseResult.Value ?? new AppArgs(); - if (Args.HomeDirectoryOverride is { } homeDir) + if (Args.HomeDirectoryOverride is { } homeDirArg) { + // Resolve relative paths now, so later consumers (file browser, file:// URIs) get absolute paths + var homeDir = Path.GetFullPath(homeDirArg); Compat.SetAppDataHome(homeDir); GlobalConfig.HomeDir = homeDir; } From 5706762a17b70a942b320f9733aefdd10d47aab0 Mon Sep 17 00:00:00 2001 From: e-nord Date: Tue, 6 Oct 2026 22:38:48 -0700 Subject: [PATCH 2/6] fix: escape log directory path for NLog layouts FileName and ArchiveFileName are parsed as NLog layouts, so a home directory containing "${...}" (e.g. "${shortdate}") was rendered instead of used literally, sending logs to a different folder than Settings and the log zip read from. Escape the path with SimpleLayout.Escape. Drop the backslash-to-slash rewrite: NLog treats backslashes literally outside "${...}", and on Unix a backslash is a valid filename character. Move file target setup into LogFileTarget.Create so it can be tested. Co-Authored-By: Claude Opus 5.5 --- StabilityMatrix.Avalonia/App.axaml.cs | 13 +-------- .../Logging/LogFileTarget.cs | 29 +++++++++++++++++++ .../Avalonia/LogFileTargetTests.cs | 25 ++++++++++++++++ 3 files changed, 55 insertions(+), 12 deletions(-) create mode 100644 StabilityMatrix.Avalonia/Logging/LogFileTarget.cs create mode 100644 StabilityMatrix.Tests/Avalonia/LogFileTargetTests.cs diff --git a/StabilityMatrix.Avalonia/App.axaml.cs b/StabilityMatrix.Avalonia/App.axaml.cs index 1281f9ce9..fc4e505fc 100644 --- a/StabilityMatrix.Avalonia/App.axaml.cs +++ b/StabilityMatrix.Avalonia/App.axaml.cs @@ -1328,18 +1328,7 @@ private static LoggingConfiguration ConfigureLogging() // File logging // Use Compat.AppDataHome so logs follow --home-dir overrides - // (forward slashes to avoid NLog layout escape parsing of backslashes) - var logDir = Compat.AppDataHome.JoinDir("Logs").FullPath.Replace('\\', '/'); - var fileTarget = new FileTarget("logfile") - { - Layout = "${longdate}|${level:uppercase=true}|${logger}|${message:withexception=true}", - FileName = $"{logDir}/app.log", - ArchiveOldFileOnStartup = true, - ArchiveFileName = $"{logDir}/app.{{#}}.log", - ArchiveDateFormat = "yyyy-MM-dd HH_mm_ss", - ArchiveNumbering = ArchiveNumberingMode.Date, - MaxArchiveFiles = 9, - }; + var fileTarget = LogFileTarget.Create(Compat.AppDataHome.JoinDir("Logs")); builder.ForLogger().FilterMinLevel(NLog.LogLevel.Debug).WriteTo(fileTarget).WithAsync(); #if SM_LOG_WINDOW diff --git a/StabilityMatrix.Avalonia/Logging/LogFileTarget.cs b/StabilityMatrix.Avalonia/Logging/LogFileTarget.cs new file mode 100644 index 000000000..893d6c504 --- /dev/null +++ b/StabilityMatrix.Avalonia/Logging/LogFileTarget.cs @@ -0,0 +1,29 @@ +using NLog.Layouts; +using NLog.Targets; +using StabilityMatrix.Core.Models.FileInterfaces; + +namespace StabilityMatrix.Avalonia.Logging; + +public static class LogFileTarget +{ + /// + /// Creates the app's rolling file target, writing app.log and its archives under . + /// + public static FileTarget Create(DirectoryPath logDir) + { + // FileName and ArchiveFileName are NLog layouts, so escape the literal path + // (e.g. a home directory named "${shortdate}" must not be rendered as a date) + var escapedLogDir = SimpleLayout.Escape(logDir.FullPath); + + return new FileTarget("logfile") + { + Layout = "${longdate}|${level:uppercase=true}|${logger}|${message:withexception=true}", + FileName = $"{escapedLogDir}/app.log", + ArchiveOldFileOnStartup = true, + ArchiveFileName = $"{escapedLogDir}/app.{{#}}.log", + ArchiveDateFormat = "yyyy-MM-dd HH_mm_ss", + ArchiveNumbering = ArchiveNumberingMode.Date, + MaxArchiveFiles = 9, + }; + } +} diff --git a/StabilityMatrix.Tests/Avalonia/LogFileTargetTests.cs b/StabilityMatrix.Tests/Avalonia/LogFileTargetTests.cs new file mode 100644 index 000000000..2815abcd6 --- /dev/null +++ b/StabilityMatrix.Tests/Avalonia/LogFileTargetTests.cs @@ -0,0 +1,25 @@ +using NLog; +using StabilityMatrix.Avalonia.Logging; +using StabilityMatrix.Core.Models.FileInterfaces; + +namespace StabilityMatrix.Tests.Avalonia; + +[TestClass] +public class LogFileTargetTests +{ + [DataTestMethod] + [DataRow("home")] + [DataRow("${shortdate}")] + [DataRow("${x}")] + [DataRow(@"home\name")] + public void Create_FileNamesUseLiteralLogDirectory(string homeDirName) + { + var logDir = new DirectoryPath(Path.GetTempPath(), homeDirName, "Logs"); + + var target = LogFileTarget.Create(logDir); + var logEvent = LogEventInfo.CreateNullEvent(); + + Assert.AreEqual($"{logDir.FullPath}/app.log", target.FileName.Render(logEvent)); + Assert.AreEqual($"{logDir.FullPath}/app.{{#}}.log", target.ArchiveFileName.Render(logEvent)); + } +} From cd584ce5e0d64cdb68445891a3b33af032bbd6cd Mon Sep 17 00:00:00 2001 From: e-nord Date: Wed, 7 Oct 2026 22:44:44 -0700 Subject: [PATCH 3/6] refactor: add Compat.LogsDir and SettingsManager.PackagesDirectory Replace the hand-built AppDataHome/Logs and LibraryDir/Packages paths with named properties, so each well-known folder is defined in one place. No behavior change. Co-Authored-By: Claude Opus 5.5 --- StabilityMatrix.Avalonia/App.axaml.cs | 2 +- .../ViewModels/Dialogs/ExceptionViewModel.cs | 2 +- .../PackageManager/MainPackageManagerViewModel.cs | 2 +- .../ViewModels/Settings/MainSettingsViewModel.cs | 7 ++----- StabilityMatrix.Core/Helper/Compat.cs | 5 +++++ StabilityMatrix.Core/Services/ISettingsManager.cs | 1 + StabilityMatrix.Core/Services/SettingsManager.cs | 1 + 7 files changed, 12 insertions(+), 8 deletions(-) diff --git a/StabilityMatrix.Avalonia/App.axaml.cs b/StabilityMatrix.Avalonia/App.axaml.cs index fc4e505fc..3910cc874 100644 --- a/StabilityMatrix.Avalonia/App.axaml.cs +++ b/StabilityMatrix.Avalonia/App.axaml.cs @@ -1328,7 +1328,7 @@ private static LoggingConfiguration ConfigureLogging() // File logging // Use Compat.AppDataHome so logs follow --home-dir overrides - var fileTarget = LogFileTarget.Create(Compat.AppDataHome.JoinDir("Logs")); + var fileTarget = LogFileTarget.Create(Compat.LogsDir); builder.ForLogger().FilterMinLevel(NLog.LogLevel.Debug).WriteTo(fileTarget).WithAsync(); #if SM_LOG_WINDOW diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/ExceptionViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/ExceptionViewModel.cs index 650f2a021..a55cdd30a 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/ExceptionViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/ExceptionViewModel.cs @@ -66,7 +66,7 @@ public static async Task CreateLogFolderZip() using var suspend = LogManager.SuspendLogging(); - var logDir = Compat.AppDataHome.JoinDir("Logs"); + var logDir = Compat.LogsDir; // Copy logs to temp directory using var tempDir = new TempDirectoryPath(); diff --git a/StabilityMatrix.Avalonia/ViewModels/PackageManager/MainPackageManagerViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/PackageManager/MainPackageManagerViewModel.cs index 49f28a0c5..83207c1de 100644 --- a/StabilityMatrix.Avalonia/ViewModels/PackageManager/MainPackageManagerViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/PackageManager/MainPackageManagerViewModel.cs @@ -209,7 +209,7 @@ private async Task CheckPackagesForUpdates() private IEnumerable IndexUnknownPackages() { - var packageDir = settingsManager.LibraryDir.JoinDir("Packages"); + var packageDir = settingsManager.PackagesDirectory; if (!packageDir.Exists) { diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs index ef8d91fbb..ea584256f 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs @@ -818,9 +818,7 @@ partial void OnShowAllAvailablePythonVersionsChanged(bool value) Resources.Label_AppData ), new CommandItem( - new AsyncRelayCommand(() => - ProcessRunner.OpenFolderBrowser(Compat.AppDataHome.JoinDir("Logs")) - ), + new AsyncRelayCommand(() => ProcessRunner.OpenFolderBrowser(Compat.LogsDir)), Resources.Label_Logs ), new CommandItem( @@ -832,8 +830,7 @@ partial void OnShowAllAvailablePythonVersionsChanged(bool value) Resources.Label_Checkpoints ), new CommandItem( - new AsyncRelayCommand(() => - ProcessRunner.OpenFolderBrowser(settingsManager.LibraryDir.JoinDir("Packages")) + new AsyncRelayCommand(() => ProcessRunner.OpenFolderBrowser(settingsManager.PackagesDirectory) ), Resources.Label_Packages ), diff --git a/StabilityMatrix.Core/Helper/Compat.cs b/StabilityMatrix.Core/Helper/Compat.cs index 1a619bab8..63b11ac77 100644 --- a/StabilityMatrix.Core/Helper/Compat.cs +++ b/StabilityMatrix.Core/Helper/Compat.cs @@ -49,6 +49,11 @@ public static class Compat /// public static DirectoryPath AppDataHome { get; private set; } + /// + /// Log files directory (AppDataHome/Logs) + /// + public static DirectoryPath LogsDir => AppDataHome.JoinDir("Logs"); + /// /// Set AppDataHome to a custom path. Used for testing. /// diff --git a/StabilityMatrix.Core/Services/ISettingsManager.cs b/StabilityMatrix.Core/Services/ISettingsManager.cs index 42c8b7a8d..10ebfb820 100644 --- a/StabilityMatrix.Core/Services/ISettingsManager.cs +++ b/StabilityMatrix.Core/Services/ISettingsManager.cs @@ -24,6 +24,7 @@ public interface ISettingsManager List PackageInstallsInProgress { get; set; } DirectoryPath WorkflowDirectory { get; } DirectoryPath ExtensionPackDirectory { get; } + DirectoryPath PackagesDirectory { get; } /// /// Event fired when the library directory is changed diff --git a/StabilityMatrix.Core/Services/SettingsManager.cs b/StabilityMatrix.Core/Services/SettingsManager.cs index a07ad9108..e8c59e36a 100644 --- a/StabilityMatrix.Core/Services/SettingsManager.cs +++ b/StabilityMatrix.Core/Services/SettingsManager.cs @@ -71,6 +71,7 @@ private set public DirectoryPath ImagesInferenceDirectory => ImagesDirectory.JoinDir("Inference"); public DirectoryPath ConsolidatedImagesDirectory => ImagesDirectory.JoinDir("Consolidated"); public DirectoryPath ExtensionPackDirectory => LibraryDir.JoinDir("ExtensionPacks"); + public DirectoryPath PackagesDirectory => LibraryDir.JoinDir("Packages"); public Settings Settings { get; private set; } = new(); From 96d6758b51ae5996cd75959c203f60a607d83b88 Mon Sep 17 00:00:00 2001 From: e-nord Date: Wed, 7 Oct 2026 22:45:49 -0700 Subject: [PATCH 4/6] fix: create app folders when the library is loaded Folders like Packages were only created on first use (e.g. the first package install), so Settings > App Folders > Packages crashed on a fresh library. Add SettingsManager.EnsureAppDirectories() as the one place that creates the app's well-known folders (AppDataHome, Logs, Packages, Workflows, Images, ExtensionPacks, and the default Models folder), and call it whenever the library is found. It runs after LoadSettings so a ModelDirectoryOverride is known; an override is never created since it may be on an unmounted drive. Images/Consolidated is left to the consolidate action, since Images subfolders show as Outputs categories. Failures are logged, not thrown. The three TryFindLibrary branches now share LoadLibrary(), and the Workflows and Outputs pages call EnsureAppDirectories() instead of creating their own folders. Co-Authored-By: Claude Opus 5.5 --- .../ViewModels/InstalledWorkflowsViewModel.cs | 5 +- .../ViewModels/OutputsPageViewModel.cs | 2 +- .../Services/ISettingsManager.cs | 8 ++ .../Services/SettingsManager.cs | 54 +++++++-- .../Core/SettingsManagerDirectoriesTests.cs | 105 ++++++++++++++++++ 5 files changed, 159 insertions(+), 15 deletions(-) create mode 100644 StabilityMatrix.Tests/Core/SettingsManagerDirectoriesTests.cs diff --git a/StabilityMatrix.Avalonia/ViewModels/InstalledWorkflowsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/InstalledWorkflowsViewModel.cs index 8c0a3a454..65f74ef7c 100644 --- a/StabilityMatrix.Avalonia/ViewModels/InstalledWorkflowsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/InstalledWorkflowsViewModel.cs @@ -76,10 +76,7 @@ private async Task LoadInstalledWorkflowsAsync() { workflowsCache.Clear(); - if (!Directory.Exists(settingsManager.WorkflowDirectory)) - { - Directory.CreateDirectory(settingsManager.WorkflowDirectory); - } + settingsManager.EnsureAppDirectories(); var count = 0; diff --git a/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs index aa7deddcd..71e6fa72b 100644 --- a/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs @@ -226,7 +226,7 @@ protected override void OnInitialLoaded() if (!settingsManager.IsLibraryDirSet) return; - Directory.CreateDirectory(settingsManager.ImagesDirectory); + settingsManager.EnsureAppDirectories(); RefreshCategories(); diff --git a/StabilityMatrix.Core/Services/ISettingsManager.cs b/StabilityMatrix.Core/Services/ISettingsManager.cs index 10ebfb820..cf5baf917 100644 --- a/StabilityMatrix.Core/Services/ISettingsManager.cs +++ b/StabilityMatrix.Core/Services/ISettingsManager.cs @@ -119,6 +119,14 @@ Action onPropertyChanged /// void SetPortableMode(); + /// + /// Creates the app's well-known directories (AppDataHome, Logs, and library folders such as Packages) + /// if they are missing. Library folders are skipped when the library is not set. + /// A custom models directory override is never created. + /// Safe to call repeatedly; failures are logged, not thrown. + /// + void EnsureAppDirectories(); + void SaveLaunchArgs(Guid packageId, IEnumerable launchArgs); bool IsEulaAccepted(); void SetEulaAccepted(); diff --git a/StabilityMatrix.Core/Services/SettingsManager.cs b/StabilityMatrix.Core/Services/SettingsManager.cs index e8c59e36a..8812b000c 100644 --- a/StabilityMatrix.Core/Services/SettingsManager.cs +++ b/StabilityMatrix.Core/Services/SettingsManager.cs @@ -1,4 +1,4 @@ -using System.ComponentModel; +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; using System.Reactive.Disposables; @@ -331,9 +331,7 @@ public bool TryFindLibrary(bool forceReload = false) { logger.LogInformation("Using library override path {Path}", libraryDirOverride.FullPath); - LibraryDir = libraryDirOverride; - SetStaticLibraryPaths(); - LoadSettings(); + LoadLibrary(libraryDirOverride); return true; } @@ -342,9 +340,7 @@ public bool TryFindLibrary(bool forceReload = false) IsPortableMode = File.Exists(Path.Combine(appDir, "Data", ".sm-portable")); if (IsPortableMode) { - LibraryDir = appDir + "Data"; - SetStaticLibraryPaths(); - LoadSettings(); + LoadLibrary(appDir + "Data"); return true; } @@ -363,9 +359,7 @@ public bool TryFindLibrary(bool forceReload = false) && Directory.Exists(librarySettings.LibraryPath) ) { - LibraryDir = librarySettings.LibraryPath; - SetStaticLibraryPaths(); - LoadSettings(); + LoadLibrary(librarySettings.LibraryPath); return true; } } @@ -376,6 +370,46 @@ public bool TryFindLibrary(bool forceReload = false) return false; } + private void LoadLibrary(DirectoryPath libraryDirectory) + { + LibraryDir = libraryDirectory; + SetStaticLibraryPaths(); + LoadSettings(); + // After LoadSettings, so a ModelDirectoryOverride is known + EnsureAppDirectories(); + } + + /// + public void EnsureAppDirectories() + { + List directories = [Compat.AppDataHome, Compat.LogsDir]; + + if (IsLibraryDirSet) + { + directories.AddRange( + [PackagesDirectory, WorkflowDirectory, ImagesDirectory, ExtensionPackDirectory] + ); + + // Don't create an override, it may be on a drive that isn't mounted + if (Settings.ModelDirectoryOverride is null) + { + directories.Add(ModelsDirectory); + } + } + + foreach (var directory in directories) + { + try + { + directory.Create(); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + logger.LogWarning(e, "Failed to create directory {Path}", directory.FullPath); + } + } + } + // Set static classes requiring library path private void SetStaticLibraryPaths() { diff --git a/StabilityMatrix.Tests/Core/SettingsManagerDirectoriesTests.cs b/StabilityMatrix.Tests/Core/SettingsManagerDirectoriesTests.cs new file mode 100644 index 000000000..3954ef5e7 --- /dev/null +++ b/StabilityMatrix.Tests/Core/SettingsManagerDirectoriesTests.cs @@ -0,0 +1,105 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Services; + +namespace StabilityMatrix.Tests.Core; + +[TestClass] +public class SettingsManagerDirectoriesTests +{ + private DirectoryPath tempDir = null!; + private DirectoryPath libraryDir = null!; + private DirectoryPath originalAppDataHome = null!; + + [TestInitialize] + public void Initialize() + { + tempDir = new DirectoryPath(Path.GetTempPath(), $"SMTest_{Guid.NewGuid():N}"); + libraryDir = tempDir.JoinDir("Library"); + libraryDir.Create(); + + originalAppDataHome = Compat.AppDataHome; + Compat.SetAppDataHome(tempDir.JoinDir("AppDataHome")); + } + + [TestCleanup] + public void Cleanup() + { + Compat.SetAppDataHome(originalAppDataHome); + tempDir.Delete(true); + } + + private SettingsManager CreateWithLibrary() + { + var settingsManager = new SettingsManager(NullLogger.Instance); + settingsManager.SetLibraryDirOverride(libraryDir); + + Assert.IsTrue(settingsManager.TryFindLibrary()); + + return settingsManager; + } + + [TestMethod] + public void TryFindLibrary_CreatesAppDirectories() + { + var settingsManager = CreateWithLibrary(); + + Assert.IsTrue(Compat.AppDataHome.Exists); + Assert.IsTrue(Compat.LogsDir.Exists); + Assert.IsTrue(settingsManager.PackagesDirectory.Exists); + Assert.IsTrue(settingsManager.WorkflowDirectory.Exists); + Assert.IsTrue(settingsManager.ImagesDirectory.Exists); + Assert.IsTrue(settingsManager.ExtensionPackDirectory.Exists); + Assert.IsTrue(Directory.Exists(settingsManager.ModelsDirectory)); + } + + [TestMethod] + public void TryFindLibrary_DoesNotCreateConsolidatedImagesDirectory() + { + // Subfolders of Images show as categories in the Outputs page + var settingsManager = CreateWithLibrary(); + + Assert.IsFalse(settingsManager.ConsolidatedImagesDirectory.Exists); + } + + [TestMethod] + public void TryFindLibrary_DoesNotCreateModelsDirectoryOverride() + { + var modelsOverride = tempDir.JoinDir("UnmountedDrive", "Models"); + libraryDir + .JoinFile("settings.json") + .WriteAllText(JsonSerializer.Serialize(new { ModelDirectoryOverride = modelsOverride.FullPath })); + + var settingsManager = CreateWithLibrary(); + + Assert.AreEqual(modelsOverride.FullPath, settingsManager.ModelsDirectory); + Assert.IsFalse(modelsOverride.Exists); + Assert.IsFalse(libraryDir.JoinDir("Models").Exists); + } + + [TestMethod] + public void EnsureAppDirectories_RecreatesDeletedDirectory() + { + var settingsManager = CreateWithLibrary(); + settingsManager.PackagesDirectory.Delete(true); + + settingsManager.EnsureAppDirectories(); + settingsManager.EnsureAppDirectories(); + + Assert.IsTrue(settingsManager.PackagesDirectory.Exists); + } + + [TestMethod] + public void EnsureAppDirectories_WithoutLibrary_OnlyCreatesAppDataDirectories() + { + var settingsManager = new SettingsManager(NullLogger.Instance); + + settingsManager.EnsureAppDirectories(); + + Assert.IsFalse(settingsManager.IsLibraryDirSet); + Assert.IsTrue(Compat.LogsDir.Exists); + Assert.IsFalse(libraryDir.JoinDir("Packages").Exists); + } +} From df82d54e84e381fd55b538cee29868813ec28d9d Mon Sep 17 00:00:00 2001 From: e-nord Date: Wed, 7 Oct 2026 22:46:40 -0700 Subject: [PATCH 5/6] fix: show a notification instead of crashing on missing folders Opening a folder that doesn't exist threw from shell execute and showed the exception dialog. Add IOCommands.OpenFolderBrowserAsync and OpenFileBrowserAsync, which check the path first and show a "Folder not found" / "File not found" warning instead. They never create anything, so a genuinely missing package or model folder is reported, not hidden. The IOCommands browser commands use them. The Settings > App Folders shortcuts call EnsureAppDirectories() first, so app folders deleted while running are recreated, then open through the checked path. A missing ModelDirectoryOverride gets the notification. CreateLogFolderZip no longer throws if the Logs folder is missing. Co-Authored-By: Claude Opus 5.5 --- .../Helpers/IOCommands.cs | 43 ++++++++++++++++++- .../Languages/Resources.Designer.cs | 18 ++++++++ .../Languages/Resources.resx | 6 +++ .../ViewModels/Dialogs/ExceptionViewModel.cs | 4 +- .../Settings/MainSettingsViewModel.cs | 19 +++++--- 5 files changed, 81 insertions(+), 9 deletions(-) diff --git a/StabilityMatrix.Avalonia/Helpers/IOCommands.cs b/StabilityMatrix.Avalonia/Helpers/IOCommands.cs index 6877ec14c..43f7a098a 100644 --- a/StabilityMatrix.Avalonia/Helpers/IOCommands.cs +++ b/StabilityMatrix.Avalonia/Helpers/IOCommands.cs @@ -1,5 +1,11 @@ using System; +using System.IO; +using System.Threading.Tasks; +using Avalonia.Controls.Notifications; using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.DependencyInjection; +using StabilityMatrix.Avalonia.Languages; +using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Core.Processes; namespace StabilityMatrix.Avalonia.Helpers; @@ -37,7 +43,7 @@ public static class IOCommands if (string.IsNullOrWhiteSpace(path)) return; - await ProcessRunner.OpenFileBrowser(path); + await OpenFileBrowserAsync(path); }, path => !string.IsNullOrWhiteSpace(path) ); @@ -49,8 +55,41 @@ public static class IOCommands if (string.IsNullOrWhiteSpace(path)) return; - await ProcessRunner.OpenFolderBrowser(path); + await OpenFolderBrowserAsync(path); }, path => !string.IsNullOrWhiteSpace(path) ); + + /// + /// Opens the folder in the system file explorer, or shows a notification if it does not exist. + /// + public static async Task OpenFolderBrowserAsync(string directoryPath) + { + if (!Directory.Exists(directoryPath)) + { + ShowNotFound(Resources.Label_FolderNotFound, directoryPath); + return; + } + + await ProcessRunner.OpenFolderBrowser(directoryPath); + } + + /// + /// Opens the file within its folder in the system file explorer, or shows a notification if it does not exist. + /// + public static async Task OpenFileBrowserAsync(string filePath) + { + if (!File.Exists(filePath)) + { + ShowNotFound(Resources.Label_FileNotFound, filePath); + return; + } + + await ProcessRunner.OpenFileBrowser(filePath); + } + + private static void ShowNotFound(string title, string path) + { + App.Services.GetRequiredService().Show(title, path, NotificationType.Warning); + } } diff --git a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs index b11dbd975..44bca5b52 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs +++ b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs @@ -2103,6 +2103,15 @@ public static string Label_FileNamePattern { } } + /// + /// Looks up a localized string similar to File not found. + /// + public static string Label_FileNotFound { + get { + return ResourceManager.GetString("Label_FileNotFound", resourceCulture); + } + } + /// /// Looks up a localized string similar to Files. /// @@ -2166,6 +2175,15 @@ public static string Label_Folder { } } + /// + /// Looks up a localized string similar to Folder not found. + /// + public static string Label_FolderNotFound { + get { + return ResourceManager.GetString("Label_FolderNotFound", resourceCulture); + } + } + /// /// Looks up a localized string similar to Frames Per Second. /// diff --git a/StabilityMatrix.Avalonia/Languages/Resources.resx b/StabilityMatrix.Avalonia/Languages/Resources.resx index 8b3915911..ede9d4b94 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.resx +++ b/StabilityMatrix.Avalonia/Languages/Resources.resx @@ -1182,6 +1182,12 @@ App Data + + Folder not found + + + File not found + {0} has been updated to the selected version diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/ExceptionViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/ExceptionViewModel.cs index a55cdd30a..5e92d46b7 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/ExceptionViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/ExceptionViewModel.cs @@ -72,7 +72,9 @@ public static async Task CreateLogFolderZip() using var tempDir = new TempDirectoryPath(); var tempLogDir = tempDir.JoinDir("Logs"); tempLogDir.Create(); - foreach (var logFile in logDir.EnumerateFiles("*.log")) + // Logs may have been deleted since NLog last wrote, zip whatever exists + var logFiles = logDir.Exists ? logDir.EnumerateFiles("*.log") : []; + foreach (var logFile in logFiles) { // Need FileShare.ReadWrite since NLog keeps the file open await logFile.CopyToAsync( diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs index ea584256f..efb77e2e8 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs @@ -814,28 +814,35 @@ partial void OnShowAllAvailablePythonVersionsChanged(bool value) public CommandItem[] DirectoryShortcutCommands => [ new CommandItem( - new AsyncRelayCommand(() => ProcessRunner.OpenFolderBrowser(Compat.AppDataHome)), + new AsyncRelayCommand(() => OpenDirectoryShortcut(Compat.AppDataHome)), Resources.Label_AppData ), new CommandItem( - new AsyncRelayCommand(() => ProcessRunner.OpenFolderBrowser(Compat.LogsDir)), + new AsyncRelayCommand(() => OpenDirectoryShortcut(Compat.LogsDir)), Resources.Label_Logs ), new CommandItem( - new AsyncRelayCommand(() => ProcessRunner.OpenFolderBrowser(settingsManager.LibraryDir)), + new AsyncRelayCommand(() => OpenDirectoryShortcut(settingsManager.LibraryDir)), Resources.Label_DataDirectory ), new CommandItem( - new AsyncRelayCommand(() => ProcessRunner.OpenFolderBrowser(settingsManager.ModelsDirectory)), + new AsyncRelayCommand(() => OpenDirectoryShortcut(settingsManager.ModelsDirectory)), Resources.Label_Checkpoints ), new CommandItem( - new AsyncRelayCommand(() => ProcessRunner.OpenFolderBrowser(settingsManager.PackagesDirectory) - ), + new AsyncRelayCommand(() => OpenDirectoryShortcut(settingsManager.PackagesDirectory)), Resources.Label_Packages ), ]; + private Task OpenDirectoryShortcut(string directoryPath) + { + // Recreate app folders in case they were deleted since startup + settingsManager.EnsureAppDirectories(); + + return IOCommands.OpenFolderBrowserAsync(directoryPath); + } + #endregion #region System From c8bfb57687fbece346967c26509092c0ad17f5d6 Mon Sep 17 00:00:00 2001 From: e-nord Date: Sat, 10 Oct 2026 17:28:32 -0700 Subject: [PATCH 6/6] refactor: stop creating app folders in TryFindLibrary Folders are created where they are used, so TryFindLibrary only finds and loads the library. Co-Authored-By: Claude Opus 5.5 --- StabilityMatrix.Core/Services/SettingsManager.cs | 2 -- .../Core/SettingsManagerDirectoriesTests.cs | 13 ++++++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/StabilityMatrix.Core/Services/SettingsManager.cs b/StabilityMatrix.Core/Services/SettingsManager.cs index 8812b000c..4511887bf 100644 --- a/StabilityMatrix.Core/Services/SettingsManager.cs +++ b/StabilityMatrix.Core/Services/SettingsManager.cs @@ -375,8 +375,6 @@ private void LoadLibrary(DirectoryPath libraryDirectory) LibraryDir = libraryDirectory; SetStaticLibraryPaths(); LoadSettings(); - // After LoadSettings, so a ModelDirectoryOverride is known - EnsureAppDirectories(); } /// diff --git a/StabilityMatrix.Tests/Core/SettingsManagerDirectoriesTests.cs b/StabilityMatrix.Tests/Core/SettingsManagerDirectoriesTests.cs index 3954ef5e7..badc8e429 100644 --- a/StabilityMatrix.Tests/Core/SettingsManagerDirectoriesTests.cs +++ b/StabilityMatrix.Tests/Core/SettingsManagerDirectoriesTests.cs @@ -42,10 +42,12 @@ private SettingsManager CreateWithLibrary() } [TestMethod] - public void TryFindLibrary_CreatesAppDirectories() + public void EnsureAppDirectories_CreatesAppDirectories() { var settingsManager = CreateWithLibrary(); + settingsManager.EnsureAppDirectories(); + Assert.IsTrue(Compat.AppDataHome.Exists); Assert.IsTrue(Compat.LogsDir.Exists); Assert.IsTrue(settingsManager.PackagesDirectory.Exists); @@ -56,16 +58,18 @@ public void TryFindLibrary_CreatesAppDirectories() } [TestMethod] - public void TryFindLibrary_DoesNotCreateConsolidatedImagesDirectory() + public void EnsureAppDirectories_DoesNotCreateConsolidatedImagesDirectory() { // Subfolders of Images show as categories in the Outputs page var settingsManager = CreateWithLibrary(); + settingsManager.EnsureAppDirectories(); + Assert.IsFalse(settingsManager.ConsolidatedImagesDirectory.Exists); } [TestMethod] - public void TryFindLibrary_DoesNotCreateModelsDirectoryOverride() + public void EnsureAppDirectories_DoesNotCreateModelsDirectoryOverride() { var modelsOverride = tempDir.JoinDir("UnmountedDrive", "Models"); libraryDir @@ -74,6 +78,8 @@ public void TryFindLibrary_DoesNotCreateModelsDirectoryOverride() var settingsManager = CreateWithLibrary(); + settingsManager.EnsureAppDirectories(); + Assert.AreEqual(modelsOverride.FullPath, settingsManager.ModelsDirectory); Assert.IsFalse(modelsOverride.Exists); Assert.IsFalse(libraryDir.JoinDir("Models").Exists); @@ -83,6 +89,7 @@ public void TryFindLibrary_DoesNotCreateModelsDirectoryOverride() public void EnsureAppDirectories_RecreatesDeletedDirectory() { var settingsManager = CreateWithLibrary(); + settingsManager.EnsureAppDirectories(); settingsManager.PackagesDirectory.Delete(true); settingsManager.EnsureAppDirectories();