Enhanced settings manager with versioned backups and migrations

This commit is contained in:
Thorsten Sommer 2026-06-21 17:26:20 +02:00
parent 6d48252db3
commit 9da669ec39
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
2 changed files with 153 additions and 25 deletions

View File

@ -17,6 +17,7 @@ namespace AIStudio.Settings;
public sealed class SettingsManager public sealed class SettingsManager
{ {
private const string SETTINGS_FILENAME = "settings.json"; private const string SETTINGS_FILENAME = "settings.json";
private const Version CURRENT_SETTINGS_VERSION = Version.V6;
private static readonly JsonSerializerOptions JSON_OPTIONS = new() private static readonly JsonSerializerOptions JSON_OPTIONS = new()
{ {
@ -26,6 +27,7 @@ public sealed class SettingsManager
private readonly ILogger<SettingsManager> logger; private readonly ILogger<SettingsManager> logger;
private readonly RustService rustService; private readonly RustService rustService;
private bool settingsWriteBlocked;
/// <summary> /// <summary>
/// The settings manager. /// The settings manager.
@ -87,6 +89,7 @@ public sealed class SettingsManager
/// <returns>A (migrated) settings snapshot, or null if it could not be read.</returns> /// <returns>A (migrated) settings snapshot, or null if it could not be read.</returns>
public async Task<Data?> TryReadSettingsSnapshot() public async Task<Data?> TryReadSettingsSnapshot()
{ {
this.settingsWriteBlocked = false;
if(!this.IsSetUp) if(!this.IsSetUp)
{ {
this.logger.LogWarning("Cannot load settings, because the configuration is not set up yet."); this.logger.LogWarning("Cannot load settings, because the configuration is not set up yet.");
@ -100,38 +103,133 @@ public sealed class SettingsManager
return null; return null;
} }
// We read the `"Version": "V3"` line to determine the version of the settings file: var settingsVersion = await this.TryReadSettingsVersion(settingsPath);
await foreach (var line in File.ReadLinesAsync(settingsPath)) if(settingsVersion is Version.UNKNOWN)
{ {
if (!line.Contains(""" this.logger.LogError("Unknown version of the settings file found. Settings writes are blocked to avoid overwriting newer or unreadable settings.");
"Version": this.settingsWriteBlocked = true;
""")) return null;
continue; }
// Extract the version from the line: if(settingsVersion > CURRENT_SETTINGS_VERSION)
var settingsVersionText = line.Split('"')[3]; {
this.logger.LogError($"The settings file uses the newer version '{settingsVersion}'. Settings writes are blocked to avoid overwriting newer settings.");
this.settingsWriteBlocked = true;
return null;
}
// Parse the version: Data? settingsData;
Enum.TryParse(settingsVersionText, out Version settingsVersion); if(settingsVersion < CURRENT_SETTINGS_VERSION)
if(settingsVersion is Version.UNKNOWN) {
settingsData = await this.TryReadCurrentVersionBackupSnapshot();
if(settingsData is not null)
{ {
this.logger.LogError("Unknown version of the settings file found."); this.PrepareLoadedSettings(settingsData);
return new(); 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); this.logger.LogInformation("No valid current-version settings backup was found. Migrating the settings file.");
settingsData = SettingsMigrations.Migrate(this.logger, settingsVersion, await File.ReadAllTextAsync(settingsPath), JSON_OPTIONS);
// this.PrepareLoadedSettings(settingsData);
// We filter the enabled preview features based on the preview visibility. await this.StoreSettingsSnapshot(settingsData, settingsPath);
// This is necessary when the app starts up: some preview features may have await this.StoreCurrentVersionBackup(settingsData);
// been disabled or released from the last time the app was started.
//
settingsData.App.EnabledPreviewFeatures = settingsData.App.PreviewVisibility.FilterPreviewFeatures(settingsData.App.EnabledPreviewFeatures);
return settingsData; return settingsData;
} }
this.logger.LogError("Failed to read the version of the settings file."); settingsData = await this.TryDeserializeCurrentSettings(settingsPath, "settings file");
return new(); if(settingsData is null)
{
this.settingsWriteBlocked = true;
return null;
}
this.PrepareLoadedSettings(settingsData);
await this.StoreCurrentVersionBackup(settingsData);
return settingsData;
}
private async Task<Version> 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 Version.UNKNOWN;
}
if(versionElement.ValueKind is JsonValueKind.String && versionElement.GetString() is { } versionText && Enum.TryParse(versionText, out Version stringVersion))
return stringVersion;
if(versionElement.ValueKind is JsonValueKind.Number && versionElement.TryGetInt32(out var numericVersion) && Enum.IsDefined(typeof(Version), numericVersion))
return (Version)numericVersion;
}
catch(Exception e)
{
this.logger.LogError(e, $"Failed to read the version of the settings file '{settingsPath}'.");
}
return 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 != CURRENT_SETTINGS_VERSION)
{
this.logger.LogWarning($"The settings backup file '{backupSettingsPath}' uses version '{backupVersion}' instead of '{CURRENT_SETTINGS_VERSION}'.");
return null;
}
return await this.TryDeserializeCurrentSettings(backupSettingsPath, "settings backup file");
}
private async Task<Data?> 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 null;
}
if(settingsData.Version != CURRENT_SETTINGS_VERSION)
{
this.logger.LogError($"The {sourceDescription} '{settingsPath}' uses version '{settingsData.Version}' instead of '{CURRENT_SETTINGS_VERSION}'.");
return null;
}
return settingsData;
}
catch(Exception e)
{
this.logger.LogError(e, $"Failed to parse the {sourceDescription} '{settingsPath}'.");
return null;
}
}
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> /// <summary>
@ -145,19 +243,48 @@ public sealed class SettingsManager
return; return;
} }
if(this.settingsWriteBlocked)
{
this.logger.LogWarning("Cannot store settings, because the loaded settings file uses an unknown or unreadable version.");
return;
}
var settingsPath = Path.Combine(ConfigDirectory!, SETTINGS_FILENAME); 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)) if(!Directory.Exists(ConfigDirectory))
{ {
this.logger.LogInformation("Creating the configuration directory."); this.logger.LogInformation("Creating the configuration directory.");
Directory.CreateDirectory(ConfigDirectory!); Directory.CreateDirectory(ConfigDirectory!);
} }
var settingsJson = JsonSerializer.Serialize(this.ConfigurationData, JSON_OPTIONS); var settingsJson = JsonSerializer.Serialize(settingsData, JSON_OPTIONS);
var tempFile = Path.GetTempFileName(); var tempFile = Path.GetTempFileName();
await File.WriteAllTextAsync(tempFile, settingsJson); await File.WriteAllTextAsync(tempFile, settingsJson);
File.Move(tempFile, settingsPath, true); 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"; public void InjectSpellchecking(Dictionary<string, object?> attributes) => attributes["spellcheck"] = this.ConfigurationData.App.EnableSpellchecking ? "true" : "false";

View File

@ -7,4 +7,5 @@
- Changed provider confidence settings to appear in their own settings panel, because they apply to LLM, embedding, and transcription providers. - Changed provider confidence settings to appear in their own settings panel, because they apply to LLM, embedding, and transcription providers.
- Fixed chat provider, profile, and template selections not updating live after configuration plugins were changed. - Fixed chat provider, profile, and template selections not updating live after configuration plugins were changed.
- Fixed organization-managed chat templates not showing the correct icon in the chat template selection menu. - Fixed organization-managed chat templates not showing the correct icon in the chat template selection menu.
- Fixed personal settings sometimes being lost after a settings-format upgrade when an older app version was started again. AI Studio now keeps versioned settings backups and restores them automatically when needed.
- Fixed self-hosted provider API keys sometimes being stored under a localized name. AI Studio now uses a stable key name, keeps correct entries working, and automatically migrates known localized entries for LLM, transcription, and embedding providers. Organizations using configuration plugins do not need to change their plugins; affected users who still see an invalid API key warning should open the provider, transcription, or embedding settings and update the API key once. Thanks, Tim & Eric, for the detailed bug report and testing help. - Fixed self-hosted provider API keys sometimes being stored under a localized name. AI Studio now uses a stable key name, keeps correct entries working, and automatically migrates known localized entries for LLM, transcription, and embedding providers. Organizations using configuration plugins do not need to change their plugins; affected users who still see an invalid API key warning should open the provider, transcription, or embedding settings and update the API key once. Thanks, Tim & Eric, for the detailed bug report and testing help.