using ClawdDotNet.Core.Backup; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Storage; using Microsoft.Extensions.Logging; namespace ClawdDotNet.App.Services; /// /// Erstellt einmal täglich zur eingestellten Uhrzeit eine Sicherung. /// /// Bewusst ohne Zugangsdaten: Die Passphrase müsste dafür gespeichert werden, und /// neben den Sicherungen abgelegt wäre sie wirkungslos. Wer die Zugangsdaten /// mitsichern will, macht das von Hand. /// /// Der Zeitpunkt wird bei jedem Durchlauf neu gegen die Instanzkonfiguration geprüft, /// damit eine Änderung ohne Neustart greift. /// /// Schalter, Uhrzeit, Zielordner und Aufbewahrung gehören der Instanz /// (#528, Stufe 1). Alle vier standen in den Anwendungseinstellungen und galten damit /// für jede Instanz: Wer die tägliche Sicherung für eine einschaltete, schaltete sie für /// alle ein, und alle Archive landeten in einem Ordner. Dort räumt /// nach dem Präfix backup_<Instanzname>_ auf — /// bei zwei Namen, die ineinander liegen (Test und Test_2), zählt die eine /// Instanz die Archive der anderen mit und wirft die überzähligen weg. /// /// Takt. Früher ein System.Windows.Forms.Timer — der braucht eine /// Nachrichtenschleife und damit ein Fenster. Jetzt über /// einen : läuft ohne Oberfläche, driftet nicht, und Tests /// können die Zeit steuern statt zu warten. /// public sealed class BackupScheduler : IAsyncDisposable { private readonly string _instanceDir; private readonly InstanceConfig _instance; private readonly ILogger _logger; private readonly TimeProvider _clock; private readonly TimeSpan _tick; private readonly BackupService _service = new(); private readonly CancellationTokenSource _cts = new(); private Task? _loop; /// Verhindert mehrere Sicherungen am selben Tag. private DateTime? _lastRun; public event Action? OnBackupCreated; /// /// Das Instanzverzeichnis. Zugleich der Bezugspunkt, gegen den ein relativ /// eingetragener Zielordner zählt. /// /// /// Die laufende Instanzkonfiguration — als Verweis, nicht als Abzug: Wer Zielordner /// oder Uhrzeit in der Oberfläche ändert, ändert dieses Objekt, und der nächste /// Durchlauf sieht es. /// public BackupScheduler( string instanceDir, InstanceConfig instance, ILogger logger, TimeProvider? clock = null, TimeSpan? tick = null) { _instanceDir = instanceDir; _instance = instance; _logger = logger; _clock = clock ?? TimeProvider.System; // Minütlich prüfen reicht — die Uhrzeit ist auf Minuten genau eingestellt. _tick = tick ?? TimeSpan.FromMinutes(1); } public void Start() => _loop ??= RunLoopAsync(_cts.Token); private async Task RunLoopAsync(CancellationToken ct) { using var timer = new PeriodicTimer(_tick, _clock); while (await timer.WaitForNextTickAsync(ct)) { try { await TickAsync(); } catch (Exception ex) when (ex is not OperationCanceledException) { // Ein Fehlschlag darf die Anwendung nicht stören. _logger.LogError(ex, "Automatische Sicherung fehlgeschlagen"); } } } /// Ein Durchlauf. Öffentlich, damit Tests ihn deterministisch auslösen können. public async Task TickAsync() { if (!_instance.AutoBackupEnabled) return; if (!TimeSpan.TryParse(_instance.AutoBackupTime, out var scheduled)) return; var now = _clock.GetLocalNow().DateTime; // Fällig, sobald die Uhrzeit erreicht ist und heute noch nichts lief. if (now.TimeOfDay < scheduled) return; if (_lastRun?.Date == now.Date) return; // Vor dem Lauf setzen: Scheitert er, wird nicht jede Minute erneut versucht, // sondern morgen wieder. Ein Retry-Sturm über Nacht hilft niemandem. _lastRun = now; await RunAsync( AppHost.ResolveBackupDirectory(_instance, _instanceDir), _instance.BackupKeepCount, now); } private async Task RunAsync(string folder, int keepCount, DateTime now) { // PortableFileName statt Path.GetInvalidFileNameChars: Unter Linux liefert das // nur '\0' und '/', ein Instanzname mit ':' ergäbe ein Archiv, das sich unter // Windows nicht mehr anlegen lässt. var safeName = PortableFileName.Sanitize(_instance.InstanceName, "Instanz"); // Der Ordner liegt jetzt in der Regel unterhalb der Instanz und muss beim ersten // Lauf erst entstehen. Directory.CreateDirectory(folder); var file = Path.Combine(folder, $"backup_{safeName}_{now:yyyy-MM-dd_HHmm}.zip"); var result = await _service.CreateAsync(_instanceDir, file, new BackupOptions { Secrets = SecretMode.Exclude, IncludeChatHistory = true, IncludeLogs = false }); _logger.LogInformation("Automatische Sicherung erstellt: {Path} ({Size} Bytes)", result.ZipPath, result.SizeBytes); ApplyRotation(folder, safeName, keepCount); OnBackupCreated?.Invoke(result.ZipPath); } /// Behält die neuesten Sicherungen dieser Instanz und entfernt den Rest. private void ApplyRotation(string folder, string safeName, int keepCount) { if (keepCount <= 0 || !Directory.Exists(folder)) return; try { var prefix = $"backup_{safeName}_"; var obsolete = new DirectoryInfo(folder) .GetFiles("*.zip") .Where(f => f.Name.StartsWith(prefix, PathBoundary.Comparison)) .OrderByDescending(f => f.LastWriteTime) .Skip(keepCount) .ToList(); foreach (var file in obsolete) { file.Delete(); _logger.LogInformation("Alte Sicherung entfernt: {Name}", file.Name); } } catch (Exception ex) { _logger.LogWarning(ex, "Rotation der Sicherungen fehlgeschlagen"); } } public async ValueTask DisposeAsync() { await _cts.CancelAsync(); if (_loop is not null) { try { await _loop; } catch (OperationCanceledException) { /* erwartet */ } } _cts.Dispose(); } }