diff --git a/installer/AeroMCSetup.csproj b/installer/AeroMCSetup.csproj new file mode 100644 index 0000000..1b0dd83 --- /dev/null +++ b/installer/AeroMCSetup.csproj @@ -0,0 +1,49 @@ + + + WinExe + + net48 + true + true + latest + enable + enable + AnyCPU + false + false + AeroMC-Setup + AeroMCSetup + ..\src\assets\icon.ico + app.manifest + AeroMC Setup + AeroMC Setup + Installiert AeroMC + M_Viper + M_Viper + Copyright (c) M_Viper. Alle Rechte vorbehalten. + de-DE + + 0.0.0 + + + + + + + + + + + + + + + + + + diff --git a/installer/App.xaml b/installer/App.xaml new file mode 100644 index 0000000..dc7754d --- /dev/null +++ b/installer/App.xaml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + diff --git a/installer/App.xaml.cs b/installer/App.xaml.cs new file mode 100644 index 0000000..be03d19 --- /dev/null +++ b/installer/App.xaml.cs @@ -0,0 +1,97 @@ +using System.IO; +using System.Windows; +using System.Windows.Media; +using Microsoft.Win32; + +namespace AeroMCSetup; + +public partial class App : Application +{ + private static Mutex? _single; + + protected override void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + bool has(string arg) => e.Args.Any(a => a.Equals(arg, StringComparison.OrdinalIgnoreCase)); + + // Das Deinstallationsprogramm ist das Setup ohne angehängte App → ohne Anhang immer Deinstallation + bool uninstall = has("--uninstall") || !Payload.Exists; + bool silent = has("/S"); + bool update = has("--update"); + bool launch = has("/launch"); + var dirArg = e.Args.FirstOrDefault(a => a.StartsWith("/D=", StringComparison.OrdinalIgnoreCase))?.Substring(3); + + _single = new Mutex(true, "AeroMC-Setup", out bool first); + if (!first) + { + Shutdown(0); + return; + } + + // Stille Installation/Deinstallation für Skripte: AeroMC-Setup.exe /S [/D=Ordner] [/launch] bzw. --uninstall /S + if (silent) + { + var progress = new Progress<(double, string)>(_ => { }); + try + { + if (uninstall) Installer.Uninstall(removeData: false, progress); + else + { + var dir = dirArg ?? Installer.InstalledDir ?? Installer.DefaultDir; + Installer.Install(dir, desktopLink: Installer.InstalledDir is null || Installer.DesktopLinkExists, + progress, waitForExit: true); + if (launch) System.Diagnostics.Process.Start(Path.Combine(dir, Installer.ExeName)); + } + Shutdown(0); + } + catch (Exception ex) + { + // ohne Oberfläche: Fehler für Skripte nachvollziehbar ablegen + try { File.WriteAllText(Path.Combine(Path.GetTempPath(), "AeroMC-Setup.log"), $"[{DateTime.Now}] {ex}"); } catch { } + Shutdown(1); + } + return; + } + + ApplyTheme(); + var mode = uninstall ? SetupMode.Uninstall : update ? SetupMode.Update : SetupMode.Install; + new MainWindow(mode).Show(); + } + + protected override void OnExit(ExitEventArgs e) + { + try { _single?.ReleaseMutex(); } catch { } + base.OnExit(e); + } + + /// Hell/Dunkel wie in Windows eingestellt. + private void ApplyTheme() + { + bool light = ReadDword(@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", "AppsUseLightTheme") != 0; + Resources.MergedDictionaries[0] = new ResourceDictionary + { + Source = new Uri($"pack://application:,,,/Themes/Colors.{(light ? "Light" : "Dark")}.xaml"), + }; + if (!light) + { + // im Dunkeln etwas heller, damit Text und Fortschrittsbalken gut lesbar bleiben + var a = Color.FromRgb(0x4A, 0x86, 0xEA); + Resources["AccentBrush"] = new SolidColorBrush(a); + Resources["AccentHoverBrush"] = new SolidColorBrush(Color.FromRgb(0x5E, 0x94, 0xEE)); + Resources["AccentSubtleBrush"] = new SolidColorBrush(Color.FromArgb(0x30, a.R, a.G, a.B)); + Resources["AccentSubtleStrokeBrush"] = new SolidColorBrush(Color.FromArgb(0x60, a.R, a.G, a.B)); + } + } + + private static int ReadDword(string key, string name) + { + try + { + using var k = Registry.CurrentUser.OpenSubKey(key); + return k?.GetValue(name) is int i ? i : -1; + } + catch { return -1; } + } +} + +public enum SetupMode { Install, Update, Uninstall } diff --git a/installer/Installer.cs b/installer/Installer.cs new file mode 100644 index 0000000..28d6f9a --- /dev/null +++ b/installer/Installer.cs @@ -0,0 +1,501 @@ +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); + + 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", + }; +} diff --git a/installer/LauncherImport.cs b/installer/LauncherImport.cs new file mode 100644 index 0000000..64b0505 --- /dev/null +++ b/installer/LauncherImport.cs @@ -0,0 +1,176 @@ +using System.IO; +using System.Text; +using System.Web.Script.Serialization; + +namespace AeroMCSetup; + +/// Ein gefundener anderer Launcher, dessen Instanzen AeroMC übernehmen kann. +/// Kennung wie im Import-Dialog des Launchers: mc, cf, mmc, technic +public sealed class FoundLauncher(string kind, string name, string path, int count, string unit) +{ + public string Kind { get; } = kind; + public string Name { get; } = name; + public string Path { get; } = path; + public int Count { get; } = count; + public string Unit { get; } = unit; +} + +/// +/// Sucht andere Launcher an ihren üblichen Orten – nur lesend, es wird nichts verändert. +/// Übernommen wird später im Launcher selbst (gleicher Import wie unter „Importieren“): +/// das Setup legt dafür nur pending-import.json ab, AeroMC öffnet beim ersten Start +/// den Import-Dialog damit fertig durchsucht. +/// +public static class LauncherImport +{ + private static string AppData => Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + private static string Home => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + private static string PendingFile => System.IO.Path.Combine(Installer.DataDir, "pending-import.json"); + + public static List Detect() + { + var found = new List(); + void Add(Func probe) + { + try { if (probe() is { Count: > 0 } f) found.Add(f); } catch { /* unlesbar → einfach nicht anbieten */ } + } + Add(DetectMinecraftLauncher); + Add(DetectCurseForge); + Add(() => DetectMultiMc("Prism Launcher", System.IO.Path.Combine(AppData, "PrismLauncher"))); + Add(() => DetectMultiMc("PolyMC", System.IO.Path.Combine(AppData, "PolyMC"))); + // MultiMC (und portable Prism/PolyMC) liegen irgendwo – über ihre Verknüpfungen finden + foreach (var (name, root) in PortableMultiMcFromShortcuts()) + { + if (found.Any(f => f.Kind == "mmc" && SamePath(f.Path, root))) continue; + Add(() => DetectMultiMc(name, root)); + } + Add(DetectTechnic); + return found; + } + + // ---------- offizieller Minecraft Launcher ---------- + + private static readonly string[] ProfileFiles = + ["launcher_profiles.json", "launcher_profiles_microsoft_store.json", "launcher_profiles_microsoft_store_2.json"]; + + private static FoundLauncher? DetectMinecraftLauncher() + { + var root = System.IO.Path.Combine(AppData, ".minecraft"); + var file = ProfileFiles.Select(f => System.IO.Path.Combine(root, f)).FirstOrDefault(File.Exists); + if (file is null) return null; + + var json = new JavaScriptSerializer { MaxJsonLength = int.MaxValue } + .Deserialize>(File.ReadAllText(file, Encoding.UTF8).TrimStart('')); + if (json?.TryGetValue("profiles", out var p) != true || p is not Dictionary profiles) return null; + + // wie isImportableProfile in src/mcimport.js: die automatischen "Neueste Version"-Profile zählen nicht + int count = profiles.Values.OfType>().Count(profile => + { + var type = (Get(profile, "type") ?? "").Trim().ToLowerInvariant(); + if (type is "latest-release" or "latest-snapshot") return false; + return !string.IsNullOrWhiteSpace(Get(profile, "name")) || !string.IsNullOrWhiteSpace(Get(profile, "lastVersionId")); + }); + return new FoundLauncher("mc", "Minecraft Launcher", root, count, count == 1 ? "Profil" : "Profile"); + } + + private static string? Get(Dictionary d, string key) => d.TryGetValue(key, out var v) ? v as string : null; + + // ---------- CurseForge ---------- + + private static FoundLauncher? DetectCurseForge() + { + var dir = new[] + { + System.IO.Path.Combine(Home, "curseforge", "minecraft", "Instances"), + System.IO.Path.Combine(Home, "CurseForge", "minecraft", "Instances"), + }.FirstOrDefault(Directory.Exists); + if (dir is null) return null; + + string[] markers = ["minecraftinstance.json", "instance.json", "mods", "config", "resourcepacks", "shaderpacks", "saves", "options.txt"]; + int count = Directory.GetDirectories(dir).Count(d => markers.Any(m => Exists(d, m))); + return new FoundLauncher("cf", "CurseForge", dir, count, count == 1 ? "Instanz" : "Instanzen"); + } + + // ---------- Prism Launcher / PolyMC (MultiMC-Format) ---------- + + private static FoundLauncher? DetectMultiMc(string name, string root) + { + var instances = System.IO.Path.Combine(root, "instances"); + if (!Directory.Exists(instances)) return null; + int count = Directory.GetDirectories(instances).Count(d => File.Exists(System.IO.Path.Combine(d, "instance.cfg"))); + return new FoundLauncher("mmc", name, root, count, count == 1 ? "Instanz" : "Instanzen"); + } + + private static readonly Dictionary MultiMcExes = new(StringComparer.OrdinalIgnoreCase) + { + ["MultiMC.exe"] = "MultiMC", + ["prismlauncher.exe"] = "Prism Launcher", + ["polymc.exe"] = "PolyMC", + }; + + /// Ordner von MultiMC-artigen Launchern, auf die eine Verknüpfung (Desktop/Startmenü) zeigt. + private static IEnumerable<(string Name, string Root)> PortableMultiMcFromShortcuts() + { + var places = new[] + { + (Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), SearchOption.TopDirectoryOnly), + (Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory), SearchOption.TopDirectoryOnly), + (Environment.GetFolderPath(Environment.SpecialFolder.Programs), SearchOption.AllDirectories), + (Environment.GetFolderPath(Environment.SpecialFolder.CommonPrograms), SearchOption.AllDirectories), + }; + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var (dir, option) in places) + { + string[] links; + try { links = string.IsNullOrEmpty(dir) || !Directory.Exists(dir) ? [] : Directory.GetFiles(dir, "*.lnk", option); } + catch { continue; } + foreach (var link in links) + { + var target = Shortcut.Target(link); + if (string.IsNullOrEmpty(target) || !MultiMcExes.TryGetValue(System.IO.Path.GetFileName(target), out var name)) continue; + var root = System.IO.Path.GetDirectoryName(target); + if (root is null || !Directory.Exists(System.IO.Path.Combine(root, "instances")) || !seen.Add(root)) continue; + yield return (name, root); + } + } + } + + private static bool SamePath(string a, string b) => + string.Equals(System.IO.Path.GetFullPath(a).TrimEnd('\\'), System.IO.Path.GetFullPath(b).TrimEnd('\\'), StringComparison.OrdinalIgnoreCase); + + // ---------- Technic ---------- + + private static FoundLauncher? DetectTechnic() + { + var modpacks = System.IO.Path.Combine(AppData, ".technic", "modpacks"); + if (!Directory.Exists(modpacks)) return null; + string[] markers = ["bin", "mods", "config", "saves", "resourcepacks", "options.txt"]; + int count = Directory.GetDirectories(modpacks).Count(d => markers.Any(m => Exists(d, m))); + return new FoundLauncher("technic", "Technic Launcher", modpacks, count, count == 1 ? "Modpack" : "Modpacks"); + } + + private static bool Exists(string dir, string name) + { + var p = System.IO.Path.Combine(dir, name); + return File.Exists(p) || Directory.Exists(p); + } + + // ---------- Übergabe an den Launcher ---------- + + /// Merkt die ausgewählten Launcher für den ersten Start von AeroMC vor (oder räumt eine alte Vormerkung weg). + public static void WritePending(IReadOnlyCollection selected) + { + if (selected.Count == 0) + { + try { if (File.Exists(PendingFile)) File.Delete(PendingFile); } catch { } + return; + } + var data = new Dictionary + { + ["sources"] = selected.Select(s => new Dictionary { ["kind"] = s.Kind, ["path"] = s.Path }).ToList(), + }; + Directory.CreateDirectory(Installer.DataDir); + File.WriteAllText(PendingFile, new JavaScriptSerializer().Serialize(data), new UTF8Encoding(false)); + } +} diff --git a/installer/MainWindow.xaml b/installer/MainWindow.xaml new file mode 100644 index 0000000..56a33ba --- /dev/null +++ b/installer/MainWindow.xaml @@ -0,0 +1,368 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +