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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -567,6 +583,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -621,5 +653,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index eb27a00..8dd4fa1 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -32,6 +32,7 @@ public partial class MainWindow : Window
public MainWindow()
{
InitializeComponent();
+ RestorePlacement();
StateChanged += (_, _) => UpdateWindowState();
Closing += OnClosing;
PreviewKeyDown += OnPreviewKeyDown;
@@ -73,6 +74,7 @@ public partial class MainWindow : Window
private void OnClosing(object? sender, CancelEventArgs e)
{
if (App.Quitting) return;
+ SavePlacement();
if (S.TrayEnabled && S.CloseToTray)
{
e.Cancel = true;
@@ -189,7 +191,7 @@ public partial class MainWindow : Window
/// Vom Watcher: Dateien haben sich geändert.
public void OnStorageChanged()
{
- if (!_dragging) Refresh();
+ if (!_dragging && !IsBusy) Refresh(); // während großer Kopien nicht ständig neu laden
}
private void Refresh()
@@ -792,7 +794,7 @@ public partial class MainWindow : Window
IconMenu.Items.Add(MenuItem(fromApp ? "Aus der App löschen" : "Vom Desktop löschen", "", () => DeleteIcons(sel), gesture: "Entf"));
}
- private void RenameIcon(IconItem item)
+ public void RenameIcon(IconItem item)
{
var ext = Storage.HiddenExtension(item.Path);
var newName = Dialog.Prompt(this, "Umbenennen", "Neuer Name:", item.Name, "Umbenennen", text =>
@@ -816,7 +818,7 @@ public partial class MainWindow : Window
catch (Exception ex) { Error("Umbenennen fehlgeschlagen", ex.Message); }
}
- private void DeleteIcons(List sel)
+ public void DeleteIcons(List sel)
{
// Ungesicherte Desktop-Icons: nachfragen. Alles andere geht direkt (Strg+Z holt es zurück).
var unsorted = sel.Count(i => !Storage.IsInStorage(i.Path) && !i.IsSorted);
@@ -842,19 +844,31 @@ public partial class MainWindow : Window
else ShowToast($"{Plural(sel.Count, "Element", "Elemente")} gelöscht", "", undoable: true);
}
- /// Kopiert/verschiebt Dateien in einen Ordner – mit Rückgängig und Rückmeldung.
- public void TransferAll(IEnumerable paths, string targetDir)
+ ///
+ /// Kopiert/verschiebt Dateien in einen Ordner – mit Rückgängig und Rückmeldung.
+ /// Läuft im Hintergrund; bei großen Datenmengen erscheint ein Fortschritt mit Abbrechen.
+ ///
+ public async void TransferAll(IEnumerable paths, string targetDir)
{
+ if (IsBusy) { ShowToast("Bitte warten, bis der laufende Vorgang fertig ist", "\uE946", info: true); return; }
var list = paths.ToList();
+ if (list.Count == 0) return;
var target = Storage.IsDesktopDir(targetDir) ? DesktopName : Path.GetFileName(targetDir.TrimEnd('\\'));
+
// Große Ordner würden beim Kopieren doppelt Platz belegen → lieber verschieben?
var bigFolders = new HashSet(StringComparer.OrdinalIgnoreCase);
if (Storage.IsInStorage(targetDir))
{
const long Limit = 200L * 1024 * 1024;
- var big = list.Where(p => Directory.Exists(p) && !Storage.IsInStorage(p))
- .Select(p => (Path: p, Size: Storage.DirectorySize(p, Limit * 50)))
- .Where(x => x.Size > Limit).ToList();
+ Mouse.OverrideCursor = Cursors.Wait;
+ List<(string Path, long Size)> big;
+ try
+ {
+ big = await Task.Run(() => list.Where(p => Directory.Exists(p) && !Storage.IsInStorage(p))
+ .Select(p => (Path: p, Size: Storage.DirectorySize(p, Limit * 50)))
+ .Where(x => x.Size > Limit).ToList());
+ }
+ finally { Mouse.OverrideCursor = null; }
if (big.Count > 0)
{
var what = big.Count == 1
@@ -867,8 +881,31 @@ public partial class MainWindow : Window
}
}
+ bool anyCopy = list.Any(p => !bigFolders.Contains(p) && !Storage.IsInStorage(p) && Storage.IsInStorage(targetDir));
+ var detail = list.Count == 1 ? $"„{Storage.DisplayName(list[0])}“ → {target}" : $"{list.Count} Elemente → {target}";
+ List results;
+ try
+ {
+ results = await RunWithProgress(anyCopy ? "Wird kopiert …" : "Wird verschoben …", detail, (sink, ct) =>
+ {
+ sink.SetTotal(list.Sum(p => Storage.BytesToCopy(p, targetDir, bigFolders.Contains(p))));
+ var r = new List();
+ foreach (var p in list)
+ {
+ if (list.Count > 1) sink.Detail = $"„{Storage.DisplayName(p)}“ → {target}";
+ r.Add(Storage.Transfer(p, targetDir, bigFolders.Contains(p), sink.Add, ct));
+ }
+ return Task.FromResult(r);
+ });
+ }
+ catch (Exception ex)
+ {
+ Refresh();
+ Error("Übertragen fehlgeschlagen", ex.Message);
+ return;
+ }
+
var entry = new UndoEntry($"{Plural(list.Count, "Element", "Elemente")} nach „{target}“");
- var results = list.Select(p => Storage.Transfer(p, targetDir, forceMove: bigFolders.Contains(p))).ToList();
foreach (var r in results.Where(r => r.Status == TransferStatus.Ok))
{
if (r.Copied) entry.AddCopy(r.Source, r.Dest!);
@@ -876,25 +913,33 @@ public partial class MainWindow : Window
}
Undo.Push(entry);
Refresh();
+ FenceManager.RefreshAll();
var ok = results.Where(r => r.Status == TransferStatus.Ok).ToList();
var skipped = results.Where(r => r.Status == TransferStatus.Skipped).ToList();
+ var cancelled = results.Count(r => r.Status == TransferStatus.Cancelled);
var errors = results.Where(r => r.Status == TransferStatus.Error).Select(r => r.Message!).ToList();
if (errors.Count > 0) { ShowErrors(errors); return; }
+ if (cancelled > 0)
+ {
+ ShowToast(ok.Count > 0 ? $"Abgebrochen – {Plural(ok.Count, "Element", "Elemente")} wurden noch übertragen" : "Abgebrochen – es wurde nichts verändert",
+ "\uE711", undoable: ok.Count > 0, info: true);
+ return;
+ }
if (ok.Count == 0 && skipped.Count > 0)
{
- ShowToast(skipped.Count == 1 ? skipped[0].Message! : $"Alle {skipped.Count} Elemente sind schon in „{target}“", "", info: true);
+ ShowToast(skipped.Count == 1 ? skipped[0].Message! : $"Alle {skipped.Count} Elemente sind schon in „{target}“", "\uE946", info: true);
return;
}
var n = Plural(ok.Count, "Element", "Elemente");
var extra = skipped.Count > 0 ? $" · {skipped.Count} waren schon da" : "";
if (Storage.IsDesktopDir(targetDir))
- ShowToast($"{n} zurück auf den Desktop gelegt{extra}", "", undoable: true);
+ ShowToast($"{n} zurück auf den Desktop gelegt{extra}", "\uE7F4", undoable: true);
else if (ok.All(r => r.Copied))
ShowToast($"{n} in „{target}“ gesichert{extra}", undoable: true);
else
- ShowToast($"{n} nach „{target}“ verschoben{extra}", "", undoable: true);
+ ShowToast($"{n} nach „{target}“ verschoben{extra}", "\uE8DE", undoable: true);
}
// ---------- Rückgängig ----------
@@ -1095,6 +1140,7 @@ public partial class MainWindow : Window
HotkeyBox.IsEnabled = S.HotkeyEnabled;
AutoSortToggle.IsChecked = S.AutoSort;
+ AutoUpdateToggle.IsChecked = S.AutoUpdateCheck;
AutoRemoveToggle.IsChecked = S.AutoSortRemoveFromDesktop;
RulesList.ItemsSource = null;
RulesList.ItemsSource = S.Rules;
@@ -1258,18 +1304,30 @@ public partial class MainWindow : Window
InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
};
if (dlg.ShowDialog(this) != true) return;
+ if (Storage.IsUnder(dlg.FileName, Storage.Root))
+ {
+ Error("Anderen Ort wählen", "Die Sicherung kann nicht im DeskCore-Ordner selbst gespeichert werden – sie würde sich sonst mitsichern. " +
+ "Am besten z. B. unter Dokumente oder auf einem USB-Stick speichern.");
+ return;
+ }
var size = await Task.Run(Backup.StorageSize);
if (size > 2L * 1024 * 1024 * 1024 &&
!Dialog.Confirm(this, "Große Sicherung",
$"Deine Kategorien sind zusammen {Backup.FormatSize(size)} groß (vor allem durch Ordner). Das Sichern kann eine Weile dauern " +
"und braucht entsprechend Platz.", "Trotzdem sichern"))
return;
- ShowToast("Sicherung läuft …", "", info: true);
+ if (IsBusy) return;
try
{
- var count = await Task.Run(() => Backup.Export(dlg.FileName));
+ var file = dlg.FileName;
+ var count = await RunWithProgress("Sicherung wird erstellt …", Path.GetFileName(file), (sink, ct) =>
+ {
+ sink.SetTotal(size);
+ return Task.FromResult(Backup.Export(file, sink.Add, ct));
+ });
ShowToast($"{Plural(count, "Datei", "Dateien")} gesichert in {Path.GetFileName(dlg.FileName)}", "");
}
+ catch (OperationCanceledException) { ShowToast("Sicherung abgebrochen – es wurde keine Datei angelegt", "\uE711", info: true); }
catch (Exception ex) { Error("Sichern fehlgeschlagen", ex.Message); }
}
@@ -1286,14 +1344,25 @@ public partial class MainWindow : Window
"Die Kategorien aus der Sicherung werden zu deinen vorhandenen hinzugefügt. Vorhandene Dateien werden nicht überschrieben.",
"Wiederherstellen"))
return;
+ if (IsBusy) return;
try
{
- var (added, skipped) = await Task.Run(() => Backup.Import(dlg.FileName));
+ var file = dlg.FileName;
+ var (added, skipped) = await RunWithProgress("Sicherung wird wiederhergestellt …", Path.GetFileName(file), (sink, ct) =>
+ {
+ sink.SetTotal(Backup.ArchiveSize(file));
+ return Task.FromResult(Backup.Import(file, sink.Add, ct));
+ });
Refresh();
FenceManager.Sync();
LoadSettingsView();
ShowToast($"{Plural(added, "Datei", "Dateien")} wiederhergestellt" + (skipped > 0 ? $" · {skipped} waren schon da" : ""), "");
}
+ catch (OperationCanceledException)
+ {
+ Refresh();
+ ShowToast("Wiederherstellen abgebrochen – bereits übernommene Dateien bleiben erhalten", "\uE711", info: true);
+ }
catch (Exception ex) { Error("Wiederherstellen fehlgeschlagen", $"Die Datei ist keine gültige Sicherung.\n\n{ex.Message}"); }
}
diff --git a/README.md b/README.md
index a60ca83..5649903 100644
--- a/README.md
+++ b/README.md
@@ -9,8 +9,8 @@ Danach kannst du die Icons vom Desktop löschen. In DeskCore bleiben sie erhalte
## Was du brauchst
-- Windows 10 oder Windows 11
-- Fehlt beim ersten Start etwas („.NET“), zeigt Windows automatisch einen Hinweis mit einem Download-Link. Einfach herunterladen, installieren und DeskCore nochmal starten.
+- Windows 10 oder Windows 11 (64 Bit)
+- Das war's. Falls DeskCore noch etwas von Microsoft braucht („.NET“), erledigt das der Installer automatisch.
---
@@ -25,6 +25,11 @@ Danach kannst du die Icons vom Desktop löschen. In DeskCore bleiben sie erhalte
5. **Installieren** klicken, und fertig.
Du brauchst dafür **keine Administratorrechte**.
+Nur wenn „.NET“ fehlt und installiert wird, fragt Windows einmal nach. Einfach mit **Ja** bestätigen.
+
+> **Windows warnt beim Start des Installers?**
+> Bei neuen Programmen zeigt Windows manchmal „Der Computer wurde durch Windows geschützt“.
+> Klick dann auf **„Weitere Informationen“** und danach auf **„Trotzdem ausführen“**.
---
@@ -86,6 +91,14 @@ Oder per Rechtsklick → **Nach oben / Nach unten**.
### Ansicht
Oben rechts gibt es den **Ansicht-Knopf**: kleine, mittlere oder große Symbole oder eine Liste.
+### Icons in den Desktop-Boxen
+Auch in einer Box auf dem Desktop kannst du per **Rechtsklick** umbenennen, löschen, in eine andere Kategorie verschieben
+oder zurück auf den Desktop legen. Icons lassen sich auch einfach aus der Box herausziehen.
+
+### Updates
+DeskCore schaut einmal am Tag nach, ob es eine neue Version gibt.
+Wenn ja, erscheint oben ein Hinweis. Ein Klick auf **„Jetzt aktualisieren“** genügt, und DeskCore startet danach von selbst neu.
+
---
## Hoppla, falsch geklickt?
@@ -110,6 +123,10 @@ Manche Icons gehören allen Benutzern des PCs. Dafür DeskCore einmal mit
**Ich habe einen großen Ordner hineingezogen. Ist der jetzt doppelt da?**
DeskCore fragt bei großen Ordnern nach. Wählst du **„Verschieben“**, liegt er nur einmal da und belegt keinen doppelten Platz.
+Beim Kopieren siehst du einen Fortschrittsbalken und kannst jederzeit **abbrechen**. Dann bleibt alles wie vorher.
+
+**Ich habe einen Bildschirm abgesteckt und das Fenster ist weg.**
+Keine Sorge: DeskCore und die Desktop-Boxen holen sich automatisch auf den verbliebenen Bildschirm zurück.
**Ich bekomme einen neuen PC. Was jetzt?**
**Einstellungen → Sicherung → „Sichern …“** speichert alles in eine ZIP-Datei.
diff --git a/Screens.cs b/Screens.cs
new file mode 100644
index 0000000..cdca928
--- /dev/null
+++ b/Screens.cs
@@ -0,0 +1,39 @@
+using System.Windows;
+
+namespace DeskCore;
+
+/// Bildschirm-Hilfen, damit Fenster nie außerhalb des sichtbaren Bereichs landen (z. B. nach Abstecken eines Monitors).
+public static class Screens
+{
+ /// Umrechnung Pixel → WPF-Einheiten (System-DPI).
+ private static double Scale
+ {
+ get
+ {
+ using var g = System.Drawing.Graphics.FromHwnd(IntPtr.Zero);
+ return g.DpiX / 96.0;
+ }
+ }
+
+ /// Arbeitsbereiche (ohne Taskleiste) aller angeschlossenen Bildschirme in WPF-Einheiten.
+ public static List WorkAreas()
+ {
+ var s = Scale;
+ return System.Windows.Forms.Screen.AllScreens
+ .Select(m => new Rect(m.WorkingArea.X / s, m.WorkingArea.Y / s, m.WorkingArea.Width / s, m.WorkingArea.Height / s))
+ .ToList();
+ }
+
+ public static Rect PrimaryWorkArea => SystemParameters.WorkArea;
+
+ /// Ist genug vom Fenster (Titelleiste) auf irgendeinem Bildschirm sichtbar, um es greifen zu können?
+ public static bool IsReachable(Rect window)
+ {
+ var title = new Rect(window.X, window.Y, window.Width, Math.Min(40, window.Height));
+ return WorkAreas().Any(a =>
+ {
+ var i = Rect.Intersect(a, title);
+ return !i.IsEmpty && i.Width >= Math.Min(120, window.Width / 2) && i.Height >= 20;
+ });
+ }
+}
diff --git a/Settings.cs b/Settings.cs
index e6a905c..b6e0a11 100644
--- a/Settings.cs
+++ b/Settings.cs
@@ -45,6 +45,18 @@ public class AppSettings
public bool FencesEnabled { get; set; } = true;
+ // Fenster merken (WPF-Einheiten)
+ public double? WindowLeft { get; set; }
+ public double? WindowTop { get; set; }
+ public double? WindowWidth { get; set; }
+ public double? WindowHeight { get; set; }
+ public bool WindowMaximized { get; set; }
+
+ // Updates
+ public bool AutoUpdateCheck { get; set; } = true;
+ public DateTime? LastUpdateCheck { get; set; }
+ public string? SkippedVersion { get; set; }
+
public List CategoryOrder { get; set; } = [];
public Dictionary Categories { get; set; } = new(StringComparer.OrdinalIgnoreCase);
diff --git a/Setup/App.xaml.cs b/Setup/App.xaml.cs
index ced191e..4e2fa34 100644
--- a/Setup/App.xaml.cs
+++ b/Setup/App.xaml.cs
@@ -11,7 +11,8 @@ public partial class App : Application
base.OnStartup(e);
bool uninstall = e.Args.Any(a => a.Equals("--uninstall", StringComparison.OrdinalIgnoreCase));
bool silent = e.Args.Any(a => a.Equals("/S", StringComparison.OrdinalIgnoreCase));
- var dirArg = e.Args.FirstOrDefault(a => a.StartsWith("/D=", StringComparison.OrdinalIgnoreCase))?[3..];
+ bool launch = e.Args.Any(a => a.Equals("/launch", StringComparison.OrdinalIgnoreCase));
+ var dirArg = e.Args.FirstOrDefault(a => a.StartsWith("/D=", StringComparison.OrdinalIgnoreCase))?.Substring(3);
// Stille Installation/Deinstallation für Skripte: DeskCore-Setup.exe /S [/D=Ordner] bzw. --uninstall /S
if (silent)
@@ -20,8 +21,14 @@ public partial class App : Application
try
{
if (uninstall) Installer.Uninstall(removeData: false, progress);
- else Installer.Install(dirArg ?? Installer.InstalledDir ?? Installer.DefaultDir,
- desktopLink: true, autostart: Installer.AutostartEnabled, progress);
+ else
+ {
+ var dir = dirArg ?? Installer.InstalledDir ?? Installer.DefaultDir;
+ Installer.Install(dir, desktopLink: Installer.InstalledDir is null || Installer.DesktopLinkExists,
+ autostart: Installer.AutostartEnabled, progress, installRuntime: true);
+ // nach einem Update aus DeskCore heraus: gleich wieder starten
+ if (launch) System.Diagnostics.Process.Start(System.IO.Path.Combine(dir, Installer.ExeName));
+ }
Shutdown(0);
}
catch (Exception ex)
diff --git a/Setup/DeskCoreSetup.csproj b/Setup/DeskCoreSetup.csproj
index fd5dd1b..6360869 100644
--- a/Setup/DeskCoreSetup.csproj
+++ b/Setup/DeskCoreSetup.csproj
@@ -1,10 +1,17 @@
WinExe
- net9.0-windows
+
+ net48
true
+ true
+ latest
enable
enable
+ AnyCPU
+ false
+ false
DeskCore-Setup
DeskCoreSetup
..\app.ico
@@ -15,9 +22,17 @@
M_Viper
Copyright (c) M_Viper. Alle Rechte vorbehalten.
de-DE
-
+
..\artifacts\app\DeskCore.exe
+
+
+
+
+
+
+
+
diff --git a/Setup/Installer.cs b/Setup/Installer.cs
index ae36c29..5a5d210 100644
--- a/Setup/Installer.cs
+++ b/Setup/Installer.cs
@@ -23,6 +23,9 @@ public static class Installer
}
}
+ /// Pfad dieses Setups (auch als Deinstallationsprogramm).
+ public static string SelfPath => System.Reflection.Assembly.GetExecutingAssembly().Location;
+
public static string DefaultDir =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", AppName);
@@ -66,8 +69,19 @@ public static class Installer
// ---------- Installieren ----------
- public static void Install(string dir, bool desktopLink, bool autostart, IProgress<(double, string)> progress)
+ public static void Install(string dir, bool desktopLink, bool autostart, IProgress<(double, string)> progress,
+ bool installRuntime = false)
{
+ if (!Runtime.Is64BitWindows)
+ throw new InvalidOperationException("DeskCore benötigt ein 64-Bit-Windows (Windows 10 oder 11).");
+ if (installRuntime && !Runtime.IsInstalled)
+ {
+ // 0–60 % für die Laufzeit, Rest für DeskCore
+ Runtime.Install((v, text) => progress.Report((v * 0.6, text)));
+ var inner = progress;
+ progress = new Scaled(inner, 0.6, 0.4);
+ }
+
progress.Report((0.05, "DeskCore wird beendet …"));
CloseRunningApp();
RemoveLegacyInstall();
@@ -79,7 +93,7 @@ public static class Installer
WriteResource("payload.LICENSE.txt", Path.Combine(dir, "LICENSE.txt"));
// Setup selbst als Deinstallationsprogramm ablegen
- var self = Environment.ProcessPath!;
+ var self = SelfPath;
var uninstaller = Path.Combine(dir, UninstallerName);
if (!string.Equals(Path.GetFullPath(self), Path.GetFullPath(uninstaller), StringComparison.OrdinalIgnoreCase))
{
@@ -149,7 +163,7 @@ public static class Installer
{
if (p.MainModule?.FileName is { } f && Storage_IsUnder(f, dir))
{
- p.Kill(entireProcessTree: true);
+ p.Kill();
p.WaitForExit(10000);
}
}
@@ -160,7 +174,7 @@ public static class Installer
DeleteIfPointsTo(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), LegacyName + ".lnk"), exe);
using (var run = Registry.CurrentUser.OpenSubKey(RunKey, writable: true))
{
- if (run?.GetValue(LegacyName) is string cmd && cmd.Contains(exe, StringComparison.OrdinalIgnoreCase))
+ if (run?.GetValue(LegacyName) is string cmd && cmd.IndexOf(exe, StringComparison.OrdinalIgnoreCase) >= 0)
run.DeleteValue(LegacyName, throwOnMissingValue: false);
}
@@ -221,7 +235,7 @@ public static class Installer
// Das laufende Deinstallationsprogramm kann sich nicht selbst löschen → kurz nach dem Beenden per cmd.
// rmdir ohne /s entfernt den Ordner nur, wenn er danach leer ist.
- var self = Environment.ProcessPath!;
+ var self = SelfPath;
if (Storage_IsUnder(self, dir))
{
Process.Start(new ProcessStartInfo("cmd.exe",
@@ -240,6 +254,12 @@ public static class Installer
// ---------- Helfer ----------
+ /// Rechnet Fortschritt 0–1 in einen Teilbereich um.
+ private sealed class Scaled(IProgress<(double, string)> inner, double offset, double factor) : IProgress<(double, string)>
+ {
+ public void Report((double, string) value) => inner.Report((offset + value.Item1 * factor, value.Item2));
+ }
+
/// Laufendes DeskCore beenden (das X versteckt es nur im Infobereich).
private static void CloseRunningApp()
{
@@ -247,7 +267,7 @@ public static class Installer
{
try
{
- p.Kill(entireProcessTree: true);
+ p.Kill();
p.WaitForExit(10000);
}
catch { }
diff --git a/Setup/MainWindow.xaml b/Setup/MainWindow.xaml
index 8d927c0..007e5d4 100644
--- a/Setup/MainWindow.xaml
+++ b/Setup/MainWindow.xaml
@@ -79,6 +79,14 @@
Text="DeskCore sortiert deine Desktop-Icons in eigene Kategorien – mit Suche, Desktop-Boxen, automatischem Einsortieren und Rückgängig-Funktion."/>
+
+
+
+
+
+
@@ -113,6 +121,8 @@
+
@@ -130,6 +140,14 @@
+
+
+
+
+
+
diff --git a/Setup/MainWindow.xaml.cs b/Setup/MainWindow.xaml.cs
index f95a1aa..e4777e5 100644
--- a/Setup/MainWindow.xaml.cs
+++ b/Setup/MainWindow.xaml.cs
@@ -26,6 +26,16 @@ public partial class MainWindow : Window
DesktopLinkBox.IsChecked = Installer.InstalledDir is null || Installer.DesktopLinkExists;
AutostartBox.IsChecked = Installer.AutostartEnabled;
+ if (!Runtime.IsInstalled)
+ {
+ RuntimeHint.Visibility = Visibility.Visible;
+ RuntimeBox.Visibility = Visibility.Visible;
+ RuntimeHintText.Text = Runtime.Is64BitWindows
+ ? "DeskCore benötigt die kostenlose „Microsoft .NET 9 Desktop Runtime“. Sie fehlt auf diesem PC und wird bei der " +
+ "Installation automatisch von Microsoft geladen (ca. 55 MB). Windows fragt dabei einmal nach Administratorrechten."
+ : "DeskCore benötigt ein 64-Bit-Windows (Windows 10 oder 11) und kann auf diesem PC leider nicht installiert werden.";
+ }
+
if (Installer.InstalledDir is null && Installer.LegacyInstallDir is not null)
{
UpdateHint.Visibility = Visibility.Visible;
@@ -102,7 +112,7 @@ public partial class MainWindow : Window
case 1 when AcceptLicense.IsChecked == true: ShowPage(2); break;
case 2:
var dir = DirBox.Text.Trim();
- if (!Path.IsPathFullyQualified(dir) || dir.IndexOfAny(Path.GetInvalidPathChars()) >= 0)
+ if (!IsFullPath(dir) || dir.IndexOfAny(Path.GetInvalidPathChars()) >= 0)
{
MessageBox.Show(this, "Bitte einen gültigen Ordner angeben, z. B. C:\\Programme\\DeskCore.", "DeskCore Setup");
return;
@@ -119,9 +129,14 @@ public partial class MainWindow : Window
private void ChangeDir_Click(object sender, RoutedEventArgs e)
{
- var dlg = new Microsoft.Win32.OpenFolderDialog { Title = "Installationsordner wählen" };
- if (dlg.ShowDialog(this) != true) return;
- var chosen = dlg.FolderName;
+ using var dlg = new System.Windows.Forms.FolderBrowserDialog
+ {
+ Description = "Installationsordner wählen",
+ SelectedPath = Directory.Exists(DirBox.Text) ? DirBox.Text : Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ ShowNewFolderButton = true,
+ };
+ if (dlg.ShowDialog() != System.Windows.Forms.DialogResult.OK) return;
+ var chosen = dlg.SelectedPath;
// wie bei üblichen Installern: Unterordner "DeskCore" anhängen
DirBox.Text = Path.GetFileName(chosen.TrimEnd('\\')).Equals("DeskCore", StringComparison.OrdinalIgnoreCase)
? chosen : Path.Combine(chosen, "DeskCore");
@@ -136,15 +151,24 @@ public partial class MainWindow : Window
SetButtons(false);
var progress = new Progress<(double Value, string Text)>(p => SetProgress(p.Value, p.Text));
bool desktop = DesktopLinkBox.IsChecked == true, autostart = AutostartBox.IsChecked == true;
+ bool runtime = RuntimeBox.Visibility == Visibility.Visible && RuntimeBox.IsChecked == true;
try
{
- await Task.Run(() => Installer.Install(dir, desktop, autostart, progress));
+ await Task.Run(() => Installer.Install(dir, desktop, autostart, progress, runtime));
_installedExe = Path.Combine(dir, Installer.ExeName);
_done = true;
ProgressTitle.Text = "Installation abgeschlossen";
ProgressText.Text = $"DeskCore wurde in {dir} installiert. Du findest es im Startmenü" + (desktop ? " und auf dem Desktop." : ".");
DoneText.Text = "Alles bereit!";
DonePanel.Visibility = Visibility.Visible;
+ if (!Runtime.IsInstalled)
+ {
+ // Laufzeit fehlt weiterhin (abgewählt) → Hinweis statt Startversuch
+ DoneText.Text = "DeskCore ist installiert";
+ RuntimeMissing.Visibility = Visibility.Visible;
+ LaunchBox.IsChecked = false;
+ LaunchBox.IsEnabled = false;
+ }
ShowPage(3);
}
catch (Exception ex)
@@ -230,6 +254,12 @@ public partial class MainWindow : Window
new DoubleAnimation(target, TimeSpan.FromMilliseconds(250)) { EasingFunction = new CubicEase() });
}
+ private static bool IsFullPath(string p) =>
+ p.Length >= 3 && char.IsLetter(p[0]) && p[1] == ':' && (p[2] == '\\' || p[2] == '/');
+
+ private void OpenRuntimePage_Click(object sender, RoutedEventArgs e) =>
+ Process.Start(new ProcessStartInfo(Runtime.DownloadPage) { UseShellExecute = true });
+
private void Minimize_Click(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
private void Cancel_Click(object sender, RoutedEventArgs e)
diff --git a/Setup/Runtime.cs b/Setup/Runtime.cs
new file mode 100644
index 0000000..ed332a6
--- /dev/null
+++ b/Setup/Runtime.cs
@@ -0,0 +1,169 @@
+using System.ComponentModel;
+using System.Diagnostics;
+using System.IO;
+using System.Net;
+using System.Net.Http;
+using System.Runtime.InteropServices;
+using System.Security.Cryptography.X509Certificates;
+using Microsoft.Win32;
+
+namespace DeskCoreSetup;
+
+/// Prüft und installiert die von DeskCore benötigte "Microsoft .NET 9 Desktop Runtime".
+public static class Runtime
+{
+ public const string DisplayName = "Microsoft .NET 9 Desktop Runtime";
+ public const int Major = 9;
+ public const string DownloadPage = "https://dotnet.microsoft.com/de-de/download/dotnet/9.0";
+ // offizieller Microsoft-Kurzlink auf die jeweils aktuelle 9.0-Version
+ private const string InstallerUrl = "https://aka.ms/dotnet/9.0/windowsdesktop-runtime-win-x64.exe";
+
+ public static bool Is64BitWindows => Environment.Is64BitOperatingSystem;
+
+ public static bool IsInstalled
+ {
+ get
+ {
+ try
+ {
+ var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),
+ "dotnet", "shared", "Microsoft.WindowsDesktop.App");
+ if (Directory.Exists(dir) && Directory.GetDirectories(dir).Any(d => Path.GetFileName(d).StartsWith($"{Major}.")))
+ return true;
+ }
+ catch { }
+ try
+ {
+ using var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32);
+ using var k = hklm.OpenSubKey(@"SOFTWARE\dotnet\Setup\InstalledVersions\x64\sharedfx\Microsoft.WindowsDesktop.App");
+ if (k?.GetValueNames().Any(n => n.StartsWith($"{Major}.")) == true) return true;
+ }
+ catch { }
+ return false;
+ }
+ }
+
+ /// Lädt die Laufzeit von Microsoft und installiert sie (Windows fragt einmal nach Administratorrechten).
+ public static void Install(Action progress)
+ {
+ ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12;
+ var file = Path.Combine(Path.GetTempPath(), $"windowsdesktop-runtime-{Major}-win-x64.exe");
+
+ using (var http = new HttpClient { Timeout = TimeSpan.FromMinutes(20) })
+ {
+ http.DefaultRequestHeaders.UserAgent.ParseAdd("DeskCore-Setup");
+ using var resp = http.GetAsync(InstallerUrl, HttpCompletionOption.ResponseHeadersRead).GetAwaiter().GetResult();
+ resp.EnsureSuccessStatusCode();
+ long total = resp.Content.Headers.ContentLength ?? 0;
+ using var input = resp.Content.ReadAsStreamAsync().GetAwaiter().GetResult();
+ using var output = File.Create(file);
+ var buffer = new byte[81920];
+ long done = 0;
+ int read;
+ while ((read = input.Read(buffer, 0, buffer.Length)) > 0)
+ {
+ output.Write(buffer, 0, read);
+ done += read;
+ progress(total > 0 ? (double)done / total : 0,
+ total > 0 ? $"{DisplayName} wird heruntergeladen … {done / 1048576} von {total / 1048576} MB"
+ : $"{DisplayName} wird heruntergeladen … {done / 1048576} MB");
+ }
+ }
+
+ // Sicherheit: nur ausführen, wenn die Datei gültig von Microsoft signiert ist
+ if (!IsSignedByMicrosoft(file))
+ {
+ try { File.Delete(file); } catch { }
+ throw new InvalidOperationException("Die heruntergeladene Datei ist nicht gültig von Microsoft signiert und wurde nicht ausgeführt.");
+ }
+
+ progress(1, $"{DisplayName} wird installiert – bitte die Windows-Abfrage bestätigen …");
+ Process p;
+ try
+ {
+ p = Process.Start(new ProcessStartInfo(file, "/install /quiet /norestart") { UseShellExecute = true, Verb = "runas" })!;
+ }
+ catch (Win32Exception ex) when (ex.NativeErrorCode == 1223)
+ {
+ throw new InvalidOperationException($"Die Installation von {DisplayName} wurde abgebrochen.");
+ }
+ p.WaitForExit();
+ try { File.Delete(file); } catch { }
+ // 0 = ok, 3010/1641 = ok, Neustart empfohlen
+ if (p.ExitCode is not (0 or 3010 or 1641) || !IsInstalled)
+ throw new InvalidOperationException($"{DisplayName} konnte nicht installiert werden (Code {p.ExitCode}).");
+ }
+
+ // ---------- Signaturprüfung (WinVerifyTrust) ----------
+
+ private static bool IsSignedByMicrosoft(string file)
+ {
+ try
+ {
+ var subject = X509Certificate.CreateFromSignedFile(file).Subject;
+ if (subject.IndexOf("O=Microsoft Corporation", StringComparison.OrdinalIgnoreCase) < 0) return false;
+ }
+ catch { return false; }
+ return VerifyTrust(file);
+ }
+
+ private static bool VerifyTrust(string file)
+ {
+ var fileInfo = new WINTRUST_FILE_INFO
+ {
+ cbStruct = (uint)Marshal.SizeOf(),
+ pcwszFilePath = file,
+ };
+ var pFile = Marshal.AllocHGlobal(Marshal.SizeOf());
+ try
+ {
+ Marshal.StructureToPtr(fileInfo, pFile, false);
+ var data = new WINTRUST_DATA
+ {
+ cbStruct = (uint)Marshal.SizeOf(),
+ dwUIChoice = 2, // WTD_UI_NONE
+ fdwRevocationChecks = 0, // WTD_REVOKE_NONE
+ dwUnionChoice = 1, // WTD_CHOICE_FILE
+ pFile = pFile,
+ dwStateAction = 0,
+ dwProvFlags = 0x40, // WTD_SAFER_FLAG
+ };
+ var action = new Guid("00AAC56B-CD44-11d0-8CC2-00C04FC295EE"); // WINTRUST_ACTION_GENERIC_VERIFY_V2
+ return WinVerifyTrust(IntPtr.Zero, ref action, ref data) == 0;
+ }
+ finally
+ {
+ Marshal.FreeHGlobal(pFile);
+ }
+ }
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ private struct WINTRUST_FILE_INFO
+ {
+ public uint cbStruct;
+ public string pcwszFilePath;
+ public IntPtr hFile;
+ public IntPtr pgKnownSubject;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct WINTRUST_DATA
+ {
+ public uint cbStruct;
+ public IntPtr pPolicyCallbackData;
+ public IntPtr pSIPClientData;
+ public uint dwUIChoice;
+ public uint fdwRevocationChecks;
+ public uint dwUnionChoice;
+ public IntPtr pFile;
+ public uint dwStateAction;
+ public IntPtr hWVTStateData;
+ public IntPtr pwszURLReference;
+ public uint dwProvFlags;
+ public uint dwUIContext;
+ public IntPtr pSignatureSettings;
+ }
+
+ [DllImport("wintrust.dll", ExactSpelling = true, SetLastError = false, CharSet = CharSet.Unicode)]
+ private static extern int WinVerifyTrust(IntPtr hwnd, ref Guid pgActionID, ref WINTRUST_DATA pWVTData);
+}
diff --git a/Storage.cs b/Storage.cs
index 86daa32..13e1894 100644
--- a/Storage.cs
+++ b/Storage.cs
@@ -3,7 +3,7 @@ using System.IO;
namespace DeskCore;
-public enum TransferStatus { Ok, Skipped, Error }
+public enum TransferStatus { Ok, Skipped, Error, Cancelled }
/// true = kopiert (Original bleibt), false = verschoben
public record TransferResult(TransferStatus Status, string Source, string? Dest = null, bool Copied = false, string? Message = null);
@@ -140,8 +140,10 @@ public static class Storage
/// wird kopiert – das Original kann danach gelöscht werden. Innerhalb der App und zurück auf den
/// Desktop wird verschoben. Gleiche Elemente werden nicht doppelt angelegt.
///
- public static TransferResult Transfer(string source, string targetDir, bool forceMove = false)
+ public static TransferResult Transfer(string source, string targetDir, bool forceMove = false,
+ Action? onBytes = null, CancellationToken ct = default)
{
+ if (ct.IsCancellationRequested) return new(TransferStatus.Cancelled, source);
var name = Path.GetFileName(source.TrimEnd('\\'));
try
{
@@ -160,8 +162,18 @@ public static class Storage
Directory.CreateDirectory(targetDir);
var dest = UniquePath(targetDir, name);
- if (copy) CopyAny(source, dest);
- else MoveAny(source, dest);
+ try
+ {
+ if (copy) CopyAny(source, dest, onBytes, ct);
+ else MoveAny(source, dest, onBytes, ct);
+ }
+ catch (OperationCanceledException)
+ {
+ // halb kopiertes Ziel wieder entfernen – die Quelle ist unverändert
+ // (beim Verschieben zwischen Laufwerken wird die Quelle erst nach vollständiger Kopie gelöscht)
+ DeletePartial(dest);
+ return new(TransferStatus.Cancelled, source);
+ }
return new(TransferStatus.Ok, source, dest, copy);
}
catch (UnauthorizedAccessException)
@@ -176,7 +188,7 @@ public static class Storage
}
/// Verschiebt Datei oder Ordner – auch zwischen Laufwerken.
- public static void MoveAny(string source, string dest)
+ public static void MoveAny(string source, string dest, Action? onBytes = null, CancellationToken ct = default)
{
NoteCreated(dest);
if (File.Exists(source))
@@ -189,15 +201,15 @@ public static class Storage
Directory.Move(source, dest);
return;
}
- CopyDirectory(source, dest);
+ CopyDirectory(source, dest, onBytes, ct);
Directory.Delete(source, recursive: true);
}
- public static void CopyAny(string source, string dest)
+ public static void CopyAny(string source, string dest, Action? onBytes = null, CancellationToken ct = default)
{
NoteCreated(dest);
- if (Directory.Exists(source)) CopyDirectory(source, dest);
- else File.Copy(source, dest);
+ if (Directory.Exists(source)) CopyDirectory(source, dest, onBytes, ct);
+ else CopyFile(source, dest, onBytes, ct);
}
/// Benennt ein Element um. Gibt den neuen Pfad zurück.
@@ -321,12 +333,60 @@ public static class Storage
}
}
- private static void CopyDirectory(string source, string dest)
+ private static void CopyDirectory(string source, string dest, Action? onBytes, CancellationToken ct)
{
Directory.CreateDirectory(dest);
foreach (var f in Directory.GetFiles(source))
- File.Copy(f, Path.Combine(dest, Path.GetFileName(f)));
+ CopyFile(f, Path.Combine(dest, Path.GetFileName(f)), onBytes, ct);
foreach (var d in Directory.GetDirectories(source))
- CopyDirectory(d, Path.Combine(dest, Path.GetFileName(d)));
+ CopyDirectory(d, Path.Combine(dest, Path.GetFileName(d)), onBytes, ct);
+ }
+
+ /// Kopiert in 1-MB-Stücken, damit Fortschritt angezeigt und abgebrochen werden kann.
+ private static void CopyFile(string source, string dest, Action? onBytes, CancellationToken ct)
+ {
+ ct.ThrowIfCancellationRequested();
+ const int Chunk = 1 << 20;
+ using (var input = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, Chunk, FileOptions.SequentialScan))
+ using (var output = new FileStream(dest, FileMode.CreateNew, FileAccess.Write, FileShare.None, Chunk))
+ {
+ 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);
+ }
+ }
+ // Zeitstempel und Attribute wie beim normalen Kopieren übernehmen
+ try
+ {
+ File.SetCreationTimeUtc(dest, File.GetCreationTimeUtc(source));
+ File.SetLastWriteTimeUtc(dest, File.GetLastWriteTimeUtc(source));
+ File.SetAttributes(dest, File.GetAttributes(source) & ~FileAttributes.ReadOnly);
+ }
+ catch { }
+ }
+
+ private static void DeletePartial(string path)
+ {
+ try
+ {
+ if (Directory.Exists(path)) Directory.Delete(path, recursive: true);
+ else if (File.Exists(path)) File.Delete(path);
+ }
+ catch { }
+ }
+
+ /// Wie viele Bytes müssen für diese Übertragung tatsächlich kopiert werden? (0 = nur umbenennen)
+ public static long BytesToCopy(string source, string targetDir, bool forceMove)
+ {
+ bool copy = !forceMove && !IsInStorage(source) && IsInStorage(targetDir);
+ bool crossDrive = !string.Equals(Path.GetPathRoot(Path.GetFullPath(source)), Path.GetPathRoot(Path.GetFullPath(targetDir)),
+ StringComparison.OrdinalIgnoreCase);
+ if (!copy && !crossDrive) return 0;
+ try { return Directory.Exists(source) ? DirectorySize(source) : new FileInfo(source).Length; }
+ catch { return 0; }
}
}
diff --git a/UpdateChecker.cs b/UpdateChecker.cs
new file mode 100644
index 0000000..d994be6
--- /dev/null
+++ b/UpdateChecker.cs
@@ -0,0 +1,102 @@
+using System.IO;
+using System.Net.Http;
+using System.Text.Json;
+
+namespace DeskCore;
+
+public record UpdateInfo(Version Version, string Tag, string PageUrl, string? SetupUrl, string? Notes);
+
+/// Fragt das neueste Release auf Gitea ab (https://git.viper.ipv64.net/M_Viper/DeskCore/releases).
+public static class UpdateChecker
+{
+ private const string Host = "git.viper.ipv64.net";
+ private const string LatestApi = $"https://{Host}/api/v1/repos/M_Viper/DeskCore/releases/latest";
+ public const string ReleasesPage = $"https://{Host}/M_Viper/DeskCore/releases";
+ private const string SetupAsset = "DeskCore-Setup.exe";
+
+ public static Version Current
+ {
+ get
+ {
+ var v = typeof(UpdateChecker).Assembly.GetName().Version ?? new Version(1, 0, 0);
+ return new Version(v.Major, v.Minor, Math.Max(0, v.Build));
+ }
+ }
+
+ private static HttpClient NewClient(TimeSpan timeout)
+ {
+ var http = new HttpClient { Timeout = timeout };
+ http.DefaultRequestHeaders.UserAgent.ParseAdd($"DeskCore/{Current}");
+ return http;
+ }
+
+ /// Neuere Version oder null. Wirft bei Netzwerk-/Serverfehlern.
+ public static async Task CheckAsync(CancellationToken ct = default)
+ {
+ using var http = NewClient(TimeSpan.FromSeconds(15));
+ using var resp = await http.GetAsync(LatestApi, ct);
+ if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
+ throw new InvalidOperationException("Es ist noch kein Release veröffentlicht (oder das Repository ist nicht öffentlich).");
+ resp.EnsureSuccessStatusCode();
+
+ using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct));
+ var root = doc.RootElement;
+ var tag = root.GetProperty("tag_name").GetString() ?? "";
+ if (!TryParseVersion(tag, out var version) || version <= Current) return null;
+
+ string? setupUrl = null;
+ if (root.TryGetProperty("assets", out var assets))
+ foreach (var a in assets.EnumerateArray())
+ if (string.Equals(a.GetProperty("name").GetString(), SetupAsset, StringComparison.OrdinalIgnoreCase))
+ setupUrl = a.GetProperty("browser_download_url").GetString();
+
+ // Nur Downloads vom eigenen Server zulassen
+ if (setupUrl is not null && !IsTrusted(setupUrl)) setupUrl = null;
+
+ var page = root.TryGetProperty("html_url", out var h) ? h.GetString() : null;
+ return new UpdateInfo(version, tag, page is not null && IsTrusted(page) ? page : ReleasesPage, setupUrl,
+ root.TryGetProperty("body", out var b) ? b.GetString() : null);
+ }
+
+ /// Lädt den Installer in den Temp-Ordner und gibt den Pfad zurück.
+ public static async Task DownloadSetupAsync(UpdateInfo info, Action? progress, CancellationToken ct)
+ {
+ if (info.SetupUrl is null) throw new InvalidOperationException("Im Release fehlt die Datei DeskCore-Setup.exe.");
+ using var http = NewClient(TimeSpan.FromMinutes(10));
+ using var resp = await http.GetAsync(info.SetupUrl, HttpCompletionOption.ResponseHeadersRead, ct);
+ resp.EnsureSuccessStatusCode();
+ long total = resp.Content.Headers.ContentLength ?? 0;
+
+ var path = Path.Combine(Path.GetTempPath(), $"DeskCore-Setup-{info.Version}.exe");
+ await using (var input = await resp.Content.ReadAsStreamAsync(ct))
+ await using (var output = File.Create(path))
+ {
+ var buffer = new byte[81920];
+ long done = 0;
+ int read;
+ while ((read = await input.ReadAsync(buffer, ct)) > 0)
+ {
+ await output.WriteAsync(buffer.AsMemory(0, read), ct);
+ done += read;
+ progress?.Invoke(done, total);
+ }
+ }
+ return path;
+ }
+
+ private static bool IsTrusted(string url) =>
+ Uri.TryCreate(url, UriKind.Absolute, out var u) && u.Scheme == Uri.UriSchemeHttps &&
+ string.Equals(u.Host, Host, StringComparison.OrdinalIgnoreCase);
+
+ public static bool TryParseVersion(string tag, out Version version)
+ {
+ var t = tag.Trim().TrimStart('v', 'V');
+ if (Version.TryParse(t, out var v))
+ {
+ version = new Version(v.Major, v.Minor, Math.Max(0, v.Build));
+ return true;
+ }
+ version = new Version(0, 0, 0);
+ return false;
+ }
+}
diff --git a/build.ps1 b/build.ps1
index 742918b..312b2a8 100644
--- a/build.ps1
+++ b/build.ps1
@@ -1,19 +1,22 @@
# Baut DeskCore und den Installer.
# DeskCore.exe -> ..\DeskCore\ (direkt startbar)
-# DeskCore-Setup.exe -> ..\DeskCore-Installer\ (zum Installieren/Weitergeben)
+# DeskCore-Setup.exe -> ..\DeskCore-Installer\ (zum Installieren/Weitergeben, mit README)
$ErrorActionPreference = "Stop"
$root = $PSScriptRoot
-$common = @("-c", "Release", "-r", "win-x64", "-p:SelfContained=false", "-p:PublishSingleFile=true", "-p:DebugType=none", "--nologo", "-v", "q")
Write-Host "1/2 DeskCore wird gebaut ..." -ForegroundColor Cyan
-dotnet publish "$root\DeskCore.csproj" @common -o "$root\artifacts\app"
+dotnet publish "$root\DeskCore.csproj" -c Release -r win-x64 -p:SelfContained=false -p:PublishSingleFile=true -p:DebugType=none --nologo -v q -o "$root\artifacts\app"
if ($LASTEXITCODE -ne 0) { throw "Build der App fehlgeschlagen" }
New-Item -ItemType Directory -Force "$root\..\DeskCore" | Out-Null
Copy-Item "$root\artifacts\app\*" "$root\..\DeskCore\" -Force
Write-Host "2/2 Installer wird gebaut ..." -ForegroundColor Cyan
-dotnet publish "$root\Setup\DeskCoreSetup.csproj" @common -o "$root\..\DeskCore-Installer"
+# Der Installer läuft auf .NET Framework 4.8 (in Windows enthalten) und ist eine einzelne .exe
+dotnet build "$root\Setup\DeskCoreSetup.csproj" -c Release -p:DebugType=none --nologo -v q -o "$root\artifacts\setup"
if ($LASTEXITCODE -ne 0) { throw "Build des Installers fehlgeschlagen" }
+New-Item -ItemType Directory -Force "$root\..\DeskCore-Installer" | Out-Null
+Copy-Item "$root\artifacts\setup\DeskCore-Setup.exe" "$root\..\DeskCore-Installer\" -Force
+Copy-Item "$root\README.md" "$root\..\DeskCore-Installer\" -Force
-Get-Item "$root\..\DeskCore-Installer\DeskCore-Setup.exe" | Select-Object Name, @{n="Größe";e={"{0:N0} KB" -f ($_.Length/1KB)}}
+Get-Item "$root\..\DeskCore-Installer\DeskCore-Setup.exe" | Select-Object Name, @{n="Groesse";e={"{0:N0} KB" -f ($_.Length/1KB)}}
Write-Host "Fertig." -ForegroundColor Green