2 Commits
Author SHA1 Message Date
M_ViperandClaude Opus 5.5 724ec48686 Version 1.0.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:50:05 +02:00
M_ViperandClaude Opus 5.5 1ae6e1abc2 Fortschritt, .NET-Prüfung, Updates, Fenster merken, Box-Funktionen
- Große Kopien laufen im Hintergrund mit Fortschritt und Abbrechen;
  abgebrochene Kopien werden aufgeräumt, das Original bleibt unverändert
- Sichern/Wiederherstellen mit Fortschritt, keine halben ZIP-Dateien
- Installer auf .NET Framework 4.8: erkennt fehlende .NET 9 Desktop Runtime
  und installiert sie auf Wunsch (Microsoft-Signatur wird geprüft)
- Update-Hinweis über Gitea-Releases, Update mit automatischem Neustart
- Fenstergröße und -position werden gemerkt
- Desktop-Boxen: umbenennen, löschen, verschieben, herausziehen
- Fenster und Boxen kehren auf sichtbare Bildschirme zurück
- README ergänzt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:46:48 +02:00
20 changed files with 1103 additions and 98 deletions
+11
View File
@@ -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();
}
/// <summary>Tastenkürzel neu registrieren. false = von anderem Programm belegt.</summary>
@@ -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();
+96 -29
View File
@@ -4,17 +4,20 @@ using System.Text.Json;
namespace DeskCore;
/// <summary>Export/Import aller Kategorien samt Einstellungen als ZIP.</summary>
/// <summary>Export/Import aller Kategorien samt Einstellungen als ZIP – mit Fortschritt und Abbrechen.</summary>
public static class Backup
{
private const string CategoriesPrefix = "Kategorien/";
private const string SettingsEntry = "einstellungen.json";
private const int Chunk = 1 << 20;
public static long StorageSize()
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",
};
/// <summary>Gibt die Anzahl gesicherter Dateien zurück.</summary>
public static int Export(string zipPath)
/// <summary>Sichert alle Kategorien. Gibt die Anzahl gesicherter Dateien zurück.</summary>
public static int Export(string zipPath, Action<long>? onBytes = null, CancellationToken ct = default) =>
Export(zipPath, Storage.Root, Storage.Categories(), JsonSerializer.Serialize(AppSettings.Current, AppSettings.Json), onBytes, ct);
public static int Export(string zipPath, string root, IEnumerable<string> categories, string settingsJson,
Action<long>? onBytes = null, CancellationToken ct = default)
{
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);
if (File.Exists(tmp)) File.Delete(tmp);
// große Sicherungen bestehen meist aus Videos/Bildern → stärkere Kompression bringt kaum etwas
var level = StorageSize(root) > 1L << 30 ? CompressionLevel.Fastest : CompressionLevel.Optimal;
using (var zip = ZipFile.Open(tmp, ZipArchiveMode.Create))
{
foreach (var cat in categories)
{
var dir = Path.Combine(root, cat);
zip.CreateEntry(CategoriesPrefix + cat + "/"); // auch leere Kategorien sichern
foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
{
var rel = Path.GetRelativePath(Storage.Root, f).Replace('\\', '/');
zip.CreateEntryFromFile(f, CategoriesPrefix + rel, CompressionLevel.Optimal);
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(Storage.Root, d).Replace('\\', '/') + "/");
zip.CreateEntry(CategoriesPrefix + Path.GetRelativePath(root, d).Replace('\\', '/') + "/");
}
var entry = zip.CreateEntry(SettingsEntry);
using (var w = new StreamWriter(entry.Open()))
w.Write(JsonSerializer.Serialize(AppSettings.Current, AppSettings.Json));
var se = zip.CreateEntry(SettingsEntry);
using (var w = new StreamWriter(se.Open()))
w.Write(settingsJson);
}
// erst am Ende an den richtigen Namen – so bleibt nie eine halbe Sicherung mit gültigem Namen liegen
if (File.Exists(zipPath)) File.Delete(zipPath);
File.Move(tmp, zipPath);
return count;
}
catch
{
try { if (File.Exists(tmp)) File.Delete(tmp); } catch { }
throw;
}
}
/// <summary>Summe der unkomprimierten Größen (für die Fortschrittsanzeige beim Wiederherstellen).</summary>
public static long ArchiveSize(string zipPath)
{
using var zip = ZipFile.OpenRead(zipPath);
return zip.Entries.Sum(e => e.Length);
}
/// <summary>Führt eine Sicherung mit dem Bestand zusammen. Vorhandenes wird nicht überschrieben.</summary>
public static (int Added, int Skipped) Import(string zipPath)
public static (int Added, int Skipped) Import(string zipPath, Action<long>? onBytes = null, CancellationToken ct = default)
{
var (added, skipped, settings) = Import(zipPath, Storage.Root, onBytes, ct);
if (settings is not null) Merge(settings);
return (added, skipped);
}
public static (int Added, int Skipped, AppSettings? Settings) Import(string zipPath, string root,
Action<long>? onBytes = null, CancellationToken ct = default)
{
int added = 0, skipped = 0;
AppSettings? settings = null;
using var zip = ZipFile.OpenRead(zipPath);
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<AppSettings>(r.ReadToEnd(), AppSettings.Json);
}
catch { }
continue;
}
if (!e.FullName.StartsWith(CategoriesPrefix, StringComparison.OrdinalIgnoreCase)) continue;
var rel = e.FullName[CategoriesPrefix.Length..];
if (rel.Length == 0) continue;
@@ -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<AppSettings>(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<long>? onBytes, CancellationToken ct)
{
var buffer = new byte[Chunk];
int read;
while ((read = input.Read(buffer, 0, buffer.Length)) > 0)
{
ct.ThrowIfCancellationRequested();
output.Write(buffer, 0, read);
onBytes?.Invoke(read);
}
return (added, skipped);
}
private static void Merge(AppSettings from)
+5 -3
View File
@@ -1,6 +1,8 @@
<Project>
<!-- Eine Versionsnummer für App und Installer – nur hier ändern -->
<Project>
<!-- Eine Versionsnummer für App und Installer – nur hier ändern -->
<PropertyGroup>
<Version>1.0.0</Version>
<Version>1.0.1</Version>
<!-- keine Git-Kennung an die Produktversion anhängen (sonst "1.0.1+a1b2c3…") -->
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
</PropertyGroup>
</Project>
+1
View File
@@ -38,6 +38,7 @@
<ListBox x:Name="Items" Padding="6,0,6,8" SelectionMode="Extended" ItemContainerStyle="{StaticResource TileItem}"
AllowDrop="True" DragOver="Items_DragOver" Drop="Items_Drop"
MouseDoubleClick="Items_MouseDoubleClick" KeyDown="Items_KeyDown"
PreviewMouseLeftButtonDown="Items_PreviewMouseLeftButtonDown" PreviewMouseMove="Items_PreviewMouseMove"
ContextMenuOpening="Items_ContextMenuOpening">
<ListBox.ContextMenu>
<ContextMenu x:Name="ItemMenu"/>
+78 -10
View File
@@ -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;
}
/// <summary>Nach Bildschirm-Änderungen: Box wieder sichtbar machen.</summary>
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<ListBoxItem>(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);
+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}"); }
}
}
+51
View File
@@ -343,6 +343,22 @@
</Button>
</StackPanel>
<!-- Update verfügbar -->
<Border x:Name="UpdateBar" DockPanel.Dock="Top" Margin="36,20,36,0" Padding="16,10" CornerRadius="6" Visibility="Collapsed"
Background="{DynamicResource AccentSubtleBrush}" BorderBrush="{DynamicResource AccentBrush}" BorderThickness="1">
<DockPanel>
<TextBlock DockPanel.Dock="Left" Text="&#xE896;" FontFamily="{DynamicResource IconFont}" FontSize="18"
Foreground="{DynamicResource AccentBrush}" VerticalAlignment="Center" Margin="0,0,14,0"/>
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="UpdateNowButton" Content="Jetzt aktualisieren" Style="{StaticResource AccentButton}" Click="UpdateNow_Click" Margin="12,0,6,0"/>
<Button Content="Später" Style="{StaticResource SubtleButton}" Click="UpdateLater_Click" Margin="0,0,2,0"/>
<Button Content="Überspringen" Style="{StaticResource SubtleButton}" Click="UpdateSkip_Click"
ToolTip="Diese Version nicht mehr anzeigen"/>
</StackPanel>
<TextBlock x:Name="UpdateText" VerticalAlignment="Center" TextWrapping="Wrap"/>
</DockPanel>
</Border>
<!-- Hinweis (InfoBar) -->
<Border x:Name="InfoBar" DockPanel.Dock="Top" Margin="36,20,36,8" Padding="16,12" CornerRadius="6"
Background="{DynamicResource AccentSubtleBrush}" BorderBrush="{DynamicResource AccentSubtleStrokeBrush}" BorderThickness="1">
@@ -567,6 +583,22 @@
</DockPanel>
</Border>
<TextBlock Text="Updates" Style="{StaticResource SectionTitle}"/>
<Border Style="{StaticResource Card}">
<DockPanel>
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="CheckUpdateButton" Content="Jetzt prüfen" Click="CheckUpdate_Click" Margin="0,0,16,0"/>
<CheckBox x:Name="AutoUpdateToggle" Style="{StaticResource ToggleSwitch}" VerticalAlignment="Center" Click="AutoUpdateToggle_Click"/>
</StackPanel>
<TextBlock DockPanel.Dock="Left" Text="&#xE896;" FontFamily="{DynamicResource IconFont}" FontSize="18" VerticalAlignment="Center" Margin="0,0,16,0"/>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Automatisch nach Updates suchen"/>
<TextBlock x:Name="UpdateStatusText" FontSize="12" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}"
Text="Einmal täglich beim Start – du wirst gefragt, bevor etwas installiert wird"/>
</StackPanel>
</DockPanel>
</Border>
<TextBlock Text="Info" Style="{StaticResource SectionTitle}"/>
<Border Style="{StaticResource Card}">
<DockPanel>
@@ -621,5 +653,24 @@
<!-- Abdunkeln bei offenem Dialog -->
<Border x:Name="DimOverlay" Grid.RowSpan="2" Grid.ColumnSpan="2" Background="{DynamicResource OverlayBrush}" Visibility="Collapsed"/>
<!-- Fortschritt bei langen Vorgängen (große Ordner, Sicherung, Update) -->
<Grid x:Name="ProgressOverlay" Grid.Row="1" Grid.ColumnSpan="2" Background="{DynamicResource OverlayBrush}" Visibility="Collapsed">
<Border Width="460" HorizontalAlignment="Center" VerticalAlignment="Center" Padding="24,22" CornerRadius="8"
Background="{DynamicResource LayerBrush}" BorderBrush="{DynamicResource FlyoutStrokeBrush}" BorderThickness="1"
Effect="{StaticResource DialogShadow}">
<StackPanel>
<TextBlock x:Name="ProgressTitle" FontFamily="{DynamicResource DisplayFont}" FontSize="20" FontWeight="SemiBold"/>
<TextBlock x:Name="ProgressDetail" Margin="0,8,0,0" Foreground="{DynamicResource TextSecondaryBrush}" TextTrimming="CharacterEllipsis"/>
<Border x:Name="ProgressTrack" Height="4" CornerRadius="2" Margin="0,18,0,0" Background="{DynamicResource SubtleSelectedBrush}">
<Border x:Name="ProgressFill" HorizontalAlignment="Left" Width="0" CornerRadius="2" Background="{DynamicResource AccentBrush}"/>
</Border>
<DockPanel Margin="0,12,0,0">
<Button x:Name="ProgressCancel" DockPanel.Dock="Right" Content="Abbrechen" Click="ProgressCancel_Click" Width="110"/>
<TextBlock x:Name="ProgressStats" VerticalAlignment="Center" FontSize="12" Foreground="{DynamicResource TextSecondaryBrush}"/>
</DockPanel>
</StackPanel>
</Border>
</Grid>
</Grid>
</Window>
+83 -14
View File
@@ -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
/// <summary>Vom Watcher: Dateien haben sich geändert.</summary>
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<IconItem> sel)
public void DeleteIcons(List<IconItem> 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);
}
/// <summary>Kopiert/verschiebt Dateien in einen Ordner – mit Rückgängig und Rückmeldung.</summary>
public void TransferAll(IEnumerable<string> paths, string targetDir)
/// <summary>
/// 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.
/// </summary>
public async void TransferAll(IEnumerable<string> 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<string>(StringComparer.OrdinalIgnoreCase);
if (Storage.IsInStorage(targetDir))
{
const long Limit = 200L * 1024 * 1024;
var big = list.Where(p => Directory.Exists(p) && !Storage.IsInStorage(p))
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();
.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<TransferResult> 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<TransferResult>();
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}"); }
}
+19 -2
View File
@@ -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.
+39
View File
@@ -0,0 +1,39 @@
using System.Windows;
namespace DeskCore;
/// <summary>Bildschirm-Hilfen, damit Fenster nie außerhalb des sichtbaren Bereichs landen (z. B. nach Abstecken eines Monitors).</summary>
public static class Screens
{
/// <summary>Umrechnung Pixel → WPF-Einheiten (System-DPI).</summary>
private static double Scale
{
get
{
using var g = System.Drawing.Graphics.FromHwnd(IntPtr.Zero);
return g.DpiX / 96.0;
}
}
/// <summary>Arbeitsbereiche (ohne Taskleiste) aller angeschlossenen Bildschirme in WPF-Einheiten.</summary>
public static List<Rect> 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;
/// <summary>Ist genug vom Fenster (Titelleiste) auf irgendeinem Bildschirm sichtbar, um es greifen zu können?</summary>
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;
});
}
}
+12
View File
@@ -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<string> CategoryOrder { get; set; } = [];
public Dictionary<string, CategoryMeta> Categories { get; set; } = new(StringComparer.OrdinalIgnoreCase);
+10 -3
View File
@@ -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)
+17 -2
View File
@@ -1,10 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0-windows</TargetFramework>
<!-- .NET Framework 4.8 ist in jedem Windows 10/11 enthalten → der Installer läuft immer
und kann die für DeskCore nötige .NET-9-Laufzeit selbst nachinstallieren -->
<TargetFramework>net48</TargetFramework>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PlatformTarget>AnyCPU</PlatformTarget>
<Prefer32Bit>false</Prefer32Bit>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<AssemblyName>DeskCore-Setup</AssemblyName>
<RootNamespace>DeskCoreSetup</RootNamespace>
<ApplicationIcon>..\app.ico</ApplicationIcon>
@@ -15,9 +22,17 @@
<Authors>M_Viper</Authors>
<Copyright>Copyright (c) M_Viper. Alle Rechte vorbehalten.</Copyright>
<NeutralLanguage>de-DE</NeutralLanguage>
<!-- fertig veröffentlichte DeskCore.exe, die eingepackt wird (build.ps1 legt sie hier ab) -->
<!-- fertig veröffentlichte DeskCore.exe, die eingepackt wird (build.ps1 legt sie hier ab) -->
<AppPayload Condition="'$(AppPayload)' == ''">..\artifacts\app\DeskCore.exe</AppPayload>
</PropertyGroup>
<ItemGroup>
<!-- WinForms nur für den Ordner-Dialog; nicht global, sonst Namenskonflikte mit WPF -->
<Using Remove="System.Windows.Forms" />
<Using Remove="System.Drawing" />
<Reference Include="Microsoft.CSharp" />
<Reference Include="Microsoft.VisualBasic" />
<Reference Include="System.Net.Http" />
</ItemGroup>
<ItemGroup>
<!-- gleiches Design wie die App -->
<Page Include="..\Themes\Colors.Light.xaml" Link="Themes\Colors.Light.xaml" />
+26 -6
View File
@@ -23,6 +23,9 @@ public static class Installer
}
}
/// <summary>Pfad dieses Setups (auch als Deinstallationsprogramm).</summary>
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 ----------
/// <summary>Rechnet Fortschritt 0–1 in einen Teilbereich um.</summary>
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));
}
/// <summary>Laufendes DeskCore beenden (das X versteckt es nur im Infobereich).</summary>
private static void CloseRunningApp()
{
@@ -247,7 +267,7 @@ public static class Installer
{
try
{
p.Kill(entireProcessTree: true);
p.Kill();
p.WaitForExit(10000);
}
catch { }
+18
View File
@@ -79,6 +79,14 @@
Text="DeskCore sortiert deine Desktop-Icons in eigene Kategorien – mit Suche, Desktop-Boxen, automatischem Einsortieren und Rückgängig-Funktion."/>
<TextBlock Style="{StaticResource Body}" Margin="0,14,0,0"
Text="Der Assistent installiert DeskCore nur für dein Benutzerkonto. Administratorrechte sind nicht nötig."/>
<Border x:Name="RuntimeHint" Margin="0,22,0,0" Padding="14,10" CornerRadius="6" Visibility="Collapsed"
Background="{DynamicResource ControlFillBrush}" BorderBrush="{DynamicResource ControlStrokeBrush}" BorderThickness="1">
<DockPanel>
<TextBlock DockPanel.Dock="Left" Text="&#xE7BA;" FontFamily="{DynamicResource IconFont}" FontSize="16"
Foreground="{DynamicResource AccentBrush}" Margin="0,2,12,0" VerticalAlignment="Top"/>
<TextBlock x:Name="RuntimeHintText" TextWrapping="Wrap" LineHeight="20"/>
</DockPanel>
</Border>
<Border x:Name="UpdateHint" Margin="0,22,0,0" Padding="14,10" CornerRadius="6" Visibility="Collapsed"
Background="{DynamicResource AccentSubtleBrush}" BorderBrush="{DynamicResource AccentSubtleStrokeBrush}" BorderThickness="1">
<DockPanel>
@@ -113,6 +121,8 @@
<CheckBox x:Name="DesktopLinkBox" Content="Verknüpfung auf dem Desktop" Margin="0,24,0,0" IsChecked="True"/>
<CheckBox x:Name="StartMenuBox" Content="Eintrag im Startmenü" Margin="0,12,0,0" IsChecked="True" IsEnabled="False"/>
<CheckBox x:Name="AutostartBox" Content="Mit Windows starten (unauffällig im Infobereich)" Margin="0,12,0,0"/>
<CheckBox x:Name="RuntimeBox" Margin="0,12,0,0" IsChecked="True" Visibility="Collapsed"
Content="Microsoft .NET 9 Desktop Runtime automatisch installieren (benötigt, ca. 55 MB)"/>
</StackPanel>
<!-- 3: Fortschritt / Fertig -->
@@ -130,6 +140,14 @@
</Border>
<TextBlock x:Name="DoneText" VerticalAlignment="Center" Margin="12,0,0,0" FontWeight="SemiBold"/>
</StackPanel>
<Border x:Name="RuntimeMissing" Margin="0,18,0,0" Padding="14,10" CornerRadius="6" Visibility="Collapsed"
Background="{DynamicResource AccentSubtleBrush}" BorderBrush="{DynamicResource AccentSubtleStrokeBrush}" BorderThickness="1">
<StackPanel>
<TextBlock TextWrapping="Wrap" LineHeight="20"
Text="Damit DeskCore startet, wird noch die kostenlose „Microsoft .NET 9 Desktop Runtime“ benötigt."/>
<Button Content="Download-Seite von Microsoft öffnen" HorizontalAlignment="Left" Margin="0,10,0,0" Click="OpenRuntimePage_Click"/>
</StackPanel>
</Border>
<CheckBox x:Name="LaunchBox" Content="DeskCore jetzt starten" IsChecked="True" Margin="0,22,0,0"/>
</StackPanel>
</StackPanel>
+35 -5
View File
@@ -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)
+169
View File
@@ -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;
/// <summary>Prüft und installiert die von DeskCore benötigte "Microsoft .NET 9 Desktop Runtime".</summary>
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;
}
}
/// <summary>Lädt die Laufzeit von Microsoft und installiert sie (Windows fragt einmal nach Administratorrechten).</summary>
public static void Install(Action<double, string> 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<WINTRUST_FILE_INFO>(),
pcwszFilePath = file,
};
var pFile = Marshal.AllocHGlobal(Marshal.SizeOf<WINTRUST_FILE_INFO>());
try
{
Marshal.StructureToPtr(fileInfo, pFile, false);
var data = new WINTRUST_DATA
{
cbStruct = (uint)Marshal.SizeOf<WINTRUST_DATA>(),
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);
}
+72 -12
View File
@@ -3,7 +3,7 @@ using System.IO;
namespace DeskCore;
public enum TransferStatus { Ok, Skipped, Error }
public enum TransferStatus { Ok, Skipped, Error, Cancelled }
/// <param name="Copied">true = kopiert (Original bleibt), false = verschoben</param>
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.
/// </summary>
public static TransferResult Transfer(string source, string targetDir, bool forceMove = false)
public static TransferResult Transfer(string source, string targetDir, bool forceMove = false,
Action<long>? 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
}
/// <summary>Verschiebt Datei oder Ordner – auch zwischen Laufwerken.</summary>
public static void MoveAny(string source, string dest)
public static void MoveAny(string source, string dest, Action<long>? 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<long>? 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);
}
/// <summary>Benennt ein Element um. Gibt den neuen Pfad zurück.</summary>
@@ -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<long>? 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);
}
/// <summary>Kopiert in 1-MB-Stücken, damit Fortschritt angezeigt und abgebrochen werden kann.</summary>
private static void CopyFile(string source, string dest, Action<long>? 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 { }
}
/// <summary>Wie viele Bytes müssen für diese Übertragung tatsächlich kopiert werden? (0 = nur umbenennen)</summary>
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; }
}
}
+102
View File
@@ -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);
/// <summary>Fragt das neueste Release auf Gitea ab (https://git.viper.ipv64.net/M_Viper/DeskCore/releases).</summary>
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;
}
/// <summary>Neuere Version oder null. Wirft bei Netzwerk-/Serverfehlern.</summary>
public static async Task<UpdateInfo?> 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);
}
/// <summary>Lädt den Installer in den Temp-Ordner und gibt den Pfad zurück.</summary>
public static async Task<string> DownloadSetupAsync(UpdateInfo info, Action<long, long>? 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;
}
}
+8 -5
View File
@@ -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