diff --git a/docs/release-drafts/v0.3.5.md b/docs/release-drafts/v0.3.5.md new file mode 100644 index 0000000..c8502f8 --- /dev/null +++ b/docs/release-drafts/v0.3.5.md @@ -0,0 +1,18 @@ +# PriorityGear v0.3.5 - winget readiness installer fix + +v0.3.5 fixes a blocker found while validating the v0.3.4 installer against the winget local lifecycle gate. + +## Installer + +- Fixes silent uninstall when `PriorityGear.Setup.exe` is launched from the installed version directory. +- Removes the uninstall registration and Start Menu shortcut before removing installed program files. +- Schedules installed program file removal after setup exits when the running setup executable is inside `%ProgramFiles%\PriorityGear`. +- Preserves `%ProgramData%\PriorityGear` rules and logs by default. + +## Distribution boundary + +- This is still an unsigned GitHub installer release. +- Store, winget acceptance, signing, MSI, and MSIX are not claimed by this release. +- The installer configures `PriorityGear.Service` as a LocalSystem service. +- Changing process priority can affect system stability. +- Arbitrary shared-host `svchost.exe` mutation remains out of scope. diff --git a/src/PriorityGear.Setup/Program.cs b/src/PriorityGear.Setup/Program.cs index 0e8040d..388dda7 100644 --- a/src/PriorityGear.Setup/Program.cs +++ b/src/PriorityGear.Setup/Program.cs @@ -356,14 +356,9 @@ internal static async Task UninstallAsync(SetupInstallPlan plan, SetupLog log) log.Info("Service is not installed."); } - if (Directory.Exists(plan.BaseInstallDirectory)) - { - Directory.Delete(plan.BaseInstallDirectory, recursive: true); - log.Info($"Removed installed program files: {plan.BaseInstallDirectory}"); - } - DeleteUninstallRegistration(plan, log); DeleteStartMenuShortcut(plan, log); + RemoveInstallDirectory(plan, log); log.Info($"Preserved data directory: {plan.ProgramDataDirectory}"); log.Info("Delete ProgramData manually only if machine rules and logs are no longer needed."); } @@ -620,6 +615,42 @@ private static void DeleteStartMenuShortcut(SetupInstallPlan plan, SetupLog log) log.Info($"Removed Start Menu shortcut if present: {plan.StartMenuShortcutPath}"); } + private static void RemoveInstallDirectory(SetupInstallPlan plan, SetupLog log) + { + if (!Directory.Exists(plan.BaseInstallDirectory)) + { + log.Info($"Installed program files were already absent: {plan.BaseInstallDirectory}"); + return; + } + + string currentProcessPath = Environment.ProcessPath ?? Application.ExecutablePath; + if (Path.GetFullPath(currentProcessPath).StartsWith( + Path.GetFullPath(plan.BaseInstallDirectory) + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase)) + { + ScheduleInstallDirectoryRemoval(plan.BaseInstallDirectory, log); + return; + } + + Directory.Delete(plan.BaseInstallDirectory, recursive: true); + log.Info($"Removed installed program files: {plan.BaseInstallDirectory}"); + } + + private static void ScheduleInstallDirectoryRemoval(string installDirectory, SetupLog log) + { + string command = $"timeout /t 2 /nobreak > nul & rmdir /s /q \"{installDirectory}\""; + using Process process = Process.Start(new ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = $"/c {command}", + WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.System), + UseShellExecute = false, + CreateNoWindow = true + }) ?? throw new InvalidOperationException("Failed to schedule installed program file removal."); + + log.Info($"Scheduled installed program file removal after setup exits: {installDirectory}; cleanup pid={process.Id}"); + } + private sealed class ProductionInstallerExecutor(SetupInstallPlan plan, SetupLog log, string payloadDirectory) : IInstallerExecutor { public void ValidatePayload() diff --git a/tests/PriorityGear.Setup.Tests/SetupPlanningTests.cs b/tests/PriorityGear.Setup.Tests/SetupPlanningTests.cs index 8ac0717..2092b49 100644 --- a/tests/PriorityGear.Setup.Tests/SetupPlanningTests.cs +++ b/tests/PriorityGear.Setup.Tests/SetupPlanningTests.cs @@ -142,6 +142,32 @@ public void StartMenuShortcutTargetsInstalledGuiApp() Assert.Equal("PriorityGear", spec.Description); } + [Fact] + public void UninstallRemovesRegistrationAndShortcutBeforeInstallDirectory() + { + string program = File.ReadAllText(Path.Combine(FindRepoRoot(), "src", "PriorityGear.Setup", "Program.cs")); + int deleteRegistration = program.IndexOf("DeleteUninstallRegistration(plan, log);", StringComparison.Ordinal); + int deleteShortcut = program.IndexOf("DeleteStartMenuShortcut(plan, log);", StringComparison.Ordinal); + int removeInstallDirectory = program.IndexOf("RemoveInstallDirectory(plan, log);", StringComparison.Ordinal); + + Assert.NotEqual(-1, deleteRegistration); + Assert.NotEqual(-1, deleteShortcut); + Assert.NotEqual(-1, removeInstallDirectory); + Assert.True(deleteRegistration < deleteShortcut); + Assert.True(deleteShortcut < removeInstallDirectory); + } + + [Fact] + public void UninstallSchedulesInstallDirectoryRemovalWhenSetupRunsFromInstallRoot() + { + string program = File.ReadAllText(Path.Combine(FindRepoRoot(), "src", "PriorityGear.Setup", "Program.cs")); + + Assert.Contains("ScheduleInstallDirectoryRemoval(plan.BaseInstallDirectory, log);", program); + Assert.Contains("timeout /t 2 /nobreak", program); + Assert.Contains("rmdir /s /q", program); + Assert.Contains("WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.System)", program); + } + private static string FindRepoRoot() { DirectoryInfo? directory = new(AppContext.BaseDirectory);