Merge branch 'main' into feat/ui-improvements

This commit is contained in:
2026-04-15 09:28:18 +00:00
65 changed files with 6489 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
<Application x:Class="ClaudeDo.Installer.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:core="clr-namespace:ClaudeDo.Installer.Core">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Theme/DarkTheme.xaml"/>
</ResourceDictionary.MergedDictionaries>
<core:NullToCollapsedConverter x:Key="NullToCollapsedConverter"/>
<core:StepActiveConverter x:Key="StepActiveConverter"/>
<BooleanToVisibilityConverter x:Key="BoolToVisConverter"/>
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@@ -0,0 +1,131 @@
using System.Net.Http;
using System.Reflection;
using System.Windows;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Pages.InstallPage;
using ClaudeDo.Installer.Pages.PathsPage;
using ClaudeDo.Installer.Pages.ServicePage;
using ClaudeDo.Installer.Pages.UiSettingsPage;
using ClaudeDo.Installer.Pages.WelcomePage;
using ClaudeDo.Installer.Steps;
using ClaudeDo.Installer.Views;
using Microsoft.Extensions.DependencyInjection;
namespace ClaudeDo.Installer;
public partial class App : Application
{
private ServiceProvider? _services;
protected override async void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
_services = BuildServices();
var context = _services.GetRequiredService<InstallContext>();
context.InstallerVersion = GetInstallerVersion();
// Default install dir for detection — on upgrade we stay where we were.
var detector = _services.GetRequiredService<InstallModeDetector>();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
// Read manifest up front so we can fall back to Config if the API times out
// on an existing install. If the API is slow, we do NOT want to drop an
// already-installed user into FreshInstall — that would risk overwriting them.
var existingManifest = InstallManifestStore.TryRead(context.InstallDirectory);
DetectedState state;
try
{
state = await detector.DetectAsync(context.InstallDirectory, cts.Token);
}
catch (OperationCanceledException)
{
state = existingManifest is not null
? new DetectedState(InstallerMode.Config, existingManifest, null, null)
: new DetectedState(InstallerMode.FreshInstall, null, null, null);
}
context.Mode = state.Mode;
context.InstalledVersion = state.Existing?.Version;
context.LatestVersion = state.LatestVersion;
if (state.Existing is not null)
context.InstallDirectory = state.Existing.InstallDir;
Window mainWindow = state.Mode switch
{
InstallerMode.FreshInstall or InstallerMode.Update => new WizardWindow
{
DataContext = _services.GetRequiredService<WizardViewModel>()
},
InstallerMode.Config => new SettingsWindow
{
DataContext = _services.GetRequiredService<SettingsViewModel>()
},
_ => throw new InvalidOperationException($"Unknown installer mode: {state.Mode}")
};
DarkTitleBar.Apply(mainWindow);
mainWindow.Show();
}
protected override void OnExit(ExitEventArgs e)
{
_services?.Dispose();
base.OnExit(e);
}
private static string GetInstallerVersion()
{
var infoAttr = Assembly.GetExecutingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
return infoAttr?.InformationalVersion ?? "0.0.0";
}
private static ServiceProvider BuildServices()
{
var sc = new ServiceCollection();
// Core
sc.AddSingleton<InstallContext>();
sc.AddSingleton<PageResolver>();
// HTTP + release client
sc.AddSingleton(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(15) });
sc.AddSingleton<IReleaseClient>(sp => new ReleaseClient(sp.GetRequiredService<HttpClient>()));
sc.AddSingleton<InstallModeDetector>();
// Pages
sc.AddSingleton<IInstallerPage, WelcomePageViewModel>();
sc.AddSingleton<IInstallerPage, PathsPageViewModel>();
sc.AddSingleton<IInstallerPage, ServicePageViewModel>();
sc.AddSingleton<IInstallerPage, UiSettingsPageViewModel>();
sc.AddSingleton<IInstallerPage, InstallPageViewModel>();
// Steps — execution order matters for the FreshInstall pipeline (IEnumerable<IInstallStep>).
// Double-registered as both IInstallStep and concrete type so Task 15's Update pipeline
// can pull them out individually via GetRequiredService<T>().
sc.AddSingleton<DownloadAndExtractStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<DownloadAndExtractStep>());
sc.AddSingleton<IInstallStep, WriteConfigStep>();
sc.AddSingleton<IInstallStep, InitDatabaseStep>();
sc.AddSingleton<IInstallStep, RegisterServiceStep>();
sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
sc.AddSingleton<WriteInstallManifestStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteInstallManifestStep>());
// Stop/Start — NOT registered as IInstallStep (not part of default FreshInstall pipeline).
// Pulled by Update flow + Repair/Uninstall.
sc.AddSingleton<StopServiceStep>();
sc.AddSingleton<StartServiceStep>();
// Runners
sc.AddSingleton<UninstallRunner>();
// ViewModels
sc.AddSingleton<WizardViewModel>();
sc.AddSingleton<SettingsViewModel>();
return sc.BuildServiceProvider();
}
}

View File

@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<!-- Debug: asInvoker so Rider/VS can debug without elevation -->
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
<ApplicationManifest>app.debug.manifest</ApplicationManifest>
</PropertyGroup>
<!-- Release: requireAdministrator for service registration + shortcuts -->
<PropertyGroup Condition="'$(Configuration)' != 'Debug'">
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<PropertyGroup Condition="'$(PublishSingleFile)' == 'true'">
<PublishTrimmed>false</PublishTrimmed>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ClaudeDo.Data\ClaudeDo.Data.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,37 @@
using System.IO;
using System.Security.Cryptography;
namespace ClaudeDo.Installer.Core;
public static class ChecksumVerifier
{
public static string ComputeSha256(string filePath)
{
using var stream = File.OpenRead(filePath);
using var sha = SHA256.Create();
var hash = sha.ComputeHash(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}
public static bool Verify(string filePath, string expectedSha256)
{
var actual = ComputeSha256(filePath);
return string.Equals(actual, expectedSha256.Trim(), StringComparison.OrdinalIgnoreCase);
}
public static IReadOnlyDictionary<string, string> ParseChecksumsFile(string content)
{
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var rawLine in content.Split('\n'))
{
var line = rawLine.Trim();
if (line.Length == 0) continue;
var parts = line.Split(new[] { ' ', '\t' }, 2, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2) continue;
var hashPart = parts[0].Trim();
if (hashPart.Length != 64) continue;
map[parts[1].Trim()] = hashPart;
}
return map;
}
}

View File

@@ -0,0 +1,109 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using ClaudeDo.Data;
namespace ClaudeDo.Installer.Core;
/// <summary>
/// Mirrors ClaudeDo.Worker.Config.WorkerConfig JSON shape.
/// Keep in sync with src/ClaudeDo.Worker/Config/WorkerConfig.cs.
/// </summary>
public sealed class InstallerWorkerConfig
{
[JsonPropertyName("db_path")]
public string DbPath { get; set; } = "~/.todo-app/todo.db";
[JsonPropertyName("sandbox_root")]
public string SandboxRoot { get; set; } = "~/.todo-app/sandbox";
[JsonPropertyName("log_root")]
public string LogRoot { get; set; } = "~/.todo-app/logs";
[JsonPropertyName("worktree_root_strategy")]
public string WorktreeRootStrategy { get; set; } = "sibling";
[JsonPropertyName("central_worktree_root")]
public string CentralWorktreeRoot { get; set; } = "~/.todo-app/worktrees";
[JsonPropertyName("queue_backstop_interval_ms")]
public int QueueBackstopIntervalMs { get; set; } = 30_000;
[JsonPropertyName("signalr_port")]
public int SignalRPort { get; set; } = 47_821;
[JsonPropertyName("claude_bin")]
public string ClaudeBin { get; set; } = "claude";
private static readonly JsonSerializerOptions ReadOpts = new()
{
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
private static readonly JsonSerializerOptions WriteOpts = new()
{
WriteIndented = true,
};
public static InstallerWorkerConfig Load()
{
var path = Path.Combine(Paths.AppDataRoot(), "worker.config.json");
if (!File.Exists(path)) return new();
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<InstallerWorkerConfig>(json, ReadOpts) ?? new();
}
public void Save()
{
var dir = Paths.AppDataRoot();
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, "worker.config.json");
var json = JsonSerializer.Serialize(this, WriteOpts);
File.WriteAllText(path, json);
}
}
/// <summary>
/// Mirrors ClaudeDo.Ui.AppSettings JSON shape.
/// Keep in sync with src/ClaudeDo.Ui/AppSettings.cs.
/// </summary>
public sealed class InstallerAppSettings
{
public string DbPath { get; set; } = "~/.todo-app/todo.db";
public string SignalRUrl { get; set; } = "http://127.0.0.1:47821/hub";
private static readonly JsonSerializerOptions ReadOpts = new()
{
PropertyNameCaseInsensitive = true,
};
private static readonly JsonSerializerOptions WriteOpts = new()
{
WriteIndented = true,
};
public static InstallerAppSettings Load()
{
var path = Path.Combine(Paths.AppDataRoot(), "ui.config.json");
if (!File.Exists(path)) return new();
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<InstallerAppSettings>(json, ReadOpts) ?? new();
}
catch
{
return new();
}
}
public void Save()
{
var dir = Paths.AppDataRoot();
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, "ui.config.json");
var json = JsonSerializer.Serialize(this, WriteOpts);
File.WriteAllText(path, json);
}
}

View File

@@ -0,0 +1,29 @@
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;
namespace ClaudeDo.Installer.Core;
public static class DarkTitleBar
{
[DllImport("dwmapi.dll", PreserveSig = true)]
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int attrValue, int attrSize);
private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20;
public static void Apply(Window window)
{
if (window.IsLoaded)
SetDarkMode(window);
else
window.SourceInitialized += (_, _) => SetDarkMode(window);
}
private static void SetDarkMode(Window window)
{
var hwnd = new WindowInteropHelper(window).Handle;
if (hwnd == IntPtr.Zero) return;
int value = 1;
DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ref value, sizeof(int));
}
}

View File

@@ -0,0 +1,20 @@
namespace ClaudeDo.Installer.Core;
public interface IInstallStep
{
string Name { get; }
Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct);
}
public sealed class StepResult
{
public bool Success { get; init; }
public string? ErrorMessage { get; init; }
public static StepResult Ok() => new() { Success = true };
public static StepResult Fail(string error) => new() { Success = false, ErrorMessage = error };
}
public enum StepStatus { Pending, Running, Done, Failed, Skipped }
public sealed record StepProgress(string StepName, StepStatus Status, string? Message = null);

View File

@@ -0,0 +1,16 @@
using System.Windows.Controls;
namespace ClaudeDo.Installer.Core;
public interface IInstallerPage
{
string Title { get; }
string Icon { get; }
int Order { get; }
bool ShowInWizard { get; }
bool ShowInSettings { get; }
UserControl View { get; }
Task LoadAsync();
Task ApplyAsync();
bool Validate();
}

View File

@@ -0,0 +1,15 @@
namespace ClaudeDo.Installer.Core;
public sealed record ReleaseAsset(string Name, string BrowserDownloadUrl, long Size);
public sealed record GiteaRelease(
string TagName,
string Name,
IReadOnlyList<ReleaseAsset> Assets);
public interface IReleaseClient
{
Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct);
Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct);
}

View File

@@ -0,0 +1,35 @@
namespace ClaudeDo.Installer.Core;
public sealed class InstallContext
{
// WelcomePage / install destination
public string InstallDirectory { get; set; } = @"C:\Program Files\ClaudeDo";
// Mode + versions (set by App startup after InstallModeDetector runs)
public InstallerMode Mode { get; set; } = InstallerMode.FreshInstall;
public string? InstallerVersion { get; set; } // from this installer's assembly
public string? InstalledVersion { get; set; } // from install.json (or set by DownloadAndExtractStep)
public string? LatestVersion { get; set; } // from Gitea API (may be null if offline)
// PathsPage
public string DbPath { get; set; } = "~/.todo-app/todo.db";
public string LogRoot { get; set; } = "~/.todo-app/logs";
public string SandboxRoot { get; set; } = "~/.todo-app/sandbox";
public string WorktreeRootStrategy { get; set; } = "sibling";
public string CentralWorktreeRoot { get; set; } = "~/.todo-app/worktrees";
// ServicePage
public int SignalRPort { get; set; } = 47_821;
public int QueueBackstopIntervalMs { get; set; } = 30_000;
public string ClaudeBin { get; set; } = "claude";
public string ServiceAccount { get; set; } = "LocalSystem";
public bool AutoStart { get; set; } = true;
public int RestartDelayMs { get; set; } = 5000;
// UiSettingsPage
public string SignalRUrl { get; set; } = "http://127.0.0.1:47821/hub";
public string UiDbPath { get; set; } = "~/.todo-app/todo.db";
// InstallPage
public bool CreateDesktopShortcut { get; set; } = true;
}

View File

@@ -0,0 +1,48 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ClaudeDo.Installer.Core;
public sealed record InstallManifest(
string Version,
string InstallDir,
string WorkerDir,
DateTimeOffset InstalledAt);
public static class InstallManifestStore
{
public const string FileName = "install.json";
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public static string ManifestPath(string installDir) => Path.Combine(installDir, FileName);
public static InstallManifest? TryRead(string installDir)
{
var path = ManifestPath(installDir);
if (!File.Exists(path)) return null;
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<InstallManifest>(json, JsonOptions);
}
catch
{
return null;
}
}
public static void Write(string installDir, InstallManifest manifest)
{
Directory.CreateDirectory(installDir);
var json = JsonSerializer.Serialize(manifest, JsonOptions);
File.WriteAllText(ManifestPath(installDir), json);
}
}

View File

@@ -0,0 +1,48 @@
namespace ClaudeDo.Installer.Core;
public sealed record DetectedState(
InstallerMode Mode,
InstallManifest? Existing,
GiteaRelease? LatestRelease,
string? LatestVersion);
public sealed class InstallModeDetector
{
private readonly IReleaseClient _releases;
public InstallModeDetector(IReleaseClient releases)
{
_releases = releases;
}
public async Task<DetectedState> DetectAsync(string installDir, CancellationToken ct)
{
var manifest = InstallManifestStore.TryRead(installDir);
if (manifest is null)
return new DetectedState(InstallerMode.FreshInstall, null, null, null);
var release = await _releases.GetLatestReleaseAsync(ct);
if (release is null)
return new DetectedState(InstallerMode.Config, manifest, null, null);
var latestVersion = release.TagName.TrimStart('v', 'V');
if (IsNewer(latestVersion, manifest.Version))
return new DetectedState(InstallerMode.Update, manifest, release, latestVersion);
return new DetectedState(InstallerMode.Config, manifest, release, latestVersion);
}
/// <summary>
/// Returns true only when both versions parse as System.Version (major.minor[.build[.revision]])
/// AND latest &gt; current. Semver pre-release tags like "0.2.0-beta" fail to parse and are
/// treated as "not newer" — the user drops into Config mode with no update offered.
/// This is deliberate: offering an update we can't compare is worse than silently skipping it.
/// If the project starts shipping pre-release tags, revisit this.
/// </summary>
private static bool IsNewer(string latest, string current)
{
if (!Version.TryParse(latest, out var lv)) return false;
if (!Version.TryParse(current, out var cv)) return false;
return lv > cv;
}
}

View File

@@ -0,0 +1,8 @@
namespace ClaudeDo.Installer.Core;
public enum InstallerMode
{
FreshInstall, // No install.json present -> run full wizard
Update, // install.json present, newer release available
Config, // install.json present, no update (or API unreachable)
}

View File

@@ -0,0 +1,49 @@
namespace ClaudeDo.Installer.Core;
public sealed class InstallerService
{
private readonly IEnumerable<IInstallStep> _steps;
public InstallerService(IEnumerable<IInstallStep> steps) => _steps = steps;
public async Task<IReadOnlyList<(IInstallStep Step, StepResult Result)>> ExecuteAsync(
InstallContext ctx,
IProgress<StepProgress> progress,
CancellationToken ct)
{
var results = new List<(IInstallStep, StepResult)>();
foreach (var step in _steps)
{
ct.ThrowIfCancellationRequested();
progress.Report(new StepProgress(step.Name, StepStatus.Running));
var lineProgress = new Progress<string>(msg =>
progress.Report(new StepProgress(step.Name, StepStatus.Running, msg)));
try
{
var result = await step.ExecuteAsync(ctx, lineProgress, ct);
var status = result.Success ? StepStatus.Done : StepStatus.Failed;
progress.Report(new StepProgress(step.Name, status, result.ErrorMessage));
results.Add((step, result));
if (!result.Success) break;
}
catch (OperationCanceledException)
{
progress.Report(new StepProgress(step.Name, StepStatus.Failed, "Cancelled"));
results.Add((step, StepResult.Fail("Cancelled")));
break;
}
catch (Exception ex)
{
progress.Report(new StepProgress(step.Name, StepStatus.Failed, ex.Message));
results.Add((step, StepResult.Fail(ex.Message)));
break;
}
}
return results;
}
}

View File

@@ -0,0 +1,14 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace ClaudeDo.Installer.Core;
public sealed class NullToCollapsedConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is null or "" ? Visibility.Collapsed : Visibility.Visible;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View File

@@ -0,0 +1,17 @@
namespace ClaudeDo.Installer.Core;
public sealed class PageResolver
{
private readonly IReadOnlyList<IInstallerPage> _allPages;
public PageResolver(IEnumerable<IInstallerPage> pages)
{
_allPages = pages.OrderBy(p => p.Order).ToList();
}
public IReadOnlyList<IInstallerPage> WizardPages =>
_allPages.Where(p => p.ShowInWizard).ToList();
public IReadOnlyList<IInstallerPage> SettingsPages =>
_allPages.Where(p => p.ShowInSettings).ToList();
}

View File

@@ -0,0 +1,60 @@
using System.Diagnostics;
using System.IO;
using System.Text;
namespace ClaudeDo.Installer.Core;
public static class ProcessRunner
{
public static async Task<(int ExitCode, string Output)> RunAsync(
string fileName,
string arguments,
string? workingDirectory,
IProgress<string>? progress,
CancellationToken ct)
{
var output = new StringBuilder();
var outputLock = new object();
using var process = new Process();
process.StartInfo = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
if (!process.Start())
return (-1, "Failed to start process");
var stdoutTask = ReadStreamAsync(process.StandardOutput, output, outputLock, progress);
var stderrTask = ReadStreamAsync(process.StandardError, output, outputLock, progress);
using var reg = ct.Register(() =>
{
try { process.Kill(entireProcessTree: true); } catch { }
});
await Task.WhenAll(stdoutTask, stderrTask);
await process.WaitForExitAsync(ct);
return (process.ExitCode, output.ToString());
}
private static async Task ReadStreamAsync(
StreamReader reader,
StringBuilder output,
object outputLock,
IProgress<string>? progress)
{
while (await reader.ReadLineAsync() is { } line)
{
lock (outputLock) { output.AppendLine(line); }
progress?.Report(line);
}
}
}

View File

@@ -0,0 +1,85 @@
using System.IO;
using System.Net.Http;
using System.Text.Json;
namespace ClaudeDo.Installer.Core;
public sealed class ReleaseClient : IReleaseClient
{
public const string DefaultApiBase = "https://git.kuns.dev/api/v1/repos/releases/ClaudeDo";
private readonly HttpClient _http;
private readonly string _apiBase;
public ReleaseClient(HttpClient http, string apiBase = DefaultApiBase)
{
_http = http;
_apiBase = apiBase.TrimEnd('/');
}
public async Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct)
{
try
{
using var response = await _http.GetAsync($"{_apiBase}/releases/latest", ct);
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync(ct);
return ParseRelease(json);
}
catch (HttpRequestException) { return null; }
catch (TaskCanceledException) when (!ct.IsCancellationRequested) { return null; }
}
public async Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct)
{
using var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
response.EnsureSuccessStatusCode();
await using var input = await response.Content.ReadAsStreamAsync(ct);
await using var output = File.Create(destPath);
var buffer = new byte[81920];
long total = 0;
int read;
while ((read = await input.ReadAsync(buffer, ct)) > 0)
{
await output.WriteAsync(buffer.AsMemory(0, read), ct);
total += read;
progress.Report(total);
}
}
private static GiteaRelease? ParseRelease(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (!root.TryGetProperty("tag_name", out var tagEl)) return null;
var tag = tagEl.GetString() ?? "";
var name = root.TryGetProperty("name", out var nameEl) ? (nameEl.GetString() ?? "") : "";
var assets = new List<ReleaseAsset>();
if (root.TryGetProperty("assets", out var arr) && arr.ValueKind == JsonValueKind.Array)
{
foreach (var item in arr.EnumerateArray())
{
if (!item.TryGetProperty("name", out var nameField)) continue;
if (!item.TryGetProperty("browser_download_url", out var urlField)) continue;
var aName = nameField.GetString() ?? "";
var aUrl = urlField.GetString() ?? "";
var aSize = item.TryGetProperty("size", out var s) ? s.GetInt64() : 0L;
assets.Add(new ReleaseAsset(aName, aUrl, aSize));
}
}
return new GiteaRelease(tag, name, assets);
}
catch (JsonException)
{
return null;
}
}
}

View File

@@ -0,0 +1,37 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;
using System.Windows.Media;
namespace ClaudeDo.Installer.Core;
/// <summary>
/// Multi-value converter: compares the page's index with the current page index
/// to determine step indicator styling.
/// </summary>
public sealed class StepActiveConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
if (values.Length < 2 ||
values[0] is not IInstallerPage page ||
values[1] is not IInstallerPage currentPage)
return DependencyProperty.UnsetValue;
var isActive = ReferenceEquals(page, currentPage);
var key = parameter?.ToString() switch
{
"Background" => isActive ? "AccentBrush" : "WindowBgBrush",
"Foreground" => isActive ? "TextPrimaryBrush" : "TextMutedBrush",
"BorderBrush" => isActive ? "AccentBrush" : "BorderSubtleBrush",
_ => null
};
if (key is null) return DependencyProperty.UnsetValue;
return Application.Current.Resources[key] as SolidColorBrush ?? DependencyProperty.UnsetValue;
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View File

@@ -0,0 +1,127 @@
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;
}
}
}

View File

@@ -0,0 +1,101 @@
<UserControl x:Class="ClaudeDo.Installer.Pages.InstallPage.InstallPageView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ClaudeDo.Installer.Pages.InstallPage"
d:DataContext="{d:DesignInstance local:InstallPageViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Header -->
<StackPanel Grid.Row="0" Margin="0,0,0,16">
<TextBlock Text="Installation" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,4"/>
<TextBlock Text="Click Install to build and deploy ClaudeDo."
Foreground="{StaticResource TextSecondaryBrush}" TextWrapping="Wrap"/>
</StackPanel>
<!-- Step List -->
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Steps}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type local:StepViewModel}">
<Border Margin="0,0,0,6" Padding="10,8"
Background="{StaticResource IslandBgBrush}"
BorderBrush="{StaticResource BorderSubtleBrush}"
BorderThickness="1" CornerRadius="4">
<StackPanel>
<!-- Step header -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Status indicator -->
<Ellipse Grid.Column="0" Width="10" Height="10" Margin="0,0,10,0"
VerticalAlignment="Center">
<Ellipse.Style>
<Style TargetType="Ellipse">
<Setter Property="Fill" Value="{StaticResource StatusGrayBrush}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Status}" Value="Running">
<Setter Property="Fill" Value="{StaticResource StatusOrangeBrush}"/>
</DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="Done">
<Setter Property="Fill" Value="{StaticResource StatusGreenBrush}"/>
</DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="Failed">
<Setter Property="Fill" Value="{StaticResource StatusRedBrush}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
<TextBlock Grid.Column="1" Text="{Binding Name}" FontSize="13"
VerticalAlignment="Center"/>
</Grid>
<!-- Messages (expandable) -->
<ItemsControl ItemsSource="{Binding Messages}" Margin="20,4,0,0"
Visibility="{Binding IsExpanded, Converter={StaticResource BoolToVisConverter}}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" FontSize="11" FontFamily="Consolas"
Foreground="{StaticResource TextDimBrush}"
TextWrapping="Wrap"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<!-- Progress Bar -->
<ProgressBar Grid.Row="2" Value="{Binding OverallProgress}" Maximum="100"
Margin="0,12,0,0"
Visibility="{Binding IsInstalling, Converter={StaticResource BoolToVisConverter}}"/>
<!-- Action buttons -->
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="Cancel" Command="{Binding CancelInstallCommand}"
Visibility="{Binding IsInstalling, Converter={StaticResource BoolToVisConverter}}"
Margin="0,0,8,0"/>
<Button Content="Launch ClaudeDo" Command="{Binding LaunchAppCommand}"
Style="{StaticResource AccentButton}"
Visibility="{Binding IsComplete, Converter={StaticResource BoolToVisConverter}}"/>
</StackPanel>
</Grid>
</UserControl>

View File

@@ -0,0 +1,8 @@
using System.Windows.Controls;
namespace ClaudeDo.Installer.Pages.InstallPage;
public partial class InstallPageView : UserControl
{
public InstallPageView() => InitializeComponent();
}

View File

@@ -0,0 +1,148 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Windows;
using System.Windows.Controls;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Steps;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
namespace ClaudeDo.Installer.Pages.InstallPage;
public partial class InstallPageViewModel : ObservableObject, IInstallerPage
{
private readonly InstallContext _context;
private readonly IServiceProvider _serviceProvider;
private InstallPageView? _view;
private CancellationTokenSource? _cts;
public string Title => "Install";
public string Icon => "\uE896";
public int Order => 99;
public bool ShowInWizard => true;
public bool ShowInSettings => false;
public UserControl View => _view ??= new InstallPageView { DataContext = this };
public ObservableCollection<StepViewModel> Steps { get; } = [];
[ObservableProperty] private bool _isInstalling;
[ObservableProperty] private bool _isComplete;
[ObservableProperty] private bool _hasErrors;
[ObservableProperty] private double _overallProgress;
public InstallPageViewModel(InstallContext context, IServiceProvider serviceProvider)
{
_context = context;
_serviceProvider = serviceProvider;
}
public Task LoadAsync()
{
Steps.Clear();
if (_context.Mode == InstallerMode.Update)
{
Steps.Add(new StepViewModel("Stop Worker Service"));
Steps.Add(new StepViewModel("Download and Extract"));
Steps.Add(new StepViewModel("Start Worker Service"));
Steps.Add(new StepViewModel("Write Install Manifest"));
}
else
{
Steps.Add(new StepViewModel("Download and Extract"));
Steps.Add(new StepViewModel("Write Configuration"));
Steps.Add(new StepViewModel("Initialize Database"));
Steps.Add(new StepViewModel("Register Windows Service"));
Steps.Add(new StepViewModel("Create Shortcuts"));
Steps.Add(new StepViewModel("Write Install Manifest"));
}
return Task.CompletedTask;
}
public Task ApplyAsync() => RunInstallAsync();
public bool Validate() => true;
[RelayCommand]
private async Task RunInstallAsync()
{
if (IsInstalling) return;
IsInstalling = true;
IsComplete = false;
HasErrors = false;
OverallProgress = 0;
_cts = new CancellationTokenSource();
var progress = new Progress<StepProgress>(p =>
{
var step = Steps.FirstOrDefault(s => s.Name == p.StepName);
if (step is null) return;
step.Status = p.Status;
if (p.Message is not null)
step.Messages.Add(p.Message);
if (p.Status is StepStatus.Running && !step.IsExpanded)
step.IsExpanded = true;
if (p.Status is StepStatus.Done or StepStatus.Failed)
{
var completed = Steps.Count(s => s.Status is StepStatus.Done or StepStatus.Failed);
OverallProgress = (double)completed / Steps.Count * 100;
}
});
try
{
IEnumerable<IInstallStep> steps;
if (_context.Mode == InstallerMode.Update)
{
steps = new IInstallStep[]
{
_serviceProvider.GetRequiredService<StopServiceStep>(),
_serviceProvider.GetRequiredService<DownloadAndExtractStep>(),
_serviceProvider.GetRequiredService<StartServiceStep>(),
_serviceProvider.GetRequiredService<WriteInstallManifestStep>(),
};
}
else
{
steps = _serviceProvider.GetServices<IInstallStep>();
}
var runner = new InstallerService(steps);
var results = await runner.ExecuteAsync(_context, progress, _cts.Token);
HasErrors = results.Any(r => !r.Result.Success);
}
catch (OperationCanceledException)
{
HasErrors = true;
}
finally
{
IsInstalling = false;
IsComplete = true;
_cts.Dispose();
_cts = null;
}
}
[RelayCommand]
private void CancelInstall()
{
_cts?.Cancel();
}
[RelayCommand]
private void LaunchApp()
{
var appExe = System.IO.Path.Combine(_context.InstallDirectory, "app", "ClaudeDo.App.exe");
if (System.IO.File.Exists(appExe))
{
Process.Start(new ProcessStartInfo(appExe) { UseShellExecute = true });
Application.Current.Shutdown();
}
}
}

View File

@@ -0,0 +1,17 @@
using System.Collections.ObjectModel;
using ClaudeDo.Installer.Core;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Installer.Pages.InstallPage;
public partial class StepViewModel : ObservableObject
{
public string Name { get; }
[ObservableProperty] private StepStatus _status = StepStatus.Pending;
[ObservableProperty] private bool _isExpanded;
public ObservableCollection<string> Messages { get; } = [];
public StepViewModel(string name) => Name = name;
}

View File

@@ -0,0 +1,41 @@
<UserControl x:Class="ClaudeDo.Installer.Pages.PathsPage.PathsPageView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ClaudeDo.Installer.Pages.PathsPage"
d:DataContext="{d:DesignInstance local:PathsPageViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel MaxWidth="520">
<TextBlock Text="Data Paths" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,4"/>
<TextBlock Text="Configure where ClaudeDo stores its data."
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,0,0,20"
TextWrapping="Wrap"/>
<Label Content="Database Path"/>
<TextBox Text="{Binding DbPath, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
<Label Content="Log Directory"/>
<TextBox Text="{Binding LogRoot, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
<Label Content="Sandbox Root"/>
<TextBox Text="{Binding SandboxRoot, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
<Label Content="Worktree Strategy"/>
<ComboBox SelectedItem="{Binding WorktreeRootStrategy}" Margin="0,0,0,12">
<sys:String xmlns:sys="clr-namespace:System;assembly=mscorlib">sibling</sys:String>
<sys:String xmlns:sys="clr-namespace:System;assembly=mscorlib">central</sys:String>
</ComboBox>
<StackPanel Visibility="{Binding IsCentralVisible, Converter={StaticResource BoolToVisConverter}}">
<Label Content="Central Worktree Root"/>
<TextBox Text="{Binding CentralWorktreeRoot, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
</StackPanel>
<TextBlock Text="{Binding ValidationError}" Foreground="{StaticResource ErrorBrush}" FontSize="11"
Visibility="{Binding ValidationError, Converter={StaticResource NullToCollapsedConverter}}"/>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,8 @@
using System.Windows.Controls;
namespace ClaudeDo.Installer.Pages.PathsPage;
public partial class PathsPageView : UserControl
{
public PathsPageView() => InitializeComponent();
}

View File

@@ -0,0 +1,74 @@
using System.Windows.Controls;
using ClaudeDo.Installer.Core;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Installer.Pages.PathsPage;
public partial class PathsPageViewModel : ObservableObject, IInstallerPage
{
private readonly InstallContext _context;
private PathsPageView? _view;
public string Title => "Paths";
public string Icon => "\uE8B7";
public int Order => 1;
public bool ShowInWizard => true;
public bool ShowInSettings => true;
public UserControl View => _view ??= new PathsPageView { DataContext = this };
[ObservableProperty] private string _dbPath = "~/.todo-app/todo.db";
[ObservableProperty] private string _logRoot = "~/.todo-app/logs";
[ObservableProperty] private string _sandboxRoot = "~/.todo-app/sandbox";
[ObservableProperty] private string _worktreeRootStrategy = "sibling";
[ObservableProperty] private string _centralWorktreeRoot = "~/.todo-app/worktrees";
[ObservableProperty] private string? _validationError;
public bool IsCentralVisible => WorktreeRootStrategy == "central";
public PathsPageViewModel(InstallContext context) => _context = context;
partial void OnWorktreeRootStrategyChanged(string value) =>
OnPropertyChanged(nameof(IsCentralVisible));
public Task LoadAsync()
{
var cfg = InstallerWorkerConfig.Load();
DbPath = cfg.DbPath;
LogRoot = cfg.LogRoot;
SandboxRoot = cfg.SandboxRoot;
WorktreeRootStrategy = cfg.WorktreeRootStrategy;
CentralWorktreeRoot = cfg.CentralWorktreeRoot;
return Task.CompletedTask;
}
public Task ApplyAsync()
{
_context.DbPath = DbPath;
_context.UiDbPath = DbPath;
_context.LogRoot = LogRoot;
_context.SandboxRoot = SandboxRoot;
_context.WorktreeRootStrategy = WorktreeRootStrategy;
_context.CentralWorktreeRoot = CentralWorktreeRoot;
return Task.CompletedTask;
}
public bool Validate()
{
if (string.IsNullOrWhiteSpace(DbPath) ||
string.IsNullOrWhiteSpace(LogRoot) ||
string.IsNullOrWhiteSpace(SandboxRoot))
{
ValidationError = "All path fields are required.";
return false;
}
if (WorktreeRootStrategy == "central" && string.IsNullOrWhiteSpace(CentralWorktreeRoot))
{
ValidationError = "Central worktree root is required when using central strategy.";
return false;
}
ValidationError = null;
return true;
}
}

View File

@@ -0,0 +1,56 @@
<UserControl x:Class="ClaudeDo.Installer.Pages.ServicePage.ServicePageView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ClaudeDo.Installer.Pages.ServicePage"
d:DataContext="{d:DesignInstance local:ServicePageViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel MaxWidth="520">
<TextBlock Text="Worker Service" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,4"/>
<TextBlock Text="Configure the ClaudeDo Worker background service."
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,0,0,20"
TextWrapping="Wrap"/>
<Label Content="SignalR Port"/>
<TextBox Text="{Binding SignalRPort, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
<Label Content="Queue Backstop Interval (ms)"/>
<TextBox Text="{Binding QueueBackstopIntervalMs, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
<Label Content="Claude CLI Path"/>
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0" Text="{Binding ClaudeBin, UpdateSourceTrigger=PropertyChanged}"/>
<Button Grid.Column="1" Content="Browse..." Command="{Binding BrowseClaudeCommand}"
Margin="8,0,0,0"/>
</Grid>
<Separator Margin="0,4,0,12"/>
<Label Content="Service Account"/>
<StackPanel Margin="0,0,0,12">
<RadioButton Content="Local System (recommended)"
IsChecked="{Binding IsLocalSystem}" Margin="0,0,0,4"/>
<RadioButton Content="Current User"
IsChecked="{Binding IsCurrentUser}"/>
<TextBlock Text="Running as current user requires 'Log on as a service' privilege."
Foreground="{StaticResource TextDimBrush}" FontSize="11" Margin="20,2,0,0"
TextWrapping="Wrap"/>
</StackPanel>
<CheckBox Content="Start service automatically" IsChecked="{Binding AutoStart}" Margin="0,0,0,12"/>
<Label Content="Restart Delay (ms)"/>
<TextBox Text="{Binding RestartDelayMs, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
<TextBlock Text="{Binding ValidationError}" Foreground="{StaticResource ErrorBrush}" FontSize="11"
Visibility="{Binding ValidationError, Converter={StaticResource NullToCollapsedConverter}}"/>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,8 @@
using System.Windows.Controls;
namespace ClaudeDo.Installer.Pages.ServicePage;
public partial class ServicePageView : UserControl
{
public ServicePageView() => InitializeComponent();
}

View File

@@ -0,0 +1,88 @@
using System.Windows.Controls;
using ClaudeDo.Installer.Core;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Win32;
namespace ClaudeDo.Installer.Pages.ServicePage;
public partial class ServicePageViewModel : ObservableObject, IInstallerPage
{
private readonly InstallContext _context;
private ServicePageView? _view;
public string Title => "Service";
public string Icon => "\uE912";
public int Order => 2;
public bool ShowInWizard => true;
public bool ShowInSettings => true;
public UserControl View => _view ??= new ServicePageView { DataContext = this };
[ObservableProperty] private int _signalRPort = 47_821;
[ObservableProperty] private int _queueBackstopIntervalMs = 30_000;
[ObservableProperty] private string _claudeBin = "claude";
[ObservableProperty] private bool _isLocalSystem = true;
[ObservableProperty] private bool _isCurrentUser;
[ObservableProperty] private bool _autoStart = true;
[ObservableProperty] private int _restartDelayMs = 5000;
[ObservableProperty] private string? _validationError;
public ServicePageViewModel(InstallContext context) => _context = context;
public Task LoadAsync()
{
var cfg = InstallerWorkerConfig.Load();
SignalRPort = cfg.SignalRPort;
QueueBackstopIntervalMs = cfg.QueueBackstopIntervalMs;
ClaudeBin = cfg.ClaudeBin;
return Task.CompletedTask;
}
public Task ApplyAsync()
{
_context.SignalRPort = SignalRPort;
_context.QueueBackstopIntervalMs = QueueBackstopIntervalMs;
_context.ClaudeBin = ClaudeBin;
_context.ServiceAccount = IsCurrentUser ? "CurrentUser" : "LocalSystem";
_context.AutoStart = AutoStart;
_context.RestartDelayMs = RestartDelayMs;
_context.SignalRUrl = $"http://127.0.0.1:{SignalRPort}/hub";
return Task.CompletedTask;
}
public bool Validate()
{
if (SignalRPort < 1024 || SignalRPort > 65535)
{
ValidationError = "Port must be between 1024 and 65535.";
return false;
}
if (QueueBackstopIntervalMs <= 0)
{
ValidationError = "Queue backstop interval must be greater than 0.";
return false;
}
if (string.IsNullOrWhiteSpace(ClaudeBin))
{
ValidationError = "Claude CLI path is required.";
return false;
}
ValidationError = null;
return true;
}
[RelayCommand]
private void BrowseClaude()
{
var dialog = new OpenFileDialog
{
Title = "Select Claude CLI executable",
Filter = "Executables (*.exe)|*.exe|All files (*.*)|*.*",
};
if (dialog.ShowDialog() == true)
ClaudeBin = dialog.FileName;
}
}

View File

@@ -0,0 +1,36 @@
<UserControl x:Class="ClaudeDo.Installer.Pages.UiSettingsPage.UiSettingsPageView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ClaudeDo.Installer.Pages.UiSettingsPage"
d:DataContext="{d:DesignInstance local:UiSettingsPageViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel MaxWidth="520">
<TextBlock Text="UI Settings" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,4"/>
<TextBlock Text="Configure the ClaudeDo desktop UI connection settings."
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,0,0,20"
TextWrapping="Wrap"/>
<CheckBox Content="Sync with service settings" IsChecked="{Binding IsSynced}" Margin="0,0,0,16"/>
<Label Content="SignalR URL"/>
<TextBox Text="{Binding SignalRUrl, UpdateSourceTrigger=PropertyChanged}"
IsReadOnly="{Binding IsSynced}" Margin="0,0,0,12"/>
<Label Content="Database Path"/>
<TextBox Text="{Binding UiDbPath, UpdateSourceTrigger=PropertyChanged}"
IsReadOnly="{Binding IsSynced}" Margin="0,0,0,12"/>
<TextBlock Text="When synced, these values are derived from the Service and Paths pages."
Foreground="{StaticResource TextDimBrush}" FontSize="11" TextWrapping="Wrap"
Visibility="{Binding IsSynced, Converter={StaticResource BoolToVisConverter}}"
Margin="0,0,0,12"/>
<TextBlock Text="{Binding ValidationError}" Foreground="{StaticResource ErrorBrush}" FontSize="11"
Visibility="{Binding ValidationError, Converter={StaticResource NullToCollapsedConverter}}"/>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,8 @@
using System.Windows.Controls;
namespace ClaudeDo.Installer.Pages.UiSettingsPage;
public partial class UiSettingsPageView : UserControl
{
public UiSettingsPageView() => InitializeComponent();
}

View File

@@ -0,0 +1,83 @@
using System.Windows.Controls;
using ClaudeDo.Installer.Core;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Installer.Pages.UiSettingsPage;
public partial class UiSettingsPageViewModel : ObservableObject, IInstallerPage
{
private readonly InstallContext _context;
private UiSettingsPageView? _view;
public string Title => "UI Settings";
public string Icon => "\uE771";
public int Order => 3;
public bool ShowInWizard => true;
public bool ShowInSettings => true;
public UserControl View => _view ??= new UiSettingsPageView { DataContext = this };
[ObservableProperty] private string _signalRUrl = "http://127.0.0.1:47821/hub";
[ObservableProperty] private string _uiDbPath = "~/.todo-app/todo.db";
[ObservableProperty] private bool _isSynced = true;
[ObservableProperty] private string? _validationError;
public UiSettingsPageViewModel(InstallContext context) => _context = context;
partial void OnIsSyncedChanged(bool value)
{
if (value) SyncFromContext();
}
private void SyncFromContext()
{
SignalRUrl = $"http://127.0.0.1:{_context.SignalRPort}/hub";
UiDbPath = _context.DbPath;
}
public Task LoadAsync()
{
if (IsSynced)
{
SyncFromContext();
}
else
{
var cfg = InstallerAppSettings.Load();
SignalRUrl = cfg.SignalRUrl;
UiDbPath = cfg.DbPath;
}
return Task.CompletedTask;
}
public Task ApplyAsync()
{
if (IsSynced) SyncFromContext();
_context.SignalRUrl = SignalRUrl;
_context.UiDbPath = UiDbPath;
return Task.CompletedTask;
}
public bool Validate()
{
if (string.IsNullOrWhiteSpace(SignalRUrl))
{
ValidationError = "SignalR URL is required.";
return false;
}
if (string.IsNullOrWhiteSpace(UiDbPath))
{
ValidationError = "Database path is required.";
return false;
}
if (!Uri.TryCreate(SignalRUrl, UriKind.Absolute, out _))
{
ValidationError = "SignalR URL must be a valid URL.";
return false;
}
ValidationError = null;
return true;
}
}

View File

@@ -0,0 +1,37 @@
<UserControl x:Class="ClaudeDo.Installer.Pages.WelcomePage.WelcomePageView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ClaudeDo.Installer.Pages.WelcomePage"
d:DataContext="{d:DesignInstance local:WelcomePageViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel MaxWidth="520">
<TextBlock Text="{Binding Heading}" FontSize="20" FontWeight="SemiBold" Margin="0,0,0,6"/>
<TextBlock Text="{Binding Subheading}" TextWrapping="Wrap"
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,0,0,24"/>
<Label Content="Install Directory"/>
<Grid Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0"
Text="{Binding InstallDirectory, UpdateSourceTrigger=PropertyChanged}"
IsEnabled="{Binding InstallDirEditable}"/>
<Button Grid.Column="1"
Content="Browse..."
Margin="8,0,0,0"
Command="{Binding BrowseInstallCommand}"
IsEnabled="{Binding InstallDirEditable}"/>
</Grid>
<TextBlock Text="{Binding InstallError}" Foreground="{StaticResource ErrorBrush}" FontSize="11"
Visibility="{Binding InstallError, Converter={StaticResource NullToCollapsedConverter}}"/>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,8 @@
using System.Windows.Controls;
namespace ClaudeDo.Installer.Pages.WelcomePage;
public partial class WelcomePageView : UserControl
{
public WelcomePageView() => InitializeComponent();
}

View File

@@ -0,0 +1,87 @@
using System.IO;
using System.Windows.Controls;
using ClaudeDo.Installer.Core;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Win32;
namespace ClaudeDo.Installer.Pages.WelcomePage;
public partial class WelcomePageViewModel : ObservableObject, IInstallerPage
{
private readonly InstallContext _context;
private WelcomePageView? _view;
public string Title => "Welcome";
public string Icon => "\uE80F";
public int Order => 0;
public bool ShowInWizard => true;
public bool ShowInSettings => false;
public UserControl View => _view ??= new WelcomePageView { DataContext = this };
[ObservableProperty] private string _installDirectory = @"C:\Program Files\ClaudeDo";
[ObservableProperty] private string? _installError;
[ObservableProperty] private string _heading = "Install ClaudeDo";
[ObservableProperty] private string _subheading = "Set the installation directory and continue.";
[ObservableProperty] private bool _installDirEditable = true;
public WelcomePageViewModel(InstallContext context)
{
_context = context;
}
public Task LoadAsync()
{
InstallDirectory = string.IsNullOrEmpty(_context.InstallDirectory)
? @"C:\Program Files\ClaudeDo"
: _context.InstallDirectory;
switch (_context.Mode)
{
case InstallerMode.FreshInstall:
Heading = "Install ClaudeDo";
Subheading = "Choose where to install ClaudeDo, then click Next.";
InstallDirEditable = true;
break;
case InstallerMode.Update:
Heading = $"Update ClaudeDo {_context.InstalledVersion ?? "?"} -> {_context.LatestVersion ?? "?"}";
Subheading = "Your tasks, config, and database will be preserved. Click Next to continue.";
InstallDirEditable = false; // stay where we were installed
break;
default:
// Config and any future modes should never reach the wizard; guard loudly if they do.
throw new InvalidOperationException(
$"WelcomePage is not valid for installer mode {_context.Mode}");
}
return Task.CompletedTask;
}
public Task ApplyAsync()
{
_context.InstallDirectory = InstallDirectory;
return Task.CompletedTask;
}
public bool Validate()
{
if (string.IsNullOrWhiteSpace(InstallDirectory))
{
InstallError = "Install directory is required";
return false;
}
InstallError = null;
return true;
}
[RelayCommand]
private void BrowseInstall()
{
if (!InstallDirEditable) return;
var dialog = new OpenFolderDialog { Title = "Select installation directory" };
if (dialog.ShowDialog() == true)
InstallDirectory = dialog.FolderName;
}
}

View File

@@ -0,0 +1,91 @@
using System.IO;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
using System.Text;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class CreateShortcutsStep : IInstallStep
{
public string Name => "Create Shortcuts";
public Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
try
{
var appExe = Path.Combine(ctx.InstallDirectory, "app", "ClaudeDo.App.exe");
var workingDir = Path.Combine(ctx.InstallDirectory, "app");
// Start Menu shortcut
var startMenuDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu),
"Programs");
Directory.CreateDirectory(startMenuDir);
var startMenuPath = Path.Combine(startMenuDir, "ClaudeDo.lnk");
CreateShortcut(startMenuPath, appExe, workingDir, "ClaudeDo Task Manager");
progress.Report($"Created Start Menu shortcut: {startMenuPath}");
// Desktop shortcut (optional)
if (ctx.CreateDesktopShortcut)
{
var desktopPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory),
"ClaudeDo.lnk");
CreateShortcut(desktopPath, appExe, workingDir, "ClaudeDo Task Manager");
progress.Report($"Created Desktop shortcut: {desktopPath}");
}
return Task.FromResult(StepResult.Ok());
}
catch (Exception ex)
{
return Task.FromResult(StepResult.Fail(ex.Message));
}
}
private static void CreateShortcut(string shortcutPath, string targetPath, string workingDir, string description)
{
var link = (IShellLink)new ShellLink();
link.SetPath(targetPath);
link.SetWorkingDirectory(workingDir);
link.SetDescription(description);
link.SetIconLocation(targetPath, 0);
var file = (IPersistFile)link;
file.Save(shortcutPath, false);
}
#region COM Interop for IShellLink
[ComImport]
[Guid("00021401-0000-0000-C000-000000000046")]
private class ShellLink { }
[ComImport]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[Guid("000214F9-0000-0000-C000-000000000046")]
private interface IShellLink
{
void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile, int cchMaxPath, IntPtr pfd, int fFlags);
void GetIDList(out IntPtr ppidl);
void SetIDList(IntPtr pidl);
void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cchMaxName);
void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName);
void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cchMaxPath);
void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir);
void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cchMaxPath);
void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs);
void GetHotkey(out short pwHotkey);
void SetHotkey(short wHotkey);
void GetShowCmd(out int piShowCmd);
void SetShowCmd(int iShowCmd);
void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath, int cchIconPath, out int piIcon);
void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon);
void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, int dwReserved);
void Resolve(IntPtr hwnd, int fFlags);
void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile);
}
#endregion
}

View File

@@ -0,0 +1,91 @@
using System.IO;
using System.IO.Compression;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class DownloadAndExtractStep : IInstallStep
{
private readonly IReleaseClient _releases;
public DownloadAndExtractStep(IReleaseClient releases)
{
_releases = releases;
}
public string Name => "Download and Extract";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(ctx.InstallDirectory))
return StepResult.Fail("Install directory is not set.");
progress.Report("Fetching latest release metadata...");
var release = await _releases.GetLatestReleaseAsync(ct);
if (release is null)
return StepResult.Fail("Could not reach the release server. Check your network connection and try again.");
var zipAsset = release.Assets.FirstOrDefault(a =>
a.Name.StartsWith("ClaudeDo-", StringComparison.OrdinalIgnoreCase) &&
a.Name.EndsWith("-win-x64.zip", StringComparison.OrdinalIgnoreCase));
var checksumAsset = release.Assets.FirstOrDefault(a =>
a.Name.Equals("checksums.txt", StringComparison.OrdinalIgnoreCase));
if (zipAsset is null)
return StepResult.Fail("Release zip asset not found in release metadata.");
if (checksumAsset is null)
return StepResult.Fail("checksums.txt not found in release metadata.");
var scratchDir = Path.Combine(Path.GetTempPath(), "ClaudeDo-install-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(scratchDir);
try
{
var zipPath = Path.Combine(scratchDir, zipAsset.Name);
var checksumPath = Path.Combine(scratchDir, "checksums.txt");
progress.Report($"Downloading {zipAsset.Name} ({zipAsset.Size / (1024 * 1024)} MB)...");
await _releases.DownloadAsync(zipAsset.BrowserDownloadUrl, zipPath,
new Progress<long>(b => progress.Report($" {b / (1024 * 1024)} MB downloaded")),
ct);
progress.Report("Downloading checksums...");
await _releases.DownloadAsync(checksumAsset.BrowserDownloadUrl, checksumPath,
new Progress<long>(_ => { }), ct);
progress.Report("Verifying checksum...");
var map = ChecksumVerifier.ParseChecksumsFile(await File.ReadAllTextAsync(checksumPath, ct));
if (!map.TryGetValue(zipAsset.Name, out var expectedHash))
return StepResult.Fail($"No checksum entry for {zipAsset.Name} in checksums.txt.");
if (!ChecksumVerifier.Verify(zipPath, expectedHash))
return StepResult.Fail("Checksum mismatch — the downloaded zip may be corrupt or tampered with.");
// Only after verification do we touch the install directory.
progress.Report("Clearing previous app/worker binaries...");
var appDest = Path.Combine(ctx.InstallDirectory, "app");
var workerDest = Path.Combine(ctx.InstallDirectory, "worker");
if (Directory.Exists(appDest)) Directory.Delete(appDest, recursive: true);
if (Directory.Exists(workerDest)) Directory.Delete(workerDest, recursive: true);
progress.Report("Extracting...");
Directory.CreateDirectory(ctx.InstallDirectory);
try
{
ZipFile.ExtractToDirectory(zipPath, ctx.InstallDirectory, overwriteFiles: true);
}
catch (Exception ex)
{
return StepResult.Fail(
$"Extraction failed after old binaries were removed: {ex.Message}. " +
"Your install directory may be incomplete. Re-run the installer to retry.");
}
ctx.InstalledVersion = release.TagName.TrimStart('v', 'V');
return StepResult.Ok();
}
finally
{
try { Directory.Delete(scratchDir, recursive: true); } catch { /* best effort */ }
}
}
}

View File

@@ -0,0 +1,28 @@
using ClaudeDo.Data;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class InitDatabaseStep : IInstallStep
{
public string Name => "Initialize Database";
public Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
try
{
var expandedPath = Paths.Expand(ctx.DbPath);
progress.Report($"Initializing database at {expandedPath}");
var factory = new SqliteConnectionFactory(expandedPath);
SchemaInitializer.Apply(factory);
progress.Report("Schema applied successfully");
return Task.FromResult(StepResult.Ok());
}
catch (Exception ex)
{
return Task.FromResult(StepResult.Fail(ex.Message));
}
}
}

View File

@@ -0,0 +1,68 @@
using System.IO;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class RegisterServiceStep : IInstallStep
{
private const string ServiceName = "ClaudeDoWorker";
public string Name => "Register Windows Service";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
var workerExe = Path.Combine(ctx.InstallDirectory, "worker", "ClaudeDo.Worker.exe");
if (!File.Exists(workerExe))
return StepResult.Fail($"Worker executable not found: {workerExe}");
// Stop existing service (ignore errors — may not exist)
progress.Report("Stopping existing service (if any)...");
await RunSc($"stop {ServiceName}", ctx, progress, ct, ignoreErrors: true);
// Delete existing service (ignore errors)
progress.Report("Removing existing service registration (if any)...");
await RunSc($"delete {ServiceName}", ctx, progress, ct, ignoreErrors: true);
// Create service
var startType = ctx.AutoStart ? "auto" : "demand";
var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}";
if (ctx.ServiceAccount == "CurrentUser")
{
var username = Environment.UserName;
createArgs += $" obj= \".\\{username}\"";
}
progress.Report("Creating service...");
var (exitCode, output) = await RunSc(createArgs, ctx, progress, ct);
if (exitCode != 0)
return StepResult.Fail($"sc.exe create failed (exit {exitCode}): {output}");
// Configure restart policy
var delay = ctx.RestartDelayMs;
var failureArgs = $"failure {ServiceName} reset= 86400 actions= restart/{delay}/restart/{delay}/restart/{delay}";
progress.Report("Configuring restart policy...");
var (failExit, failOutput) = await RunSc(failureArgs, ctx, progress, ct);
if (failExit != 0)
progress.Report($"Warning: failed to set restart policy (exit {failExit})");
// Start service if auto-start
if (ctx.AutoStart)
{
progress.Report("Starting service...");
var (startExit, _) = await RunSc($"start {ServiceName}", ctx, progress, ct);
if (startExit != 0)
progress.Report("Warning: service created but failed to start. You may need to start it manually.");
}
return StepResult.Ok();
}
private static async Task<(int ExitCode, string Output)> RunSc(
string arguments, InstallContext ctx, IProgress<string> progress,
CancellationToken ct, bool ignoreErrors = false)
{
var result = await ProcessRunner.RunAsync("sc.exe", arguments, null, progress, ct);
return result;
}
}

View File

@@ -0,0 +1,27 @@
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class StartServiceStep : IInstallStep
{
private const string ServiceName = StopServiceStep.ServiceName;
public string Name => "Start Worker Service";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
progress.Report($"Starting {ServiceName}...");
var (exit, _) = await ProcessRunner.RunAsync("sc.exe", $"start {ServiceName}", null, progress, ct);
if (exit == 0) return StepResult.Ok();
// Exit 1056 = ERROR_SERVICE_ALREADY_RUNNING — that's fine too.
if (exit == 1056)
{
progress.Report("Service was already running.");
return StepResult.Ok();
}
return StepResult.Fail($"sc.exe start failed with exit code {exit}");
}
}

View File

@@ -0,0 +1,48 @@
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class StopServiceStep : IInstallStep
{
public const string ServiceName = "ClaudeDoWorker";
public string Name => "Stop Worker Service";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
progress.Report($"Stopping {ServiceName} (if running)...");
// sc.exe query -> returns non-zero if the service does not exist; that's fine.
var (queryExit, queryOutput) = await ProcessRunner.RunAsync("sc.exe", $"query {ServiceName}", null, progress, ct);
if (queryExit != 0)
{
progress.Report("Service is not registered — nothing to stop.");
return StepResult.Ok();
}
if (queryOutput.Contains("STOPPED", StringComparison.OrdinalIgnoreCase))
{
progress.Report("Service is already stopped.");
return StepResult.Ok();
}
var (stopExit, _) = await ProcessRunner.RunAsync("sc.exe", $"stop {ServiceName}", null, progress, ct);
if (stopExit != 0)
return StepResult.Fail($"sc.exe stop failed with exit code {stopExit}");
// Poll until stopped or timeout (up to 30s).
for (var i = 0; i < 30; i++)
{
ct.ThrowIfCancellationRequested();
await Task.Delay(1000, ct);
var (e, o) = await ProcessRunner.RunAsync("sc.exe", $"query {ServiceName}", null, progress, ct);
if (e != 0 || o.Contains("STOPPED", StringComparison.OrdinalIgnoreCase))
{
progress.Report("Service stopped.");
return StepResult.Ok();
}
}
return StepResult.Fail("Service did not stop within 30 seconds.");
}
}

View File

@@ -0,0 +1,42 @@
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class WriteConfigStep : IInstallStep
{
public string Name => "Write Configuration";
public Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
try
{
var workerCfg = new InstallerWorkerConfig
{
DbPath = ctx.DbPath,
SandboxRoot = ctx.SandboxRoot,
LogRoot = ctx.LogRoot,
WorktreeRootStrategy = ctx.WorktreeRootStrategy,
CentralWorktreeRoot = ctx.CentralWorktreeRoot,
QueueBackstopIntervalMs = ctx.QueueBackstopIntervalMs,
SignalRPort = ctx.SignalRPort,
ClaudeBin = ctx.ClaudeBin,
};
workerCfg.Save();
progress.Report("Written worker.config.json");
var uiCfg = new InstallerAppSettings
{
DbPath = ctx.UiDbPath,
SignalRUrl = ctx.SignalRUrl,
};
uiCfg.Save();
progress.Report("Written ui.config.json");
return Task.FromResult(StepResult.Ok());
}
catch (Exception ex)
{
return Task.FromResult(StepResult.Fail(ex.Message));
}
}
}

View File

@@ -0,0 +1,32 @@
using System.IO;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class WriteInstallManifestStep : IInstallStep
{
public string Name => "Write Install Manifest";
public Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(ctx.InstalledVersion))
return Task.FromResult(StepResult.Fail("Installed version is not set — DownloadAndExtractStep must run first."));
try
{
var manifest = new InstallManifest(
Version: ctx.InstalledVersion,
InstallDir: ctx.InstallDirectory,
WorkerDir: Path.Combine(ctx.InstallDirectory, "worker"),
InstalledAt: DateTimeOffset.UtcNow);
InstallManifestStore.Write(ctx.InstallDirectory, manifest);
progress.Report($"Wrote {InstallManifestStore.ManifestPath(ctx.InstallDirectory)}");
return Task.FromResult(StepResult.Ok());
}
catch (Exception ex)
{
return Task.FromResult(StepResult.Fail(ex.Message));
}
}
}

View File

@@ -0,0 +1,280 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ═══════════════════════════════════════════════════════════
Color palette — mirrored from ClaudeDo.App App.axaml
═══════════════════════════════════════════════════════════ -->
<!-- Accent: Forest Teal -->
<Color x:Key="AccentColor">#3d9474</Color>
<Color x:Key="AccentLightColor">#6bb89e</Color>
<SolidColorBrush x:Key="AccentBrush" Color="#3d9474"/>
<SolidColorBrush x:Key="AccentLightBrush" Color="#6bb89e"/>
<SolidColorBrush x:Key="AccentSubtleBrush" Color="#1A3D9474"/>
<SolidColorBrush x:Key="AccentSelectedBrush" Color="#263D9474"/>
<!-- Text -->
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#f1f5f9"/>
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#c8d0dc"/>
<SolidColorBrush x:Key="TextMutedBrush" Color="#8892a2"/>
<SolidColorBrush x:Key="TextDimBrush" Color="#6b7688"/>
<!-- Borders & Backgrounds -->
<SolidColorBrush x:Key="BorderSubtleBrush" Color="#3a3f46"/>
<SolidColorBrush x:Key="WindowBgBrush" Color="#1c1e21"/>
<SolidColorBrush x:Key="IslandBgBrush" Color="#272a2e"/>
<SolidColorBrush x:Key="SidebarBgBrush" Color="#272a2e"/>
<SolidColorBrush x:Key="ContentBgBrush" Color="#272a2e"/>
<!-- Status -->
<SolidColorBrush x:Key="StatusGrayBrush" Color="#475569"/>
<SolidColorBrush x:Key="StatusOrangeBrush" Color="#e67e22"/>
<SolidColorBrush x:Key="StatusGreenBrush" Color="#3d9474"/>
<SolidColorBrush x:Key="StatusRedBrush" Color="#ef4444"/>
<!-- Selection highlights -->
<SolidColorBrush x:Key="SelectionBrush" Color="#333d9474"/>
<SolidColorBrush x:Key="SelectionHoverBrush" Color="#1A3D9474"/>
<SolidColorBrush x:Key="SelectionActiveHoverBrush" Color="#403D9474"/>
<!-- Validation -->
<SolidColorBrush x:Key="ErrorBrush" Color="#ef4444"/>
<!-- ═══════════════════════════════════════════════════════════
Global control styles
═══════════════════════════════════════════════════════════ -->
<!-- Window -->
<Style TargetType="Window">
<Setter Property="Background" Value="{StaticResource WindowBgBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="FontFamily" Value="Segoe UI"/>
<Setter Property="FontSize" Value="13"/>
</Style>
<!-- UserControl — transparent so window background shows through -->
<Style TargetType="UserControl">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
</Style>
<!-- ContentControl — transparent container -->
<Style TargetType="ContentControl">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
</Style>
<!-- TextBlock -->
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
</Style>
<!-- Label -->
<Style TargetType="Label">
<Setter Property="Foreground" Value="{StaticResource TextMutedBrush}"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Padding" Value="0,0,0,2"/>
</Style>
<!-- TextBox -->
<Style TargetType="TextBox">
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="CaretBrush" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="SelectionBrush" Value="{StaticResource AccentSubtleBrush}"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
<Trigger Property="IsFocused" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
<Trigger Property="IsReadOnly" Value="True">
<Setter Property="Foreground" Value="{StaticResource TextMutedBrush}"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- PasswordBox -->
<Style TargetType="PasswordBox">
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="CaretBrush" Value="{StaticResource TextPrimaryBrush}"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
<Trigger Property="IsFocused" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- Button (default) -->
<Style TargetType="Button">
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="16,6"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentSubtleBrush}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.4"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Accent Button style -->
<Style x:Key="AccentButton" TargetType="Button">
<Setter Property="Background" Value="{StaticResource AccentBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="16,6"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentLightBrush}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentBrush}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.4"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ComboBox -->
<Style TargetType="ComboBox">
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="8,6"/>
</Style>
<!-- CheckBox -->
<Style TargetType="CheckBox">
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="Padding" Value="4,0,0,0"/>
</Style>
<!-- RadioButton -->
<Style TargetType="RadioButton">
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="Padding" Value="4,0,0,0"/>
</Style>
<!-- ListBox -->
<Style TargetType="ListBox">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
</Style>
<!-- ListBoxItem -->
<Style TargetType="ListBoxItem">
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="Padding" Value="10,8"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="Bd"
Background="Transparent"
CornerRadius="4"
Padding="{TemplateBinding Padding}">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource SelectionHoverBrush}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource SelectionBrush}"/>
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsSelected" Value="True"/>
<Condition Property="IsMouseOver" Value="True"/>
</MultiTrigger.Conditions>
<Setter TargetName="Bd" Property="Background" Value="{StaticResource SelectionActiveHoverBrush}"/>
</MultiTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ProgressBar -->
<Style TargetType="ProgressBar">
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
<Setter Property="Foreground" Value="{StaticResource AccentBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Height" Value="6"/>
</Style>
<!-- ScrollViewer -->
<Style TargetType="ScrollViewer">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
</Style>
<!-- Border — default transparent -->
<Style TargetType="Border">
<Setter Property="Background" Value="Transparent"/>
</Style>
<!-- ItemsControl -->
<Style TargetType="ItemsControl">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
</Style>
<!-- Separator -->
<Style TargetType="Separator">
<Setter Property="Background" Value="{StaticResource BorderSubtleBrush}"/>
<Setter Property="Height" Value="1"/>
<Setter Property="Margin" Value="0,8"/>
</Style>
</ResourceDictionary>

View File

@@ -0,0 +1,161 @@
using System.Windows;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Steps;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Installer.Views;
public partial class SettingsViewModel : ObservableObject
{
private readonly InstallContext _context;
private readonly IReleaseClient _releases;
private readonly StopServiceStep _stopService;
private readonly StartServiceStep _startService;
private readonly DownloadAndExtractStep _downloadStep;
private readonly UninstallRunner _uninstallRunner;
public IReadOnlyList<IInstallerPage> Pages { get; }
[ObservableProperty]
private IInstallerPage? _selectedPage;
[ObservableProperty]
private string? _statusMessage;
[ObservableProperty]
private bool _isStatusError;
[ObservableProperty]
private string _versionLabel = "";
public SettingsViewModel(
PageResolver resolver,
InstallContext context,
IReleaseClient releases,
StopServiceStep stopService,
StartServiceStep startService,
DownloadAndExtractStep downloadStep,
UninstallRunner uninstallRunner)
{
Pages = resolver.SettingsPages;
_context = context;
_releases = releases;
_stopService = stopService;
_startService = startService;
_downloadStep = downloadStep;
_uninstallRunner = uninstallRunner;
_selectedPage = Pages.FirstOrDefault();
VersionLabel = $"Installed: {context.InstalledVersion ?? "?"} Installer: {context.InstallerVersion ?? "?"}";
_ = LoadAllAsync();
}
private async Task LoadAllAsync()
{
foreach (var page in Pages)
await page.LoadAsync();
}
[RelayCommand]
private async Task Save()
{
foreach (var page in Pages)
{
if (!page.Validate())
{
SelectedPage = page;
StatusMessage = $"Validation failed on {page.Title}. Please fix the errors.";
IsStatusError = true;
return;
}
}
foreach (var page in Pages)
await page.ApplyAsync();
var workerCfg = new InstallerWorkerConfig
{
DbPath = _context.DbPath,
SandboxRoot = _context.SandboxRoot,
LogRoot = _context.LogRoot,
WorktreeRootStrategy = _context.WorktreeRootStrategy,
CentralWorktreeRoot = _context.CentralWorktreeRoot,
QueueBackstopIntervalMs = _context.QueueBackstopIntervalMs,
SignalRPort = _context.SignalRPort,
ClaudeBin = _context.ClaudeBin,
};
workerCfg.Save();
var uiCfg = new InstallerAppSettings
{
DbPath = _context.UiDbPath,
SignalRUrl = _context.SignalRUrl,
};
uiCfg.Save();
StatusMessage = "Settings saved.";
IsStatusError = false;
}
[RelayCommand]
private async Task Repair()
{
var confirm = MessageBox.Show(
"Re-download and reinstall the ClaudeDo binaries? Your config and database are NOT affected.",
"Repair ClaudeDo",
MessageBoxButton.OKCancel,
MessageBoxImage.Question);
if (confirm != MessageBoxResult.OK) return;
StatusMessage = "Repairing...";
IsStatusError = false;
var progress = new Progress<string>(msg => StatusMessage = msg);
var steps = new IInstallStep[] { _stopService, _downloadStep, _startService };
foreach (var step in steps)
{
var r = await step.ExecuteAsync(_context, progress, CancellationToken.None);
if (!r.Success)
{
StatusMessage = $"{step.Name} failed: {r.ErrorMessage}";
IsStatusError = true;
return;
}
}
StatusMessage = "Repair complete.";
}
[RelayCommand]
private async Task Uninstall()
{
var confirm = MessageBox.Show(
"This will remove ClaudeDo AND delete all of your tasks, configuration, and database.\n\nContinue?",
"Uninstall ClaudeDo",
MessageBoxButton.YesNo,
MessageBoxImage.Warning);
if (confirm != MessageBoxResult.Yes) return;
var progress = new Progress<string>(msg => StatusMessage = msg);
var r = await _uninstallRunner.RunAsync(progress, CancellationToken.None);
if (!r.Success)
{
StatusMessage = $"Uninstall failed: {r.ErrorMessage}";
IsStatusError = true;
return;
}
MessageBox.Show("ClaudeDo has been removed.", "Uninstall complete",
MessageBoxButton.OK, MessageBoxImage.Information);
Application.Current.Shutdown();
}
[RelayCommand]
private void Close() => Application.Current.Shutdown();
}

View File

@@ -0,0 +1,98 @@
<Window x:Class="ClaudeDo.Installer.Views.SettingsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="clr-namespace:ClaudeDo.Installer.Views"
Title="ClaudeDo Settings"
Width="720" Height="520"
MinWidth="620" MinHeight="460"
WindowStartupLocation="CenterScreen"
d:DataContext="{d:DesignInstance views:SettingsViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Main content area -->
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="180"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Sidebar -->
<Border Grid.Column="0" Background="{StaticResource SidebarBgBrush}"
Padding="8,12">
<ListBox ItemsSource="{Binding Pages}"
SelectedItem="{Binding SelectedPage}"
HorizontalContentAlignment="Stretch">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Icon}" FontSize="14" Margin="0,0,8,0"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding Title}" FontSize="13"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<!-- Separator -->
<Border Grid.Column="1" Width="1" Background="{StaticResource BorderSubtleBrush}"/>
<!-- Page content -->
<Border Grid.Column="2" Padding="24,20">
<ContentControl Content="{Binding SelectedPage.View}"/>
</Border>
</Grid>
<!-- Bottom Bar -->
<Border Grid.Row="1" Background="{StaticResource IslandBgBrush}"
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="0,1,0,0"
Padding="20,12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Status message / version label -->
<StackPanel Grid.Column="0" VerticalAlignment="Center">
<TextBlock Text="{Binding VersionLabel}" FontSize="11" Opacity="0.7"/>
<TextBlock Text="{Binding StatusMessage}" FontSize="12">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource AccentBrush}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsStatusError}" Value="True">
<Setter Property="Foreground" Value="{StaticResource ErrorBrush}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
<Button Grid.Column="1" Content="Uninstall" Margin="0,0,8,0"
Command="{Binding UninstallCommand}"/>
<Button Grid.Column="2" Content="Repair" Margin="0,0,8,0"
Command="{Binding RepairCommand}"/>
<Button Grid.Column="3" Content="Save" Margin="0,0,8,0"
Command="{Binding SaveCommand}"
Style="{StaticResource AccentButton}"/>
<Button Grid.Column="4" Content="Close"
Command="{Binding CloseCommand}"/>
</Grid>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,11 @@
using System.Windows;
namespace ClaudeDo.Installer.Views;
public partial class SettingsWindow : Window
{
public SettingsWindow()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,85 @@
using System.Linq;
using System.Windows;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Pages.InstallPage;
using ClaudeDo.Installer.Pages.WelcomePage;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Installer.Views;
public partial class WizardViewModel : ObservableObject
{
private readonly InstallContext _context;
public IReadOnlyList<IInstallerPage> Pages { get; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanGoBack))]
[NotifyPropertyChangedFor(nameof(IsLastPage))]
[NotifyPropertyChangedFor(nameof(NextButtonText))]
[NotifyPropertyChangedFor(nameof(CurrentPage))]
private int _currentPageIndex;
public IInstallerPage CurrentPage => Pages[CurrentPageIndex];
public bool CanGoBack => CurrentPageIndex > 0;
public bool IsLastPage => CurrentPageIndex == Pages.Count - 1;
public string NextButtonText => IsLastPage ? "Install" : "Next \u2192";
[ObservableProperty]
private string? _validationError;
public WizardViewModel(PageResolver resolver, InstallContext context)
{
_context = context;
var all = resolver.WizardPages;
Pages = context.Mode == InstallerMode.Update
? all.Where(p => p is WelcomePageViewModel
|| p is InstallPageViewModel).ToList()
: all;
if (Pages.Count > 0)
_ = InitAsync();
}
private async Task InitAsync()
{
try { await Pages[0].LoadAsync(); }
catch { /* first page loads with defaults on error */ }
}
[RelayCommand]
private async Task GoBack()
{
if (!CanGoBack) return;
CurrentPageIndex--;
await CurrentPage.LoadAsync();
ValidationError = null;
}
[RelayCommand]
private async Task GoNext()
{
if (!CurrentPage.Validate())
{
ValidationError = "Please fix the highlighted errors before continuing.";
return;
}
ValidationError = null;
await CurrentPage.ApplyAsync();
if (CurrentPageIndex < Pages.Count - 1)
{
CurrentPageIndex++;
await CurrentPage.LoadAsync();
}
}
[RelayCommand]
private void Close()
{
Application.Current.Shutdown();
}
}

View File

@@ -0,0 +1,95 @@
<Window x:Class="ClaudeDo.Installer.Views.WizardWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="clr-namespace:ClaudeDo.Installer.Views"
Title="ClaudeDo Installer"
Width="720" Height="520"
MinWidth="620" MinHeight="460"
WindowStartupLocation="CenterScreen"
d:DataContext="{d:DesignInstance views:WizardViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Step Indicator -->
<Border Grid.Row="0" Background="{StaticResource IslandBgBrush}"
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="0,0,0,1"
Padding="20,14">
<ItemsControl ItemsSource="{Binding Pages}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border x:Name="StepBorder" CornerRadius="4" Padding="10,5" Margin="0,0,6,0"
BorderThickness="1">
<Border.Background>
<MultiBinding Converter="{StaticResource StepActiveConverter}" ConverterParameter="Background">
<Binding/>
<Binding RelativeSource="{RelativeSource AncestorType=Window}" Path="DataContext.CurrentPage"/>
</MultiBinding>
</Border.Background>
<Border.BorderBrush>
<MultiBinding Converter="{StaticResource StepActiveConverter}" ConverterParameter="BorderBrush">
<Binding/>
<Binding RelativeSource="{RelativeSource AncestorType=Window}" Path="DataContext.CurrentPage"/>
</MultiBinding>
</Border.BorderBrush>
<TextBlock Text="{Binding Title}" FontSize="12">
<TextBlock.Foreground>
<MultiBinding Converter="{StaticResource StepActiveConverter}" ConverterParameter="Foreground">
<Binding/>
<Binding RelativeSource="{RelativeSource AncestorType=Window}" Path="DataContext.CurrentPage"/>
</MultiBinding>
</TextBlock.Foreground>
</TextBlock>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
<!-- Page Content -->
<Border Grid.Row="1" Padding="24,20">
<ContentControl Content="{Binding CurrentPage.View}"/>
</Border>
<!-- Bottom Bar -->
<Border Grid.Row="2" Background="{StaticResource IslandBgBrush}"
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="0,1,0,0"
Padding="20,12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Validation error -->
<TextBlock Grid.Column="0" Text="{Binding ValidationError}"
Foreground="{StaticResource ErrorBrush}"
VerticalAlignment="Center" FontSize="12"
Visibility="{Binding ValidationError, Converter={StaticResource NullToCollapsedConverter}}"/>
<Button Grid.Column="1" Content="Back"
Command="{Binding GoBackCommand}"
IsEnabled="{Binding CanGoBack}"
Margin="0,0,8,0" MinWidth="80"/>
<Button Grid.Column="2" Content="{Binding NextButtonText}"
Command="{Binding GoNextCommand}"
Style="{StaticResource AccentButton}"
MinWidth="100"/>
</Grid>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,11 @@
using System.Windows;
namespace ClaudeDo.Installer.Views;
public partial class WizardWindow : Window
{
public WizardWindow()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="ClaudeDo.Installer"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="ClaudeDo.Installer"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>