Some checks failed
Release / release (push) Failing after 0s
Worker: - Wire UseWindowsService + Microsoft.Extensions.Hosting.WindowsServices so SCM's Service Control Protocol handshake succeeds. Previously the binary exited immediately under sc start, leaving the service registered but never running. Installer: - Pin SDK to .NET 9 (global.json) — SDK 10 dropped win-arm from its RID graph, breaking restore of the WPF project; .NET 9 keeps win-arm AND understands the .slnx solution format. - Force SelfContained=true and default RID=win-x64 when PublishSingleFile is set, so Rider Publish and CLI produce the same bundle. - Dark theme: set Background/Foreground explicitly on WizardWindow and SettingsWindow roots (WPF implicit styles don't cascade to derived Window types). Custom ComboBox template + ComboBoxItem style so dropdowns honour the dark palette instead of system defaults. - Throttle download progress to one report per MB and overwrite the same UI line (\r prefix marker) instead of appending per chunk. - Register ClaudeDo in HKLM\...\Uninstall so it appears in Apps & Features. Copy installer into InstallDir\uninstaller\ for the UninstallString, and schedule a cmd.exe trampoline to handle the self-delete case when Apps & Features launches the copy from inside the install dir. - Treat sc.exe stop exit 1062 (ERROR_SERVICE_NOT_ACTIVE) as success. - Delete the uninstall registry key during UninstallRunner. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
187 lines
7.1 KiB
C#
187 lines
7.1 KiB
C#
using System.Diagnostics;
|
|
using System.IO;
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Installer.Steps;
|
|
using Microsoft.Win32;
|
|
|
|
namespace ClaudeDo.Installer.Core;
|
|
|
|
public sealed class UninstallRunner
|
|
{
|
|
private readonly InstallContext _context;
|
|
private readonly StopServiceStep _stopService;
|
|
|
|
public UninstallRunner(InstallContext context, StopServiceStep stopService)
|
|
{
|
|
_context = context;
|
|
_stopService = stopService;
|
|
}
|
|
|
|
public async Task<StepResult> RunAsync(IProgress<string> progress, CancellationToken ct)
|
|
{
|
|
// 1) Validate install dir up front — refuse obviously unsafe paths.
|
|
// Prevents Directory.Delete(recursive:true) from wiping C:\ or C:\Program Files\.
|
|
if (!IsSafeInstallDir(_context.InstallDirectory, out var safeError))
|
|
return StepResult.Fail($"Refusing to uninstall: {safeError}");
|
|
|
|
// 2) Stop service. If stop fails we MUST abort — deleting a service whose
|
|
// process is still running leaves orphan locked binaries under the install dir
|
|
// which Directory.Delete will silently skip.
|
|
progress.Report("Stopping worker service...");
|
|
var stopResult = await _stopService.ExecuteAsync(_context, progress, ct);
|
|
if (!stopResult.Success)
|
|
return StepResult.Fail(
|
|
$"Cannot uninstall: worker service did not stop cleanly. {stopResult.ErrorMessage} " +
|
|
"Kill the worker manually and re-run uninstall.");
|
|
|
|
// 3) Unregister service.
|
|
progress.Report("Unregistering service...");
|
|
await ProcessRunner.RunAsync("sc.exe", $"delete {StopServiceStep.ServiceName}", null, progress, ct);
|
|
|
|
// 3b) Remove Apps & Features registry entry (best-effort).
|
|
progress.Report("Removing Add/Remove Programs entry...");
|
|
try
|
|
{
|
|
Registry.LocalMachine.DeleteSubKeyTree(WriteUninstallRegistryStep.UninstallKeyPath, throwOnMissingSubKey: false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
progress.Report($"Warning: could not delete uninstall registry key: {ex.Message}");
|
|
}
|
|
|
|
// 4) Remove shortcuts (best-effort — a stuck .lnk must not block the rest).
|
|
progress.Report("Removing shortcuts...");
|
|
TryDeleteFile(Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory),
|
|
"ClaudeDo.lnk"));
|
|
TryDeleteFile(Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu),
|
|
"Programs", "ClaudeDo.lnk"));
|
|
|
|
// 5) Delete install directory. Track success so we can report partial state.
|
|
var failures = new List<string>();
|
|
if (Directory.Exists(_context.InstallDirectory))
|
|
{
|
|
progress.Report($"Deleting {_context.InstallDirectory}...");
|
|
if (!TryDeleteDir(_context.InstallDirectory, out var err))
|
|
failures.Add($"install dir ({_context.InstallDirectory}): {err}");
|
|
}
|
|
|
|
// 6) Delete ~/.todo-app (config + DB + logs) — user opted into full removal.
|
|
var appData = Paths.AppDataRoot();
|
|
if (Directory.Exists(appData))
|
|
{
|
|
progress.Report($"Deleting {appData}...");
|
|
if (!TryDeleteDir(appData, out var err))
|
|
failures.Add($"app data ({appData}): {err}");
|
|
}
|
|
|
|
// 7) If we were launched from inside the install dir (Apps & Features case),
|
|
// our own exe is still locked — schedule a cmd.exe trampoline to finish
|
|
// the deletion after this process exits. Best-effort: if this fails the
|
|
// user is left with an empty <uninstaller> folder which is harmless.
|
|
var runningExe = Environment.ProcessPath;
|
|
if (runningExe is not null
|
|
&& IsInsideDirectory(runningExe, _context.InstallDirectory)
|
|
&& Directory.Exists(_context.InstallDirectory))
|
|
{
|
|
progress.Report("Scheduling final cleanup after exit...");
|
|
TryScheduleTrampolineDelete(_context.InstallDirectory);
|
|
// The trampoline will finish the job — clear the residual failure entry for the install dir.
|
|
failures.RemoveAll(f => f.StartsWith("install dir"));
|
|
}
|
|
|
|
if (failures.Count > 0)
|
|
{
|
|
return StepResult.Fail(
|
|
"Uninstall partially succeeded — the following could not be removed:\n " +
|
|
string.Join("\n ", failures));
|
|
}
|
|
|
|
progress.Report("Uninstall complete.");
|
|
return StepResult.Ok();
|
|
}
|
|
|
|
private static bool IsInsideDirectory(string filePath, string directory)
|
|
{
|
|
try
|
|
{
|
|
var full = Path.GetFullPath(filePath);
|
|
var dir = Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
|
return full.StartsWith(dir, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
catch { return false; }
|
|
}
|
|
|
|
private static void TryScheduleTrampolineDelete(string installDir)
|
|
{
|
|
try
|
|
{
|
|
var pid = Environment.ProcessId;
|
|
// Wait for this process to exit, then recursively remove the install dir.
|
|
// /B timeout avoids a visible window; ping as a portable sleep; rmdir /S /Q is silent.
|
|
var cmd = $"/C start \"\" /MIN cmd /C \"ping 127.0.0.1 -n 3 >nul & rmdir /S /Q \"\"{installDir}\"\"\"";
|
|
Process.Start(new ProcessStartInfo
|
|
{
|
|
FileName = "cmd.exe",
|
|
Arguments = cmd,
|
|
UseShellExecute = false,
|
|
CreateNoWindow = true,
|
|
WindowStyle = ProcessWindowStyle.Hidden,
|
|
});
|
|
}
|
|
catch { /* best effort */ }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Guards against catastrophic recursive-delete paths. The install dir must be
|
|
/// a non-root directory with a non-empty name (e.g. reject "", "C:\\", "/").
|
|
/// </summary>
|
|
private static bool IsSafeInstallDir(string path, out string reason)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path))
|
|
{
|
|
reason = "install directory is empty";
|
|
return false;
|
|
}
|
|
|
|
string full;
|
|
try { full = Path.GetFullPath(path); }
|
|
catch (Exception ex)
|
|
{
|
|
reason = $"install directory is not a valid path: {ex.Message}";
|
|
return false;
|
|
}
|
|
|
|
var name = Path.GetFileName(full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
|
if (string.IsNullOrEmpty(name))
|
|
{
|
|
reason = $"install directory resolves to a drive root ({full})";
|
|
return false;
|
|
}
|
|
|
|
reason = "";
|
|
return true;
|
|
}
|
|
|
|
private static void TryDeleteFile(string path)
|
|
{
|
|
try { if (File.Exists(path)) File.Delete(path); } catch { /* best effort — single shortcut */ }
|
|
}
|
|
|
|
private static bool TryDeleteDir(string path, out string error)
|
|
{
|
|
try
|
|
{
|
|
Directory.Delete(path, recursive: true);
|
|
error = "";
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
error = ex.Message;
|
|
return false;
|
|
}
|
|
}
|
|
}
|