using System.Text.Json; using ClawdDotNet.Core.Security; using ClawdDotNet.Core.Storage; namespace ClawdDotNet.App.Settings; /// /// Lädt und speichert die anwendungsweiten Einstellungen. /// /// Ablageort. Bis zur Linux-Portierung lag Settings.json neben der /// Programmdatei. Unter Windows in einem Benutzerverzeichnis ging das; unter Linux liegt /// die Anwendung in /opt oder /usr/lib und ist für den Dienstbenutzer nicht /// beschreibbar. Jetzt entscheidet — XDG unter /// Linux, %APPDATA% unter Windows, per CLAWD_CONFIG_DIR überschreibbar. /// /// Kein Migrationspfad. Bewusst: Zum Zeitpunkt der Umstellung lief noch /// keine Installation produktiv. Eine bestehende Settings.json neben der /// Programmdatei wird also nicht übernommen — der Ort wechselt einmal sauber, /// statt eine Ausweichlogik zu hinterlassen, die niemand mehr anfasst. /// /// Keine Meldungsfenster. Diese Schicht kennt keine Oberfläche. Ein /// Speicherfehler kommt als heraus; ob daraus /// ein Dialog, ein Logeintrag oder ein Rückgabewert wird, entscheidet der Aufrufer. /// public sealed class SettingsManager { private const string SettingsFileName = "Settings.json"; private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, PropertyNameCaseInsensitive = true }; /// /// Schlüssel, die es in einmal gab und die dorthin nicht /// zurückkommen. Steht eine alte Datei noch voll davon, sagt der Start das einmal an /// (siehe ). /// private static readonly string[] RetiredKeys = [ // Alle fünf sind mit #528 (Stufe 1) in die Instanz gewandert: Sie galten // anwendungsweit und waren damit fuer alle Instanzen derselbe Ordner // beziehungsweise derselbe Zeitplan. "logDirectory", "backupDirectory", "backupKeepCount", "autoBackupEnabled", "autoBackupTime" ]; private readonly string _settingsPath; public AppSettings AppSettings { get; private set; } = new(); /// /// Welche der aufgegebenen Schlüssel beim letzten noch in der /// Datei standen. /// /// System.Text.Json überliest unbekannte Schlüssel wortlos, und /// schreibt sie danach nicht wieder — der Wert verschwindet also /// still. Wer ein eigenes Log- oder Sicherungsverzeichnis eingetragen hatte, soll /// nicht raten müssen, warum dort nichts mehr ankommt; deshalb wird das hier /// festgehalten und beim Start einmal protokolliert. /// /// Bewusst nur eine Ansage und keine Übernahme: Ein anwendungsweiter Pfad, in /// alle Instanzen kopiert, wäre wieder derselbe Ordner für alle — also genau der /// Zustand, den Stufe 1 beseitigt. /// public IReadOnlyList AbandonedKeys { get; private set; } = []; /// Der Ort der Einstellungsdatei — für Meldungen und Diagnose. public string SettingsPath => _settingsPath; public SettingsManager(string? basePath = null) { var dir = basePath ?? AppPaths.ConfigDirectory; _settingsPath = Path.Combine(dir, SettingsFileName); } public void Load() { AbandonedKeys = []; if (!File.Exists(_settingsPath)) { AppSettings = new AppSettings(); Save(); // Vorgaben festschreiben, damit der Ort sichtbar wird return; } try { var json = AtomicFile.ReadAllText(_settingsPath); AppSettings = JsonSerializer.Deserialize(json, JsonOptions) ?? new AppSettings(); AbandonedKeys = FindRetiredKeys(json); } catch (Exception ex) when (ex is JsonException or IOException) { // Eine unlesbare Datei darf den Start nicht verhindern — mit Vorgaben // weiterzumachen ist besser, als gar nicht zu starten. AppSettings = new AppSettings(); return; } // Geheimnisse liegen in der Datei verschlüsselt und werden zur Laufzeit im // Klartext gehalten. AppSettings.LicenseKey = TryUnprotect(AppSettings.LicenseKey); AppSettings.DeploymentcenterToken = TryUnprotect(AppSettings.DeploymentcenterToken); } /// /// Welche aufgegebenen Schlüssel die Datei noch enthält. Ein zweiter Lesevorgang /// über denselben Text — die Deserialisierung kann es nicht sagen, weil sie /// Unbekanntes überliest. /// private static IReadOnlyList FindRetiredKeys(string json) { try { using var doc = JsonDocument.Parse(json, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true }); if (doc.RootElement.ValueKind != JsonValueKind.Object) return []; return RetiredKeys .Where(key => doc.RootElement.TryGetProperty(key, out _)) .ToList(); } catch (JsonException) { // Der Deserialisierer kam durch, dieser Weg nicht — dann ist die Ansage // verzichtbar, der Start nicht. return []; } } /// /// Entschlüsselt einen Wert; bei Nicht-Lesbarkeit (anderer Benutzer, anderer Rechner, /// Umzug von Windows) leer, damit der Nutzer ihn neu eintragen kann statt einen /// unbrauchbaren Wert an eine Gegenstelle zu schicken. /// private static string TryUnprotect(string value) { try { return SecretProtector.Unprotect(value) ?? ""; } catch (SecretProtectionException) { return ""; } } /// Wenn die Datei nicht geschrieben werden kann. public void Save() { // Nur zum Schreiben verschlüsseln; die laufende Instanz braucht Klartext. var plainLicenseKey = AppSettings.LicenseKey; var plainDeploymentcenterToken = AppSettings.DeploymentcenterToken; try { AppSettings.LicenseKey = SecretProtector.Protect(plainLicenseKey) ?? ""; AppSettings.DeploymentcenterToken = SecretProtector.Protect(plainDeploymentcenterToken) ?? ""; AppPaths.EnsureDirectory(Path.GetDirectoryName(_settingsPath)!); var json = JsonSerializer.Serialize(AppSettings, JsonOptions); AtomicFile.WriteAllText(_settingsPath, json); AppPaths.RestrictToOwner(_settingsPath); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecretProtectionException) { throw new SettingsPersistenceException( $"Die Einstellungen konnten nicht nach {_settingsPath} geschrieben werden: {ex.Message}", ex); } finally { AppSettings.LicenseKey = plainLicenseKey; AppSettings.DeploymentcenterToken = plainDeploymentcenterToken; } } } public sealed class SettingsPersistenceException(string message, Exception inner) : Exception(message, inner);