Files
DeskCore/Storage.cs
T
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

393 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Concurrent;
using System.IO;
namespace DeskCore;
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);
/// <summary>
/// Kategorien sind einfache Unterordner von %USERPROFILE%\DeskCore.
/// Icons vom Desktop werden dorthin kopiert – es bleiben normale Dateien.
/// </summary>
public static class Storage
{
public static readonly string Root = Migration.Pick(Environment.SpecialFolder.UserProfile);
public static readonly string UserDesktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
public static readonly string PublicDesktop = Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory);
/// <summary>Hier liegen gelöschte Elemente, solange man sie noch rückgängig machen kann.</summary>
public static readonly string Holding = Migration.Pick(Environment.SpecialFolder.LocalApplicationData, "Geloescht");
// ---------- Kategorien ----------
public static List<string> Categories()
{
Directory.CreateDirectory(Root);
var order = AppSettings.Current.CategoryOrder;
return Directory.GetDirectories(Root)
.Select(Path.GetFileName)
.OfType<string>()
.OrderBy(n => order.FindIndex(o => string.Equals(o, n, StringComparison.OrdinalIgnoreCase)) is var i and >= 0 ? i : int.MaxValue)
.ThenBy(n => n, StringComparer.CurrentCultureIgnoreCase)
.ToList();
}
public static string CategoryPath(string name) => Path.Combine(Root, name);
public static IEnumerable<string> Entries(string dir)
{
if (!Directory.Exists(dir)) return [];
try
{
return Directory.EnumerateFileSystemEntries(dir)
.Where(p =>
{
var n = Path.GetFileName(p);
if (n.Equals("desktop.ini", StringComparison.OrdinalIgnoreCase)) return false;
try
{
var attr = File.GetAttributes(p);
return !attr.HasFlag(FileAttributes.Hidden) && !attr.HasFlag(FileAttributes.System);
}
catch { return false; }
})
.ToList();
}
catch { return []; }
}
public static IEnumerable<string> DesktopEntries() =>
Entries(UserDesktop).Concat(Entries(PublicDesktop));
public static bool IsDesktopDir(string dir) =>
SamePath(dir, UserDesktop) || SamePath(dir, PublicDesktop);
public static bool IsInStorage(string path) =>
path.StartsWith(Root.TrimEnd('\\') + "\\", StringComparison.OrdinalIgnoreCase);
public static string? ValidateName(string name)
{
name = name.Trim();
if (name.Length == 0) return "Bitte einen Namen eingeben.";
if (name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) return "Der Name enthält ungültige Zeichen ( \\ / : * ? \" < > | ).";
if (name is "." or ".." || name.EndsWith('.')) return "Ungültiger Name.";
return null;
}
// ---------- Duplikate ----------
private static readonly ConcurrentDictionary<string, (DateTime Stamp, string? Target)> _lnkCache = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Ziel einer Verknüpfung (.lnk/.url), sonst null.</summary>
public static string? ShortcutTarget(string path)
{
var ext = Path.GetExtension(path).ToLowerInvariant();
if (ext is not (".lnk" or ".url")) return null;
DateTime stamp;
try { stamp = File.GetLastWriteTimeUtc(path); } catch { return null; }
if (_lnkCache.TryGetValue(path, out var c) && c.Stamp == stamp) return c.Target;
string? target = null;
try
{
if (ext == ".url")
{
target = File.ReadLines(path).FirstOrDefault(l => l.StartsWith("URL=", StringComparison.OrdinalIgnoreCase))?[4..];
}
else
{
dynamic shell = Activator.CreateInstance(Type.GetTypeFromProgID("WScript.Shell")!)!;
dynamic lnk = shell.CreateShortcut(path);
string t = lnk.TargetPath;
string a = lnk.Arguments;
target = string.IsNullOrEmpty(t) ? null : (t + " " + a).Trim();
}
}
catch { }
if (string.IsNullOrWhiteSpace(target)) target = null;
_lnkCache[path] = (stamp, target);
return target;
}
/// <summary>Gleicher Dateiname oder Verknüpfung mit gleichem Ziel?</summary>
public static bool SameItem(string a, string b)
{
if (string.Equals(Path.GetFileName(a), Path.GetFileName(b), StringComparison.OrdinalIgnoreCase)) return true;
var ta = ShortcutTarget(a);
return ta is not null && string.Equals(ta, ShortcutTarget(b), StringComparison.OrdinalIgnoreCase);
}
/// <summary>Liegt das Element (oder ein gleichwertiges) schon in diesem Ordner?</summary>
public static string? FindEquivalent(string dir, string item) =>
Entries(dir).FirstOrDefault(e => !SamePath(e, item) && SameItem(e, item));
/// <summary>In welcher Kategorie liegt das Element bereits? (für das ✓ auf dem Desktop)</summary>
public static string? FindCategoryOf(string item, IReadOnlyDictionary<string, List<string>> categoryEntries)
{
foreach (var (cat, entries) in categoryEntries)
if (entries.Any(e => SameItem(e, item))) return cat;
return null;
}
// ---------- Übertragen ----------
/// <summary>
/// Übernimmt Datei/Ordner nach targetDir. Kommt die Quelle von außerhalb der App (z. B. vom Desktop),
/// 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,
Action<long>? onBytes = null, CancellationToken ct = default)
{
if (ct.IsCancellationRequested) return new(TransferStatus.Cancelled, source);
var name = Path.GetFileName(source.TrimEnd('\\'));
try
{
if (SamePath(Path.GetDirectoryName(source), targetDir))
return new(TransferStatus.Skipped, source, Message: $"„{name}“ ist schon dort.");
if (Directory.Exists(source) &&
(targetDir.TrimEnd('\\') + "\\").StartsWith(source.TrimEnd('\\') + "\\", StringComparison.OrdinalIgnoreCase))
return new(TransferStatus.Error, source, Message: $"„{name}“ kann nicht in sich selbst verschoben werden.");
bool copy = !forceMove && !IsInStorage(source) && IsInStorage(targetDir);
if (FindEquivalent(targetDir, source) is { } existing)
{
var where = IsDesktopDir(targetDir) ? "auf dem Desktop" : $"in „{Path.GetFileName(targetDir)}“";
return new(TransferStatus.Skipped, source, existing, Message: $"„{DisplayName(source)}“ ist schon {where}.");
}
Directory.CreateDirectory(targetDir);
var dest = UniquePath(targetDir, name);
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)
{
return new(TransferStatus.Error, source, Message: $"„{name}“: Keine Berechtigung. " +
"(Icons auf dem öffentlichen Desktop brauchen Administratorrechte – App ggf. als Administrator starten.)");
}
catch (Exception ex)
{
return new(TransferStatus.Error, source, Message: $"„{name}“: {ex.Message}");
}
}
/// <summary>Verschiebt Datei oder Ordner – auch zwischen Laufwerken.</summary>
public static void MoveAny(string source, string dest, Action<long>? onBytes = null, CancellationToken ct = default)
{
NoteCreated(dest);
if (File.Exists(source))
{
File.Move(source, dest);
return;
}
if (string.Equals(Path.GetPathRoot(source), Path.GetPathRoot(dest), StringComparison.OrdinalIgnoreCase))
{
Directory.Move(source, dest);
return;
}
CopyDirectory(source, dest, onBytes, ct);
Directory.Delete(source, recursive: true);
}
public static void CopyAny(string source, string dest, Action<long>? onBytes = null, CancellationToken ct = default)
{
NoteCreated(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>
public static string Rename(string path, string newName)
{
var dir = Path.GetDirectoryName(path)!;
var dest = Path.Combine(dir, newName);
if (string.Equals(path, dest, StringComparison.Ordinal)) return path;
if (string.Equals(path, dest, StringComparison.OrdinalIgnoreCase))
{ // nur Groß-/Kleinschreibung geändert
var tmp = path + ".umbenennen";
MoveAny(path, tmp);
MoveAny(tmp, dest);
return dest;
}
if (File.Exists(dest) || Directory.Exists(dest)) throw new IOException($"„{newName}“ gibt es dort schon.");
MoveAny(path, dest);
return dest;
}
/// <summary>
/// "Löscht" ein Element: es wandert erst in einen internen Ordner, damit Strg+Z funktioniert.
/// Erst wenn es nicht mehr rückgängig gemacht werden kann, geht es in den Papierkorb.
/// </summary>
public static string Delete(string path)
{
var dir = Path.Combine(Holding, Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var dest = Path.Combine(dir, Path.GetFileName(path.TrimEnd('\\')));
MoveAny(path, dest);
return dest;
}
/// <summary>Endgültig in den Windows-Papierkorb.</summary>
public static void Recycle(string path)
{
try
{
if (Directory.Exists(path))
Microsoft.VisualBasic.FileIO.FileSystem.DeleteDirectory(path,
Microsoft.VisualBasic.FileIO.UIOption.OnlyErrorDialogs, Microsoft.VisualBasic.FileIO.RecycleOption.SendToRecycleBin);
else if (File.Exists(path))
Microsoft.VisualBasic.FileIO.FileSystem.DeleteFile(path,
Microsoft.VisualBasic.FileIO.UIOption.OnlyErrorDialogs, Microsoft.VisualBasic.FileIO.RecycleOption.SendToRecycleBin);
// leeren Hilfsordner aufräumen
var parent = Path.GetDirectoryName(path);
if (parent is not null && IsUnder(parent, Holding) && Directory.Exists(parent) && !Directory.EnumerateFileSystemEntries(parent).Any())
Directory.Delete(parent);
}
catch { }
}
/// <summary>Reste von einem Absturz in den Papierkorb legen.</summary>
public static void FlushHolding()
{
if (!Directory.Exists(Holding)) return;
foreach (var d in Directory.GetDirectories(Holding))
foreach (var e in Directory.EnumerateFileSystemEntries(d).ToList())
Recycle(e);
}
// ---------- Selbst angelegte Desktop-Elemente merken (damit die Auto-Sortierung sie ignoriert) ----------
private static readonly ConcurrentDictionary<string, DateTime> _created = new(StringComparer.OrdinalIgnoreCase);
private static void NoteCreated(string path) => _created[path] = DateTime.UtcNow;
public static bool CreatedByApp(string path) =>
_created.TryGetValue(path, out var t) && DateTime.UtcNow - t < TimeSpan.FromSeconds(15);
/// <summary>Größe eines Ordners in Bytes (bricht ab, sobald limit überschritten ist).</summary>
public static long DirectorySize(string dir, long limit = long.MaxValue)
{
long sum = 0;
try
{
foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
{
try { sum += new FileInfo(f).Length; } catch { }
if (sum > limit) break;
}
}
catch { }
return sum;
}
// ---------- Helfer ----------
public static string DisplayName(string path)
{
var n = Path.GetFileName(path.TrimEnd('\\'));
var ext = Path.GetExtension(n).ToLowerInvariant();
return ext is ".lnk" or ".url" or ".appref-ms" ? Path.GetFileNameWithoutExtension(n) : n;
}
/// <summary>Endung, die beim Umbenennen erhalten bleibt (bei Verknüpfungen versteckt).</summary>
public static string HiddenExtension(string path)
{
var ext = Path.GetExtension(path);
return ext.ToLowerInvariant() is ".lnk" or ".url" or ".appref-ms" ? ext : "";
}
public static bool SamePath(string? a, string? b) =>
a is not null && b is not null &&
string.Equals(Path.GetFullPath(a).TrimEnd('\\'), Path.GetFullPath(b).TrimEnd('\\'), StringComparison.OrdinalIgnoreCase);
public static bool IsUnder(string path, string dir) =>
(Path.GetFullPath(path).TrimEnd('\\') + "\\").StartsWith(Path.GetFullPath(dir).TrimEnd('\\') + "\\", StringComparison.OrdinalIgnoreCase);
public static string UniquePath(string dir, string fileName)
{
var path = Path.Combine(dir, fileName);
if (!File.Exists(path) && !Directory.Exists(path)) return path;
var stem = Path.GetFileNameWithoutExtension(fileName);
var ext = Path.GetExtension(fileName);
for (int i = 2; ; i++)
{
path = Path.Combine(dir, $"{stem} ({i}){ext}");
if (!File.Exists(path) && !Directory.Exists(path)) return path;
}
}
private static void CopyDirectory(string source, string dest, Action<long>? onBytes, CancellationToken ct)
{
Directory.CreateDirectory(dest);
foreach (var f in Directory.GetFiles(source))
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)), 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; }
}
}