using ClawdDotNet.Core.Backup; using ClawdDotNet.Models; using ClawdDotNet.Services; using Microsoft.Extensions.Logging; namespace ClawdDotNet.UI; /// /// Bedienoberfläche für Sicherung und Wiederherstellung einer Instanz. /// /// Zwei Entscheidungen prägen das Verhalten: /// /// Die Passphrase wird nirgends gespeichert. Läge sie neben den Sicherungen, wäre die /// Verschlüsselung wirkungslos. Deshalb muss sie bei jeder geschützten Sicherung neu /// eingegeben werden, und die Automatik sichert ohne Zugangsdaten. /// /// Wiederhergestellt wird standardmäßig in einen NEUEN Ordner, nicht über die laufende /// Instanz. Diese hält Chatverläufe im Speicher und die Datenbank geöffnet — ein /// Überschreiben im Betrieb würde teils sofort wieder überschrieben und teils /// scheitern. /// public sealed partial class BackupPanel : UserControl { private readonly BackupService _service = new(); private SettingsManager? _settingsManager; private ILogger? _logger; private string _instanceDir = ""; private string _instanceName = ""; private bool _loading; public BackupPanel() { InitializeComponent(); WireEvents(); } /// /// Aktualisiert die Liste von außen — etwa nachdem die Automatik gesichert hat. /// public void RefreshFromOutside() { if (!IsHandleCreated || IsDisposed) return; RefreshBackupList(); } /// Wird vom Hauptfenster nach dem Laden der Instanz aufgerufen. public void Initialize(string instanceDir, string instanceName, SettingsManager settingsManager, ILogger logger) { _instanceDir = instanceDir; _instanceName = instanceName; _settingsManager = settingsManager; _logger = logger; LoadSettings(); UpdateEnabledState(); RefreshBackupList(); } private void WireEvents() { btn_browseFolder.Click += OnBrowseFolder; btn_createBackup.Click += async (_, _) => await CreateBackupAsync(); btn_refresh.Click += (_, _) => RefreshBackupList(); btn_restore.Click += async (_, _) => await RestoreSelectedAsync(); btn_showInFolder.Click += (_, _) => ShowSelectedInFolder(); btn_delete.Click += (_, _) => DeleteSelected(); rb_secretsExclude.CheckedChanged += (_, _) => UpdateEnabledState(); rb_secretsPassphrase.CheckedChanged += (_, _) => UpdateEnabledState(); chk_autoEnabled.CheckedChanged += (_, _) => { UpdateEnabledState(); SaveSettings(); }; dtp_autoTime.ValueChanged += (_, _) => SaveSettings(); num_keepCount.ValueChanged += (_, _) => SaveSettings(); txt_targetFolder.Leave += (_, _) => SaveSettings(); lv_backups.SelectedIndexChanged += (_, _) => UpdateEnabledState(); lv_backups.DoubleClick += async (_, _) => await RestoreSelectedAsync(); } // ═══════════════════════════════════════════════════ // Einstellungen // ═══════════════════════════════════════════════════ private void LoadSettings() { if (_settingsManager is null) return; _loading = true; try { var settings = _settingsManager.AppSettings; txt_targetFolder.Text = Path.GetFullPath(settings.BackupDirectory); chk_autoEnabled.Checked = settings.AutoBackupEnabled; num_keepCount.Value = Math.Clamp(settings.BackupKeepCount, 0, 999); dtp_autoTime.Value = TimeSpan.TryParse(settings.AutoBackupTime, out var time) ? DateTime.Today.Add(time) : DateTime.Today.AddHours(3); } finally { _loading = false; } } private void SaveSettings() { if (_loading || _settingsManager is null) return; var settings = _settingsManager.AppSettings; settings.BackupDirectory = txt_targetFolder.Text.Trim(); settings.AutoBackupEnabled = chk_autoEnabled.Checked; settings.AutoBackupTime = dtp_autoTime.Value.ToString("HH:mm"); settings.BackupKeepCount = (int)num_keepCount.Value; _settingsManager.Save(); } private void UpdateEnabledState() { var withPassphrase = rb_secretsPassphrase.Checked; label_passphrase.Enabled = withPassphrase; txt_passphrase.Enabled = withPassphrase; label_passphraseRepeat.Enabled = withPassphrase; txt_passphraseRepeat.Enabled = withPassphrase; dtp_autoTime.Enabled = chk_autoEnabled.Checked; num_keepCount.Enabled = chk_autoEnabled.Checked; var hasSelection = lv_backups.SelectedItems.Count > 0; btn_restore.Enabled = hasSelection; btn_showInFolder.Enabled = hasSelection; btn_delete.Enabled = hasSelection; } // ═══════════════════════════════════════════════════ // Sichern // ═══════════════════════════════════════════════════ private void OnBrowseFolder(object? sender, EventArgs e) { using var dialog = new FolderBrowserDialog { Description = "Ordner für Sicherungen wählen", UseDescriptionForTitle = true, SelectedPath = Directory.Exists(txt_targetFolder.Text) ? txt_targetFolder.Text : Environment.CurrentDirectory }; if (dialog.ShowDialog(this) == DialogResult.OK) { txt_targetFolder.Text = dialog.SelectedPath; SaveSettings(); RefreshBackupList(); } } private async Task CreateBackupAsync() { if (string.IsNullOrWhiteSpace(_instanceDir)) return; var options = BuildOptions(); if (options is null) return; // Eingabefehler, bereits gemeldet var folder = txt_targetFolder.Text.Trim(); var file = Path.Combine(folder, BuildFileName()); btn_createBackup.Enabled = false; SetStatus("Sicherung läuft…", SystemColors.GrayText); try { var result = await Task.Run(() => _service.CreateAsync(_instanceDir, file, options)); SetStatus($"Fertig: {Path.GetFileName(result.ZipPath)} ({FormatSize(result.SizeBytes)})", Color.FromArgb(0, 128, 0)); _logger?.LogInformation("Sicherung erstellt: {Path} ({Size} Bytes)", result.ZipPath, result.SizeBytes); txt_passphrase.Clear(); txt_passphraseRepeat.Clear(); ApplyRotation(folder); RefreshBackupList(); } catch (Exception ex) { SetStatus("Fehlgeschlagen", Color.Firebrick); _logger?.LogError(ex, "Sicherung fehlgeschlagen"); MessageBox.Show(this, $"Die Sicherung ist fehlgeschlagen:\n\n{ex.Message}", "Sicherung", MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { btn_createBackup.Enabled = true; } } /// Liest die Eingaben. Gibt null zurück, wenn etwas fehlt. private BackupOptions? BuildOptions() { if (string.IsNullOrWhiteSpace(txt_targetFolder.Text)) { MessageBox.Show(this, "Bitte einen Zielordner angeben.", "Sicherung", MessageBoxButtons.OK, MessageBoxIcon.Information); return null; } if (!rb_secretsPassphrase.Checked) { return new BackupOptions { Secrets = SecretMode.Exclude, IncludeLogs = chk_includeLogs.Checked, IncludeChatHistory = chk_includeChatHistory.Checked }; } var passphrase = txt_passphrase.Text; if (string.IsNullOrEmpty(passphrase)) { MessageBox.Show(this, "Bitte eine Passphrase eingeben.", "Sicherung", MessageBoxButtons.OK, MessageBoxIcon.Information); txt_passphrase.Focus(); return null; } if (passphrase != txt_passphraseRepeat.Text) { MessageBox.Show(this, "Die beiden Passphrasen stimmen nicht überein.", "Sicherung", MessageBoxButtons.OK, MessageBoxIcon.Warning); txt_passphraseRepeat.Focus(); txt_passphraseRepeat.SelectAll(); return null; } // Eine vergessene Passphrase bedeutet unwiederbringlich verlorene Zugangsdaten. var answer = MessageBox.Show(this, "Ohne diese Passphrase lassen sich die Zugangsdaten in der Sicherung " + "nicht wiederherstellen. Es gibt keinen Ersatzweg.\n\n" + "Passphrase sicher notiert?", "Passphrase bestätigen", MessageBoxButtons.YesNo, MessageBoxIcon.Warning); if (answer != DialogResult.Yes) return null; return new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = passphrase, IncludeLogs = chk_includeLogs.Checked, IncludeChatHistory = chk_includeChatHistory.Checked }; } private string BuildFileName() { var safeName = string.Concat( (_instanceName.Length > 0 ? _instanceName : "Instanz") .Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c)); return $"backup_{safeName}_{DateTime.Now:yyyy-MM-dd_HHmm}.zip"; } // ═══════════════════════════════════════════════════ // Liste // ═══════════════════════════════════════════════════ private void RefreshBackupList() { lv_backups.BeginUpdate(); lv_backups.Items.Clear(); try { var folder = txt_targetFolder.Text.Trim(); if (!Directory.Exists(folder)) return; var files = new DirectoryInfo(folder) .GetFiles("*.zip") .OrderByDescending(f => f.LastWriteTime); foreach (var file in files) { var item = new ListViewItem(file.Name) { Tag = file.FullName }; item.SubItems.Add(file.LastWriteTime.ToString("g")); // Das Manifest verrät Instanz und ob Zugangsdaten enthalten sind. try { var manifest = _service.InspectAsync(file.FullName).GetAwaiter().GetResult(); item.SubItems.Add(manifest.InstanceName); item.SubItems.Add(FormatSize(file.Length)); item.SubItems.Add(manifest.HasSecrets ? $"ja ({manifest.SecretCount}, Passphrase)" : "nein"); } catch (Exception) { // Fremde oder beschädigte Archive trotzdem anzeigen — der Benutzer // soll sehen, was im Ordner liegt. item.SubItems.Add("—"); item.SubItems.Add(FormatSize(file.Length)); item.SubItems.Add("unlesbar"); item.ForeColor = SystemColors.GrayText; } lv_backups.Items.Add(item); } } finally { lv_backups.EndUpdate(); UpdateEnabledState(); } } private string? SelectedBackupPath => lv_backups.SelectedItems.Count > 0 ? lv_backups.SelectedItems[0].Tag as string : null; private void ShowSelectedInFolder() { if (SelectedBackupPath is not { } path) return; ClawdDotNet.Core.Storage.SystemShell.RevealFile(path); } private void DeleteSelected() { if (SelectedBackupPath is not { } path) return; var answer = MessageBox.Show(this, $"Sicherung endgültig löschen?\n\n{Path.GetFileName(path)}", "Löschen", MessageBoxButtons.YesNo, MessageBoxIcon.Warning); if (answer != DialogResult.Yes) return; try { File.Delete(path); RefreshBackupList(); } catch (Exception ex) { MessageBox.Show(this, $"Konnte nicht gelöscht werden:\n\n{ex.Message}", "Löschen", MessageBoxButtons.OK, MessageBoxIcon.Error); } } // ═══════════════════════════════════════════════════ // Wiederherstellen // ═══════════════════════════════════════════════════ private async Task RestoreSelectedAsync() { if (SelectedBackupPath is not { } zipPath) return; BackupManifest manifest; try { manifest = await _service.InspectAsync(zipPath); } catch (Exception ex) { MessageBox.Show(this, $"Die Sicherung ist nicht lesbar:\n\n{ex.Message}", "Wiederherstellen", MessageBoxButtons.OK, MessageBoxIcon.Error); return; } using var dialog = new frm_RestoreBackup(manifest, SuggestRestoreTarget(manifest)); if (dialog.ShowDialog(this) != DialogResult.OK) return; var options = new RestoreOptions { Passphrase = dialog.Passphrase, Overwrite = dialog.Overwrite }; try { // Erst ohne zu schreiben prüfen — Prüfsummen und Konflikte. var preview = await Task.Run(() => _service.RestoreAsync(zipPath, dialog.TargetDirectory, options with { DryRun = true, Overwrite = true })); if (preview.WouldOverwrite.Count > 0 && !dialog.Overwrite) { var answer = MessageBox.Show(this, $"Im Zielordner liegen bereits {preview.WouldOverwrite.Count} der enthaltenen " + "Dateien.\n\nVorhandene Dateien überschreiben?", "Wiederherstellen", MessageBoxButtons.YesNoCancel, MessageBoxIcon.Warning); if (answer == DialogResult.Cancel) return; options = options with { Overwrite = answer == DialogResult.Yes }; } var result = await Task.Run(() => _service.RestoreAsync(zipPath, dialog.TargetDirectory, options)); _logger?.LogInformation("Wiederherstellung nach {Target}: {Written} Dateien, {Skipped} übersprungen", dialog.TargetDirectory, result.Written.Count, result.Skipped.Count); var summary = $"{result.Written.Count} Datei(en) wiederhergestellt nach:\n{dialog.TargetDirectory}"; if (result.Skipped.Count > 0) summary += $"\n\n{result.Skipped.Count} vorhandene Datei(en) blieben unangetastet."; if (!manifest.HasSecrets && manifest.SecretCount > 0) summary += "\n\nHinweis: Diese Sicherung enthält keine Zugangsdaten. " + "API-Schlüssel und Passwörter müssen neu eingetragen werden."; summary += "\n\nDie wiederhergestellte Instanz wird erst nach einem Neustart " + "der Anwendung sichtbar."; MessageBox.Show(this, summary, "Wiederherstellen", MessageBoxButtons.OK, MessageBoxIcon.Information); } catch (Exception ex) { _logger?.LogError(ex, "Wiederherstellung fehlgeschlagen"); MessageBox.Show(this, $"Die Wiederherstellung ist fehlgeschlagen:\n\n{ex.Message}", "Wiederherstellen", MessageBoxButtons.OK, MessageBoxIcon.Error); } } /// /// Schlägt einen neuen Ordner neben der Instanz vor, nicht die laufende Instanz /// selbst — diese hält Dateien geöffnet und würde Teile sofort überschreiben. /// private string SuggestRestoreTarget(BackupManifest manifest) { var parent = Directory.GetParent(_instanceDir)?.FullName ?? _instanceDir; var name = string.IsNullOrWhiteSpace(manifest.InstanceName) ? "Instanz" : manifest.InstanceName; var safeName = string.Concat( name.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c)); return Path.Combine(parent, $"Instance-{safeName}_wiederhergestellt_{DateTime.Now:yyyyMMdd_HHmm}"); } // ═══════════════════════════════════════════════════ // Rotation // ═══════════════════════════════════════════════════ /// Entfernt die ältesten Sicherungen dieser Instanz über der Aufbewahrungszahl. private void ApplyRotation(string folder) { var keep = (int)num_keepCount.Value; if (keep <= 0 || !Directory.Exists(folder)) return; try { var prefix = $"backup_{_instanceName}_"; var obsolete = new DirectoryInfo(folder) .GetFiles("*.zip") .Where(f => f.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) .OrderByDescending(f => f.LastWriteTime) .Skip(keep) .ToList(); foreach (var file in obsolete) { file.Delete(); _logger?.LogInformation("Alte Sicherung entfernt: {Name}", file.Name); } } catch (Exception ex) { // Aufräumen darf eine erfolgreiche Sicherung nicht nachträglich entwerten. _logger?.LogWarning(ex, "Rotation der Sicherungen fehlgeschlagen"); } } // ═══════════════════════════════════════════════════ // Kleinkram // ═══════════════════════════════════════════════════ private void SetStatus(string text, Color color) { label_createStatus.Text = text; label_createStatus.ForeColor = color; } private static string FormatSize(long bytes) => bytes switch { < 1024 => $"{bytes} B", < 1024 * 1024 => $"{bytes / 1024.0:F1} KB", < 1024L * 1024 * 1024 => $"{bytes / 1024.0 / 1024:F1} MB", _ => $"{bytes / 1024.0 / 1024 / 1024:F2} GB" }; }