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]