From f599f8d0af3c95c3db7783ded95f3066e4a25d77 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 15 Apr 2026 14:19:09 +0200 Subject: [PATCH] fix(installer,worker): service hosting, dark theme, uninstall polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- global.json | 6 ++ src/ClaudeDo.Installer/App.xaml.cs | 1 + .../ClaudeDo.Installer.csproj | 2 + .../Core/UninstallRunner.cs | 59 ++++++++++++ .../Pages/InstallPage/InstallPageViewModel.cs | 17 +++- .../Steps/DownloadAndExtractStep.cs | 13 ++- .../Steps/StopServiceStep.cs | 6 ++ .../Steps/WriteUninstallRegistryStep.cs | 81 ++++++++++++++++ src/ClaudeDo.Installer/Theme/DarkTheme.xaml | 93 +++++++++++++++++++ .../Views/SettingsWindow.xaml | 4 + .../Views/WizardWindow.xaml | 4 + src/ClaudeDo.Worker/ClaudeDo.Worker.csproj | 1 + src/ClaudeDo.Worker/Program.cs | 4 + 13 files changed, 288 insertions(+), 3 deletions(-) create mode 100644 global.json create mode 100644 src/ClaudeDo.Installer/Steps/WriteUninstallRegistryStep.cs diff --git a/global.json b/global.json new file mode 100644 index 0000000..b777c9b --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "9.0.304", + "rollForward": "latestFeature" + } +} diff --git a/src/ClaudeDo.Installer/App.xaml.cs b/src/ClaudeDo.Installer/App.xaml.cs index 31e38f8..d1502a6 100644 --- a/src/ClaudeDo.Installer/App.xaml.cs +++ b/src/ClaudeDo.Installer/App.xaml.cs @@ -111,6 +111,7 @@ public partial class App : Application sc.AddSingleton(); sc.AddSingleton(); sc.AddSingleton(); + sc.AddSingleton(); sc.AddSingleton(); sc.AddSingleton(sp => sp.GetRequiredService()); diff --git a/src/ClaudeDo.Installer/ClaudeDo.Installer.csproj b/src/ClaudeDo.Installer/ClaudeDo.Installer.csproj index 8854b1a..5aa49db 100644 --- a/src/ClaudeDo.Installer/ClaudeDo.Installer.csproj +++ b/src/ClaudeDo.Installer/ClaudeDo.Installer.csproj @@ -21,6 +21,8 @@ + true + win-x64 false true true diff --git a/src/ClaudeDo.Installer/Core/UninstallRunner.cs b/src/ClaudeDo.Installer/Core/UninstallRunner.cs index b3d5b0d..39809bb 100644 --- a/src/ClaudeDo.Installer/Core/UninstallRunner.cs +++ b/src/ClaudeDo.Installer/Core/UninstallRunner.cs @@ -1,6 +1,8 @@ +using System.Diagnostics; using System.IO; using ClaudeDo.Data; using ClaudeDo.Installer.Steps; +using Microsoft.Win32; namespace ClaudeDo.Installer.Core; @@ -36,6 +38,17 @@ public sealed class UninstallRunner 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( @@ -63,6 +76,21 @@ public sealed class UninstallRunner 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( @@ -74,6 +102,37 @@ public sealed class UninstallRunner 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:\\", "/"). diff --git a/src/ClaudeDo.Installer/Pages/InstallPage/InstallPageViewModel.cs b/src/ClaudeDo.Installer/Pages/InstallPage/InstallPageViewModel.cs index fed4636..e6618b9 100644 --- a/src/ClaudeDo.Installer/Pages/InstallPage/InstallPageViewModel.cs +++ b/src/ClaudeDo.Installer/Pages/InstallPage/InstallPageViewModel.cs @@ -54,6 +54,7 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage Steps.Add(new StepViewModel("Initialize Database")); Steps.Add(new StepViewModel("Register Windows Service")); Steps.Add(new StepViewModel("Create Shortcuts")); + Steps.Add(new StepViewModel("Register in Add/Remove Programs")); Steps.Add(new StepViewModel("Write Install Manifest")); } return Task.CompletedTask; @@ -82,7 +83,21 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage step.Status = p.Status; if (p.Message is not null) - step.Messages.Add(p.Message); + { + // Messages starting with "\r" overwrite the previous line (live progress). + if (p.Message.StartsWith('\r')) + { + var line = p.Message[1..]; + if (step.Messages.Count > 0 && step.Messages[^1].StartsWith(" ")) + step.Messages[^1] = line; + else + step.Messages.Add(line); + } + else + { + step.Messages.Add(p.Message); + } + } if (p.Status is StepStatus.Running && !step.IsExpanded) step.IsExpanded = true; diff --git a/src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs b/src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs index 94145bf..0400e95 100644 --- a/src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs +++ b/src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs @@ -44,9 +44,18 @@ public sealed class DownloadAndExtractStep : IInstallStep var zipPath = Path.Combine(scratchDir, zipAsset.Name); var checksumPath = Path.Combine(scratchDir, "checksums.txt"); - progress.Report($"Downloading {zipAsset.Name} ({zipAsset.Size / (1024 * 1024)} MB)..."); + var totalMb = zipAsset.Size / (1024 * 1024); + progress.Report($"Downloading {zipAsset.Name} ({totalMb} MB)..."); + long lastReportedMb = -1; await _releases.DownloadAsync(zipAsset.BrowserDownloadUrl, zipPath, - new Progress(b => progress.Report($" {b / (1024 * 1024)} MB downloaded")), + new Progress(b => + { + var mb = b / (1024 * 1024); + if (mb == lastReportedMb) return; + lastReportedMb = mb; + // Leading "\r" tells the UI to overwrite the previous line instead of appending. + progress.Report($"\r {mb} / {totalMb} MB downloaded"); + }), ct); progress.Report("Downloading checksums..."); diff --git a/src/ClaudeDo.Installer/Steps/StopServiceStep.cs b/src/ClaudeDo.Installer/Steps/StopServiceStep.cs index be3c271..c5279a4 100644 --- a/src/ClaudeDo.Installer/Steps/StopServiceStep.cs +++ b/src/ClaudeDo.Installer/Steps/StopServiceStep.cs @@ -27,6 +27,12 @@ public sealed class StopServiceStep : IInstallStep } var (stopExit, _) = await ProcessRunner.RunAsync("sc.exe", $"stop {ServiceName}", null, progress, ct); + // 1062 = ERROR_SERVICE_NOT_ACTIVE — registered but not running, treat as already stopped. + if (stopExit == 1062) + { + progress.Report("Service was registered but not running."); + return StepResult.Ok(); + } if (stopExit != 0) return StepResult.Fail($"sc.exe stop failed with exit code {stopExit}"); diff --git a/src/ClaudeDo.Installer/Steps/WriteUninstallRegistryStep.cs b/src/ClaudeDo.Installer/Steps/WriteUninstallRegistryStep.cs new file mode 100644 index 0000000..d768915 --- /dev/null +++ b/src/ClaudeDo.Installer/Steps/WriteUninstallRegistryStep.cs @@ -0,0 +1,81 @@ +using System.IO; +using ClaudeDo.Installer.Core; +using Microsoft.Win32; + +namespace ClaudeDo.Installer.Steps; + +/// +/// Registers ClaudeDo under HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo +/// 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. +/// +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 ExecuteAsync(InstallContext ctx, IProgress 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."); + 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; + } +} diff --git a/src/ClaudeDo.Installer/Theme/DarkTheme.xaml b/src/ClaudeDo.Installer/Theme/DarkTheme.xaml index 0987946..efbe9ff 100644 --- a/src/ClaudeDo.Installer/Theme/DarkTheme.xaml +++ b/src/ClaudeDo.Installer/Theme/DarkTheme.xaml @@ -184,6 +184,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ClaudeDo.Installer/Views/SettingsWindow.xaml b/src/ClaudeDo.Installer/Views/SettingsWindow.xaml index fa01301..25d5e15 100644 --- a/src/ClaudeDo.Installer/Views/SettingsWindow.xaml +++ b/src/ClaudeDo.Installer/Views/SettingsWindow.xaml @@ -6,6 +6,10 @@ Width="720" Height="520" MinWidth="620" MinHeight="460" WindowStartupLocation="CenterScreen" + Background="{StaticResource WindowBgBrush}" + Foreground="{StaticResource TextPrimaryBrush}" + FontFamily="Segoe UI" + FontSize="13" d:DataContext="{d:DesignInstance views:SettingsViewModel}" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" diff --git a/src/ClaudeDo.Installer/Views/WizardWindow.xaml b/src/ClaudeDo.Installer/Views/WizardWindow.xaml index 91d0a31..be7573b 100644 --- a/src/ClaudeDo.Installer/Views/WizardWindow.xaml +++ b/src/ClaudeDo.Installer/Views/WizardWindow.xaml @@ -6,6 +6,10 @@ Width="720" Height="520" MinWidth="620" MinHeight="460" WindowStartupLocation="CenterScreen" + Background="{StaticResource WindowBgBrush}" + Foreground="{StaticResource TextPrimaryBrush}" + FontFamily="Segoe UI" + FontSize="13" d:DataContext="{d:DesignInstance views:WizardViewModel}" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" diff --git a/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj b/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj index ad75153..c2314f0 100644 --- a/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj +++ b/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj @@ -6,6 +6,7 @@ + diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 15220a5..dbef489 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -10,6 +10,10 @@ var cfg = WorkerConfig.Load(); var builder = WebApplication.CreateBuilder(args); +// When launched by the Windows SCM, speak the Service Control Protocol so SCM +// doesn't think we crashed (~30s timeout). No-op when running interactively. +builder.Host.UseWindowsService(o => o.ServiceName = "ClaudeDoWorker"); + // Initialize DB schema before the host starts accepting connections. var dbFactory = new SqliteConnectionFactory(cfg.DbPath); SchemaInitializer.Apply(dbFactory);