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>
This commit is contained in:
2026-10-01 20:46:48 +02:00
co-authored by Claude Opus 5.5
parent 77bb3e6b9d
commit 1ae6e1abc2
19 changed files with 1098 additions and 95 deletions
+244
View File
@@ -0,0 +1,244 @@
using System.Diagnostics;
using System.Windows;
using System.Windows.Threading;
namespace DeskCore;
/// <summary>Fortschrittsanzeige für lange Vorgänge, Fensterposition merken und Update-Hinweis.</summary>
public partial class MainWindow
{
// =====================================================================
// Fortschritt mit Abbrechen
// =====================================================================
/// <summary>Meldet Fortschritt aus dem Hintergrund (threadsicher).</summary>
public sealed class ProgressSink
{
private long _done, _total;
public long Done => Interlocked.Read(ref _done);
public long Total => Interlocked.Read(ref _total);
public void Add(long bytes) => Interlocked.Add(ref _done, bytes);
public void SetTotal(long total) => Interlocked.Exchange(ref _total, total);
public void SetDone(long done) => Interlocked.Exchange(ref _done, done);
public volatile string? Detail;
}
private CancellationTokenSource? _busyCts;
private Task? _busyTask;
public bool IsBusy => _busyCts is not null;
/// <summary>
/// Führt einen langen Vorgang im Hintergrund aus. Das Fenster bleibt bedienbar; nach kurzer Zeit erscheint
/// eine Fortschrittsanzeige mit Abbrechen-Knopf (bei schnellen Vorgängen gar nicht erst).
/// </summary>
private async Task<T> RunWithProgress<T>(string title, string detail, Func<ProgressSink, CancellationToken, Task<T>> work)
{
var cts = new CancellationTokenSource();
_busyCts = cts;
var sink = new ProgressSink { Detail = detail };
var sw = Stopwatch.StartNew();
ProgressTitle.Text = title;
ProgressDetail.Text = detail;
ProgressStats.Text = "";
ProgressFill.Width = 0;
ProgressCancel.IsEnabled = true;
ProgressCancel.Content = "Abbrechen";
var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(200) };
timer.Tick += (_, _) =>
{
if (ProgressOverlay.Visibility != Visibility.Visible && sw.ElapsedMilliseconds > 500)
ProgressOverlay.Visibility = Visibility.Visible;
if (sink.Detail is { } d) ProgressDetail.Text = d;
long done = sink.Done, total = sink.Total;
if (total > 0)
{
double frac = Math.Min(1, done / (double)total);
ProgressFill.Width = ProgressTrack.ActualWidth * frac;
ProgressStats.Text = $"{Backup.FormatSize(done)} von {Backup.FormatSize(total)}{Remaining(done, total, sw.Elapsed)}";
}
else if (done > 0) ProgressStats.Text = Backup.FormatSize(done);
};
timer.Start();
var task = Task.Run(() => work(sink, cts.Token));
_busyTask = task;
try
{
return await task;
}
finally
{
timer.Stop();
ProgressOverlay.Visibility = Visibility.Collapsed;
_busyCts = null;
_busyTask = null;
cts.Dispose();
}
}
private static string Remaining(long done, long total, TimeSpan elapsed)
{
if (done <= 0 || elapsed.TotalSeconds < 2) return "";
var rest = TimeSpan.FromSeconds((total - done) / (done / elapsed.TotalSeconds));
return rest.TotalSeconds < 5 ? " · gleich fertig"
: rest.TotalMinutes < 1 ? $" · noch ca. {Math.Ceiling(rest.TotalSeconds / 5) * 5:0} Sek."
: rest.TotalHours < 1 ? $" · noch ca. {Math.Ceiling(rest.TotalMinutes):0} Min."
: $" · noch ca. {rest.TotalHours:0.#} Std.";
}
private void ProgressCancel_Click(object sender, RoutedEventArgs e)
{
_busyCts?.Cancel();
ProgressCancel.IsEnabled = false;
ProgressCancel.Content = "Wird abgebrochen …";
}
/// <summary>Beim Beenden: laufenden Vorgang abbrechen und kurz aufs Aufräumen warten.</summary>
public void CancelBusyAndWait()
{
if (_busyCts is null) return;
_busyCts.Cancel();
try { _busyTask?.Wait(TimeSpan.FromSeconds(5)); } catch { }
}
// =====================================================================
// Fensterposition merken
// =====================================================================
private void RestorePlacement()
{
if (S.WindowLeft is double l && S.WindowTop is double t && S.WindowWidth is double w && S.WindowHeight is double h)
{
var r = new Rect(l, t, Math.Max(MinWidth, w), Math.Max(MinHeight, h));
// nur übernehmen, wenn das Fenster dort noch erreichbar ist (Monitor evtl. abgesteckt)
if (Screens.IsReachable(r))
{
WindowStartupLocation = WindowStartupLocation.Manual;
Left = r.Left;
Top = r.Top;
Width = r.Width;
Height = r.Height;
}
}
if (S.WindowMaximized) WindowState = WindowState.Maximized;
}
public void SavePlacement()
{
if (!IsLoaded) return; // nie angezeigt (Start im Infobereich) → nichts zu merken
var r = WindowState == WindowState.Normal ? new Rect(Left, Top, ActualWidth, ActualHeight) : RestoreBounds;
if (r.IsEmpty || r.Width < 100) return;
S.WindowLeft = r.Left;
S.WindowTop = r.Top;
S.WindowWidth = r.Width;
S.WindowHeight = r.Height;
S.WindowMaximized = WindowState == WindowState.Maximized;
S.Save();
}
/// <summary>Bildschirm abgesteckt/umgestellt: Fenster zurückholen, falls es nicht mehr erreichbar ist.</summary>
public void EnsureOnScreen()
{
if (WindowState != WindowState.Normal) return;
if (Screens.IsReachable(new Rect(Left, Top, ActualWidth, ActualHeight))) return;
var wa = Screens.PrimaryWorkArea;
Width = Math.Min(Width, wa.Width);
Height = Math.Min(Height, wa.Height);
Left = wa.Left + (wa.Width - Width) / 2;
Top = wa.Top + (wa.Height - Height) / 2;
}
// =====================================================================
// Updates
// =====================================================================
private UpdateInfo? _update;
/// <summary>Beim Start: höchstens einmal am Tag nachsehen, leise im Hintergrund.</summary>
public async void CheckForUpdatesOnStartup()
{
if (!S.AutoUpdateCheck) return;
if (S.LastUpdateCheck is { } last && DateTime.Now - last < TimeSpan.FromHours(20)) return;
await Task.Delay(TimeSpan.FromSeconds(8)); // Start nicht ausbremsen
await CheckForUpdates(manual: false);
}
private async Task CheckForUpdates(bool manual)
{
if (manual)
{
CheckUpdateButton.IsEnabled = false;
UpdateStatusText.Text = "Suche nach Updates …";
}
try
{
var info = await UpdateChecker.CheckAsync();
S.LastUpdateCheck = DateTime.Now;
S.Save();
if (info is null)
{
if (manual) UpdateStatusText.Text = $"Du hast die neueste Version ({UpdateChecker.Current}).";
return;
}
if (!manual && S.SkippedVersion == info.Version.ToString()) return;
_update = info;
UpdateText.Text = $"DeskCore {info.Version} ist verfügbar – du hast Version {UpdateChecker.Current}.";
UpdateNowButton.Content = info.SetupUrl is not null ? "Jetzt aktualisieren" : "Zur Download-Seite";
UpdateBar.Visibility = Visibility.Visible;
if (manual) UpdateStatusText.Text = $"Version {info.Version} ist verfügbar – oben auf der Startseite aktualisieren.";
else if (!IsVisible) App.TrayIcon?.Notify("Update verfügbar", $"DeskCore {info.Version} ist verfügbar. Zum Aktualisieren DeskCore öffnen.");
}
catch (Exception ex)
{
if (manual) UpdateStatusText.Text = $"Konnte nicht prüfen: {ex.Message}";
}
finally
{
if (manual) CheckUpdateButton.IsEnabled = true;
}
}
private async void CheckUpdate_Click(object sender, RoutedEventArgs e) => await CheckForUpdates(manual: true);
private void AutoUpdateToggle_Click(object sender, RoutedEventArgs e)
{
S.AutoUpdateCheck = AutoUpdateToggle.IsChecked == true;
S.Save();
}
private void UpdateLater_Click(object sender, RoutedEventArgs e) => UpdateBar.Visibility = Visibility.Collapsed;
private void UpdateSkip_Click(object sender, RoutedEventArgs e)
{
if (_update is not null)
{
S.SkippedVersion = _update.Version.ToString();
S.Save();
}
UpdateBar.Visibility = Visibility.Collapsed;
}
private async void UpdateNow_Click(object sender, RoutedEventArgs e)
{
if (_update is not { } info || IsBusy) return;
if (info.SetupUrl is null)
{
Process.Start(new ProcessStartInfo(info.PageUrl) { UseShellExecute = true });
return;
}
try
{
var setup = await RunWithProgress($"Update auf {info.Version}", "Neue Version wird heruntergeladen …",
(sink, ct) => UpdateChecker.DownloadSetupAsync(info, (done, total) => { sink.SetTotal(total); sink.SetDone(done); }, ct));
// Installer still ausführen und DeskCore danach automatisch neu starten
Process.Start(new ProcessStartInfo(setup, "/S /launch") { UseShellExecute = true });
App.Quit();
}
catch (OperationCanceledException) { ShowToast("Update abgebrochen", "", info: true); }
catch (Exception ex) { Error("Update fehlgeschlagen", $"{ex.Message}\n\nDu kannst die neue Version auch selbst herunterladen:\n{info.PageUrl}"); }
}
}