diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml new file mode 100644 index 000000000..172277891 --- /dev/null +++ b/.github/workflows/backport.yml @@ -0,0 +1,37 @@ +name: Automatic Backport + +on: + pull_request_target: + types: ["labeled", "closed"] + +jobs: + backport: + if: startsWith(github.event.pull_request.labels.*.name, 'backport-to-') + name: Backport PR + runs-on: ubuntu-latest + steps: + - name: Write json + id: create-json + uses: jsdaniell/create-json@v1.2.3 + with: + name: ".backportrc.json" + json: | + { + "targetPRLabels": "backport", + "prTitle": "[{{sourceBranch}} to {{targetBranch}}] backport: {{sourcePullRequest.title}} ({{sourcePullRequest.number}})" + } + + - name: Backport Action + uses: sorenlouv/backport-github-action@v9.3.0 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + auto_backport_label_prefix: backport-to- + + - name: Info log + if: ${{ success() }} + run: cat ~/.backport/backport.info.log + + - name: Debug log + if: ${{ failure() }} + run: cat ~/.backport/backport.debug.log + diff --git a/CHANGELOG.md b/CHANGELOG.md index c9787d1d7..d4d14700d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to Stability Matrix will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2.0.0.html). +## v2.8.1 +### Fixed +- Fixed model links not working in RuinedFooocus for new installations +- Fixed incorrect nodejs download link on Linux (thanks to slogonomo for the fix) +- Fixed failing InvokeAI install on macOS due to missing nodejs +- Increased timeout on Recommended Models call to prevent potential timeout errors on slow connections +- Fixed SynchronizationLockException when saving settings +- Improved error messages with process output for 7z extraction errors +- Fixed missing tkinter dependency for OneTrainer on Windows +- Fixed auto-update on macOS not starting new version from an issue in starting .app bundles with arguments + ## v2.8.0 ### Added - Added Image to Video project type diff --git a/README.md b/README.md index 8c0517965..4070d2202 100644 --- a/README.md +++ b/README.md @@ -9,21 +9,37 @@ [download-macos-arm64]: https://github.com/LykosAI/StabilityMatrix/releases/latest/download/StabilityMatrix-macos-arm64.dmg [auto1111]: https://github.com/AUTOMATIC1111/stable-diffusion-webui +[auto1111-directml]: https://github.com/lshqqytiger/stable-diffusion-webui-directml +[webui-ux]: https://github.com/anapnoe/stable-diffusion-webui-ux [comfy]: https://github.com/comfyanonymous/ComfyUI [sdnext]: https://github.com/vladmandic/automatic [voltaml]: https://github.com/VoltaML/voltaML-fast-stable-diffusion [invokeai]: https://github.com/invoke-ai/InvokeAI [fooocus]: https://github.com/lllyasviel/Fooocus [fooocus-mre]: https://github.com/MoonRide303/Fooocus-MRE +[ruined-fooocus]: https://github.com/runew0lf/RuinedFooocus +[fooocus-controlnet]: https://github.com/fenneishi/Fooocus-ControlNet-SDXL +[kohya-ss]: https://github.com/bmaltais/kohya_ss +[onetrainer]: https://github.com/Nerogar/OneTrainer [civitai]: https://civitai.com/ +[huggingface]: https://huggingface.co/ Multi-Platform Package Manager and Inference UI for Stable Diffusion ### ✨ New in 2.5 - [Inference](#inference-A-reimagined-built-in-Stable-Diffusion-experience), a built-in interface for Stable Diffusion powered by ComfyUI ### 🖱️ One click install and update for Stable Diffusion Web UI Packages -- Supports [Automatic 1111][auto1111], [Comfy UI][comfy], [SD.Next (Vladmandic)][sdnext], [VoltaML][voltaml], [InvokeAI][invokeai], [Fooocus][fooocus], and [Fooocus MRE][fooocus-mre] +- Supports: + - [Automatic 1111][auto1111], [Automatic 1111 DirectML][auto1111-directml], [SD Web UI-UX][webui-ux], [SD.Next][sdnext] + - [Fooocus][fooocus], [Fooocus MRE][fooocus-mre], [Fooocus ControlNet SDXL][fooocus-controlnet], [Ruined Fooocus][ruined-fooocus] + - [ComfyUI][comfy] + - [VoltaML][voltaml] + - [InvokeAI][invokeai] + - [Kohya's GUI][kohya-ss] + - [OneTrainer][onetrainer] +- Manage plugins / extensions for supported packages ([Automatic 1111][auto1111], [Comfy UI][comfy]) +- Easily install or update Python dependencies for each package - Embedded Git and Python dependencies, with no need for either to be globally installed - Fully portable - move Stability Matrix's Data Directory to a new drive or computer at any time @@ -34,7 +50,7 @@ Multi-Platform Package Manager and Inference UI for Stable Diffusion ### 🗃️ Checkpoint Manager, configured to be shared by all Package installs - Option to find CivitAI metadata and preview thumbnails for new local imports -### ☁️ Model Browser to import from [CivitAI][civitai] +### ☁️ Model Browser to import from [CivitAI][civitai] and [HuggingFace][huggingface] - Automatically imports to the associated model folder depending on the model type - Downloads relevant metadata files and preview image @@ -42,7 +58,7 @@ Multi-Platform Package Manager and Inference UI for Stable Diffusion [![Release](https://img.shields.io/github/v/release/LykosAI/StabilityMatrix?label=Latest%20Release&link=https%3A%2F%2Fgithub.com%2FLykosAI%2FStabilityMatrix%2Freleases%2Flatest)][release] -[![Windows](https://img.shields.io/badge/Windows-%230079d5.svg?style=for-the-badge&logo=Windows%2011&logoColor=white)][download-win-x64] +[![Windows](https://img.shields.io/badge/Windows%2010,%2011-%230079d5.svg?style=for-the-badge&logo=Windows%2011&logoColor=white)][download-win-x64] [![Linux](https://img.shields.io/badge/Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black)][download-linux-x64] [![macOS](https://img.shields.io/badge/mac%20os%20%28apple%20silicon%29-000000?style=for-the-badge&logo=macos&logoColor=F0F0F0)][download-macos-arm64] diff --git a/StabilityMatrix.Avalonia/App.axaml.cs b/StabilityMatrix.Avalonia/App.axaml.cs index 655f0ad05..f1c10ddd7 100644 --- a/StabilityMatrix.Avalonia/App.axaml.cs +++ b/StabilityMatrix.Avalonia/App.axaml.cs @@ -574,7 +574,7 @@ internal static IServiceCollection ConfigureServices() .ConfigureHttpClient(c => { c.BaseAddress = new Uri("https://auth.lykos.ai"); - c.Timeout = TimeSpan.FromSeconds(15); + c.Timeout = TimeSpan.FromSeconds(60); }) .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false }) .AddPolicyHandler(retryPolicy) diff --git a/StabilityMatrix.Avalonia/DesignData/MockSettingsManager.cs b/StabilityMatrix.Avalonia/DesignData/MockSettingsManager.cs index 865642116..e2c1c8f08 100644 --- a/StabilityMatrix.Avalonia/DesignData/MockSettingsManager.cs +++ b/StabilityMatrix.Avalonia/DesignData/MockSettingsManager.cs @@ -1,9 +1,22 @@ -using StabilityMatrix.Core.Services; +using System.Threading; +using System.Threading.Tasks; +using StabilityMatrix.Core.Services; namespace StabilityMatrix.Avalonia.DesignData; public class MockSettingsManager : SettingsManager { - protected override void LoadSettings() {} - protected override void SaveSettings() {} + protected override void LoadSettings(CancellationToken cancellationToken = default) { } + + protected override Task LoadSettingsAsync(CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + protected override void SaveSettings(CancellationToken cancellationToken = default) { } + + protected override Task SaveSettingsAsync(CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } } diff --git a/StabilityMatrix.Avalonia/Helpers/UnixPrerequisiteHelper.cs b/StabilityMatrix.Avalonia/Helpers/UnixPrerequisiteHelper.cs index 2ee7f8112..296b1202e 100644 --- a/StabilityMatrix.Avalonia/Helpers/UnixPrerequisiteHelper.cs +++ b/StabilityMatrix.Avalonia/Helpers/UnixPrerequisiteHelper.cs @@ -23,14 +23,14 @@ namespace StabilityMatrix.Avalonia.Helpers; [SupportedOSPlatform("macos")] [SupportedOSPlatform("linux")] -public class UnixPrerequisiteHelper : IPrerequisiteHelper +public class UnixPrerequisiteHelper( + IDownloadService downloadService, + ISettingsManager settingsManager, + IPyRunner pyRunner +) : 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.JoinDir("Assets"); @@ -46,17 +46,6 @@ public class UnixPrerequisiteHelper : IPrerequisiteHelper // 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 CheckIsGitInstalled() { var result = await ProcessRunner.RunBashCommand("git --version"); @@ -298,7 +287,7 @@ public async Task RunNpm( { var command = args.Prepend([NpmPath]); - var result = await ProcessRunner.RunBashCommand(command.ToArray(), workingDirectory ?? ""); + var result = await ProcessRunner.RunBashCommand(command.ToArray(), workingDirectory ?? "", envVars); if (result.ExitCode != 0) { Logger.Error( @@ -333,7 +322,7 @@ public async Task InstallNodeIfNecessary(IProgress? progress = n var downloadUrl = Compat.IsMacOS ? "https://nodejs.org/dist/v20.11.0/node-v20.11.0-darwin-arm64.tar.gz" - : "https://nodejs.org/dist/v20.11.0/node-v20.11.0-linux-x64.tar.xz"; + : "https://nodejs.org/dist/v20.11.0/node-v20.11.0-linux-x64.tar.gz"; var nodeDownloadPath = AssetsDir.JoinFile(Path.GetFileName(downloadUrl)); diff --git a/StabilityMatrix.Avalonia/Helpers/WindowsPrerequisiteHelper.cs b/StabilityMatrix.Avalonia/Helpers/WindowsPrerequisiteHelper.cs index c3cc382f5..18c331433 100644 --- a/StabilityMatrix.Avalonia/Helpers/WindowsPrerequisiteHelper.cs +++ b/StabilityMatrix.Avalonia/Helpers/WindowsPrerequisiteHelper.cs @@ -20,15 +20,16 @@ namespace StabilityMatrix.Avalonia.Helpers; [SupportedOSPlatform("windows")] -public class WindowsPrerequisiteHelper : IPrerequisiteHelper +public class WindowsPrerequisiteHelper( + IDownloadService downloadService, + ISettingsManager settingsManager, + IPyRunner pyRunner +) : IPrerequisiteHelper { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); - private readonly IGitHubClient gitHubClient; - private readonly IDownloadService downloadService; - private readonly ISettingsManager settingsManager; - private readonly IPyRunner pyRunner; - + private const string PortableGitDownloadUrl = + "https://github.com/git-for-windows/git/releases/download/v2.41.0.windows.1/PortableGit-2.41.0-64-bit.7z.exe"; private const string VcRedistDownloadUrl = "https://aka.ms/vs/16/release/vc_redist.x64.exe"; private const string TkinterDownloadUrl = "https://cdn.lykos.ai/tkinter-cpython-embedded-3.10.11-win-x64.zip"; @@ -62,19 +63,6 @@ public class WindowsPrerequisiteHelper : IPrerequisiteHelper public string GitBinPath => Path.Combine(PortableGitInstallDir, "bin"); public bool IsPythonInstalled => File.Exists(PythonDllPath); - public WindowsPrerequisiteHelper( - IGitHubClient gitHubClient, - IDownloadService downloadService, - ISettingsManager settingsManager, - IPyRunner pyRunner - ) - { - this.gitHubClient = gitHubClient; - this.downloadService = downloadService; - this.settingsManager = settingsManager; - this.pyRunner = pyRunner; - } - public async Task RunGit( ProcessArgs args, Action? onProcessOutput, @@ -166,6 +154,11 @@ public async Task InstallPackageRequirements( { await InstallNodeIfNecessary(progress); } + + if (prerequisites.Contains(PackagePrerequisite.Tkinter)) + { + await InstallTkinterIfNecessary(progress); + } } public async Task InstallAllIfNecessary(IProgress? progress = null) @@ -363,13 +356,11 @@ public async Task InstallGitIfNecessary(IProgress? progress = nu 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"; - + // Download if (!File.Exists(PortableGitDownloadPath)) { await downloadService.DownloadToFileAsync( - portableGitUrl, + PortableGitDownloadUrl, PortableGitDownloadPath, progress: progress ); diff --git a/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageInstallDetailViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageInstallDetailViewModel.cs index 80d4caa90..b91acda8a 100644 --- a/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageInstallDetailViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageInstallDetailViewModel.cs @@ -126,6 +126,8 @@ private async Task Install() return; } + InstallName = InstallName.Trim(); + var setPackageInstallingStep = new SetPackageInstallingStep(settingsManager, InstallName); var installLocation = Path.Combine(settingsManager.LibraryDir, "Packages", InstallName); diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs index 1e2dace5b..55b6c896b 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs @@ -595,6 +595,7 @@ AppData Directory [SpecialFolder.ApplicationData] AppDataHome: {Compat.AppDataHome} AppCurrentDir: {Compat.AppCurrentDir} ExecutableName: {Compat.GetExecutableName()} + AppName: {Compat.GetAppName()} -- Settings -- Expected Portable Marker file: {expectedPortableFile} Portable Marker file exists: {isPortableMode} diff --git a/StabilityMatrix.Core/Exceptions/ProcessException.cs b/StabilityMatrix.Core/Exceptions/ProcessException.cs index b5903b5f8..1ed9d6015 100644 --- a/StabilityMatrix.Core/Exceptions/ProcessException.cs +++ b/StabilityMatrix.Core/Exceptions/ProcessException.cs @@ -1,4 +1,6 @@ -using StabilityMatrix.Core.Processes; +using System.Diagnostics; +using System.Text; +using StabilityMatrix.Core.Processes; namespace StabilityMatrix.Core.Exceptions; @@ -14,9 +16,64 @@ public ProcessException(string message) public ProcessException(ProcessResult processResult) : base( - $"Process {processResult.ProcessName} exited with code {processResult.ExitCode}. {{StdOut = {processResult.StandardOutput}, StdErr = {processResult.StandardError}}}" + $"Process {processResult.ProcessName} exited with code {processResult.ExitCode}. " + + $"{{StdOut = {processResult.StandardOutput}, StdErr = {processResult.StandardError}}}" ) { ProcessResult = processResult; } + + public static void ThrowIfNonZeroExitCode(ProcessResult processResult) + { + if (processResult.IsSuccessExitCode) + return; + + throw new ProcessException(processResult); + } + + public static void ThrowIfNonZeroExitCode(Process process, string output) + { + if (!process.HasExited || process.ExitCode == 0) + return; + + throw new ProcessException( + new ProcessResult + { + ProcessName = process.StartInfo.FileName, + ExitCode = process.ExitCode, + StandardOutput = output + } + ); + } + + public static void ThrowIfNonZeroExitCode(Process process, StringBuilder outputBuilder) + { + if (!process.HasExited || process.ExitCode == 0) + return; + + throw new ProcessException( + new ProcessResult + { + ProcessName = process.StartInfo.FileName, + ExitCode = process.ExitCode, + StandardOutput = outputBuilder.ToString() + } + ); + } + + public static void ThrowIfNonZeroExitCode(Process process, string stdOut, string stdErr) + { + if (!process.HasExited || process.ExitCode == 0) + return; + + throw new ProcessException( + new ProcessResult + { + ProcessName = process.StartInfo.FileName, + ExitCode = process.ExitCode, + StandardOutput = stdOut, + StandardError = stdErr + } + ); + } } diff --git a/StabilityMatrix.Core/Helper/ArchiveHelper.cs b/StabilityMatrix.Core/Helper/ArchiveHelper.cs index c61893bd6..f4f21db79 100644 --- a/StabilityMatrix.Core/Helper/ArchiveHelper.cs +++ b/StabilityMatrix.Core/Helper/ArchiveHelper.cs @@ -5,6 +5,7 @@ using NLog; using SharpCompress.Common; using SharpCompress.Readers; +using StabilityMatrix.Core.Exceptions; using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Models.FileInterfaces; using StabilityMatrix.Core.Models.Progress; @@ -89,9 +90,10 @@ public static async Task Extract7Z(string archivePath, string extra { var args = $"x {ProcessRunner.Quote(archivePath)} -o{ProcessRunner.Quote(extractDirectory)} -y"; - var result = await ProcessRunner.GetProcessResultAsync(SevenZipPath, args).ConfigureAwait(false); - - result.EnsureSuccessExitCode(); + var result = await ProcessRunner + .GetProcessResultAsync(SevenZipPath, args) + .EnsureSuccessExitCode() + .ConfigureAwait(false); var output = result.StandardOutput ?? ""; @@ -145,7 +147,10 @@ IProgress progress Logger.Debug($"Starting process '{SevenZipPath}' with arguments '{args}'"); using var process = ProcessRunner.StartProcess(SevenZipPath, args, outputDataReceived: onOutput); - await ProcessRunner.WaitForExitConditionAsync(process).ConfigureAwait(false); + + await process.WaitForExitAsync().ConfigureAwait(false); + + ProcessException.ThrowIfNonZeroExitCode(process, outputStore); progress.Report(new ProgressReport(1f, "Finished extracting", type: ProgressType.Extract)); diff --git a/StabilityMatrix.Core/Models/PackagePrerequisite.cs b/StabilityMatrix.Core/Models/PackagePrerequisite.cs index 4d3441735..c00a53fcf 100644 --- a/StabilityMatrix.Core/Models/PackagePrerequisite.cs +++ b/StabilityMatrix.Core/Models/PackagePrerequisite.cs @@ -7,5 +7,6 @@ public enum PackagePrerequisite Git, Node, Dotnet7, - Dotnet8 + Dotnet8, + Tkinter, } diff --git a/StabilityMatrix.Core/Models/Packages/KohyaSs.cs b/StabilityMatrix.Core/Models/Packages/KohyaSs.cs index c38c0e1f2..c9cdf0411 100644 --- a/StabilityMatrix.Core/Models/Packages/KohyaSs.cs +++ b/StabilityMatrix.Core/Models/Packages/KohyaSs.cs @@ -50,6 +50,8 @@ IPyRunner runner public override IEnumerable AvailableTorchVersions => [TorchVersion.Cuda]; public override IEnumerable AvailableSharedFolderMethods => new[] { SharedFolderMethod.None }; + public override IEnumerable Prerequisites => + base.Prerequisites.Concat([PackagePrerequisite.Tkinter]); public override List LaunchOptions => [ @@ -114,12 +116,6 @@ public override async Task InstallPackage( Action? onConsoleOutput = null ) { - if (Compat.IsWindows) - { - progress?.Report(new ProgressReport(-1f, "Installing prerequisites...", isIndeterminate: true)); - await PrerequisiteHelper.InstallTkinterIfNecessary(progress).ConfigureAwait(false); - } - progress?.Report(new ProgressReport(-1f, "Setting up venv", isIndeterminate: true)); // Setup venv await using var venvRunner = new PyVenvRunner(Path.Combine(installLocation, "venv")); diff --git a/StabilityMatrix.Core/Models/Packages/OneTrainer.cs b/StabilityMatrix.Core/Models/Packages/OneTrainer.cs index d113cb2dc..d4a92c342 100644 --- a/StabilityMatrix.Core/Models/Packages/OneTrainer.cs +++ b/StabilityMatrix.Core/Models/Packages/OneTrainer.cs @@ -41,6 +41,8 @@ IPrerequisiteHelper prerequisiteHelper public override PackageDifficulty InstallerSortOrder => PackageDifficulty.Nightmare; public override bool OfferInOneClickInstaller => false; public override bool ShouldIgnoreReleases => true; + public override IEnumerable Prerequisites => + base.Prerequisites.Concat([PackagePrerequisite.Tkinter]); public override async Task InstallPackage( string installLocation, diff --git a/StabilityMatrix.Core/Models/Packages/RuinedFooocus.cs b/StabilityMatrix.Core/Models/Packages/RuinedFooocus.cs index fbf2535f8..181abc321 100644 --- a/StabilityMatrix.Core/Models/Packages/RuinedFooocus.cs +++ b/StabilityMatrix.Core/Models/Packages/RuinedFooocus.cs @@ -27,6 +27,9 @@ IPrerequisiteHelper prerequisiteHelper public override Uri PreviewImageUri => new("https://raw.githubusercontent.com/runew0lf/pmmconfigs/main/RuinedFooocus_ss.png"); public override PackageDifficulty InstallerSortOrder => PackageDifficulty.Expert; + public override IEnumerable AvailableSharedFolderMethods => + [SharedFolderMethod.Symlink, SharedFolderMethod.None]; + public override SharedFolderMethod RecommendedSharedFolderMethod => SharedFolderMethod.Symlink; public override List LaunchOptions => new() diff --git a/StabilityMatrix.Core/Models/Settings/SettingsTransaction.cs b/StabilityMatrix.Core/Models/Settings/SettingsTransaction.cs index 759714f93..222920975 100644 --- a/StabilityMatrix.Core/Models/Settings/SettingsTransaction.cs +++ b/StabilityMatrix.Core/Models/Settings/SettingsTransaction.cs @@ -6,7 +6,7 @@ namespace StabilityMatrix.Core.Models.Settings; /// /// Transaction object which saves settings manager changes when disposed. /// -public class SettingsTransaction(ISettingsManager settingsManager, Func onCommit) +public class SettingsTransaction(ISettingsManager settingsManager, Action onCommit, Func onCommitAsync) : IDisposable, IAsyncDisposable { @@ -14,13 +14,13 @@ public class SettingsTransaction(ISettingsManager settingsManager, Func on public void Dispose() { - onCommit().SafeFireAndForget(); + onCommit(); GC.SuppressFinalize(this); } public async ValueTask DisposeAsync() { - await onCommit().ConfigureAwait(false); + await onCommitAsync().ConfigureAwait(false); GC.SuppressFinalize(this); } } diff --git a/StabilityMatrix.Core/Processes/ProcessRunner.cs b/StabilityMatrix.Core/Processes/ProcessRunner.cs index 2da50556b..e0f08caf9 100644 --- a/StabilityMatrix.Core/Processes/ProcessRunner.cs +++ b/StabilityMatrix.Core/Processes/ProcessRunner.cs @@ -39,7 +39,7 @@ public static Process StartApp(string path, ProcessArgs args) if (Compat.IsMacOS) { startInfo.FileName = "open"; - startInfo.Arguments = args.Prepend(path).ToString(); + startInfo.Arguments = args.Prepend([path, "--args"]).ToString(); startInfo.UseShellExecute = true; } else diff --git a/StabilityMatrix.Core/Services/SettingsManager.cs b/StabilityMatrix.Core/Services/SettingsManager.cs index 115c8615b..4959227be 100644 --- a/StabilityMatrix.Core/Services/SettingsManager.cs +++ b/StabilityMatrix.Core/Services/SettingsManager.cs @@ -19,9 +19,11 @@ namespace StabilityMatrix.Core.Services; public class SettingsManager : ISettingsManager { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); - private static readonly ReaderWriterLockSlim FileLock = new(); + private static string GlobalSettingsPath => Path.Combine(Compat.AppDataHome, "global.json"); + private readonly SemaphoreSlim fileLock = new(1, 1); + private bool isLoaded; private DirectoryPath? libraryDirOverride; @@ -113,7 +115,7 @@ public SettingsTransaction BeginTransaction() { throw new InvalidOperationException("LibraryDir not set when BeginTransaction was called"); } - return new SettingsTransaction(this, SaveSettingsAsync); + return new SettingsTransaction(this, () => SaveSettings(), () => SaveSettingsAsync()); } /// @@ -438,64 +440,102 @@ public void IndexCheckpoints() } /// - /// Loads settings from the settings file - /// If the settings file does not exist, it will be created with default values + /// 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() + protected virtual void LoadSettings(CancellationToken cancellationToken = default) { - FileLock.EnterReadLock(); + fileLock.Wait(cancellationToken); + try { if (!SettingsFile.Exists) { - SettingsFile.Directory?.Create(); - SettingsFile.Create(); + return; + } - var settingsJson = JsonSerializer.Serialize( - Settings, - SettingsSerializerContext.Default.Settings - ); - SettingsFile.WriteAllText(settingsJson); + using var fileStream = SettingsFile.Info.OpenRead(); - Loaded?.Invoke(this, EventArgs.Empty); - isLoaded = true; + if (fileStream.Length == 0) + { + Logger.Warn("Settings file is empty, using default settings"); return; } - using var fileStream = SettingsFile.Info.OpenRead(); + 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"); - isLoaded = true; return; } - if ( - JsonSerializer.Deserialize(fileStream, SettingsSerializerContext.Default.Settings) is - { } loadedSettings - ) + var loadedSettings = await JsonSerializer + .DeserializeAsync(fileStream, SettingsSerializerContext.Default.Settings, cancellationToken) + .ConfigureAwait(false); + + if (loadedSettings is not null) { Settings = loadedSettings; - isLoaded = true; } Loaded?.Invoke(this, EventArgs.Empty); } finally { - FileLock.ExitReadLock(); + fileLock.Release(); + + isLoaded = true; + + Loaded?.Invoke(this, EventArgs.Empty); } } - protected virtual void SaveSettings() + protected virtual void SaveSettings(CancellationToken cancellationToken = default) { - FileLock.TryEnterWriteLock(TimeSpan.FromSeconds(30)); + // Skip saving if not loaded yet + if (!isLoaded) + return; + + fileLock.Wait(cancellationToken); + try { - if (!isLoaded) - return; - // Create empty settings file if it doesn't exist if (!SettingsFile.Exists) { @@ -503,6 +543,7 @@ protected virtual void SaveSettings() SettingsFile.Create(); } + // Check disk space if (SystemInfo.GetDiskFreeSpaceBytes(SettingsFile) is < 1 * SystemInfo.Mebibyte) { Logger.Warn("Not enough disk space to save settings"); @@ -527,20 +568,63 @@ protected virtual void SaveSettings() fs.Flush(); fs.SetLength(jsonBytes.Length); } - fs.Close(); } finally { - FileLock.ExitWriteLock(); + fileLock.Release(); } } - private Task SaveSettingsAsync() + protected virtual async Task SaveSettingsAsync(CancellationToken cancellationToken = default) { - return Task.Run(SaveSettings); + // 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 CancellationTokenSource? delayedSaveCts; + private volatile CancellationTokenSource? delayedSaveCts; private Task SaveSettingsDelayed(TimeSpan delay) { @@ -561,7 +645,7 @@ private Task SaveSettingsDelayed(TimeSpan delay) { await Task.Delay(delay, cts.Token).ConfigureAwait(false); - await SaveSettingsAsync().ConfigureAwait(false); + await SaveSettingsAsync(cts.Token).ConfigureAwait(false); } catch (TaskCanceledException) { } finally