From 1ae6e1abc26a3068624493a8456adadb04f81e45 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Thu, 1 Oct 2026 20:46:48 +0200 Subject: [PATCH] =?UTF-8?q?Fortschritt,=20.NET-Pr=C3=BCfung,=20Updates,=20?= =?UTF-8?q?Fenster=20merken,=20Box-Funktionen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- App.xaml.cs | 11 ++ Backup.cs | 137 +++++++++++++++------ FenceWindow.xaml | 1 + FenceWindow.xaml.cs | 88 +++++++++++-- MainWindow.Extras.cs | 244 +++++++++++++++++++++++++++++++++++++ MainWindow.xaml | 51 ++++++++ MainWindow.xaml.cs | 99 ++++++++++++--- README.md | 21 +++- Screens.cs | 39 ++++++ Settings.cs | 12 ++ Setup/App.xaml.cs | 13 +- Setup/DeskCoreSetup.csproj | 19 ++- Setup/Installer.cs | 32 ++++- Setup/MainWindow.xaml | 18 +++ Setup/MainWindow.xaml.cs | 40 +++++- Setup/Runtime.cs | 169 +++++++++++++++++++++++++ Storage.cs | 84 +++++++++++-- UpdateChecker.cs | 102 ++++++++++++++++ build.ps1 | 13 +- 19 files changed, 1098 insertions(+), 95 deletions(-) create mode 100644 MainWindow.Extras.cs create mode 100644 Screens.cs create mode 100644 Setup/Runtime.cs create mode 100644 UpdateChecker.cs diff --git a/App.xaml.cs b/App.xaml.cs index 9483c4b..83fceb7 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -79,6 +79,15 @@ public partial class App : Application bool minimized = e.Args.Contains("--minimized", StringComparer.OrdinalIgnoreCase); if (!minimized || !AppSettings.Current.TrayEnabled) Win.Show(); FenceManager.Sync(); + + // Bildschirm abgesteckt/Auflösung geändert → Fenster und Boxen wieder in den sichtbaren Bereich holen + Microsoft.Win32.SystemEvents.DisplaySettingsChanged += (_, _) => Dispatcher.BeginInvoke(() => + { + Win.EnsureOnScreen(); + FenceManager.EnsureOnScreen(); + }); + + Win.CheckForUpdatesOnStartup(); } /// Tastenkürzel neu registrieren. false = von anderem Programm belegt. @@ -99,6 +108,8 @@ public partial class App : Application { if (Quitting) return; Quitting = true; + Win?.CancelBusyAndWait(); // z. B. große Kopie abbrechen, halbe Dateien aufräumen + Win?.SavePlacement(); Win?.Undo.FlushAll(); FenceManager.CloseAll(); _watcher?.Dispose(); diff --git a/Backup.cs b/Backup.cs index b7f51f7..6269354 100644 --- a/Backup.cs +++ b/Backup.cs @@ -4,17 +4,20 @@ using System.Text.Json; namespace DeskCore; -/// Export/Import aller Kategorien samt Einstellungen als ZIP. +/// Export/Import aller Kategorien samt Einstellungen als ZIP – mit Fortschritt und Abbrechen. 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() + public static long StorageSize() => StorageSize(Storage.Root); + + public static long StorageSize(string root) { try { - return Directory.EnumerateFiles(Storage.Root, "*", SearchOption.AllDirectories) + return Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories) .Sum(f => { try { return new FileInfo(f).Length; } catch { return 0L; } }); } catch { return 0; } @@ -27,41 +30,93 @@ public static class Backup _ => $"{bytes / 1024.0 / 1024 / 1024:0.##} GB", }; - /// Gibt die Anzahl gesicherter Dateien zurück. - public static int Export(string zipPath) + /// Sichert alle Kategorien. Gibt die Anzahl gesicherter Dateien zurück. + public static int Export(string zipPath, Action? 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 categories, string settingsJson, + Action? onBytes = null, CancellationToken ct = default) { - if (File.Exists(zipPath)) File.Delete(zipPath); + var tmp = zipPath + ".teil"; int count = 0; - using var zip = ZipFile.Open(zipPath, ZipArchiveMode.Create); - foreach (var cat in Storage.Categories()) + try { - var dir = Storage.CategoryPath(cat); - zip.CreateEntry(CategoriesPrefix + cat + "/"); // auch leere Kategorien sichern - foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories)) + 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)) { - var rel = Path.GetRelativePath(Storage.Root, f).Replace('\\', '/'); - zip.CreateEntryFromFile(f, CategoriesPrefix + rel, CompressionLevel.Optimal); - count++; + 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); } - foreach (var d in Directory.EnumerateDirectories(dir, "*", SearchOption.AllDirectories)) - if (!Directory.EnumerateFileSystemEntries(d).Any()) - zip.CreateEntry(CategoriesPrefix + Path.GetRelativePath(Storage.Root, d).Replace('\\', '/') + "/"); + // 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; } - var entry = zip.CreateEntry(SettingsEntry); - using (var w = new StreamWriter(entry.Open())) - w.Write(JsonSerializer.Serialize(AppSettings.Current, AppSettings.Json)); - return count; + catch + { + try { if (File.Exists(tmp)) File.Delete(tmp); } catch { } + throw; + } + } + + /// Summe der unkomprimierten Größen (für die Fortschrittsanzeige beim Wiederherstellen). + public static long ArchiveSize(string zipPath) + { + using var zip = ZipFile.OpenRead(zipPath); + return zip.Entries.Sum(e => e.Length); } /// Führt eine Sicherung mit dem Bestand zusammen. Vorhandenes wird nicht überschrieben. - public static (int Added, int Skipped) Import(string zipPath) + public static (int Added, int Skipped) Import(string zipPath, Action? 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? onBytes = null, CancellationToken ct = default) { int added = 0, skipped = 0; + AppSettings? settings = null; using var zip = ZipFile.OpenRead(zipPath); - var root = Path.GetFullPath(Storage.Root); + 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(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; @@ -73,23 +128,35 @@ public static class Backup Directory.CreateDirectory(target); continue; } - if (File.Exists(target)) { skipped++; continue; } + if (File.Exists(target)) { skipped++; onBytes?.Invoke(e.Length); continue; } Directory.CreateDirectory(Path.GetDirectoryName(target)!); - e.ExtractToFile(target); - added++; - } - - if (zip.GetEntry(SettingsEntry) is { } se) - { try { - using var r = new StreamReader(se.Open()); - var imported = JsonSerializer.Deserialize(r.ReadToEnd(), AppSettings.Json); - if (imported is not null) Merge(imported); + 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 { } + 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? 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); } - return (added, skipped); } private static void Merge(AppSettings from) diff --git a/FenceWindow.xaml b/FenceWindow.xaml index 2a700dd..3c3a4ac 100644 --- a/FenceWindow.xaml +++ b/FenceWindow.xaml @@ -38,6 +38,7 @@ diff --git a/FenceWindow.xaml.cs b/FenceWindow.xaml.cs index 50482ba..1805332 100644 --- a/FenceWindow.xaml.cs +++ b/FenceWindow.xaml.cs @@ -51,15 +51,34 @@ public partial class FenceWindow : Window { _applying = true; var m = Meta; - var wa = SystemParameters.WorkArea; Width = Math.Max(MinWidth, m.FenceW); Height = m.FenceCollapsed ? CollapsedHeight : Math.Max(120, m.FenceH); - Left = Math.Clamp(m.FenceX ?? wa.Left + 24, wa.Left - Width + 80, wa.Right - 80); - Top = Math.Clamp(m.FenceY ?? wa.Top + 24, wa.Top, wa.Bottom - 40); + var wa = Screens.PrimaryWorkArea; + double left = m.FenceX ?? wa.Right - Width - 24, top = m.FenceY ?? wa.Top + 24; + // Bildschirm abgesteckt? → zurück auf den Hauptbildschirm (oben rechts) + if (!Screens.IsReachable(new Rect(left, top, Width, Height))) + { + left = wa.Right - Width - 24; + top = wa.Top + 24; + m.FenceX = left; + m.FenceY = top; + } + Left = left; + Top = top; Body.Visibility = m.FenceCollapsed ? Visibility.Collapsed : Visibility.Visible; _applying = false; } + /// Nach Bildschirm-Änderungen: Box wieder sichtbar machen. + public void EnsureOnScreen() + { + if (Screens.IsReachable(new Rect(Left, Top, ActualWidth, ActualHeight))) return; + Meta.FenceX = null; + Meta.FenceY = null; + ApplyBounds(); + AppSettings.Current.Save(); + } + private void RememberBounds() { if (_applying || !IsLoaded) return; @@ -114,20 +133,40 @@ public partial class FenceWindow : Window private void Items_KeyDown(object sender, KeyEventArgs e) { - if (e.Key == Key.Enter) Selected.ForEach(i => Open(i.Path)); + var sel = Selected; + if (sel.Count == 0 || App.Win is not { } win) return; + switch (e.Key) + { + case Key.Enter: sel.ForEach(i => Open(i.Path)); e.Handled = true; break; + case Key.Delete: win.DeleteIcons(sel); e.Handled = true; break; + case Key.F2 when sel.Count == 1: win.RenameIcon(sel[0]); e.Handled = true; break; + } } private void Items_ContextMenuOpening(object sender, ContextMenuEventArgs e) { var sel = Selected; - if (sel.Count == 0) { e.Handled = true; return; } + if (sel.Count == 0 || App.Win is not { } win) { e.Handled = true; return; } ItemMenu.Items.Clear(); - ItemMenu.Items.Add(MainWindow.MenuItem("Öffnen", "", () => sel.ForEach(i => Open(i.Path)), bold: true)); + ItemMenu.Items.Add(MainWindow.MenuItem("Öffnen", "\uE8A7", () => sel.ForEach(i => Open(i.Path)), bold: true, gesture: "Enter")); if (sel.Count == 1) - ItemMenu.Items.Add(MainWindow.MenuItem("Dateispeicherort öffnen", "", () => + { + ItemMenu.Items.Add(MainWindow.MenuItem("Dateispeicherort öffnen", "\uED25", () => Process.Start("explorer.exe", $"/select,\"{sel[0].Path}\""))); + ItemMenu.Items.Add(MainWindow.MenuItem("Umbenennen", "\uE70F", () => win.RenameIcon(sel[0]), gesture: "F2")); + } ItemMenu.Items.Add(new Separator()); - ItemMenu.Items.Add(MainWindow.MenuItem("In DeskCore zeigen", "", ShowInApp)); + + var moveTo = MainWindow.MenuItem("Verschieben nach", "\uE8DE", null); + foreach (var c in Storage.Categories().Where(c => !string.Equals(c, Category, StringComparison.OrdinalIgnoreCase))) + moveTo.Items.Add(MainWindow.MenuItem(c, AppSettings.Current.Categories.GetValueOrDefault(c)?.Glyph ?? "\uF12B", + () => win.TransferAll(sel.Select(i => i.Path), Storage.CategoryPath(c)))); + if (moveTo.Items.Count > 0) ItemMenu.Items.Add(moveTo); + ItemMenu.Items.Add(MainWindow.MenuItem("Zurück auf den Desktop legen", "\uE7F4", + () => win.TransferAll(sel.Select(i => i.Path), Storage.UserDesktop))); + ItemMenu.Items.Add(MainWindow.MenuItem("Löschen", "\uE74D", () => win.DeleteIcons(sel), gesture: "Entf")); + ItemMenu.Items.Add(new Separator()); + ItemMenu.Items.Add(MainWindow.MenuItem("In DeskCore zeigen", "\uE8A7", ShowInApp)); } private void MenuButton_Click(object sender, RoutedEventArgs e) @@ -149,9 +188,33 @@ public partial class FenceWindow : Window private void ShowInApp() => App.Win?.ShowCategory(Category); + private Point _dragStart; + private bool _dragging; + + private void Items_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) => _dragStart = e.GetPosition(null); + + private void Items_PreviewMouseMove(object sender, MouseEventArgs e) + { + if (e.LeftButton != MouseButtonState.Pressed || _dragging) return; + var d = e.GetPosition(null) - _dragStart; + if (Math.Abs(d.X) < SystemParameters.MinimumHorizontalDragDistance && + Math.Abs(d.Y) < SystemParameters.MinimumVerticalDragDistance) return; + if (MainWindow.FindParent(e.OriginalSource as DependencyObject) is null) return; + var paths = Selected.Select(i => i.Path).ToArray(); + if (paths.Length == 0) return; + _dragging = true; + try + { + // nur "Kopieren" erlauben: der Explorer soll die Datei nicht aus der Kategorie entfernen. + // In eine andere Box/Kategorie gezogen, verschiebt DeskCore selbst. + DragDrop.DoDragDrop(Items, new DataObject(DataFormats.FileDrop, paths), DragDropEffects.Copy); + } + finally { _dragging = false; } + } + private void Items_DragOver(object sender, DragEventArgs e) { - e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop) + e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop) && !_dragging ? (e.AllowedEffects.HasFlag(DragDropEffects.Copy) ? DragDropEffects.Copy : DragDropEffects.Move) : DragDropEffects.None; e.Handled = true; @@ -159,7 +222,7 @@ public partial class FenceWindow : Window private void Items_Drop(object sender, DragEventArgs e) { - if (e.Data.GetData(DataFormats.FileDrop) is string[] files) + if (!_dragging && e.Data.GetData(DataFormats.FileDrop) is string[] files) App.Win?.TransferAll(files, Storage.CategoryPath(Category)); } @@ -265,6 +328,11 @@ public static class FenceManager foreach (var f in _open) f.Refresh(); } + public static void EnsureOnScreen() + { + foreach (var f in _open) f.EnsureOnScreen(); + } + public static void Renamed(string oldName, string newName) { _open.FirstOrDefault(x => string.Equals(x.Category, oldName, StringComparison.OrdinalIgnoreCase))?.Rename(newName); diff --git a/MainWindow.Extras.cs b/MainWindow.Extras.cs new file mode 100644 index 0000000..78198fa --- /dev/null +++ b/MainWindow.Extras.cs @@ -0,0 +1,244 @@ +using System.Diagnostics; +using System.Windows; +using System.Windows.Threading; + +namespace DeskCore; + +/// Fortschrittsanzeige für lange Vorgänge, Fensterposition merken und Update-Hinweis. +public partial class MainWindow +{ + // ===================================================================== + // Fortschritt mit Abbrechen + // ===================================================================== + + /// Meldet Fortschritt aus dem Hintergrund (threadsicher). + 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; + + /// + /// 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). + /// + private async Task RunWithProgress(string title, string detail, Func> 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 …"; + } + + /// Beim Beenden: laufenden Vorgang abbrechen und kurz aufs Aufräumen warten. + 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(); + } + + /// Bildschirm abgesteckt/umgestellt: Fenster zurückholen, falls es nicht mehr erreichbar ist. + 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; + + /// Beim Start: höchstens einmal am Tag nachsehen, leise im Hintergrund. + 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}"); } + } +} diff --git a/MainWindow.xaml b/MainWindow.xaml index 5d422b7..99ca071 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -343,6 +343,22 @@ + + + + + +