diff --git a/StabilityMatrix.Avalonia/App.axaml.cs b/StabilityMatrix.Avalonia/App.axaml.cs index 07a196cf2..3910cc874 100644 --- a/StabilityMatrix.Avalonia/App.axaml.cs +++ b/StabilityMatrix.Avalonia/App.axaml.cs @@ -1327,16 +1327,8 @@ private static LoggingConfiguration ConfigureLogging() builder.ForLogger().FilterMinLevel(NLog.LogLevel.Trace).WriteTo(consoleTarget).WithAsync(); // File logging - var fileTarget = new FileTarget("logfile") - { - Layout = "${longdate}|${level:uppercase=true}|${logger}|${message:withexception=true}", - FileName = "${specialfolder:folder=ApplicationData}/StabilityMatrix/Logs/app.log", - ArchiveOldFileOnStartup = true, - ArchiveFileName = "${specialfolder:folder=ApplicationData}/StabilityMatrix/Logs/app.{#}.log", - ArchiveDateFormat = "yyyy-MM-dd HH_mm_ss", - ArchiveNumbering = ArchiveNumberingMode.Date, - MaxArchiveFiles = 9, - }; + // Use Compat.AppDataHome so logs follow --home-dir overrides + var fileTarget = LogFileTarget.Create(Compat.LogsDir); builder.ForLogger().FilterMinLevel(NLog.LogLevel.Debug).WriteTo(fileTarget).WithAsync(); #if SM_LOG_WINDOW 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/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.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; } diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/ExceptionViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/ExceptionViewModel.cs index 650f2a021..5e92d46b7 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/ExceptionViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/ExceptionViewModel.cs @@ -66,13 +66,15 @@ 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(); 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/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.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..efb77e2e8 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs @@ -814,31 +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.AppDataHome.JoinDir("Logs")) - ), + 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.LibraryDir.JoinDir("Packages")) - ), + 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 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..cf5baf917 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 @@ -118,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 a07ad9108..4511887bf 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; @@ -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(); @@ -330,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; } @@ -341,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; } @@ -362,9 +359,7 @@ public bool TryFindLibrary(bool forceReload = false) && Directory.Exists(librarySettings.LibraryPath) ) { - LibraryDir = librarySettings.LibraryPath; - SetStaticLibraryPaths(); - LoadSettings(); + LoadLibrary(librarySettings.LibraryPath); return true; } } @@ -375,6 +370,44 @@ public bool TryFindLibrary(bool forceReload = false) return false; } + private void LoadLibrary(DirectoryPath libraryDirectory) + { + LibraryDir = libraryDirectory; + SetStaticLibraryPaths(); + LoadSettings(); + } + + /// + 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/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)); + } +} diff --git a/StabilityMatrix.Tests/Core/SettingsManagerDirectoriesTests.cs b/StabilityMatrix.Tests/Core/SettingsManagerDirectoriesTests.cs new file mode 100644 index 000000000..badc8e429 --- /dev/null +++ b/StabilityMatrix.Tests/Core/SettingsManagerDirectoriesTests.cs @@ -0,0 +1,112 @@ +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 EnsureAppDirectories_CreatesAppDirectories() + { + var settingsManager = CreateWithLibrary(); + + settingsManager.EnsureAppDirectories(); + + 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 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 EnsureAppDirectories_DoesNotCreateModelsDirectoryOverride() + { + var modelsOverride = tempDir.JoinDir("UnmountedDrive", "Models"); + libraryDir + .JoinFile("settings.json") + .WriteAllText(JsonSerializer.Serialize(new { ModelDirectoryOverride = modelsOverride.FullPath })); + + var settingsManager = CreateWithLibrary(); + + settingsManager.EnsureAppDirectories(); + + 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.EnsureAppDirectories(); + 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); + } +}