- 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>
393 lines
17 KiB
C#
393 lines
17 KiB
C#
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; }
|
||
}
|
||
}
|