Fortschritt, .NET-Prüfung, Updates, Fenster merken, Box-Funktionen
- Große Kopien laufen im Hintergrund mit Fortschritt und Abbrechen; abgebrochene Kopien werden aufgeräumt, das Original bleibt unverändert - Sichern/Wiederherstellen mit Fortschritt, keine halben ZIP-Dateien - Installer auf .NET Framework 4.8: erkennt fehlende .NET 9 Desktop Runtime und installiert sie auf Wunsch (Microsoft-Signatur wird geprüft) - Update-Hinweis über Gitea-Releases, Update mit automatischem Neustart - Fenstergröße und -position werden gemerkt - Desktop-Boxen: umbenennen, löschen, verschieben, herausziehen - Fenster und Boxen kehren auf sichtbare Bildschirme zurück - README ergänzt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+11
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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)
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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}"); }
|
||||
}
|
||||
}
|
||||
@@ -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="" 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="" 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>
|
||||
|
||||
+84
-15
@@ -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))
|
||||
.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<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}"); }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 { }
|
||||
|
||||
@@ -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="" 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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user