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);