feat(installer): show version info and offer worker restart in settings
- Surface Latest version and flag unparseable pre-release tags in VersionLabel so users know why auto-update was skipped. - Prompt to stop/start the worker service after Save, since the worker only reads its config at process start.
This commit is contained in:
@@ -50,6 +50,7 @@ public partial class App : Application
|
|||||||
context.Mode = state.Mode;
|
context.Mode = state.Mode;
|
||||||
context.InstalledVersion = state.Existing?.Version;
|
context.InstalledVersion = state.Existing?.Version;
|
||||||
context.LatestVersion = state.LatestVersion;
|
context.LatestVersion = state.LatestVersion;
|
||||||
|
context.LatestTagUnparseable = state.LatestTagUnparseable;
|
||||||
if (state.Existing is not null)
|
if (state.Existing is not null)
|
||||||
context.InstallDirectory = state.Existing.InstallDir;
|
context.InstallDirectory = state.Existing.InstallDir;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public sealed class InstallContext
|
|||||||
public string? InstallerVersion { get; set; } // from this installer's assembly
|
public string? InstallerVersion { get; set; } // from this installer's assembly
|
||||||
public string? InstalledVersion { get; set; } // from install.json (or set by DownloadAndExtractStep)
|
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 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
|
// PathsPage
|
||||||
public string DbPath { get; set; } = "~/.todo-app/todo.db";
|
public string DbPath { get; set; } = "~/.todo-app/todo.db";
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ public sealed record DetectedState(
|
|||||||
InstallerMode Mode,
|
InstallerMode Mode,
|
||||||
InstallManifest? Existing,
|
InstallManifest? Existing,
|
||||||
GiteaRelease? LatestRelease,
|
GiteaRelease? LatestRelease,
|
||||||
string? LatestVersion);
|
string? LatestVersion)
|
||||||
|
{
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
public bool LatestTagUnparseable { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class InstallModeDetector
|
public sealed class InstallModeDetector
|
||||||
{
|
{
|
||||||
@@ -26,23 +31,26 @@ public sealed class InstallModeDetector
|
|||||||
return new DetectedState(InstallerMode.Config, manifest, null, null);
|
return new DetectedState(InstallerMode.Config, manifest, null, null);
|
||||||
|
|
||||||
var latestVersion = release.TagName.TrimStart('v', 'V');
|
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.Update, manifest, release, latestVersion);
|
||||||
|
|
||||||
return new DetectedState(InstallerMode.Config, manifest, release, latestVersion);
|
return new DetectedState(InstallerMode.Config, manifest, release, latestVersion)
|
||||||
|
{
|
||||||
|
LatestTagUnparseable = unparseable,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns true only when both versions parse as System.Version (major.minor[.build[.revision]])
|
/// 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
|
/// 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.
|
/// treated as "not newer" — the user drops into Config mode with no update offered, but
|
||||||
/// This is deliberate: offering an update we can't compare is worse than silently skipping it.
|
/// <paramref name="unparseable"/> is set so the UI can surface a hint.
|
||||||
/// If the project starts shipping pre-release tags, revisit this.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
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;
|
unparseable = !Version.TryParse(latest, out var lv) | !Version.TryParse(current, out var cv);
|
||||||
if (!Version.TryParse(current, out var cv)) return false;
|
if (unparseable) return false;
|
||||||
return lv > cv;
|
return lv > cv;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,12 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
_uninstallRunner = uninstallRunner;
|
_uninstallRunner = uninstallRunner;
|
||||||
_selectedPage = Pages.FirstOrDefault();
|
_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();
|
_ = LoadAllAsync();
|
||||||
}
|
}
|
||||||
@@ -98,8 +103,39 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
};
|
};
|
||||||
uiCfg.Save();
|
uiCfg.Save();
|
||||||
|
|
||||||
StatusMessage = "Settings saved.";
|
|
||||||
IsStatusError = false;
|
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<string>(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]
|
[RelayCommand]
|
||||||
|
|||||||
Reference in New Issue
Block a user