From 5cec50aecb876c6478895a64b6d9da6cfb359fc8 Mon Sep 17 00:00:00 2001 From: Yuji Tsuchimoto Date: Tue, 19 May 2026 21:23:12 +0900 Subject: [PATCH 1/2] Fix silent uninstall cleanup --- docs/release-drafts/v0.3.5.md | 18 ++++++++ src/PriorityGear.Setup/Program.cs | 42 ++++++++++++++++--- .../SetupPlanningTests.cs | 23 ++++++++++ 3 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 docs/release-drafts/v0.3.5.md 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..d78674e 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,41 @@ 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}", + 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..aea3d90 100644 --- a/tests/PriorityGear.Setup.Tests/SetupPlanningTests.cs +++ b/tests/PriorityGear.Setup.Tests/SetupPlanningTests.cs @@ -142,6 +142,29 @@ public void StartMenuShortcutTargetsInstalledGuiApp() Assert.Equal("PriorityGear", spec.Description); } + [Fact] + public void UninstallRemovesRegistrationAndShortcutBeforeInstallDirectory() + { + string program = File.ReadAllText(Path.Combine(FindRepoRoot(), "src", "PriorityGear.Setup", "Program.cs")); + + Assert.True( + program.IndexOf("DeleteUninstallRegistration(plan, log);", StringComparison.Ordinal) < + program.IndexOf("DeleteStartMenuShortcut(plan, log);", StringComparison.Ordinal)); + Assert.True( + program.IndexOf("DeleteStartMenuShortcut(plan, log);", StringComparison.Ordinal) < + program.IndexOf("RemoveInstallDirectory(plan, log);", StringComparison.Ordinal)); + } + + [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); + } + private static string FindRepoRoot() { DirectoryInfo? directory = new(AppContext.BaseDirectory); From 52b5f28f716c68fa13408e3ff86d3683abfb0783 Mon Sep 17 00:00:00 2001 From: Yuji Tsuchimoto Date: Tue, 19 May 2026 21:28:18 +0900 Subject: [PATCH 2/2] Address installer cleanup review --- src/PriorityGear.Setup/Program.cs | 1 + .../SetupPlanningTests.cs | 17 ++++++++++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/PriorityGear.Setup/Program.cs b/src/PriorityGear.Setup/Program.cs index d78674e..388dda7 100644 --- a/src/PriorityGear.Setup/Program.cs +++ b/src/PriorityGear.Setup/Program.cs @@ -643,6 +643,7 @@ private static void ScheduleInstallDirectoryRemoval(string installDirectory, Set { 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."); diff --git a/tests/PriorityGear.Setup.Tests/SetupPlanningTests.cs b/tests/PriorityGear.Setup.Tests/SetupPlanningTests.cs index aea3d90..2092b49 100644 --- a/tests/PriorityGear.Setup.Tests/SetupPlanningTests.cs +++ b/tests/PriorityGear.Setup.Tests/SetupPlanningTests.cs @@ -146,13 +146,15 @@ public void StartMenuShortcutTargetsInstalledGuiApp() public void UninstallRemovesRegistrationAndShortcutBeforeInstallDirectory() { string program = File.ReadAllText(Path.Combine(FindRepoRoot(), "src", "PriorityGear.Setup", "Program.cs")); - - Assert.True( - program.IndexOf("DeleteUninstallRegistration(plan, log);", StringComparison.Ordinal) < - program.IndexOf("DeleteStartMenuShortcut(plan, log);", StringComparison.Ordinal)); - Assert.True( - program.IndexOf("DeleteStartMenuShortcut(plan, log);", StringComparison.Ordinal) < - program.IndexOf("RemoveInstallDirectory(plan, log);", StringComparison.Ordinal)); + 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] @@ -163,6 +165,7 @@ public void UninstallSchedulesInstallDirectoryRemovalWhenSetupRunsFromInstallRoo 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()