using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Reflection; using System.Text.RegularExpressions; using Microsoft.Win32; namespace AeroMCSetup; /// Installation/Deinstallation – nur für den aktuellen Benutzer, ohne Administratorrechte. 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()?.InformationalVersion; if (!string.IsNullOrEmpty(info)) return info!.Split('+')[0]; var v = typeof(Installer).Assembly.GetName().Version!; return $"{v.Major}.{v.Minor}.{v.Build}"; } } /// Pfad dieses Setups (bzw. des Deinstallationsprogramms). public static string SelfPath => Assembly.GetExecutingAssembly().Location; public static string DefaultDir => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", AppName); /// Launcher-Daten: Einstellungen, Konten, Java und (standardmäßig) die Instanzen. 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; } /// Ordner einer vorhandenen Installation (dieses Setups oder des alten NSIS-Setups), oder null. 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; } } /// Stammt die vorhandene Installation noch vom alten NSIS-Setup? 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(); } } /// Eigener Instanzen-Ordner außerhalb der Launcher-Daten (falls in den Einstellungen gesetzt), sonst null. 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 ---------- /// nach einem Update aus dem Launcher: ihm erst Zeit geben, sich selbst zu beenden 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 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 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); } /// Setup ohne angehängte App als Deinstallationsprogramm ablegen (nur ein paar hundert KB). 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")); } /// Autostart-Einträge entfernen, die der Launcher selbst gesetzt hat (Name vergibt Electron). 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 ---------- /// /// 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. /// private static void RemoveAppFiles(string dir, IEnumerable? 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)); } } } /// /// Längster Pfad im Paket. Windows erlaubt (ohne Sonderfreigabe) nur 259 Zeichen pro Pfad – /// der Installationsordner darf deshalb höchstens Zeichen lang sein. /// 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); } } } /// /// 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. /// 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 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", }; }