Files
M_ViperandClaude Opus 5.5 1ae6e1abc2 Fortschritt, .NET-Prüfung, Updates, Fenster merken, Box-Funktionen
- Große Kopien laufen im Hintergrund mit Fortschritt und Abbrechen;
  abgebrochene Kopien werden aufgeräumt, das Original bleibt unverändert
- Sichern/Wiederherstellen mit Fortschritt, keine halben ZIP-Dateien
- Installer auf .NET Framework 4.8: erkennt fehlende .NET 9 Desktop Runtime
  und installiert sie auf Wunsch (Microsoft-Signatur wird geprüft)
- Update-Hinweis über Gitea-Releases, Update mit automatischem Neustart
- Fenstergröße und -position werden gemerkt
- Desktop-Boxen: umbenennen, löschen, verschieben, herausziehen
- Fenster und Boxen kehren auf sichtbare Bildschirme zurück
- README ergänzt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:46:48 +02:00

176 lines
7.6 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.IO;
using System.IO.Compression;
using System.Text.Json;
namespace DeskCore;
/// <summary>Export/Import aller Kategorien samt Einstellungen als ZIP – mit Fortschritt und Abbrechen.</summary>
public static class Backup
{
private const string CategoriesPrefix = "Kategorien/";
private const string SettingsEntry = "einstellungen.json";
private const int Chunk = 1 << 20;
public static long StorageSize() => StorageSize(Storage.Root);
public static long StorageSize(string root)
{
try
{
return Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)
.Sum(f => { try { return new FileInfo(f).Length; } catch { return 0L; } });
}
catch { return 0; }
}
public static string FormatSize(long bytes) => bytes switch
{
< 1024 * 1024 => $"{Math.Max(1, bytes / 1024)} KB",
< 1024L * 1024 * 1024 => $"{bytes / 1024.0 / 1024:0.#} MB",
_ => $"{bytes / 1024.0 / 1024 / 1024:0.##} GB",
};
/// <summary>Sichert alle Kategorien. Gibt die Anzahl gesicherter Dateien zurück.</summary>
public static int Export(string zipPath, Action<long>? onBytes = null, CancellationToken ct = default) =>
Export(zipPath, Storage.Root, Storage.Categories(), JsonSerializer.Serialize(AppSettings.Current, AppSettings.Json), onBytes, ct);
public static int Export(string zipPath, string root, IEnumerable<string> categories, string settingsJson,
Action<long>? onBytes = null, CancellationToken ct = default)
{
var tmp = zipPath + ".teil";
int count = 0;
try
{
if (File.Exists(tmp)) File.Delete(tmp);
// große Sicherungen bestehen meist aus Videos/Bildern → stärkere Kompression bringt kaum etwas
var level = StorageSize(root) > 1L << 30 ? CompressionLevel.Fastest : CompressionLevel.Optimal;
using (var zip = ZipFile.Open(tmp, ZipArchiveMode.Create))
{
foreach (var cat in categories)
{
var dir = Path.Combine(root, cat);
zip.CreateEntry(CategoriesPrefix + cat + "/"); // auch leere Kategorien sichern
foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
{
ct.ThrowIfCancellationRequested();
var rel = Path.GetRelativePath(root, f).Replace('\\', '/');
var entry = zip.CreateEntry(CategoriesPrefix + rel, level);
entry.LastWriteTime = File.GetLastWriteTime(f);
using (var input = new FileStream(f, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, Chunk, FileOptions.SequentialScan))
using (var output = entry.Open())
CopyWithProgress(input, output, onBytes, ct);
count++;
}
foreach (var d in Directory.EnumerateDirectories(dir, "*", SearchOption.AllDirectories))
if (!Directory.EnumerateFileSystemEntries(d).Any())
zip.CreateEntry(CategoriesPrefix + Path.GetRelativePath(root, d).Replace('\\', '/') + "/");
}
var se = zip.CreateEntry(SettingsEntry);
using (var w = new StreamWriter(se.Open()))
w.Write(settingsJson);
}
// erst am Ende an den richtigen Namen – so bleibt nie eine halbe Sicherung mit gültigem Namen liegen
if (File.Exists(zipPath)) File.Delete(zipPath);
File.Move(tmp, zipPath);
return count;
}
catch
{
try { if (File.Exists(tmp)) File.Delete(tmp); } catch { }
throw;
}
}
/// <summary>Summe der unkomprimierten Größen (für die Fortschrittsanzeige beim Wiederherstellen).</summary>
public static long ArchiveSize(string zipPath)
{
using var zip = ZipFile.OpenRead(zipPath);
return zip.Entries.Sum(e => e.Length);
}
/// <summary>Führt eine Sicherung mit dem Bestand zusammen. Vorhandenes wird nicht überschrieben.</summary>
public static (int Added, int Skipped) Import(string zipPath, Action<long>? onBytes = null, CancellationToken ct = default)
{
var (added, skipped, settings) = Import(zipPath, Storage.Root, onBytes, ct);
if (settings is not null) Merge(settings);
return (added, skipped);
}
public static (int Added, int Skipped, AppSettings? Settings) Import(string zipPath, string root,
Action<long>? onBytes = null, CancellationToken ct = default)
{
int added = 0, skipped = 0;
AppSettings? settings = null;
using var zip = ZipFile.OpenRead(zipPath);
root = Path.GetFullPath(root);
foreach (var e in zip.Entries)
{
ct.ThrowIfCancellationRequested();
if (e.FullName.Equals(SettingsEntry, StringComparison.OrdinalIgnoreCase))
{
try
{
using var r = new StreamReader(e.Open());
settings = JsonSerializer.Deserialize<AppSettings>(r.ReadToEnd(), AppSettings.Json);
}
catch { }
continue;
}
if (!e.FullName.StartsWith(CategoriesPrefix, StringComparison.OrdinalIgnoreCase)) continue;
var rel = e.FullName[CategoriesPrefix.Length..];
if (rel.Length == 0) continue;
var target = Path.GetFullPath(Path.Combine(root, rel));
if (!Storage.IsUnder(target, root)) continue; // Schutz vor manipulierten ZIPs
if (e.FullName.EndsWith('/'))
{
Directory.CreateDirectory(target);
continue;
}
if (File.Exists(target)) { skipped++; onBytes?.Invoke(e.Length); continue; }
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
try
{
using (var input = e.Open())
using (var output = new FileStream(target, FileMode.CreateNew, FileAccess.Write, FileShare.None, Chunk))
CopyWithProgress(input, output, onBytes, ct);
File.SetLastWriteTime(target, e.LastWriteTime.DateTime);
}
catch (OperationCanceledException)
{
try { File.Delete(target); } catch { } // halbe Datei nicht liegen lassen
throw;
}
added++;
}
return (added, skipped, settings);
}
private static void CopyWithProgress(Stream input, Stream output, Action<long>? onBytes, CancellationToken ct)
{
var buffer = new byte[Chunk];
int read;
while ((read = input.Read(buffer, 0, buffer.Length)) > 0)
{
ct.ThrowIfCancellationRequested();
output.Write(buffer, 0, read);
onBytes?.Invoke(read);
}
}
private static void Merge(AppSettings from)
{
var s = AppSettings.Current;
foreach (var (name, meta) in from.Categories ?? [])
if (!s.Categories.ContainsKey(name)) s.Categories[name] = meta;
foreach (var n in from.CategoryOrder ?? [])
if (!s.CategoryOrder.Contains(n, StringComparer.OrdinalIgnoreCase)) s.CategoryOrder.Add(n);
foreach (var r in from.Rules ?? [])
if (!s.Rules.Any(x => string.Equals(x.Contains, r.Contains, StringComparison.OrdinalIgnoreCase) &&
string.Equals(x.Category, r.Category, StringComparison.OrdinalIgnoreCase)))
s.Rules.Add(r);
s.Save();
}
}