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",
};
}