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

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-06-21 18:46:21 +02:00
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,
}