feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
using ClawdDotNet.App.Settings;
|
||||
using ClawdDotNet.Core.Backup;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 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 Einstellungen geprüft, damit
|
||||
/// eine Änderung ohne Neustart greift.
|
||||
///
|
||||
/// <para><b>Takt.</b> Früher ein <c>System.Windows.Forms.Timer</c> — der braucht eine
|
||||
/// Nachrichtenschleife und damit ein Fenster. Jetzt <see cref="PeriodicTimer"/> über
|
||||
/// einen <see cref="TimeProvider"/>: läuft ohne Oberfläche, driftet nicht, und Tests
|
||||
/// können die Zeit steuern statt zu warten.</para>
|
||||
/// </summary>
|
||||
public sealed class BackupScheduler : IAsyncDisposable
|
||||
{
|
||||
private readonly string _instanceDir;
|
||||
private readonly string _instanceName;
|
||||
private readonly SettingsManager _settings;
|
||||
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;
|
||||
|
||||
/// <summary>Verhindert mehrere Sicherungen am selben Tag.</summary>
|
||||
private DateTime? _lastRun;
|
||||
|
||||
public event Action<string>? OnBackupCreated;
|
||||
|
||||
public BackupScheduler(
|
||||
string instanceDir,
|
||||
string instanceName,
|
||||
SettingsManager settings,
|
||||
ILogger logger,
|
||||
TimeProvider? clock = null,
|
||||
TimeSpan? tick = null)
|
||||
{
|
||||
_instanceDir = instanceDir;
|
||||
_instanceName = instanceName;
|
||||
_settings = settings;
|
||||
_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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ein Durchlauf. Öffentlich, damit Tests ihn deterministisch auslösen können.</summary>
|
||||
public async Task TickAsync()
|
||||
{
|
||||
var settings = _settings.AppSettings;
|
||||
if (!settings.AutoBackupEnabled)
|
||||
return;
|
||||
|
||||
if (!TimeSpan.TryParse(settings.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(settings.BackupDirectory, settings.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(_instanceName, "Instanz");
|
||||
|
||||
var file = Path.Combine(
|
||||
Path.GetFullPath(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(Path.GetFullPath(folder), safeName, keepCount);
|
||||
|
||||
OnBackupCreated?.Invoke(result.ZipPath);
|
||||
}
|
||||
|
||||
/// <summary>Behält die neuesten Sicherungen dieser Instanz und entfernt den Rest.</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user