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 RunAsync(bool removeAppData, IProgress 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(); 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) — only if user opted in. if (removeAppData) { 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 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 */ } } /// /// Guards against catastrophic recursive-delete paths. The install dir must be /// a non-root directory with a non-empty name (e.g. reject "", "C:\\", "/"). /// 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; } } }