A LocalSystem Windows service can't see the logged-in user's Claude CLI authentication, so the worker now runs as the current user via a hidden per-user logon Scheduled Task with restart-on-failure. - Worker is WinExe (no console window) with a Serilog rolling file sink and a single-instance mutex so the logon task, app ensure-running, and Restart button can't fight over the SignalR port. - Installer replaces the service steps (register/start/stop) with autostart task steps, migrates the legacy ClaudeDoWorker service away on update, and removes the task on uninstall. ServicePage drops the service-account UI. - UI gains a WorkerLocator; the app ensures the worker is running at startup and the Restart button kills+relaunches this install's worker process. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
90 lines
3.9 KiB
C#
90 lines
3.9 KiB
C#
using System.IO;
|
|
using ClaudeDo.Installer.Core;
|
|
using Microsoft.Win32;
|
|
|
|
namespace ClaudeDo.Installer.Steps;
|
|
|
|
/// <summary>
|
|
/// Registers ClaudeDo under <c>HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo</c>
|
|
/// so it shows up in Windows "Apps & Features" / "Programs and Features".
|
|
/// Also copies the running installer into the install directory so there is an exe
|
|
/// for UninstallString to reference after the temp-extracted single-file bundle is gone.
|
|
/// </summary>
|
|
public sealed class WriteUninstallRegistryStep : IInstallStep
|
|
{
|
|
internal const string UninstallKeyPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo";
|
|
|
|
public string Name => "Register in Add/Remove Programs";
|
|
|
|
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
|
{
|
|
var uninstallDir = Path.Combine(ctx.InstallDirectory, "uninstaller");
|
|
Directory.CreateDirectory(uninstallDir);
|
|
var targetExe = Path.Combine(uninstallDir, "ClaudeDo.Installer.exe");
|
|
|
|
// Copy the running installer so Apps & Features has a stable exe to launch —
|
|
// the single-file temp extract is gone once this process exits.
|
|
var sourceExe = Environment.ProcessPath
|
|
?? throw new InvalidOperationException("Cannot resolve running installer path.");
|
|
// In the self-update path the installer already runs from uninstaller/ (the
|
|
// --replace-self handoff put it there), so source == target and the copy would
|
|
// throw. Skip it; the binary is already in place.
|
|
var alreadyInPlace = string.Equals(
|
|
Path.GetFullPath(sourceExe), Path.GetFullPath(targetExe), StringComparison.OrdinalIgnoreCase);
|
|
if (!alreadyInPlace)
|
|
{
|
|
try
|
|
{
|
|
progress.Report("Copying uninstaller binary...");
|
|
File.Copy(sourceExe, targetExe, overwrite: true);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return StepResult.Fail($"Failed to copy uninstaller exe: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
progress.Report("Writing Add/Remove Programs entry...");
|
|
try
|
|
{
|
|
using var key = Registry.LocalMachine.CreateSubKey(UninstallKeyPath, writable: true);
|
|
if (key is null)
|
|
return StepResult.Fail("Could not open uninstall registry key (permission denied?).");
|
|
|
|
key.SetValue("DisplayName", "ClaudeDo", RegistryValueKind.String);
|
|
key.SetValue("DisplayVersion", ctx.InstallerVersion ?? "0.0.0", RegistryValueKind.String);
|
|
key.SetValue("Publisher", "Mika Kuns", RegistryValueKind.String);
|
|
key.SetValue("InstallLocation", ctx.InstallDirectory, RegistryValueKind.String);
|
|
key.SetValue("UninstallString", $"\"{targetExe}\"", RegistryValueKind.String);
|
|
key.SetValue("DisplayIcon", targetExe, RegistryValueKind.String);
|
|
key.SetValue("NoModify", 1, RegistryValueKind.DWord);
|
|
key.SetValue("NoRepair", 1, RegistryValueKind.DWord);
|
|
|
|
// Best-effort install size (KB) — scan install dir.
|
|
try
|
|
{
|
|
var sizeKb = (int)(DirectorySizeBytes(ctx.InstallDirectory) / 1024);
|
|
key.SetValue("EstimatedSize", sizeKb, RegistryValueKind.DWord);
|
|
}
|
|
catch { /* best-effort only */ }
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return StepResult.Fail($"Failed to write uninstall registry: {ex.Message}");
|
|
}
|
|
|
|
await Task.CompletedTask;
|
|
return StepResult.Ok();
|
|
}
|
|
|
|
private static long DirectorySizeBytes(string path)
|
|
{
|
|
long total = 0;
|
|
foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
|
|
{
|
|
try { total += new FileInfo(file).Length; } catch { /* ignore */ }
|
|
}
|
|
return total;
|
|
}
|
|
}
|