Files
ClaudeDo/src/ClaudeDo.Installer/Core/UninstallRunner.cs

128 lines
4.6 KiB
C#

using System.IO;
using ClaudeDo.Data;
using ClaudeDo.Installer.Steps;
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);
// 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}");
}
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();
}
/// <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;
}
}
}