- Große Kopien laufen im Hintergrund mit Fortschritt und Abbrechen; abgebrochene Kopien werden aufgeräumt, das Original bleibt unverändert - Sichern/Wiederherstellen mit Fortschritt, keine halben ZIP-Dateien - Installer auf .NET Framework 4.8: erkennt fehlende .NET 9 Desktop Runtime und installiert sie auf Wunsch (Microsoft-Signatur wird geprüft) - Update-Hinweis über Gitea-Releases, Update mit automatischem Neustart - Fenstergröße und -position werden gemerkt - Desktop-Boxen: umbenennen, löschen, verschieben, herausziehen - Fenster und Boxen kehren auf sichtbare Bildschirme zurück - README ergänzt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
389 lines
15 KiB
C#
389 lines
15 KiB
C#
using System.Diagnostics;
|
||
using System.IO;
|
||
using Microsoft.Win32;
|
||
|
||
namespace DeskCoreSetup;
|
||
|
||
/// <summary>Installation/Deinstallation – nur für den aktuellen Benutzer, ohne Administratorrechte.</summary>
|
||
public static class Installer
|
||
{
|
||
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}";
|
||
}
|
||
}
|
||
|
||
/// <summary>Pfad dieses Setups (auch als Deinstallationsprogramm).</summary>
|
||
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");
|
||
|
||
/// <summary>Ordner einer vorhandenen Installation (oder null).</summary>
|
||
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";
|
||
|
||
/// <summary>Ordner einer alten Installation unter dem früheren Namen "DeskHub" – nur unsere eigene.</summary>
|
||
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 ----------
|
||
|
||
/// <summary>Rechnet Fortschritt 0–1 in einen Teilbereich um.</summary>
|
||
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));
|
||
}
|
||
|
||
/// <summary>Laufendes DeskCore beenden (das X versteckt es nur im Infobereich).</summary>
|
||
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.");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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 { }
|
||
}
|
||
|
||
/// <summary>Beiseitegelegte Dateien früherer Installationen wegräumen (sofern nicht mehr in Benutzung).</summary>
|
||
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",
|
||
};
|
||
}
|