diff --git a/Setup/App.xaml b/Setup/App.xaml new file mode 100644 index 0000000..d5c910d --- /dev/null +++ b/Setup/App.xaml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + diff --git a/Setup/App.xaml.cs b/Setup/App.xaml.cs new file mode 100644 index 0000000..02a9a5d --- /dev/null +++ b/Setup/App.xaml.cs @@ -0,0 +1,77 @@ +using System.Windows; +using System.Windows.Media; +using Microsoft.Win32; + +namespace DeskCoreSetup; + +public partial class App : Application +{ + protected override void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + bool uninstall = e.Args.Any(a => a.Equals("--uninstall", StringComparison.OrdinalIgnoreCase)); + bool silent = e.Args.Any(a => a.Equals("/S", StringComparison.OrdinalIgnoreCase)); + bool launch = e.Args.Any(a => a.Equals("/launch", StringComparison.OrdinalIgnoreCase)); + var dirArg = e.Args.FirstOrDefault(a => a.StartsWith("/D=", StringComparison.OrdinalIgnoreCase))?.Substring(3); + + // Stille Installation/Deinstallation für Skripte: DeskCore-Setup.exe /S [/D=Ordner] 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, + autostart: Installer.AutostartEnabled, progress, installRuntime: true); + // nach einem Update aus DeskCore heraus: gleich wieder starten + if (launch) Installer.StartUnelevated(System.IO.Path.Combine(dir, Installer.ExeName)); + } + Shutdown(0); + } + catch (Exception ex) + { + // ohne Oberfläche: Fehler für Skripte nachvollziehbar ablegen + try { System.IO.File.WriteAllText(System.IO.Path.Combine(System.IO.Path.GetTempPath(), "DeskCore-Setup.log"), $"[{DateTime.Now}] {ex}"); } catch { } + Shutdown(1); + } + return; + } + + ApplyTheme(); + new MainWindow(uninstall).Show(); + } + + /// Hell/Dunkel und Akzentfarbe 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"), + }; + int abgr = ReadDword(@"Software\Microsoft\Windows\DWM", "AccentColor") is var v and not -1 ? v : unchecked((int)0xFFD77800); + var accent = Color.FromRgb((byte)abgr, (byte)(abgr >> 8), (byte)(abgr >> 16)); + var a = light ? Mix(accent, Colors.Black, 0.12) : Mix(accent, Colors.White, 0.45); + Resources["AccentBrush"] = new SolidColorBrush(a); + Resources["AccentHoverBrush"] = new SolidColorBrush(light ? Mix(a, Colors.White, 0.1) : Mix(a, Colors.Black, 0.1)); + Resources["OnAccentBrush"] = new SolidColorBrush(light ? Colors.White : Colors.Black); + Resources["AccentSubtleBrush"] = new SolidColorBrush(Color.FromArgb(light ? (byte)0x22 : (byte)0x30, a.R, a.G, a.B)); + Resources["AccentSubtleStrokeBrush"] = new SolidColorBrush(Color.FromArgb(light ? (byte)0x55 : (byte)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; } + } + + private static Color Mix(Color c, Color with, double t) => Color.FromRgb( + (byte)(c.R + (with.R - c.R) * t), (byte)(c.G + (with.G - c.G) * t), (byte)(c.B + (with.B - c.B) * t)); +} diff --git a/Setup/DeskCoreSetup.csproj b/Setup/DeskCoreSetup.csproj new file mode 100644 index 0000000..4a57e19 --- /dev/null +++ b/Setup/DeskCoreSetup.csproj @@ -0,0 +1,49 @@ + + + WinExe + + net48 + true + true + latest + enable + enable + AnyCPU + false + false + DeskCore-Setup + DeskCoreSetup + ..\app.ico + + app.manifest + DeskCore Setup + DeskCore Setup + Installiert DeskCore + M_Viper + M_Viper + Copyright (c) M_Viper. Alle Rechte vorbehalten. + de-DE + + ..\artifacts\app\DeskCore.exe + + + + + + + + + + + + + + + + + + + + + diff --git a/Setup/Installer.cs b/Setup/Installer.cs new file mode 100644 index 0000000..77b46c3 --- /dev/null +++ b/Setup/Installer.cs @@ -0,0 +1,401 @@ +using System.Diagnostics; +using System.IO; +using Microsoft.Win32; + +namespace DeskCoreSetup; + +/// +/// Installation/Deinstallation für den aktuellen Benutzer. Der Installer selbst läuft mit Administratorrechten +/// (wegen der .NET-Laufzeit von Microsoft), DeskCore wird aber immer ohne gestartet. +/// +public static class Installer +{ + /// + /// DeskCore ohne Administratorrechte starten – über den Explorer, der als normaler Benutzer läuft. + /// Wichtig: Ein Programm mit Adminrechten darf von Windows aus keine Icons per Ziehen vom Desktop annehmen. + /// + public static void StartUnelevated(string exe) + { + try { Process.Start(new ProcessStartInfo("explorer.exe", $"\"{exe}\"") { UseShellExecute = false }); } + catch { } + } + + public const string AppName = "DeskCore"; + public const string Publisher = "M_Viper"; + public const string ExeName = "DeskCore.exe"; + public const string UninstallerName = "DeskCore-Uninstall.exe"; + private const string UninstallKey = @"Software\Microsoft\Windows\CurrentVersion\Uninstall\DeskCore"; + private const string RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run"; + + public static string Version + { + get + { + var v = typeof(Installer).Assembly.GetName().Version!; + return $"{v.Major}.{v.Minor}.{v.Build}"; + } + } + + /// Pfad dieses Setups (auch als Deinstallationsprogramm). + public static string SelfPath => System.Reflection.Assembly.GetExecutingAssembly().Location; + + public static string DefaultDir => + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", AppName); + + 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"); + + /// Ordner einer vorhandenen Installation (oder null). + public static string? InstalledDir + { + get + { + using var k = Registry.CurrentUser.OpenSubKey(UninstallKey); + return k?.GetValue("InstallLocation") as string is { } d && File.Exists(Path.Combine(d, ExeName)) ? d : null; + } + } + + public static string? InstalledVersion + { + get + { + using var k = Registry.CurrentUser.OpenSubKey(UninstallKey); + return k?.GetValue("DisplayVersion") as string; + } + } + + public static bool AutostartEnabled + { + get + { + using var k = Registry.CurrentUser.OpenSubKey(RunKey); + return k?.GetValue(AppName) is string; + } + } + + public static bool DesktopLinkExists => File.Exists(DesktopLink); + + public static string LicenseText => ReadResourceText("payload.LICENSE.txt"); + + // ---------- Installieren ---------- + + public static void Install(string dir, bool desktopLink, bool autostart, IProgress<(double, string)> progress, + bool installRuntime = false) + { + if (!Runtime.Is64BitWindows) + throw new InvalidOperationException("DeskCore benötigt ein 64-Bit-Windows (Windows 10 oder 11)."); + if (installRuntime && !Runtime.IsInstalled) + { + // 0–60 % für die Laufzeit, Rest für DeskCore + Runtime.Install((v, text) => progress.Report((v * 0.6, text))); + var inner = progress; + progress = new Scaled(inner, 0.6, 0.4); + } + + progress.Report((0.05, "DeskCore wird beendet …")); + CloseRunningApp(); + RemoveLegacyInstall(); + + progress.Report((0.2, "Dateien werden kopiert …")); + Directory.CreateDirectory(dir); + var exe = Path.Combine(dir, ExeName); + WriteResource("payload.DeskCore.exe", exe); + WriteResource("payload.LICENSE.txt", Path.Combine(dir, "LICENSE.txt")); + + // Setup selbst als Deinstallationsprogramm ablegen + var self = SelfPath; + var uninstaller = Path.Combine(dir, UninstallerName); + if (!string.Equals(Path.GetFullPath(self), Path.GetFullPath(uninstaller), StringComparison.OrdinalIgnoreCase)) + { + MoveAside(uninstaller); + File.Copy(self, uninstaller, overwrite: true); + } + + progress.Report((0.55, "Verknüpfungen werden angelegt …")); + CreateShortcut(StartMenuLink, exe); + if (desktopLink) CreateShortcut(DesktopLink, exe); + + progress.Report((0.75, "Windows wird eingerichtet …")); + using (var run = Registry.CurrentUser.CreateSubKey(RunKey)) + { + if (autostart) run.SetValue(AppName, $"\"{exe}\" --minimized"); + else run.DeleteValue(AppName, throwOnMissingValue: false); + } + + using (var k = Registry.CurrentUser.CreateSubKey(UninstallKey)) + { + long size = new[] { exe, uninstaller, Path.Combine(dir, "LICENSE.txt") } + .Where(File.Exists).Sum(f => new FileInfo(f).Length) / 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)size, RegistryValueKind.DWord); + k.SetValue("NoModify", 1, RegistryValueKind.DWord); + k.SetValue("NoRepair", 1, RegistryValueKind.DWord); + } + + progress.Report((1.0, "Fertig")); + } + + // ---------- Frühere Version "DeskHub" entfernen ---------- + + private const string LegacyName = "DeskHub"; + private const string LegacyUninstallKey = @"Software\Microsoft\Windows\CurrentVersion\Uninstall\DeskHub"; + + /// Ordner einer alten Installation unter dem früheren Namen "DeskHub" – nur unsere eigene. + public static string? LegacyInstallDir + { + get + { + using var k = Registry.CurrentUser.OpenSubKey(LegacyUninstallKey); + // "DeskHub" heißt auch ein anderes Programm → nur mit unserem Herausgeber anfassen + if (k?.GetValue("Publisher") as string != Publisher) return null; + return k.GetValue("InstallLocation") as string; + } + } + + private static void RemoveLegacyInstall() + { + var dir = LegacyInstallDir; + if (dir is null) return; + var exe = Path.Combine(dir, LegacyName + ".exe"); + + // nur Prozesse beenden, die wirklich aus dem alten Ordner laufen + foreach (var p in Process.GetProcessesByName(LegacyName)) + { + try + { + if (p.MainModule?.FileName is { } f && Storage_IsUnder(f, dir)) + { + p.Kill(); + p.WaitForExit(10000); + } + } + catch { } + } + + DeleteIfPointsTo(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Programs), LegacyName + ".lnk"), exe); + DeleteIfPointsTo(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), LegacyName + ".lnk"), exe); + using (var run = Registry.CurrentUser.OpenSubKey(RunKey, writable: true)) + { + if (run?.GetValue(LegacyName) is string cmd && cmd.IndexOf(exe, StringComparison.OrdinalIgnoreCase) >= 0) + run.DeleteValue(LegacyName, throwOnMissingValue: false); + } + + foreach (var f in new[] { exe, Path.Combine(dir, "LICENSE.txt"), Path.Combine(dir, LegacyName + "-Uninstall.exe") }) + MoveAside(f); + CleanupOldFiles(dir); + try { Directory.Delete(dir); } catch { } // nur wenn leer + Registry.CurrentUser.DeleteSubKeyTree(LegacyUninstallKey, throwOnMissingSubKey: false); + } + + // ---------- Deinstallieren ---------- + + public static readonly string[] DataDirs = + [ + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), AppName), // Kategorien + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), AppName), // Einstellungen + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), AppName), // Rückgängig-Zwischenablage + ]; + + 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, "DeskCore wird beendet …")); + CloseRunningApp(); + + progress.Report((0.3, "Verknüpfungen werden entfernt …")); + DeleteIfPointsTo(StartMenuLink, exe); + DeleteIfPointsTo(DesktopLink, exe); + + progress.Report((0.45, "Windows-Einträge werden entfernt …")); + using (var run = Registry.CurrentUser.OpenSubKey(RunKey, writable: true)) + run?.DeleteValue(AppName, throwOnMissingValue: false); + Registry.CurrentUser.DeleteSubKeyTree(UninstallKey, throwOnMissingSubKey: false); + + if (removeData) + { + progress.Report((0.6, "Kategorien und Einstellungen werden in den Papierkorb gelegt …")); + foreach (var d in DataDirs.Where(Directory.Exists)) + { + try + { + Microsoft.VisualBasic.FileIO.FileSystem.DeleteDirectory(d, + Microsoft.VisualBasic.FileIO.UIOption.OnlyErrorDialogs, + Microsoft.VisualBasic.FileIO.RecycleOption.SendToRecycleBin); + } + catch { } + } + } + + progress.Report((0.85, "Dateien werden entfernt …")); + if (dir is null) { progress.Report((1.0, "Fertig")); return; } + TryDelete(exe); + TryDelete(Path.Combine(dir, "LICENSE.txt")); + CleanupOldFiles(dir); + + // 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 (Storage_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")); + } + + // ---------- Helfer ---------- + + /// Rechnet Fortschritt 0–1 in einen Teilbereich um. + private sealed class Scaled(IProgress<(double, string)> inner, double offset, double factor) : IProgress<(double, string)> + { + public void Report((double, string) value) => inner.Report((offset + value.Item1 * factor, value.Item2)); + } + + /// Laufendes DeskCore beenden (das X versteckt es nur im Infobereich). + private static void CloseRunningApp() + { + foreach (var p in Process.GetProcessesByName("DeskCore")) + { + try + { + p.Kill(); + p.WaitForExit(10000); + } + catch { } + } + } + + private static void WriteResource(string name, string path) + { + CleanupOldFiles(Path.GetDirectoryName(path)!); + MoveAside(path); + for (int attempt = 1; ; attempt++) + { + try + { + using var s = typeof(Installer).Assembly.GetManifestResourceStream(name) + ?? throw new InvalidOperationException($"Installationsdatei {name} fehlt."); + using var f = File.Create(path); + s.CopyTo(f); + return; + } + catch (IOException) when (attempt < 10) + { + Thread.Sleep(500); + } + catch (IOException) when (attempt >= 10) + { + throw new IOException($"„{Path.GetFileName(path)}“ wird noch verwendet. Bitte DeskCore beenden (Rechtsklick auf das Symbol im Infobereich → Beenden) und erneut versuchen."); + } + } + } + + /// + /// Vorhandene Datei beiseite umbenennen. Das klappt unter Windows auch dann, wenn sie noch geöffnet ist + /// (z. B. vom Explorer für das Icon oder vom Virenscanner) – überschreiben würde dagegen fehlschlagen. + /// + private static void MoveAside(string path) + { + if (!File.Exists(path)) return; + try { File.Delete(path); return; } catch { } + try { File.Move(path, $"{path}.{Guid.NewGuid():N}.old"); } catch { } + } + + /// Beiseitegelegte Dateien früherer Installationen wegräumen (sofern nicht mehr in Benutzung). + private static void CleanupOldFiles(string dir) + { + if (!Directory.Exists(dir)) return; + foreach (var f in Directory.GetFiles(dir, "*.old")) + try { File.Delete(f); } catch { } + } + + private static string ReadResourceText(string name) + { + using var s = typeof(Installer).Assembly.GetManifestResourceStream(name); + if (s is null) return ""; + using var r = new StreamReader(s); + return r.ReadToEnd().Trim(); + } + + private static void CreateShortcut(string link, string target) + { + Directory.CreateDirectory(Path.GetDirectoryName(link)!); + dynamic shell = Activator.CreateInstance(Type.GetTypeFromProgID("WScript.Shell")!)!; + dynamic lnk = shell.CreateShortcut(link); + lnk.TargetPath = target; + lnk.WorkingDirectory = Path.GetDirectoryName(target); + lnk.IconLocation = $"{target},0"; + lnk.Description = "Desktop-Icons in Kategorien sortieren"; + lnk.Save(); + } + + private static void DeleteIfPointsTo(string link, string target) + { + if (!File.Exists(link)) return; + try + { + dynamic shell = Activator.CreateInstance(Type.GetTypeFromProgID("WScript.Shell")!)!; + string t = shell.CreateShortcut(link).TargetPath; + if (!string.Equals(t, target, StringComparison.OrdinalIgnoreCase)) return; + } + catch { 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); } + } + } + + private static void TryDelete(string path) + { + try { if (File.Exists(path)) File.Delete(path); } catch { } + } + + private static bool Storage_IsUnder(string path, string dir) => + (Path.GetFullPath(path)).StartsWith(Path.GetFullPath(dir).TrimEnd('\\') + "\\", StringComparison.OrdinalIgnoreCase); + + public static long DataSize() + { + long sum = 0; + foreach (var d in DataDirs.Where(Directory.Exists)) + { + try + { + foreach (var f in Directory.EnumerateFiles(d, "*", SearchOption.AllDirectories)) + try { sum += new FileInfo(f).Length; } catch { } + } + catch { } + } + return sum; + } + + 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/Setup/MainWindow.xaml b/Setup/MainWindow.xaml new file mode 100644 index 0000000..3189aa3 --- /dev/null +++ b/Setup/MainWindow.xaml @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +