Backup-Oberflaeche im Settings-Tab

Der BackupService bekommt eine Bedienoberflaeche im neuen Unterreiter "Backup".

Aufbau als eigenes UserControl (UI/BackupPanel) statt direkt in frm_main.Designer.cs.
Diese Datei ist ueber 1250 Zeilen gewachsene Handarbeit; ein Fehler beim Bearbeiten
waere teuer. Eingehaengt wird das Panel mit vier Zeilen.

Drei Bereiche: Sicherung erstellen (Zielordner, Umgang mit Zugangsdaten, Umfang),
automatische Sicherung (Uhrzeit, Aufbewahrungszahl) und die Liste vorhandener
Sicherungen mit Wiederherstellen, Anzeigen und Loeschen. Die Liste liest das
Manifest jeder Datei und zeigt Instanz und ob Zugangsdaten enthalten sind;
unlesbare oder fremde Archive werden ausgegraut mitgezeigt, statt sie zu
verschweigen.

Zwei Entscheidungen praegen das Verhalten.

Die Passphrase wird nirgends gespeichert. Laege sie neben den Sicherungen, waere
die Verschluesselung wirkungslos. Sie muss deshalb bei jeder geschuetzten
Sicherung neu eingegeben und wiederholt werden, und vor dem Erstellen wird
ausdruecklich bestaetigt, dass sie notiert ist — ohne sie sind die Zugangsdaten
unwiederbringlich. Die Automatik sichert aus demselben Grund ohne Zugangsdaten.

Wiederhergestellt wird standardmaessig in einen NEUEN Ordner neben der Instanz,
nicht ueber die laufende. Diese haelt Chatverlaeufe im Speicher und die Datenbank
geoeffnet — ein Ueberschreiben im Betrieb wuerde teils sofort wieder ueberschrieben
und teils scheitern. Der Dialog benennt das.

Vor dem Schreiben laeuft immer ein Probelauf: Er prueft die Pruefsummen und zaehlt
die Konflikte, sodass ein beschaedigtes Archiv auffaellt und Ueberschreiben
bestaetigt wird, bevor etwas passiert.

BackupScheduler sichert taeglich zur eingestellten Uhrzeit und wendet die Rotation
an. Der Zeitpunkt wird bei jedem Durchlauf neu aus den Einstellungen gelesen,
damit eine Aenderung ohne Neustart greift.

Die Oberflaeche ist noch nicht optisch geprueft — die Dev-Instanz haelt einen
echten API-Key und wuerde beim Start Agenten ausfuehren.

385 Tests gruen (237 Core, 148 Tools).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-30 10:37:16 +02:00
co-authored by Claude Opus 4.8
parent b55512e734
commit b51cc29667
7 changed files with 1399 additions and 365 deletions
+428
View File
@@ -0,0 +1,428 @@
namespace ClawdDotNet.UI;
partial class BackupPanel
{
private System.ComponentModel.IContainer components = null;
protected override void Dispose(bool disposing)
{
if (disposing && components is not null)
components.Dispose();
base.Dispose(disposing);
}
private void InitializeComponent()
{
layoutRoot = new TableLayoutPanel();
grp_create = new GroupBox();
layoutCreate = new TableLayoutPanel();
label_target = new Label();
txt_targetFolder = new TextBox();
btn_browseFolder = new Button();
label_secrets = new Label();
panel_secretMode = new FlowLayoutPanel();
rb_secretsExclude = new RadioButton();
rb_secretsPassphrase = new RadioButton();
label_passphrase = new Label();
txt_passphrase = new TextBox();
label_passphraseRepeat = new Label();
txt_passphraseRepeat = new TextBox();
label_scope = new Label();
panel_scope = new FlowLayoutPanel();
chk_includeChatHistory = new CheckBox();
chk_includeLogs = new CheckBox();
panel_createActions = new FlowLayoutPanel();
btn_createBackup = new Button();
label_createStatus = new Label();
grp_auto = new GroupBox();
layoutAuto = new TableLayoutPanel();
chk_autoEnabled = new CheckBox();
dtp_autoTime = new DateTimePicker();
label_keepCount = new Label();
num_keepCount = new NumericUpDown();
label_autoHint = new Label();
grp_existing = new GroupBox();
layoutExisting = new TableLayoutPanel();
lv_backups = new ListView();
col_file = new ColumnHeader();
col_created = new ColumnHeader();
col_instance = new ColumnHeader();
col_size = new ColumnHeader();
col_secrets = new ColumnHeader();
panel_existingActions = new FlowLayoutPanel();
btn_refresh = new Button();
btn_restore = new Button();
btn_showInFolder = new Button();
btn_delete = new Button();
layoutRoot.SuspendLayout();
grp_create.SuspendLayout();
layoutCreate.SuspendLayout();
panel_secretMode.SuspendLayout();
panel_scope.SuspendLayout();
panel_createActions.SuspendLayout();
grp_auto.SuspendLayout();
layoutAuto.SuspendLayout();
((System.ComponentModel.ISupportInitialize)num_keepCount).BeginInit();
grp_existing.SuspendLayout();
layoutExisting.SuspendLayout();
panel_existingActions.SuspendLayout();
SuspendLayout();
// ─── Wurzel ───
layoutRoot.ColumnCount = 1;
layoutRoot.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
layoutRoot.Dock = DockStyle.Fill;
layoutRoot.Name = "layoutRoot";
layoutRoot.Padding = new Padding(12);
layoutRoot.RowCount = 3;
layoutRoot.RowStyles.Add(new RowStyle(SizeType.AutoSize));
layoutRoot.RowStyles.Add(new RowStyle(SizeType.AutoSize));
layoutRoot.RowStyles.Add(new RowStyle(SizeType.Percent, 100F));
layoutRoot.Controls.Add(grp_create, 0, 0);
layoutRoot.Controls.Add(grp_auto, 0, 1);
layoutRoot.Controls.Add(grp_existing, 0, 2);
// ═══ Sicherung erstellen ═══
grp_create.AutoSize = true;
grp_create.AutoSizeMode = AutoSizeMode.GrowAndShrink;
grp_create.Controls.Add(layoutCreate);
grp_create.Dock = DockStyle.Fill;
grp_create.Name = "grp_create";
grp_create.Padding = new Padding(10);
grp_create.TabIndex = 0;
grp_create.TabStop = false;
grp_create.Text = "Sicherung erstellen";
layoutCreate.AutoSize = true;
layoutCreate.AutoSizeMode = AutoSizeMode.GrowAndShrink;
layoutCreate.ColumnCount = 3;
layoutCreate.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
layoutCreate.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
layoutCreate.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
layoutCreate.Dock = DockStyle.Fill;
layoutCreate.Name = "layoutCreate";
layoutCreate.RowCount = 6;
for (var i = 0; i < 6; i++)
layoutCreate.RowStyles.Add(new RowStyle(SizeType.AutoSize));
label_target.Anchor = AnchorStyles.Left;
label_target.AutoSize = true;
label_target.Name = "label_target";
label_target.Text = "Zielordner:";
txt_targetFolder.Anchor = AnchorStyles.Left | AnchorStyles.Right;
txt_targetFolder.Name = "txt_targetFolder";
btn_browseFolder.AutoSize = true;
btn_browseFolder.Name = "btn_browseFolder";
btn_browseFolder.Text = "Durchsuchen…";
btn_browseFolder.UseVisualStyleBackColor = true;
label_secrets.Anchor = AnchorStyles.Left;
label_secrets.AutoSize = true;
label_secrets.Name = "label_secrets";
label_secrets.Text = "Zugangsdaten:";
panel_secretMode.AutoSize = true;
panel_secretMode.Controls.Add(rb_secretsExclude);
panel_secretMode.Controls.Add(rb_secretsPassphrase);
panel_secretMode.Margin = new Padding(0);
panel_secretMode.Name = "panel_secretMode";
panel_secretMode.WrapContents = false;
rb_secretsExclude.AutoSize = true;
rb_secretsExclude.Checked = true;
rb_secretsExclude.Name = "rb_secretsExclude";
rb_secretsExclude.TabStop = true;
rb_secretsExclude.Text = "nicht mitsichern";
rb_secretsExclude.UseVisualStyleBackColor = true;
rb_secretsPassphrase.AutoSize = true;
rb_secretsPassphrase.Name = "rb_secretsPassphrase";
rb_secretsPassphrase.Text = "mit Passphrase schützen";
rb_secretsPassphrase.UseVisualStyleBackColor = true;
label_passphrase.Anchor = AnchorStyles.Left;
label_passphrase.AutoSize = true;
label_passphrase.Name = "label_passphrase";
label_passphrase.Text = "Passphrase:";
txt_passphrase.Anchor = AnchorStyles.Left | AnchorStyles.Right;
txt_passphrase.Name = "txt_passphrase";
txt_passphrase.UseSystemPasswordChar = true;
label_passphraseRepeat.Anchor = AnchorStyles.Left;
label_passphraseRepeat.AutoSize = true;
label_passphraseRepeat.Name = "label_passphraseRepeat";
label_passphraseRepeat.Text = "Wiederholen:";
txt_passphraseRepeat.Anchor = AnchorStyles.Left | AnchorStyles.Right;
txt_passphraseRepeat.Name = "txt_passphraseRepeat";
txt_passphraseRepeat.UseSystemPasswordChar = true;
label_scope.Anchor = AnchorStyles.Left;
label_scope.AutoSize = true;
label_scope.Name = "label_scope";
label_scope.Text = "Umfang:";
panel_scope.AutoSize = true;
panel_scope.Controls.Add(chk_includeChatHistory);
panel_scope.Controls.Add(chk_includeLogs);
panel_scope.Margin = new Padding(0);
panel_scope.Name = "panel_scope";
panel_scope.WrapContents = false;
chk_includeChatHistory.AutoSize = true;
chk_includeChatHistory.Checked = true;
chk_includeChatHistory.CheckState = CheckState.Checked;
chk_includeChatHistory.Name = "chk_includeChatHistory";
chk_includeChatHistory.Text = "Chatverläufe";
chk_includeChatHistory.UseVisualStyleBackColor = true;
chk_includeLogs.AutoSize = true;
chk_includeLogs.Name = "chk_includeLogs";
chk_includeLogs.Text = "Protokolle";
chk_includeLogs.UseVisualStyleBackColor = true;
panel_createActions.AutoSize = true;
panel_createActions.Controls.Add(btn_createBackup);
panel_createActions.Controls.Add(label_createStatus);
panel_createActions.Margin = new Padding(0, 8, 0, 0);
panel_createActions.Name = "panel_createActions";
panel_createActions.WrapContents = false;
btn_createBackup.AutoSize = true;
btn_createBackup.Name = "btn_createBackup";
btn_createBackup.Padding = new Padding(8, 2, 8, 2);
btn_createBackup.Text = "Sicherung jetzt erstellen";
btn_createBackup.UseVisualStyleBackColor = true;
label_createStatus.Anchor = AnchorStyles.Left;
label_createStatus.AutoSize = true;
label_createStatus.Margin = new Padding(12, 8, 3, 0);
label_createStatus.Name = "label_createStatus";
layoutCreate.Controls.Add(label_target, 0, 0);
layoutCreate.Controls.Add(txt_targetFolder, 1, 0);
layoutCreate.Controls.Add(btn_browseFolder, 2, 0);
layoutCreate.Controls.Add(label_secrets, 0, 1);
layoutCreate.Controls.Add(panel_secretMode, 1, 1);
layoutCreate.Controls.Add(label_passphrase, 0, 2);
layoutCreate.Controls.Add(txt_passphrase, 1, 2);
layoutCreate.Controls.Add(label_passphraseRepeat, 0, 3);
layoutCreate.Controls.Add(txt_passphraseRepeat, 1, 3);
layoutCreate.Controls.Add(label_scope, 0, 4);
layoutCreate.Controls.Add(panel_scope, 1, 4);
layoutCreate.Controls.Add(panel_createActions, 1, 5);
// ═══ Automatik ═══
grp_auto.AutoSize = true;
grp_auto.AutoSizeMode = AutoSizeMode.GrowAndShrink;
grp_auto.Controls.Add(layoutAuto);
grp_auto.Dock = DockStyle.Fill;
grp_auto.Margin = new Padding(3, 10, 3, 3);
grp_auto.Name = "grp_auto";
grp_auto.Padding = new Padding(10);
grp_auto.TabIndex = 1;
grp_auto.TabStop = false;
grp_auto.Text = "Automatische Sicherung";
layoutAuto.AutoSize = true;
layoutAuto.AutoSizeMode = AutoSizeMode.GrowAndShrink;
layoutAuto.ColumnCount = 5;
for (var i = 0; i < 4; i++)
layoutAuto.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
layoutAuto.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
layoutAuto.Dock = DockStyle.Fill;
layoutAuto.Name = "layoutAuto";
layoutAuto.RowCount = 2;
layoutAuto.RowStyles.Add(new RowStyle(SizeType.AutoSize));
layoutAuto.RowStyles.Add(new RowStyle(SizeType.AutoSize));
chk_autoEnabled.Anchor = AnchorStyles.Left;
chk_autoEnabled.AutoSize = true;
chk_autoEnabled.Name = "chk_autoEnabled";
chk_autoEnabled.Text = "täglich sichern um";
chk_autoEnabled.UseVisualStyleBackColor = true;
dtp_autoTime.Anchor = AnchorStyles.Left;
dtp_autoTime.Format = DateTimePickerFormat.Time;
dtp_autoTime.Name = "dtp_autoTime";
dtp_autoTime.ShowUpDown = true;
dtp_autoTime.Width = 90;
label_keepCount.Anchor = AnchorStyles.Left;
label_keepCount.AutoSize = true;
label_keepCount.Margin = new Padding(24, 6, 3, 0);
label_keepCount.Name = "label_keepCount";
label_keepCount.Text = "Aufbewahren:";
num_keepCount.Anchor = AnchorStyles.Left;
num_keepCount.Maximum = 999;
num_keepCount.Name = "num_keepCount";
num_keepCount.Width = 70;
label_autoHint.Anchor = AnchorStyles.Left;
label_autoHint.AutoSize = true;
label_autoHint.ForeColor = SystemColors.GrayText;
label_autoHint.Name = "label_autoHint";
label_autoHint.Text = "Die Automatik sichert ohne Zugangsdaten — eine gespeicherte Passphrase "
+ "neben den Sicherungen wäre wirkungslos.";
layoutAuto.Controls.Add(chk_autoEnabled, 0, 0);
layoutAuto.Controls.Add(dtp_autoTime, 1, 0);
layoutAuto.Controls.Add(label_keepCount, 2, 0);
layoutAuto.Controls.Add(num_keepCount, 3, 0);
layoutAuto.Controls.Add(label_autoHint, 0, 1);
layoutAuto.SetColumnSpan(label_autoHint, 5);
// ═══ Vorhandene Sicherungen ═══
grp_existing.Controls.Add(layoutExisting);
grp_existing.Dock = DockStyle.Fill;
grp_existing.Margin = new Padding(3, 10, 3, 3);
grp_existing.Name = "grp_existing";
grp_existing.Padding = new Padding(10);
grp_existing.TabIndex = 2;
grp_existing.TabStop = false;
grp_existing.Text = "Vorhandene Sicherungen";
layoutExisting.ColumnCount = 1;
layoutExisting.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
layoutExisting.Dock = DockStyle.Fill;
layoutExisting.Name = "layoutExisting";
layoutExisting.RowCount = 2;
layoutExisting.RowStyles.Add(new RowStyle(SizeType.Percent, 100F));
layoutExisting.RowStyles.Add(new RowStyle(SizeType.AutoSize));
layoutExisting.Controls.Add(lv_backups, 0, 0);
layoutExisting.Controls.Add(panel_existingActions, 0, 1);
lv_backups.Columns.AddRange(new[] { col_file, col_created, col_instance, col_size, col_secrets });
lv_backups.Dock = DockStyle.Fill;
lv_backups.FullRowSelect = true;
lv_backups.MultiSelect = false;
lv_backups.Name = "lv_backups";
lv_backups.UseCompatibleStateImageBehavior = false;
lv_backups.View = View.Details;
col_file.Text = "Datei";
col_file.Width = 420;
col_created.Text = "Erstellt";
col_created.Width = 150;
col_instance.Text = "Instanz";
col_instance.Width = 180;
col_size.Text = "Größe";
col_size.Width = 100;
col_secrets.Text = "Zugangsdaten";
col_secrets.Width = 140;
panel_existingActions.AutoSize = true;
panel_existingActions.Controls.Add(btn_refresh);
panel_existingActions.Controls.Add(btn_restore);
panel_existingActions.Controls.Add(btn_showInFolder);
panel_existingActions.Controls.Add(btn_delete);
panel_existingActions.Margin = new Padding(0, 8, 0, 0);
panel_existingActions.Name = "panel_existingActions";
panel_existingActions.WrapContents = false;
btn_refresh.AutoSize = true;
btn_refresh.Name = "btn_refresh";
btn_refresh.Text = "Aktualisieren";
btn_refresh.UseVisualStyleBackColor = true;
btn_restore.AutoSize = true;
btn_restore.Name = "btn_restore";
btn_restore.Padding = new Padding(8, 2, 8, 2);
btn_restore.Text = "Wiederherstellen…";
btn_restore.UseVisualStyleBackColor = true;
btn_showInFolder.AutoSize = true;
btn_showInFolder.Name = "btn_showInFolder";
btn_showInFolder.Text = "Im Ordner zeigen";
btn_showInFolder.UseVisualStyleBackColor = true;
btn_delete.AutoSize = true;
btn_delete.Name = "btn_delete";
btn_delete.Text = "Löschen";
btn_delete.UseVisualStyleBackColor = true;
// ─── Steuerelement ───
AutoScaleDimensions = new SizeF(10F, 25F);
AutoScaleMode = AutoScaleMode.Font;
Controls.Add(layoutRoot);
Name = "BackupPanel";
Size = new Size(1200, 800);
layoutRoot.ResumeLayout(false);
layoutRoot.PerformLayout();
grp_create.ResumeLayout(false);
grp_create.PerformLayout();
layoutCreate.ResumeLayout(false);
layoutCreate.PerformLayout();
panel_secretMode.ResumeLayout(false);
panel_secretMode.PerformLayout();
panel_scope.ResumeLayout(false);
panel_scope.PerformLayout();
panel_createActions.ResumeLayout(false);
panel_createActions.PerformLayout();
grp_auto.ResumeLayout(false);
grp_auto.PerformLayout();
layoutAuto.ResumeLayout(false);
layoutAuto.PerformLayout();
((System.ComponentModel.ISupportInitialize)num_keepCount).EndInit();
grp_existing.ResumeLayout(false);
layoutExisting.ResumeLayout(false);
layoutExisting.PerformLayout();
panel_existingActions.ResumeLayout(false);
panel_existingActions.PerformLayout();
ResumeLayout(false);
}
private TableLayoutPanel layoutRoot;
private GroupBox grp_create;
private TableLayoutPanel layoutCreate;
private Label label_target;
private TextBox txt_targetFolder;
private Button btn_browseFolder;
private Label label_secrets;
private FlowLayoutPanel panel_secretMode;
private RadioButton rb_secretsExclude;
private RadioButton rb_secretsPassphrase;
private Label label_passphrase;
private TextBox txt_passphrase;
private Label label_passphraseRepeat;
private TextBox txt_passphraseRepeat;
private Label label_scope;
private FlowLayoutPanel panel_scope;
private CheckBox chk_includeChatHistory;
private CheckBox chk_includeLogs;
private FlowLayoutPanel panel_createActions;
private Button btn_createBackup;
private Label label_createStatus;
private GroupBox grp_auto;
private TableLayoutPanel layoutAuto;
private CheckBox chk_autoEnabled;
private DateTimePicker dtp_autoTime;
private Label label_keepCount;
private NumericUpDown num_keepCount;
private Label label_autoHint;
private GroupBox grp_existing;
private TableLayoutPanel layoutExisting;
private ListView lv_backups;
private ColumnHeader col_file;
private ColumnHeader col_created;
private ColumnHeader col_instance;
private ColumnHeader col_size;
private ColumnHeader col_secrets;
private FlowLayoutPanel panel_existingActions;
private Button btn_refresh;
private Button btn_restore;
private Button btn_showInFolder;
private Button btn_delete;
}
+511
View File
@@ -0,0 +1,511 @@
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;
System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{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"
};
}