diff --git a/src/ClaudeDo.Installer/App.xaml.cs b/src/ClaudeDo.Installer/App.xaml.cs index 4f5511c..fd282fb 100644 --- a/src/ClaudeDo.Installer/App.xaml.cs +++ b/src/ClaudeDo.Installer/App.xaml.cs @@ -50,6 +50,7 @@ public partial class App : Application context.Mode = state.Mode; context.InstalledVersion = state.Existing?.Version; context.LatestVersion = state.LatestVersion; + context.LatestTagUnparseable = state.LatestTagUnparseable; if (state.Existing is not null) context.InstallDirectory = state.Existing.InstallDir; diff --git a/src/ClaudeDo.Installer/Core/InstallContext.cs b/src/ClaudeDo.Installer/Core/InstallContext.cs index 4efa103..2966b5b 100644 --- a/src/ClaudeDo.Installer/Core/InstallContext.cs +++ b/src/ClaudeDo.Installer/Core/InstallContext.cs @@ -10,6 +10,7 @@ public sealed class InstallContext 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) + public bool LatestTagUnparseable { get; set; } // true if latest tag isn't a System.Version // PathsPage public string DbPath { get; set; } = "~/.todo-app/todo.db"; diff --git a/src/ClaudeDo.Installer/Core/InstallModeDetector.cs b/src/ClaudeDo.Installer/Core/InstallModeDetector.cs index 50b93c6..574d300 100644 --- a/src/ClaudeDo.Installer/Core/InstallModeDetector.cs +++ b/src/ClaudeDo.Installer/Core/InstallModeDetector.cs @@ -4,7 +4,12 @@ public sealed record DetectedState( InstallerMode Mode, InstallManifest? Existing, GiteaRelease? LatestRelease, - string? LatestVersion); + string? LatestVersion) +{ + /// True when a release was returned but its tag isn't a parseable + /// System.Version (e.g. "0.2.0-beta") — so we couldn't decide if it's newer. + public bool LatestTagUnparseable { get; init; } +} public sealed class InstallModeDetector { @@ -26,23 +31,26 @@ public sealed class InstallModeDetector return new DetectedState(InstallerMode.Config, manifest, null, null); var latestVersion = release.TagName.TrimStart('v', 'V'); - if (IsNewer(latestVersion, manifest.Version)) + var newer = IsNewer(latestVersion, manifest.Version, out var unparseable); + if (newer) return new DetectedState(InstallerMode.Update, manifest, release, latestVersion); - return new DetectedState(InstallerMode.Config, manifest, release, latestVersion); + return new DetectedState(InstallerMode.Config, manifest, release, latestVersion) + { + LatestTagUnparseable = unparseable, + }; } /// /// Returns true only when both versions parse as System.Version (major.minor[.build[.revision]]) /// AND latest > 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. + /// treated as "not newer" — the user drops into Config mode with no update offered, but + /// is set so the UI can surface a hint. /// - private static bool IsNewer(string latest, string current) + private static bool IsNewer(string latest, string current, out bool unparseable) { - if (!Version.TryParse(latest, out var lv)) return false; - if (!Version.TryParse(current, out var cv)) return false; + unparseable = !Version.TryParse(latest, out var lv) | !Version.TryParse(current, out var cv); + if (unparseable) return false; return lv > cv; } } diff --git a/src/ClaudeDo.Installer/Views/SettingsViewModel.cs b/src/ClaudeDo.Installer/Views/SettingsViewModel.cs index 659c5f2..60afb58 100644 --- a/src/ClaudeDo.Installer/Views/SettingsViewModel.cs +++ b/src/ClaudeDo.Installer/Views/SettingsViewModel.cs @@ -50,7 +50,12 @@ public partial class SettingsViewModel : ObservableObject _uninstallRunner = uninstallRunner; _selectedPage = Pages.FirstOrDefault(); - VersionLabel = $"Installed: {context.InstalledVersion ?? "?"} Installer: {context.InstallerVersion ?? "?"}"; + var label = $"Installed: {context.InstalledVersion ?? "?"} Installer: {context.InstallerVersion ?? "?"}"; + if (!string.IsNullOrEmpty(context.LatestVersion)) + label += $" Latest: {context.LatestVersion}"; + if (context.LatestTagUnparseable) + label += " (pre-release tag — auto-update disabled)"; + VersionLabel = label; _ = LoadAllAsync(); } @@ -98,8 +103,39 @@ public partial class SettingsViewModel : ObservableObject }; uiCfg.Save(); - StatusMessage = "Settings saved."; IsStatusError = false; + StatusMessage = "Settings saved."; + + // Worker reads its config at process start, so changes only take effect after a restart. + var restart = MessageBox.Show( + "Restart the worker service now so the new settings take effect?", + "Restart Worker", + MessageBoxButton.YesNo, + MessageBoxImage.Question); + + if (restart != MessageBoxResult.Yes) + { + StatusMessage = "Settings saved. Restart the worker service manually to apply."; + return; + } + + var progress = new Progress(msg => StatusMessage = msg); + var stop = await _stopService.ExecuteAsync(_context, progress, CancellationToken.None); + if (!stop.Success) + { + StatusMessage = $"Saved, but worker stop failed: {stop.ErrorMessage}"; + IsStatusError = true; + return; + } + var start = await _startService.ExecuteAsync(_context, progress, CancellationToken.None); + if (!start.Success) + { + StatusMessage = $"Saved, but worker start failed: {start.ErrorMessage}"; + IsStatusError = true; + return; + } + + StatusMessage = "Settings saved. Worker restarted."; } [RelayCommand]