diff --git a/dist/Git-Manager-GUI-Setup-2.2.0.exe.sha256 b/dist/Git-Manager-GUI-Setup-2.2.0.exe.sha256 new file mode 100644 index 0000000..77a8692 --- /dev/null +++ b/dist/Git-Manager-GUI-Setup-2.2.0.exe.sha256 @@ -0,0 +1 @@ +573763bb3b211a522220f4706864fcfbc1fe5f8467a7e68054c6e02aed71d5dc Git-Manager-GUI-Setup-2.2.0.exe diff --git a/installer/App.xaml b/installer/App.xaml new file mode 100644 index 0000000..c5ffef1 --- /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..d75c0e0 --- /dev/null +++ b/installer/App.xaml.cs @@ -0,0 +1,99 @@ +using System.IO; +using System.Windows; +using System.Windows.Media; +using Microsoft.Win32; + +namespace GitManagerSetup; + +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, "GitManagerGUI-Setup", out bool first); + if (!first) + { + Shutdown(0); + return; + } + + // Stille Installation/Deinstallation für Skripte: + // Git-Manager-GUI-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(), "Git-Manager-GUI-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 das Cyan der App, damit Text und Fortschrittsbalken gut lesbar bleiben + var a = Color.FromRgb(0x4C, 0xB8, 0xF5); + Resources["AccentBrush"] = new SolidColorBrush(a); + Resources["AccentHoverBrush"] = new SolidColorBrush(Color.FromRgb(0x67, 0xC6, 0xF8)); + Resources["OnAccentBrush"] = new SolidColorBrush(Color.FromRgb(0x04, 0x10, 0x1F)); + 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/GitManagerSetup.csproj b/installer/GitManagerSetup.csproj new file mode 100644 index 0000000..68ae1fb --- /dev/null +++ b/installer/GitManagerSetup.csproj @@ -0,0 +1,46 @@ + + + WinExe + + net48 + true + true + latest + enable + enable + AnyCPU + false + false + Git-Manager-GUI-Setup + GitManagerSetup + ..\assets\icon.ico + app.manifest + Git Manager GUI Setup + Git Manager GUI Setup + Installiert Git Manager GUI + M_Viper + M_Viper + Copyright (c) M_Viper + de-DE + + 0.0.0 + + + + + + + + + + + + + + + diff --git a/installer/Installer.cs b/installer/Installer.cs new file mode 100644 index 0000000..429a020 --- /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 GitManagerSetup; + +/// Installation/Deinstallation – nur für den aktuellen Benutzer, ohne Administratorrechte. +public static class Installer +{ + public const string AppName = "Git Manager GUI"; + public const string Publisher = "M_Viper"; + // productName aus package.json → so heißt die EXE im entpackten Electron-Ordner + public const string ExeName = "Git Manager GUI.exe"; + private const string ProcessName = "Git Manager GUI"; + // Gleicher Name wie beim früheren NSIS-Setup (electron-builder), damit eine alte Installation sauber ersetzt wird + public const string UninstallerName = "Uninstall Git Manager GUI.exe"; + // wie "appId" in package.json – gleiche Taskleisten-/Benachrichtigungs-Zuordnung wie bisher + public const string AppUserModelId = "com.viper.gitmanager"; + + // Schlüssel vom alten electron-builder-Setup (Version 2.1.7) übernommen → in "Apps" bleibt es ein einziger Eintrag + private const string KeyId = "4156cc7f-a3e7-5b1d-9238-0af3e0f54ecd"; + 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); + + /// App-Daten: Zugangsdaten (verschlüsselt), Einstellungen, Favoriten, Verlauf, Backup-Einstellung. + public static string DataDir => + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "git-manager-gui"); + + 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(); + + // ---------- Installieren ---------- + + /// nach einem Update aus der App: ihr 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("Git Manager GUI benötigt ein 64-Bit-Windows (Windows 10 oder 11)."); + + var oldDir = InstalledDir; + progress.Report((0.02, "Git Manager GUI 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, "Repositories auf Gitea und GitHub verwalten", AppUserModelId); + if (desktopLink) Shortcut.Create(DesktopLink, exe, "Repositories auf Gitea und GitHub verwalten", 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://git.viper.ipv64.net/M_Viper/Git-Manager-Gui"); + 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((0.98, "Installation wird abgeschlossen …")); + Vorwaermen(exe); + + progress.Report((1.0, "Fertig")); + } + + /// + /// Startet die App einmal kurz mit "--gm-vorwaermen" – sie beendet sich + /// damit sofort wieder (siehe main.js). Windows Defender prüft eine neue, + /// noch unbekannte EXE beim ersten Start mehrere Sekunden lang (gemessen: ~8 s); + /// das passiert so während des Fortschrittsbalkens und nicht erst beim Klick + /// auf "Fertigstellen". Das Ergebnis merkt sich Windows, der echte Start ist + /// danach sofort da. + /// + private static void Vorwaermen(string exe) + { + try + { + using var p = Process.Start(new ProcessStartInfo(exe, "--gm-vorwaermen") + { + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = Path.GetDirectoryName(exe)!, + }); + if (p != null && !p.WaitForExit(30000)) { try { p.Kill(); } catch { } } + } + catch { /* nur eine Beschleunigung – Fehler sind egal */ } + } + + 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, "Git Manager GUI 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 Electron selbst an) + Registry.CurrentUser.DeleteSubKeyTree(@"Software\Classes\AppUserModelId\" + AppUserModelId, throwOnMissingSubKey: false); + + if (removeData && Directory.Exists(DataDir)) + { + progress.Report((0.6, "Zugangsdaten 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 die App 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 zur App 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 Git Manager GUI 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; + // die App (mehrere Electron-Prozesse) und das mitgelieferte Git, falls gerade ein Upload läuft + foreach (var p in Process.GetProcessesByName(ProcessName).Concat(Process.GetProcessesByName("git"))) + { + 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/MainWindow.xaml b/installer/MainWindow.xaml new file mode 100644 index 0000000..928e4c6 --- /dev/null +++ b/installer/MainWindow.xaml @@ -0,0 +1,352 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +