512 lines
19 KiB
C#
512 lines
19 KiB
C#
using ClawdDotNet.Core.Backup;
|
|
using ClawdDotNet.Models;
|
|
using ClawdDotNet.Services;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace ClawdDotNet.UI;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Aktualisiert die Liste von außen — etwa nachdem die Automatik gesichert hat.
|
|
/// </summary>
|
|
public void RefreshFromOutside()
|
|
{
|
|
if (!IsHandleCreated || IsDisposed)
|
|
return;
|
|
|
|
RefreshBackupList();
|
|
}
|
|
|
|
/// <summary>Wird vom Hauptfenster nach dem Laden der Instanz aufgerufen.</summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>Liest die Eingaben. Gibt null zurück, wenn etwas fehlt.</summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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
|
|
// ═══════════════════════════════════════════════════
|
|
|
|
/// <summary>Entfernt die ältesten Sicherungen dieser Instanz über der Aufbewahrungszahl.</summary>
|
|
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"
|
|
};
|
|
}
|