504 lines
21 KiB
C#
504 lines
21 KiB
C#
using System.Diagnostics;
|
||
using System.IO;
|
||
using System.IO.Compression;
|
||
using System.Reflection;
|
||
using System.Text.RegularExpressions;
|
||
using Microsoft.Win32;
|
||
|
||
namespace AeroMCSetup;
|
||
|
||
/// <summary>Installation/Deinstallation – nur für den aktuellen Benutzer, ohne Administratorrechte.</summary>
|
||
public static class Installer
|
||
{
|
||
public const string AppName = "AeroMC";
|
||
public const string Publisher = "M_Viper";
|
||
public const string ExeName = "AeroMC.exe";
|
||
// Gleicher Name wie beim früheren NSIS-Setup: der Launcher erkennt daran eine Setup-Installation
|
||
// und bietet Updates als Setup an (src/updater.js → hasUninstaller).
|
||
public const string UninstallerName = "Uninstall AeroMC.exe";
|
||
// wie "appId" in package.json – gleiche Taskleisten-/Benachrichtigungs-Zuordnung wie bisher
|
||
public const string AppUserModelId = "com.aeromc.launcher";
|
||
|
||
// Schlüssel vom alten electron-builder-Setup übernommen → in "Apps" bleibt es ein einziger Eintrag
|
||
private const string KeyId = "12814222-87ee-5ae1-9e10-8eb2741fedd1";
|
||
private const string UninstallKey = @"Software\Microsoft\Windows\CurrentVersion\Uninstall\" + KeyId;
|
||
private const string LegacyAppKey = @"Software\" + KeyId;
|
||
private const string RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run";
|
||
private const string FileListName = "install-files.txt";
|
||
|
||
public static string Version
|
||
{
|
||
get
|
||
{
|
||
var info = typeof(Installer).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
|
||
if (!string.IsNullOrEmpty(info)) return info!.Split('+')[0];
|
||
var v = typeof(Installer).Assembly.GetName().Version!;
|
||
return $"{v.Major}.{v.Minor}.{v.Build}";
|
||
}
|
||
}
|
||
|
||
/// <summary>Pfad dieses Setups (bzw. des Deinstallationsprogramms).</summary>
|
||
public static string SelfPath => Assembly.GetExecutingAssembly().Location;
|
||
|
||
public static string DefaultDir =>
|
||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", AppName);
|
||
|
||
/// <summary>Launcher-Daten: Einstellungen, Konten, Java und (standardmäßig) die Instanzen.</summary>
|
||
public static string DataDir =>
|
||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "aeromc");
|
||
|
||
private static string StartMenuLink =>
|
||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Programs), AppName + ".lnk");
|
||
|
||
private static string DesktopLink =>
|
||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), AppName + ".lnk");
|
||
|
||
// ---------- vorhandene Installation ----------
|
||
|
||
private static string? RegValue(string name)
|
||
{
|
||
using var k = Registry.CurrentUser.OpenSubKey(UninstallKey);
|
||
return k?.GetValue(name) as string;
|
||
}
|
||
|
||
/// <summary>Ordner einer vorhandenen Installation (dieses Setups oder des alten NSIS-Setups), oder null.</summary>
|
||
public static string? InstalledDir
|
||
{
|
||
get
|
||
{
|
||
var dir = RegValue("InstallLocation");
|
||
if (string.IsNullOrWhiteSpace(dir))
|
||
{
|
||
// altes NSIS-Setup trägt keinen InstallLocation ein → aus dem Deinstallationsbefehl ablesen
|
||
var m = Regex.Match(RegValue("UninstallString") ?? "", "^\"([^\"]+)\"");
|
||
dir = m.Success ? Path.GetDirectoryName(m.Groups[1].Value) : null;
|
||
}
|
||
return dir is not null && File.Exists(Path.Combine(dir, ExeName)) ? dir : null;
|
||
}
|
||
}
|
||
|
||
/// <summary>Stammt die vorhandene Installation noch vom alten NSIS-Setup?</summary>
|
||
public static bool IsLegacyInstall =>
|
||
InstalledDir is not null && (RegValue("UninstallString") ?? "").IndexOf("--uninstall", StringComparison.OrdinalIgnoreCase) < 0;
|
||
|
||
public static string? InstalledVersion => RegValue("DisplayVersion");
|
||
|
||
public static bool DesktopLinkExists => File.Exists(DesktopLink);
|
||
|
||
public static bool IsRunning(string? dir) => AppProcesses(dir, InstalledDir).Any();
|
||
|
||
public static string LicenseText
|
||
{
|
||
get
|
||
{
|
||
using var s = typeof(Installer).Assembly.GetManifestResourceStream("LICENSE.txt");
|
||
if (s is null) return "";
|
||
using var r = new StreamReader(s);
|
||
return r.ReadToEnd().Trim();
|
||
}
|
||
}
|
||
|
||
/// <summary>Eigener Instanzen-Ordner außerhalb der Launcher-Daten (falls in den Einstellungen gesetzt), sonst null.</summary>
|
||
public static string? CustomInstancesDir
|
||
{
|
||
get
|
||
{
|
||
try
|
||
{
|
||
var json = File.ReadAllText(Path.Combine(DataDir, "settings.json"));
|
||
var m = Regex.Match(json, "\"instancesDir\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"");
|
||
if (!m.Success) return null;
|
||
var dir = Regex.Unescape(m.Groups[1].Value).Trim();
|
||
// liegt er doch in den Launcher-Daten (z. B. ausdrücklich der Standardordner), ist er nicht "eigen"
|
||
return dir.Length > 0 && !IsUnder(dir, DataDir) && !SamePath(dir, DataDir) ? dir : null;
|
||
}
|
||
catch { return null; }
|
||
}
|
||
}
|
||
|
||
// ---------- Installieren ----------
|
||
|
||
/// <param name="waitForExit">nach einem Update aus dem Launcher: ihm erst Zeit geben, sich selbst zu beenden</param>
|
||
public static void Install(string dir, bool desktopLink, IProgress<(double, string)> progress, bool waitForExit = false)
|
||
{
|
||
if (!Environment.Is64BitOperatingSystem)
|
||
throw new InvalidOperationException("AeroMC benötigt ein 64-Bit-Windows (Windows 10 oder 11).");
|
||
|
||
var oldDir = InstalledDir;
|
||
progress.Report((0.02, "AeroMC wird beendet …"));
|
||
CloseRunningApp(waitForExit ? 20000 : 0, dir, oldDir);
|
||
|
||
using var zip = Payload.OpenArchive();
|
||
var topLevel = TopLevelNames(zip);
|
||
|
||
progress.Report((0.05, "Alte Version wird entfernt …"));
|
||
Directory.CreateDirectory(dir);
|
||
RemoveAppFiles(dir, topLevel);
|
||
if (oldDir is not null && !SamePath(oldDir, dir))
|
||
{
|
||
// Installation wurde in einen anderen Ordner verlegt → alten Ordner aufräumen
|
||
var oldExe = Path.Combine(oldDir, ExeName);
|
||
DeleteIfPointsTo(StartMenuLink, oldExe);
|
||
DeleteIfPointsTo(DesktopLink, oldExe);
|
||
RemoveAppFiles(oldDir, topLevel);
|
||
try { Directory.Delete(oldDir); } catch { } // nur wenn leer
|
||
}
|
||
|
||
Extract(zip, dir, p => progress.Report((0.08 + p * 0.8, "Dateien werden kopiert …")));
|
||
|
||
progress.Report((0.9, "Deinstallationsprogramm wird angelegt …"));
|
||
var exe = Path.Combine(dir, ExeName);
|
||
var uninstaller = Path.Combine(dir, UninstallerName);
|
||
if (!SamePath(SelfPath, uninstaller))
|
||
{
|
||
MoveAside(uninstaller);
|
||
CopyStub(uninstaller);
|
||
}
|
||
File.WriteAllLines(Path.Combine(dir, FileListName), topLevel.Concat([UninstallerName, FileListName]));
|
||
|
||
progress.Report((0.94, "Verknüpfungen werden angelegt …"));
|
||
Shortcut.Create(StartMenuLink, exe, "Minecraft-Launcher", AppUserModelId);
|
||
if (desktopLink) Shortcut.Create(DesktopLink, exe, "Minecraft-Launcher", AppUserModelId);
|
||
else DeleteIfPointsTo(DesktopLink, exe);
|
||
|
||
progress.Report((0.97, "Windows wird eingerichtet …"));
|
||
Registry.CurrentUser.DeleteSubKeyTree(UninstallKey, throwOnMissingSubKey: false); // Reste des alten Setups
|
||
Registry.CurrentUser.DeleteSubKeyTree(LegacyAppKey, throwOnMissingSubKey: false);
|
||
using (var k = Registry.CurrentUser.CreateSubKey(UninstallKey))
|
||
{
|
||
long size = DirSize(dir) / 1024;
|
||
k.SetValue("DisplayName", AppName);
|
||
k.SetValue("DisplayVersion", Version);
|
||
k.SetValue("Publisher", Publisher);
|
||
k.SetValue("DisplayIcon", $"{exe},0");
|
||
k.SetValue("InstallLocation", dir);
|
||
k.SetValue("UninstallString", $"\"{uninstaller}\" --uninstall");
|
||
k.SetValue("QuietUninstallString", $"\"{uninstaller}\" --uninstall /S");
|
||
k.SetValue("URLInfoAbout", "https://m-viper.de");
|
||
k.SetValue("InstallDate", DateTime.Now.ToString("yyyyMMdd"));
|
||
k.SetValue("EstimatedSize", (int)Math.Min(int.MaxValue, size), RegistryValueKind.DWord);
|
||
k.SetValue("NoModify", 1, RegistryValueKind.DWord);
|
||
k.SetValue("NoRepair", 1, RegistryValueKind.DWord);
|
||
}
|
||
|
||
progress.Report((1.0, "Fertig"));
|
||
}
|
||
|
||
private static List<string> TopLevelNames(ZipArchive zip) =>
|
||
zip.Entries
|
||
.Select(e => e.FullName.Replace('\\', '/').Split('/')[0])
|
||
.Where(n => n.Length > 0)
|
||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
|
||
private static void Extract(ZipArchive zip, string dir, Action<double> report)
|
||
{
|
||
var root = Path.GetFullPath(dir).TrimEnd('\\') + "\\";
|
||
long total = Math.Max(1, zip.Entries.Sum(e => e.Length)), done = 0;
|
||
var buffer = new byte[1 << 20];
|
||
int lastPercent = -1;
|
||
|
||
foreach (var entry in zip.Entries)
|
||
{
|
||
var target = Path.GetFullPath(Path.Combine(dir, entry.FullName));
|
||
if (!target.StartsWith(root, StringComparison.OrdinalIgnoreCase)) continue; // nie außerhalb des Ordners schreiben
|
||
if (entry.Name.Length == 0)
|
||
{
|
||
Directory.CreateDirectory(target);
|
||
continue;
|
||
}
|
||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||
long before = done;
|
||
Retry(target, () =>
|
||
{
|
||
done = before;
|
||
MoveAside(target);
|
||
using var src = entry.Open();
|
||
using var dst = File.Create(target);
|
||
int n;
|
||
while ((n = src.Read(buffer, 0, buffer.Length)) > 0)
|
||
{
|
||
dst.Write(buffer, 0, n);
|
||
done += n;
|
||
int percent = (int)(done * 100 / total);
|
||
if (percent != lastPercent)
|
||
{
|
||
lastPercent = percent;
|
||
report(Math.Min(1.0, (double)done / total));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
report(1.0);
|
||
}
|
||
|
||
/// <summary>Setup ohne angehängte App als Deinstallationsprogramm ablegen (nur ein paar hundert KB).</summary>
|
||
private static void CopyStub(string path)
|
||
{
|
||
long length = Payload.StubLength;
|
||
using var src = File.OpenRead(SelfPath);
|
||
using var dst = File.Create(path);
|
||
var buffer = new byte[81920];
|
||
long left = length;
|
||
while (left > 0)
|
||
{
|
||
int n = src.Read(buffer, 0, (int)Math.Min(buffer.Length, left));
|
||
if (n <= 0) break;
|
||
dst.Write(buffer, 0, n);
|
||
left -= n;
|
||
}
|
||
}
|
||
|
||
// ---------- Deinstallieren ----------
|
||
|
||
public static void Uninstall(bool removeData, IProgress<(double, string)> progress)
|
||
{
|
||
// Nur im eingetragenen Installationsordner löschen – nie dort, wo das Setup zufällig liegt (z. B. Downloads)
|
||
var dir = InstalledDir;
|
||
var exe = dir is null ? "" : Path.Combine(dir, ExeName);
|
||
|
||
progress.Report((0.1, "AeroMC wird beendet …"));
|
||
CloseRunningApp(0, dir);
|
||
|
||
progress.Report((0.3, "Verknüpfungen werden entfernt …"));
|
||
DeleteIfPointsTo(StartMenuLink, exe);
|
||
DeleteIfPointsTo(DesktopLink, exe);
|
||
|
||
progress.Report((0.45, "Windows-Einträge werden entfernt …"));
|
||
RemoveAutostart(exe);
|
||
Registry.CurrentUser.DeleteSubKeyTree(UninstallKey, throwOnMissingSubKey: false);
|
||
Registry.CurrentUser.DeleteSubKeyTree(LegacyAppKey, throwOnMissingSubKey: false);
|
||
// Name/Logo für Windows-Benachrichtigungen (legt der Launcher selbst an)
|
||
Registry.CurrentUser.DeleteSubKeyTree(@"Software\Classes\AppUserModelId\" + AppUserModelId, throwOnMissingSubKey: false);
|
||
|
||
if (removeData && Directory.Exists(DataDir))
|
||
{
|
||
progress.Report((0.6, "Instanzen, Konten und Einstellungen werden in den Papierkorb gelegt …"));
|
||
try
|
||
{
|
||
Microsoft.VisualBasic.FileIO.FileSystem.DeleteDirectory(DataDir,
|
||
Microsoft.VisualBasic.FileIO.UIOption.OnlyErrorDialogs,
|
||
Microsoft.VisualBasic.FileIO.RecycleOption.SendToRecycleBin);
|
||
}
|
||
catch { }
|
||
}
|
||
|
||
progress.Report((0.8, "Dateien werden entfernt …"));
|
||
if (dir is null) { progress.Report((1.0, "Fertig")); return; }
|
||
RemoveAppFiles(dir, null);
|
||
|
||
// 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 = SelfPath;
|
||
if (IsUnder(self, dir))
|
||
{
|
||
Process.Start(new ProcessStartInfo("cmd.exe",
|
||
$"/c ping 127.0.0.1 -n 3 > nul & del /f /q \"{self}\" & del /f /q \"{Path.Combine(dir, "*.old")}\" & rmdir \"{dir}\"")
|
||
{
|
||
CreateNoWindow = true, UseShellExecute = false, WindowStyle = ProcessWindowStyle.Hidden,
|
||
});
|
||
}
|
||
else
|
||
{
|
||
TryDelete(Path.Combine(dir, UninstallerName));
|
||
try { Directory.Delete(dir); } catch { } // nur wenn leer
|
||
}
|
||
progress.Report((1.0, "Fertig"));
|
||
}
|
||
|
||
/// <summary>Autostart-Einträge entfernen, die der Launcher selbst gesetzt hat (Name vergibt Electron).</summary>
|
||
private static void RemoveAutostart(string exe)
|
||
{
|
||
if (exe.Length == 0) return;
|
||
using var run = Registry.CurrentUser.OpenSubKey(RunKey, writable: true);
|
||
if (run is null) return;
|
||
foreach (var name in run.GetValueNames())
|
||
{
|
||
if (run.GetValue(name) is string cmd && cmd.IndexOf(exe, StringComparison.OrdinalIgnoreCase) >= 0)
|
||
run.DeleteValue(name, throwOnMissingValue: false);
|
||
}
|
||
}
|
||
|
||
// ---------- Dateien ----------
|
||
|
||
/// <summary>
|
||
/// Entfernt die App-Dateien aus einem Installationsordner. Gelöscht wird nur, was nachweislich zu AeroMC gehört:
|
||
/// Einträge aus der Dateiliste der letzten Installation bzw. die Namen aus dem neuen Paket –
|
||
/// Ordner nur, wenn sie eindeutig von Electron stammen. Fremde Dateien im Ordner bleiben unangetastet.
|
||
/// </summary>
|
||
private static void RemoveAppFiles(string dir, IEnumerable<string>? extraNames)
|
||
{
|
||
if (!Directory.Exists(dir)) return;
|
||
CleanupOldFiles(dir);
|
||
var listFile = Path.Combine(dir, FileListName);
|
||
var fromList = File.Exists(listFile) ? File.ReadAllLines(listFile).Where(l => l.Trim().Length > 0).ToList() : [];
|
||
var names = fromList.Concat(extraNames ?? []).Concat([UninstallerName, FileListName])
|
||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||
|
||
foreach (var name in names)
|
||
{
|
||
if (name.IndexOfAny(['\\', '/', ':']) >= 0 || name is "." or "..") continue;
|
||
var path = Path.Combine(dir, name);
|
||
if (SamePath(path, SelfPath)) continue; // das laufende Deinstallationsprogramm räumt cmd weg
|
||
|
||
if (Directory.Exists(path))
|
||
{
|
||
bool ours = fromList.Contains(name, StringComparer.OrdinalIgnoreCase)
|
||
|| (name.Equals("resources", StringComparison.OrdinalIgnoreCase) && File.Exists(Path.Combine(path, "app.asar")))
|
||
|| (name.Equals("locales", StringComparison.OrdinalIgnoreCase) && File.Exists(Path.Combine(path, "en-US.pak")));
|
||
if (ours) Retry(path, () => Directory.Delete(path, recursive: true));
|
||
}
|
||
else if (File.Exists(path))
|
||
{
|
||
Retry(path, () => MoveAside(path));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Längster Pfad im Paket. Windows erlaubt (ohne Sonderfreigabe) nur 259 Zeichen pro Pfad –
|
||
/// der Installationsordner darf deshalb höchstens <see cref="MaxDirLength"/> Zeichen lang sein.
|
||
/// </summary>
|
||
private static int? _longestEntry;
|
||
public static int MaxDirLength
|
||
{
|
||
get
|
||
{
|
||
if (_longestEntry is null)
|
||
{
|
||
try
|
||
{
|
||
using var zip = Payload.OpenArchive();
|
||
_longestEntry = zip.Entries.Count == 0 ? 0 : zip.Entries.Max(e => e.FullName.Length);
|
||
}
|
||
catch { _longestEntry = 0; }
|
||
}
|
||
return 259 - 1 - _longestEntry.Value;
|
||
}
|
||
}
|
||
|
||
private static void Retry(string path, Action action)
|
||
{
|
||
for (int attempt = 1; ; attempt++)
|
||
{
|
||
try
|
||
{
|
||
action();
|
||
return;
|
||
}
|
||
catch (PathTooLongException ex)
|
||
{
|
||
throw new IOException($"Der Installationsordner ist zu lang. Bitte einen kürzeren Ordner wählen (höchstens {MaxDirLength} Zeichen).", ex);
|
||
}
|
||
catch (Exception ex) when ((ex is IOException || ex is UnauthorizedAccessException) && attempt < 10)
|
||
{
|
||
Thread.Sleep(500);
|
||
}
|
||
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
|
||
{
|
||
throw new IOException($"„{Path.GetFileName(path)}“ wird noch verwendet. Bitte AeroMC beenden " +
|
||
"(auch das Symbol im Infobereich neben der Uhr) und erneut versuchen.", ex);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Vorhandene Datei löschen oder – falls sie noch geöffnet ist (Explorer, Virenscanner) – beiseite umbenennen.
|
||
/// Umbenennen klappt unter Windows auch bei geöffneten Dateien, überschreiben nicht.
|
||
/// </summary>
|
||
private static void MoveAside(string path)
|
||
{
|
||
if (!File.Exists(path)) return;
|
||
try { File.Delete(path); return; } catch { }
|
||
File.Move(path, $"{path}.{Guid.NewGuid():N}.old");
|
||
}
|
||
|
||
private static void CleanupOldFiles(string dir)
|
||
{
|
||
if (!Directory.Exists(dir)) return;
|
||
foreach (var f in Directory.GetFiles(dir, "*.old", SearchOption.AllDirectories))
|
||
try { File.Delete(f); } catch { }
|
||
}
|
||
|
||
private static void TryDelete(string path)
|
||
{
|
||
try { if (File.Exists(path)) File.Delete(path); } catch { }
|
||
}
|
||
|
||
private static void DeleteIfPointsTo(string link, string target)
|
||
{
|
||
if (!File.Exists(link) || target.Length == 0) return;
|
||
if (!SamePath(Shortcut.Target(link) ?? "", target)) return;
|
||
// Explorer/Indexdienst halten Verknüpfungen manchmal kurz fest → mehrmals versuchen
|
||
for (int i = 0; i < 10 && File.Exists(link); i++)
|
||
{
|
||
try { File.Delete(link); }
|
||
catch { Thread.Sleep(300); }
|
||
}
|
||
}
|
||
|
||
// ---------- Prozesse ----------
|
||
|
||
private static IEnumerable<Process> AppProcesses(params string?[] dirs)
|
||
{
|
||
var roots = dirs.Where(d => !string.IsNullOrEmpty(d)).Select(d => d!).ToList();
|
||
if (roots.Count == 0) yield break;
|
||
// Launcher (mehrere Electron-Prozesse) und ein evtl. laufendes ffmpeg der Aufzeichnung
|
||
foreach (var p in Process.GetProcessesByName("AeroMC").Concat(Process.GetProcessesByName("ffmpeg")))
|
||
{
|
||
string? file = null;
|
||
try { file = p.MainModule?.FileName; } catch { }
|
||
if (file is not null && roots.Any(r => IsUnder(file, r))) yield return p;
|
||
}
|
||
}
|
||
|
||
private static void CloseRunningApp(int waitMs, params string?[] dirs)
|
||
{
|
||
var until = DateTime.Now.AddMilliseconds(waitMs);
|
||
while (waitMs > 0 && DateTime.Now < until && AppProcesses(dirs).Any())
|
||
Thread.Sleep(300);
|
||
|
||
foreach (var p in AppProcesses(dirs).ToList())
|
||
{
|
||
try
|
||
{
|
||
p.Kill();
|
||
p.WaitForExit(10000);
|
||
}
|
||
catch { }
|
||
}
|
||
}
|
||
|
||
// ---------- Helfer ----------
|
||
|
||
private static bool SamePath(string a, string b)
|
||
{
|
||
try { return string.Equals(Path.GetFullPath(a).TrimEnd('\\'), Path.GetFullPath(b).TrimEnd('\\'), StringComparison.OrdinalIgnoreCase); }
|
||
catch { return false; }
|
||
}
|
||
|
||
private static bool IsUnder(string path, string dir) =>
|
||
Path.GetFullPath(path).StartsWith(Path.GetFullPath(dir).TrimEnd('\\') + "\\", StringComparison.OrdinalIgnoreCase);
|
||
|
||
private static long DirSize(string dir)
|
||
{
|
||
long sum = 0;
|
||
try
|
||
{
|
||
foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
|
||
try { sum += new FileInfo(f).Length; } catch { }
|
||
}
|
||
catch { }
|
||
return sum;
|
||
}
|
||
|
||
public static long DataSize() => Directory.Exists(DataDir) ? DirSize(DataDir) : 0;
|
||
|
||
public static string FormatSize(long bytes) => bytes switch
|
||
{
|
||
< 1024 * 1024 => $"{Math.Max(1, bytes / 1024)} KB",
|
||
< 1024L * 1024 * 1024 => $"{bytes / 1024.0 / 1024:0.#} MB",
|
||
_ => $"{bytes / 1024.0 / 1024 / 1024:0.##} GB",
|
||
};
|
||
}
|