From 97fb215ce6dd9c51f3c7eac497e7ed9c52b8eef6 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 15 Apr 2026 09:19:16 +0200 Subject: [PATCH] feat(installer): replace sync ModeDetector with async InstallModeDetector Placeholder edit to App.xaml.cs to keep the project building until Task 11 wires the new async detector. Co-Authored-By: Claude Sonnet 4.6 --- src/ClaudeDo.Installer/App.xaml.cs | 14 +-- .../Core/InstallModeDetector.cs | 41 +++++++ src/ClaudeDo.Installer/Core/InstallerMode.cs | 19 +--- .../InstallModeDetectorTests.cs | 105 ++++++++++++++++++ 4 files changed, 153 insertions(+), 26 deletions(-) create mode 100644 src/ClaudeDo.Installer/Core/InstallModeDetector.cs create mode 100644 tests/ClaudeDo.Installer.Tests/InstallModeDetectorTests.cs diff --git a/src/ClaudeDo.Installer/App.xaml.cs b/src/ClaudeDo.Installer/App.xaml.cs index 11376fa..0388aa0 100644 --- a/src/ClaudeDo.Installer/App.xaml.cs +++ b/src/ClaudeDo.Installer/App.xaml.cs @@ -19,20 +19,12 @@ public partial class App : Application { base.OnStartup(e); - var mode = ModeDetector.Detect(); _services = BuildServices(); - Window mainWindow = mode switch + // TODO(Task 11): replace with async InstallModeDetector + Window mainWindow = new WizardWindow { - InstallerMode.Wizard => new WizardWindow - { - DataContext = _services.GetRequiredService() - }, - InstallerMode.Settings => new SettingsWindow - { - DataContext = _services.GetRequiredService() - }, - _ => throw new InvalidOperationException($"Unknown installer mode: {mode}") + DataContext = _services.GetRequiredService() }; DarkTitleBar.Apply(mainWindow); diff --git a/src/ClaudeDo.Installer/Core/InstallModeDetector.cs b/src/ClaudeDo.Installer/Core/InstallModeDetector.cs new file mode 100644 index 0000000..69616ab --- /dev/null +++ b/src/ClaudeDo.Installer/Core/InstallModeDetector.cs @@ -0,0 +1,41 @@ +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 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); + } + + 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; + } +} diff --git a/src/ClaudeDo.Installer/Core/InstallerMode.cs b/src/ClaudeDo.Installer/Core/InstallerMode.cs index 03a9eb2..3b26696 100644 --- a/src/ClaudeDo.Installer/Core/InstallerMode.cs +++ b/src/ClaudeDo.Installer/Core/InstallerMode.cs @@ -1,19 +1,8 @@ -using System.IO; -using ClaudeDo.Data; - namespace ClaudeDo.Installer.Core; -public enum InstallerMode { Wizard, Settings } - -public static class ModeDetector +public enum InstallerMode { - public static InstallerMode Detect() - { - var root = Paths.AppDataRoot(); - var workerConfig = Path.Combine(root, "worker.config.json"); - var uiConfig = Path.Combine(root, "ui.config.json"); - return File.Exists(workerConfig) && File.Exists(uiConfig) - ? InstallerMode.Settings - : InstallerMode.Wizard; - } + FreshInstall, // No install.json present -> run full wizard + Update, // install.json present, newer release available + Config, // install.json present, no update (or API unreachable) } diff --git a/tests/ClaudeDo.Installer.Tests/InstallModeDetectorTests.cs b/tests/ClaudeDo.Installer.Tests/InstallModeDetectorTests.cs new file mode 100644 index 0000000..521be81 --- /dev/null +++ b/tests/ClaudeDo.Installer.Tests/InstallModeDetectorTests.cs @@ -0,0 +1,105 @@ +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Tests; + +public sealed class InstallModeDetectorTests : IDisposable +{ + private readonly string _tempDir; + + public InstallModeDetectorTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoDetector-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + try { Directory.Delete(_tempDir, recursive: true); } catch { } + } + + private sealed class FakeReleaseClient : IReleaseClient + { + public GiteaRelease? Release { get; set; } + public Task GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult(Release); + public Task DownloadAsync(string url, string destPath, IProgress progress, CancellationToken ct) + => throw new NotSupportedException(); + } + + [Fact] + public async Task Detect_FreshInstall_WhenManifestMissing() + { + var detector = new InstallModeDetector(new FakeReleaseClient()); + + var state = await detector.DetectAsync(_tempDir, CancellationToken.None); + + Assert.Equal(InstallerMode.FreshInstall, state.Mode); + Assert.Null(state.Existing); + } + + [Fact] + public async Task Detect_Config_WhenManifestPresent_And_Api_Unreachable() + { + InstallManifestStore.Write(_tempDir, + new InstallManifest("0.1.0", _tempDir, _tempDir, DateTimeOffset.UtcNow)); + + var detector = new InstallModeDetector(new FakeReleaseClient { Release = null }); + + var state = await detector.DetectAsync(_tempDir, CancellationToken.None); + + Assert.Equal(InstallerMode.Config, state.Mode); + Assert.Equal("0.1.0", state.Existing!.Version); + } + + [Fact] + public async Task Detect_Update_WhenLatest_GreaterThan_Installed() + { + InstallManifestStore.Write(_tempDir, + new InstallManifest("0.1.0", _tempDir, _tempDir, DateTimeOffset.UtcNow)); + + var fake = new FakeReleaseClient + { + Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty()) + }; + var detector = new InstallModeDetector(fake); + + var state = await detector.DetectAsync(_tempDir, CancellationToken.None); + + Assert.Equal(InstallerMode.Update, state.Mode); + Assert.Equal("0.1.0", state.Existing!.Version); + Assert.Equal("0.2.0", state.LatestVersion); + } + + [Fact] + public async Task Detect_Config_WhenLatest_EqualsOrOlderThan_Installed() + { + InstallManifestStore.Write(_tempDir, + new InstallManifest("0.2.0", _tempDir, _tempDir, DateTimeOffset.UtcNow)); + + var fake = new FakeReleaseClient + { + Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty()) + }; + var detector = new InstallModeDetector(fake); + + var state = await detector.DetectAsync(_tempDir, CancellationToken.None); + + Assert.Equal(InstallerMode.Config, state.Mode); + } + + [Fact] + public async Task Detect_Config_WhenInstalledIs_Newer_Than_Latest() + { + InstallManifestStore.Write(_tempDir, + new InstallManifest("0.3.0", _tempDir, _tempDir, DateTimeOffset.UtcNow)); + + var fake = new FakeReleaseClient + { + Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty()) + }; + var detector = new InstallModeDetector(fake); + + var state = await detector.DetectAsync(_tempDir, CancellationToken.None); + + Assert.Equal(InstallerMode.Config, state.Mode); + } +}