mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-06 12:29:40 +00:00
Enhanced settings manager with versioned backups and migrations (#819)
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
This commit is contained in:
1 parent
6d48252db3
commit
5af616f565
7 files changed
+326
-25
No files matched your search
@@ -17,6 +17,11 @@ namespace AIStudio.Settings;
|
||||
public sealed class SettingsManager
|
||||
{
|
||||
private const string SETTINGS_FILENAME = "settings.json";
|
||||
private const Version CURRENT_SETTINGS_VERSION = Version.V6;
|
||||
|
||||
private readonly record struct SettingsVersionReadResult(Version Version, SettingsWriteBlockReason FailureReason);
|
||||
|
||||
private readonly record struct CurrentSettingsReadResult(Data? SettingsData, SettingsWriteBlockReason FailureReason);
|
||||
|
||||
private static readonly JsonSerializerOptions JSON_OPTIONS = new()
|
||||
{
|
||||
@@ -62,6 +67,16 @@ public sealed class SettingsManager
|
||||
/// </summary>
|
||||
public bool HasCompletedInitialSettingsLoad { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates why settings writes are blocked for the current session.
|
||||
/// </summary>
|
||||
public SettingsWriteBlockReason SettingsWriteBlockReason { get; private set; } = SettingsWriteBlockReason.NONE;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates that settings writes are blocked for the current session.
|
||||
/// </summary>
|
||||
public bool SettingsWriteBlocked => this.SettingsWriteBlockReason is not SettingsWriteBlockReason.NONE;
|
||||
|
||||
/// <summary>
|
||||
/// The configuration data.
|
||||
/// </summary>
|
||||
@@ -87,6 +102,7 @@ public sealed class SettingsManager
|
||||
/// <returns>A (migrated) settings snapshot, or null if it could not be read.</returns>
|
||||
public async Task<Data?> TryReadSettingsSnapshot()
|
||||
{
|
||||
this.SettingsWriteBlockReason = SettingsWriteBlockReason.NONE;
|
||||
if(!this.IsSetUp)
|
||||
{
|
||||
this.logger.LogWarning("Cannot load settings, because the configuration is not set up yet.");
|
||||
@@ -100,38 +116,175 @@ public sealed class SettingsManager
|
||||
return null;
|
||||
}
|
||||
|
||||
// We read the `"Version": "V3"` line to determine the version of the settings file:
|
||||
await foreach (var line in File.ReadLinesAsync(settingsPath))
|
||||
var settingsVersion = await this.TryReadSettingsVersion(settingsPath);
|
||||
if(settingsVersion.FailureReason is not SettingsWriteBlockReason.NONE)
|
||||
{
|
||||
if (!line.Contains("""
|
||||
"Version":
|
||||
"""))
|
||||
continue;
|
||||
this.BlockSettingsWrites(settingsVersion.FailureReason, "The settings file version could not be identified. Settings writes are blocked to avoid overwriting newer or unreadable settings.");
|
||||
return await this.TryReadCurrentVersionBackupSnapshotForBlockedSettings();
|
||||
}
|
||||
|
||||
// Extract the version from the line:
|
||||
var settingsVersionText = line.Split('"')[3];
|
||||
if(settingsVersion.Version > CURRENT_SETTINGS_VERSION)
|
||||
{
|
||||
this.BlockSettingsWrites(SettingsWriteBlockReason.VERSION_NEWER_THAN_APP, $"The settings file uses the newer version '{settingsVersion.Version}'. Settings writes are blocked to avoid overwriting newer settings.");
|
||||
return await this.TryReadCurrentVersionBackupSnapshotForBlockedSettings();
|
||||
}
|
||||
|
||||
// Parse the version:
|
||||
Enum.TryParse(settingsVersionText, out Version settingsVersion);
|
||||
if(settingsVersion is Version.UNKNOWN)
|
||||
Data? settingsData;
|
||||
if(settingsVersion.Version < CURRENT_SETTINGS_VERSION)
|
||||
{
|
||||
settingsData = await this.TryReadCurrentVersionBackupSnapshot();
|
||||
if(settingsData is not null)
|
||||
{
|
||||
this.logger.LogError("Unknown version of the settings file found.");
|
||||
return new();
|
||||
this.PrepareLoadedSettings(settingsData);
|
||||
await this.StoreSettingsSnapshot(settingsData, settingsPath);
|
||||
await this.StoreCurrentVersionBackup(settingsData);
|
||||
this.logger.LogInformation($"Restored settings from the '{GetBackupSettingsFilename(CURRENT_SETTINGS_VERSION)}' backup file.");
|
||||
return settingsData;
|
||||
}
|
||||
|
||||
var settingsData = SettingsMigrations.Migrate(this.logger, settingsVersion, await File.ReadAllTextAsync(settingsPath), JSON_OPTIONS);
|
||||
|
||||
//
|
||||
// We filter the enabled preview features based on the preview visibility.
|
||||
// This is necessary when the app starts up: some preview features may have
|
||||
// been disabled or released from the last time the app was started.
|
||||
//
|
||||
settingsData.App.EnabledPreviewFeatures = settingsData.App.PreviewVisibility.FilterPreviewFeatures(settingsData.App.EnabledPreviewFeatures);
|
||||
this.logger.LogInformation("No valid current-version settings backup was found. Migrating the settings file.");
|
||||
settingsData = SettingsMigrations.Migrate(this.logger, settingsVersion.Version, await File.ReadAllTextAsync(settingsPath), JSON_OPTIONS);
|
||||
this.PrepareLoadedSettings(settingsData);
|
||||
await this.StoreSettingsSnapshot(settingsData, settingsPath);
|
||||
await this.StoreCurrentVersionBackup(settingsData);
|
||||
return settingsData;
|
||||
}
|
||||
|
||||
this.logger.LogError("Failed to read the version of the settings file.");
|
||||
return new();
|
||||
var currentSettings = await this.TryDeserializeCurrentSettings(settingsPath, "settings file");
|
||||
if(currentSettings.FailureReason is not SettingsWriteBlockReason.NONE)
|
||||
{
|
||||
this.BlockSettingsWrites(currentSettings.FailureReason, "The current settings file could not be safely loaded. Settings writes are blocked to avoid overwriting recoverable settings.");
|
||||
return await this.TryReadCurrentVersionBackupSnapshotForBlockedSettings();
|
||||
}
|
||||
|
||||
settingsData = currentSettings.SettingsData!;
|
||||
this.PrepareLoadedSettings(settingsData);
|
||||
await this.StoreCurrentVersionBackup(settingsData);
|
||||
return settingsData;
|
||||
}
|
||||
|
||||
private async Task<SettingsVersionReadResult> TryReadSettingsVersion(string settingsPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var settingsStream = File.OpenRead(settingsPath);
|
||||
using var settingsDocument = await JsonDocument.ParseAsync(settingsStream);
|
||||
if(!settingsDocument.RootElement.TryGetProperty("Version", out var versionElement))
|
||||
{
|
||||
this.logger.LogError($"Failed to read the version of the settings file '{settingsPath}'.");
|
||||
return new(Version.UNKNOWN, SettingsWriteBlockReason.VERSION_MISSING);
|
||||
}
|
||||
|
||||
if(versionElement.ValueKind is JsonValueKind.String && versionElement.GetString() is { } versionText)
|
||||
{
|
||||
if(Enum.TryParse(versionText, out Version stringVersion) && Enum.IsDefined(stringVersion) && stringVersion is not Version.UNKNOWN)
|
||||
return new(stringVersion, SettingsWriteBlockReason.NONE);
|
||||
|
||||
if(versionText.StartsWith('V') && int.TryParse(versionText[1..], out var futureVersion) && futureVersion > (int)CURRENT_SETTINGS_VERSION)
|
||||
return new((Version)futureVersion, SettingsWriteBlockReason.NONE);
|
||||
|
||||
if(int.TryParse(versionText, out var numericStringVersion) && numericStringVersion > (int)CURRENT_SETTINGS_VERSION)
|
||||
return new((Version)numericStringVersion, SettingsWriteBlockReason.NONE);
|
||||
}
|
||||
|
||||
if(versionElement.ValueKind is JsonValueKind.Number && versionElement.TryGetInt32(out var numericVersion) && numericVersion > (int)Version.UNKNOWN && (Enum.IsDefined(typeof(Version), numericVersion) || numericVersion > (int)CURRENT_SETTINGS_VERSION))
|
||||
return new((Version)numericVersion, SettingsWriteBlockReason.NONE);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
this.logger.LogError(e, $"Failed to read the version of the settings file '{settingsPath}'.");
|
||||
return new(Version.UNKNOWN, SettingsWriteBlockReason.FILE_UNREADABLE);
|
||||
}
|
||||
|
||||
return new(Version.UNKNOWN, SettingsWriteBlockReason.VERSION_UNKNOWN);
|
||||
}
|
||||
|
||||
private async Task<Data?> TryReadCurrentVersionBackupSnapshot()
|
||||
{
|
||||
var backupSettingsPath = GetBackupSettingsPath(CURRENT_SETTINGS_VERSION);
|
||||
if(!File.Exists(backupSettingsPath))
|
||||
{
|
||||
this.logger.LogInformation($"The settings backup file '{backupSettingsPath}' does not exist.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var backupVersion = await this.TryReadSettingsVersion(backupSettingsPath);
|
||||
if(backupVersion.FailureReason is not SettingsWriteBlockReason.NONE)
|
||||
{
|
||||
this.logger.LogWarning($"The settings backup file '{backupSettingsPath}' could not be used because its version could not be identified. Reason: '{backupVersion.FailureReason}'.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if(backupVersion.Version != CURRENT_SETTINGS_VERSION)
|
||||
{
|
||||
this.logger.LogWarning($"The settings backup file '{backupSettingsPath}' uses version '{backupVersion.Version}' instead of '{CURRENT_SETTINGS_VERSION}'.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var backupSettings = await this.TryDeserializeCurrentSettings(backupSettingsPath, "settings backup file");
|
||||
if(backupSettings.FailureReason is not SettingsWriteBlockReason.NONE)
|
||||
{
|
||||
this.logger.LogWarning($"The settings backup file '{backupSettingsPath}' could not be used. Reason: '{backupSettings.FailureReason}'.");
|
||||
return null;
|
||||
}
|
||||
|
||||
return backupSettings.SettingsData;
|
||||
}
|
||||
|
||||
private async Task<Data?> TryReadCurrentVersionBackupSnapshotForBlockedSettings()
|
||||
{
|
||||
var settingsData = await this.TryReadCurrentVersionBackupSnapshot();
|
||||
if(settingsData is null)
|
||||
{
|
||||
this.logger.LogWarning($"No valid current-version settings backup was found while settings writes are blocked. Reason: '{this.SettingsWriteBlockReason}'.");
|
||||
return null;
|
||||
}
|
||||
|
||||
this.PrepareLoadedSettings(settingsData);
|
||||
this.logger.LogWarning($"Loaded settings from the '{GetBackupSettingsFilename(CURRENT_SETTINGS_VERSION)}' backup file while settings writes remain blocked. Reason: '{this.SettingsWriteBlockReason}'.");
|
||||
return settingsData;
|
||||
}
|
||||
|
||||
private async Task<CurrentSettingsReadResult> TryDeserializeCurrentSettings(string settingsPath, string sourceDescription)
|
||||
{
|
||||
try
|
||||
{
|
||||
var settingsData = JsonSerializer.Deserialize<Data>(await File.ReadAllTextAsync(settingsPath), JSON_OPTIONS);
|
||||
if(settingsData is null)
|
||||
{
|
||||
this.logger.LogError($"Failed to parse the {sourceDescription} '{settingsPath}'.");
|
||||
return new(null, SettingsWriteBlockReason.CURRENT_VERSION_INVALID);
|
||||
}
|
||||
|
||||
if(settingsData.Version != CURRENT_SETTINGS_VERSION)
|
||||
{
|
||||
this.logger.LogError($"The {sourceDescription} '{settingsPath}' uses version '{settingsData.Version}' instead of '{CURRENT_SETTINGS_VERSION}'.");
|
||||
return new(null, SettingsWriteBlockReason.CURRENT_VERSION_INVALID);
|
||||
}
|
||||
|
||||
return new(settingsData, SettingsWriteBlockReason.NONE);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
this.logger.LogError(e, $"Failed to parse the {sourceDescription} '{settingsPath}'.");
|
||||
return new(null, SettingsWriteBlockReason.FILE_UNREADABLE);
|
||||
}
|
||||
}
|
||||
|
||||
private void BlockSettingsWrites(SettingsWriteBlockReason reason, string message)
|
||||
{
|
||||
this.SettingsWriteBlockReason = reason;
|
||||
this.logger.LogError($"{message} Reason: '{reason}'.");
|
||||
}
|
||||
|
||||
private void PrepareLoadedSettings(Data settingsData)
|
||||
{
|
||||
//
|
||||
// We filter the enabled preview features based on the preview visibility.
|
||||
// This is necessary when the app starts up: some preview features may have
|
||||
// been disabled or released from the last time the app was started.
|
||||
//
|
||||
settingsData.App.EnabledPreviewFeatures = settingsData.App.PreviewVisibility.FilterPreviewFeatures(settingsData.App.EnabledPreviewFeatures);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -145,19 +298,48 @@ public sealed class SettingsManager
|
||||
return;
|
||||
}
|
||||
|
||||
if(this.SettingsWriteBlocked)
|
||||
{
|
||||
this.logger.LogWarning($"Cannot store settings, because settings writes are blocked. Reason: '{this.SettingsWriteBlockReason}'.");
|
||||
return;
|
||||
}
|
||||
|
||||
var settingsPath = Path.Combine(ConfigDirectory!, SETTINGS_FILENAME);
|
||||
await this.StoreSettingsSnapshot(this.ConfigurationData, settingsPath);
|
||||
await this.StoreCurrentVersionBackup(this.ConfigurationData);
|
||||
}
|
||||
|
||||
private static string GetBackupSettingsFilename(Version version) => $"settings.{version.ToString().ToLowerInvariant()}.json";
|
||||
|
||||
private static string GetBackupSettingsPath(Version version) => Path.Combine(ConfigDirectory!, GetBackupSettingsFilename(version));
|
||||
|
||||
private async Task StoreCurrentVersionBackup(Data settingsData)
|
||||
{
|
||||
if(settingsData.Version != CURRENT_SETTINGS_VERSION)
|
||||
{
|
||||
this.logger.LogWarning($"Skipping settings backup because the settings version '{settingsData.Version}' is not the current version '{CURRENT_SETTINGS_VERSION}'.");
|
||||
return;
|
||||
}
|
||||
|
||||
var backupSettingsPath = GetBackupSettingsPath(CURRENT_SETTINGS_VERSION);
|
||||
await this.StoreSettingsSnapshot(settingsData, backupSettingsPath);
|
||||
this.logger.LogInformation($"Stored the settings backup file '{backupSettingsPath}'.");
|
||||
}
|
||||
|
||||
private async Task StoreSettingsSnapshot(Data settingsData, string settingsPath)
|
||||
{
|
||||
if(!Directory.Exists(ConfigDirectory))
|
||||
{
|
||||
this.logger.LogInformation("Creating the configuration directory.");
|
||||
Directory.CreateDirectory(ConfigDirectory!);
|
||||
}
|
||||
|
||||
var settingsJson = JsonSerializer.Serialize(this.ConfigurationData, JSON_OPTIONS);
|
||||
var settingsJson = JsonSerializer.Serialize(settingsData, JSON_OPTIONS);
|
||||
var tempFile = Path.GetTempFileName();
|
||||
await File.WriteAllTextAsync(tempFile, settingsJson);
|
||||
|
||||
File.Move(tempFile, settingsPath, true);
|
||||
this.logger.LogInformation("Stored the settings to the file system.");
|
||||
this.logger.LogInformation($"Stored the settings to '{settingsPath}'.");
|
||||
}
|
||||
|
||||
public void InjectSpellchecking(Dictionary<string, object?> attributes) => attributes["spellcheck"] = this.ConfigurationData.App.EnableSpellchecking ? "true" : "false";
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace AIStudio.Settings;
|
||||
|
||||
public enum SettingsWriteBlockReason
|
||||
{
|
||||
NONE,
|
||||
VERSION_MISSING,
|
||||
VERSION_UNKNOWN,
|
||||
VERSION_NEWER_THAN_APP,
|
||||
FILE_UNREADABLE,
|
||||
CURRENT_VERSION_INVALID,
|
||||
}
|
||||
Reference in new issue
Block a user