diff --git a/Locales/en-US.xaml b/Locales/en-US.xaml
index b09572b..e2bc6fd 100644
--- a/Locales/en-US.xaml
+++ b/Locales/en-US.xaml
@@ -436,6 +436,12 @@
Translator: Ylimhs
License: AGPLv3
Check for updates
+ Download and install update
+ Check automatically on startup
+ Check interval (days):
+ Include prerelease updates
+ Latest version:
+ Last checked:
Checks on demand using the official GitHub Releases.
Reset to Defaults
@@ -474,6 +480,15 @@
New version available: {0}
Application is up to date. Installed version: {0}
Error while checking updates: {0}
+ Unknown
+ Not checked
+ Never
+ Install ThreadPilot update
+ ThreadPilot will download and verify version {0}, then ask Windows for permission to run the installer. Continue?
+ Update canceled.
+ Downloading and verifying update...
+ Update installer started.
+ Update install failed: {0}
Settings have been modified
Settings match the saved configuration
Simplified Chinese
diff --git a/Locales/zh-CN.xaml b/Locales/zh-CN.xaml
index 8f4bcfc..d3cc1fa 100644
--- a/Locales/zh-CN.xaml
+++ b/Locales/zh-CN.xaml
@@ -436,6 +436,12 @@
翻译人员:Ylimhs
软件授权: AGPLv3
检查新版本
+ 下载并安装更新
+ 启动时自动检查更新
+ 检查间隔(天):
+ 包含预发布版本
+ 最新版本:
+ 上次检查:
按需通过官方 GitHub Releases API 检索最新稳定版本。
重置为默认值
@@ -474,6 +480,15 @@
发现新版本: {0}
应用已是最新版本。已安装版本: {0}
检查更新时出错: {0}
+ 未知
+ 尚未检查
+ 从未检查
+ 安装 ThreadPilot 更新
+ ThreadPilot 将下载并验证版本 {0},然后请求 Windows 权限运行安装程序。是否继续?
+ 更新已取消。
+ 正在下载并验证更新...
+ 更新安装程序已启动。
+ 更新安装失败: {0}
设置已被修改
设置与已保存的配置一致
简体中文
diff --git a/MainWindow.Behaviors.partial.cs b/MainWindow.Behaviors.partial.cs
index a4da578..1c857ee 100644
--- a/MainWindow.Behaviors.partial.cs
+++ b/MainWindow.Behaviors.partial.cs
@@ -157,27 +157,26 @@ private async Task CheckForUpdatesAtStartupAsync()
try
{
this.LogDebug("Startup update check started");
- var checker = this.serviceProvider.GetRequiredService();
- var currentVersion = GetCurrentApplicationVersion();
- var (latest, _) = await checker.GetLatestVersionAsync("PrimeBuild-pc", "ThreadPilot");
+ var updateService = this.serviceProvider.GetRequiredService();
+ var result = await updateService.CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Startup));
- if (latest == null)
+ if (result.Status == UpdateCheckStatus.Skipped)
{
- this.LogDebug("Startup update check completed without release information");
+ this.LogDebug($"Startup update check skipped: {result.Message}");
return;
}
- if (latest <= currentVersion)
+ if (!result.IsUpdateAvailable || result.Release == null)
{
- this.LogDebug($"Startup update check complete: installed {currentVersion}, latest {latest}");
+ this.LogDebug($"Startup update check complete: {result.Message}");
return;
}
await this.notificationService.ShowNotificationAsync(
"Update available",
- $"ThreadPilot {latest} is available from GitHub releases.",
+ $"ThreadPilot {result.Release.Version} is available. Open Settings to download and install it.",
NotificationType.Information);
- this.LogDebug($"Startup update check found update: installed {currentVersion}, latest {latest}");
+ this.LogDebug($"Startup update check found update: installed {result.CurrentVersion}, latest {result.Release.Version}");
}
catch (Exception ex)
{
diff --git a/Models/ApplicationSettingsModel.cs b/Models/ApplicationSettingsModel.cs
index 40e6c78..3219d8a 100644
--- a/Models/ApplicationSettingsModel.cs
+++ b/Models/ApplicationSettingsModel.cs
@@ -154,6 +154,18 @@ public partial class ApplicationSettingsModel : ObservableObject, IModel
[ObservableProperty]
private string language = LocalizationService.DefaultLanguage;
+ [ObservableProperty]
+ private bool enableAutomaticUpdateChecks = true;
+
+ [ObservableProperty]
+ private DateTimeOffset? lastUpdateCheckUtc = null;
+
+ [ObservableProperty]
+ private int updateCheckIntervalDays = 7;
+
+ [ObservableProperty]
+ private bool includePrereleaseUpdates = false;
+
// Monitoring Settings
[ObservableProperty]
private int pollingIntervalMs = 5000;
@@ -253,6 +265,10 @@ public void CopyFrom(ApplicationSettingsModel other)
this.UseDarkTheme = other.UseDarkTheme;
this.HasUserThemePreference = other.HasUserThemePreference;
this.Language = LocalizationService.NormalizeLanguage(other.Language);
+ this.EnableAutomaticUpdateChecks = other.EnableAutomaticUpdateChecks;
+ this.LastUpdateCheckUtc = other.LastUpdateCheckUtc;
+ this.UpdateCheckIntervalDays = other.UpdateCheckIntervalDays;
+ this.IncludePrereleaseUpdates = other.IncludePrereleaseUpdates;
// Monitoring Settings
this.PollingIntervalMs = other.PollingIntervalMs;
@@ -298,6 +314,11 @@ public ValidationResult Validate()
errors.Add("Fallback polling interval must be between 1 and 60 seconds");
}
+ if (this.UpdateCheckIntervalDays < 1 || this.UpdateCheckIntervalDays > 365)
+ {
+ errors.Add("Update check interval must be between 1 and 365 days");
+ }
+
return errors.Count == 0 ? ValidationResult.Success() : ValidationResult.Failure(errors.ToArray());
}
diff --git a/Services/Abstractions/IGitHubReleaseClient.cs b/Services/Abstractions/IGitHubReleaseClient.cs
index bf062d6..15f0c2e 100644
--- a/Services/Abstractions/IGitHubReleaseClient.cs
+++ b/Services/Abstractions/IGitHubReleaseClient.cs
@@ -9,5 +9,7 @@ namespace ThreadPilot.Services.Abstractions
public interface IGitHubReleaseClient
{
Task GetLatestReleaseJsonAsync(string owner, string repo, CancellationToken cancellationToken = default);
+
+ Task GetReleasesJsonAsync(string owner, string repo, CancellationToken cancellationToken = default);
}
}
diff --git a/Services/ApplicationSettingsService.cs b/Services/ApplicationSettingsService.cs
index 02d52b7..d7ae681 100644
--- a/Services/ApplicationSettingsService.cs
+++ b/Services/ApplicationSettingsService.cs
@@ -246,18 +246,28 @@ public void ValidateAndFixSettings()
this.settings.MaxNotificationHistoryItems = 1000;
}
- // Validate custom icon path
- if (this.settings.UseCustomTrayIcon && !string.IsNullOrEmpty(this.settings.CustomTrayIconPath))
- {
- if (!File.Exists(this.settings.CustomTrayIconPath))
- {
+ // Validate custom icon path
+ if (this.settings.UseCustomTrayIcon && !string.IsNullOrEmpty(this.settings.CustomTrayIconPath))
+ {
+ if (!File.Exists(this.settings.CustomTrayIconPath))
+ {
this.logger.LogWarning("Custom tray icon file not found: {Path}", this.settings.CustomTrayIconPath);
- this.settings.UseCustomTrayIcon = false;
- }
- }
-
- this.settings.Language = LocalizationService.NormalizeLanguage(this.settings.Language);
- }
+ this.settings.UseCustomTrayIcon = false;
+ }
+ }
+
+ this.settings.Language = LocalizationService.NormalizeLanguage(this.settings.Language);
+
+ if (this.settings.UpdateCheckIntervalDays < 1)
+ {
+ this.settings.UpdateCheckIntervalDays = 1;
+ }
+
+ if (this.settings.UpdateCheckIntervalDays > 365)
+ {
+ this.settings.UpdateCheckIntervalDays = 365;
+ }
+ }
public async Task ExportSettingsAsync(string filePath)
{
diff --git a/Services/ApplicationVersionProvider.cs b/Services/ApplicationVersionProvider.cs
new file mode 100644
index 0000000..b62b44a
--- /dev/null
+++ b/Services/ApplicationVersionProvider.cs
@@ -0,0 +1,36 @@
+/*
+ * ThreadPilot - application version provider for update checks.
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+ using System.Linq;
+ using System.Reflection;
+
+ public sealed class ApplicationVersionProvider : IApplicationVersionProvider
+ {
+ public SemanticVersion CurrentVersion
+ {
+ get
+ {
+ var rawVersion = GetRawVersion();
+ return SemanticVersion.TryParse(rawVersion, out var version)
+ ? version
+ : new SemanticVersion(0, 0, 0);
+ }
+ }
+
+ public string DisplayVersion => $"v{this.CurrentVersion}";
+
+ private static string GetRawVersion()
+ {
+ return typeof(App).Assembly
+ .GetCustomAttributes(typeof(AssemblyInformationalVersionAttribute), false)
+ .OfType()
+ .FirstOrDefault()?
+ .InformationalVersion
+ ?? typeof(App).Assembly.GetName().Version?.ToString()
+ ?? "0.0.0";
+ }
+ }
+}
diff --git a/Services/AuthenticodeSignatureVerifier.cs b/Services/AuthenticodeSignatureVerifier.cs
new file mode 100644
index 0000000..63b1735
--- /dev/null
+++ b/Services/AuthenticodeSignatureVerifier.cs
@@ -0,0 +1,46 @@
+/*
+ * ThreadPilot - best-effort Authenticode signature detection.
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+ using System.IO;
+ using System.Security.Cryptography;
+ using System.Security.Cryptography.X509Certificates;
+
+ public sealed class AuthenticodeSignatureVerifier : IUpdateSignatureVerifier
+ {
+ public UpdateSignatureStatus Verify(string installerPath)
+ {
+ if (!File.Exists(installerPath))
+ {
+ return UpdateSignatureStatus.Invalid;
+ }
+
+ try
+ {
+ using var certificate = new X509Certificate2(X509Certificate.CreateFromSignedFile(installerPath));
+ using var chain = new X509Chain
+ {
+ ChainPolicy =
+ {
+ RevocationMode = X509RevocationMode.Online,
+ RevocationFlag = X509RevocationFlag.ExcludeRoot,
+ },
+ };
+
+ return chain.Build(certificate)
+ ? UpdateSignatureStatus.Valid
+ : UpdateSignatureStatus.Unknown;
+ }
+ catch (CryptographicException)
+ {
+ return UpdateSignatureStatus.Unknown;
+ }
+ catch (PlatformNotSupportedException)
+ {
+ return UpdateSignatureStatus.Unknown;
+ }
+ }
+ }
+}
diff --git a/Services/GitHubReleaseClient.cs b/Services/GitHubReleaseClient.cs
index ac4a542..655ce50 100644
--- a/Services/GitHubReleaseClient.cs
+++ b/Services/GitHubReleaseClient.cs
@@ -23,5 +23,11 @@ public Task GetLatestReleaseJsonAsync(string owner, string repo, Cancell
var url = $"https://api.github.com/repos/{owner}/{repo}/releases/latest";
return this.httpClient.GetStringAsync(url, cancellationToken);
}
+
+ public Task GetReleasesJsonAsync(string owner, string repo, CancellationToken cancellationToken = default)
+ {
+ var url = $"https://api.github.com/repos/{owner}/{repo}/releases";
+ return this.httpClient.GetStringAsync(url, cancellationToken);
+ }
}
}
diff --git a/Services/GitHubUpdateChecker.cs b/Services/GitHubUpdateChecker.cs
index 027753c..621b31e 100644
--- a/Services/GitHubUpdateChecker.cs
+++ b/Services/GitHubUpdateChecker.cs
@@ -17,6 +17,8 @@
namespace ThreadPilot.Services
{
using System;
+ using System.Collections.Generic;
+ using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -26,7 +28,14 @@ public sealed class GitHubUpdateChecker
{
private readonly IGitHubReleaseClient gitHubReleaseClient;
- private record LatestRelease(string Tag_name, bool Prerelease, bool Draft, string Html_url);
+ private record LatestRelease(
+ string Tag_name,
+ bool Prerelease,
+ bool Draft,
+ string Html_url,
+ IReadOnlyList? Assets);
+
+ private record LatestReleaseAsset(string Name, string Browser_download_url, long Size);
public GitHubUpdateChecker(IGitHubReleaseClient gitHubReleaseClient)
{
@@ -71,6 +80,64 @@ public GitHubUpdateChecker(IGitHubReleaseClient gitHubReleaseClient)
? (version, release.Html_url)
: (null, release.Html_url);
}
+
+ public async Task GetLatestReleaseInfoAsync(
+ string owner,
+ string repo,
+ bool includePrereleases = false,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(owner))
+ {
+ throw new ArgumentException("Owner is required", nameof(owner));
+ }
+
+ if (string.IsNullOrWhiteSpace(repo))
+ {
+ throw new ArgumentException("Repository is required", nameof(repo));
+ }
+
+ var json = await this.gitHubReleaseClient.GetReleasesJsonAsync(owner, repo, cancellationToken).ConfigureAwait(false);
+ var releases = JsonSerializer.Deserialize>(json, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true,
+ });
+
+ if (releases == null || releases.Count == 0)
+ {
+ return null;
+ }
+
+ return releases
+ .Where(release => !release.Draft)
+ .Where(release => includePrereleases || !release.Prerelease)
+ .Select(TryMapRelease)
+ .Where(release => release != null)
+ .Cast()
+ .OrderByDescending(release => release.Version)
+ .FirstOrDefault();
+ }
+
+ private static UpdateReleaseInfo? TryMapRelease(LatestRelease release)
+ {
+ if (!SemanticVersion.TryParse(release.Tag_name, out var version) ||
+ string.IsNullOrWhiteSpace(release.Html_url) ||
+ !Uri.TryCreate(release.Html_url, UriKind.Absolute, out var releasePageUrl))
+ {
+ return null;
+ }
+
+ var assets = (release.Assets ?? Array.Empty())
+ .Where(asset => !string.IsNullOrWhiteSpace(asset.Name))
+ .Where(asset => Uri.TryCreate(asset.Browser_download_url, UriKind.Absolute, out _))
+ .Select(asset => new UpdateAsset(
+ asset.Name,
+ new Uri(asset.Browser_download_url, UriKind.Absolute),
+ asset.Size))
+ .ToArray();
+
+ return new UpdateReleaseInfo(version, release.Tag_name, releasePageUrl, release.Prerelease, assets);
+ }
}
}
diff --git a/Services/HttpUpdateDownloadClient.cs b/Services/HttpUpdateDownloadClient.cs
new file mode 100644
index 0000000..a98aba1
--- /dev/null
+++ b/Services/HttpUpdateDownloadClient.cs
@@ -0,0 +1,44 @@
+/*
+ * ThreadPilot - HTTP downloads for update assets.
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+ using System.IO;
+ using System.Net.Http;
+ using System.Threading;
+ using System.Threading.Tasks;
+
+ public sealed class HttpUpdateDownloadClient : IUpdateDownloadClient
+ {
+ private readonly HttpClient httpClient;
+
+ public HttpUpdateDownloadClient(HttpClient httpClient)
+ {
+ this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+ }
+
+ public async Task DownloadFileAsync(Uri uri, string destinationPath, CancellationToken cancellationToken = default)
+ {
+ using var response = await this.httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
+ .ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+
+ await using var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ await using var destination = File.Create(destinationPath);
+ await source.CopyToAsync(destination, cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task TryDownloadStringAsync(Uri uri, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ return await this.httpClient.GetStringAsync(uri, cancellationToken).ConfigureAwait(false);
+ }
+ catch (HttpRequestException)
+ {
+ return null;
+ }
+ }
+ }
+}
diff --git a/Services/SemanticVersion.cs b/Services/SemanticVersion.cs
new file mode 100644
index 0000000..cf8dd64
--- /dev/null
+++ b/Services/SemanticVersion.cs
@@ -0,0 +1,104 @@
+/*
+ * ThreadPilot - semantic version parsing for updater decisions.
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+ using System.Globalization;
+
+ public readonly record struct SemanticVersion(int Major, int Minor, int Patch, string? Prerelease = null)
+ : IComparable
+ {
+ public bool IsPrerelease => !string.IsNullOrWhiteSpace(this.Prerelease);
+
+ public static bool TryParse(string? value, out SemanticVersion version)
+ {
+ version = default;
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return false;
+ }
+
+ var sanitized = value.Trim();
+ if (sanitized.StartsWith("v", StringComparison.OrdinalIgnoreCase))
+ {
+ sanitized = sanitized[1..];
+ }
+
+ sanitized = sanitized.Split('+')[0];
+ var versionAndPrerelease = sanitized.Split('-', 2);
+ var parts = versionAndPrerelease[0].Split('.');
+ if (parts.Length < 2 || parts.Length > 3)
+ {
+ return false;
+ }
+
+ if (!int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var major) ||
+ !int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var minor))
+ {
+ return false;
+ }
+
+ var patch = 0;
+ if (parts.Length == 3 &&
+ !int.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out patch))
+ {
+ return false;
+ }
+
+ version = new SemanticVersion(
+ major,
+ minor,
+ patch,
+ versionAndPrerelease.Length == 2 ? versionAndPrerelease[1] : null);
+ return true;
+ }
+
+ public int CompareTo(SemanticVersion other)
+ {
+ var major = this.Major.CompareTo(other.Major);
+ if (major != 0)
+ {
+ return major;
+ }
+
+ var minor = this.Minor.CompareTo(other.Minor);
+ if (minor != 0)
+ {
+ return minor;
+ }
+
+ var patch = this.Patch.CompareTo(other.Patch);
+ if (patch != 0)
+ {
+ return patch;
+ }
+
+ if (!this.IsPrerelease && other.IsPrerelease)
+ {
+ return 1;
+ }
+
+ if (this.IsPrerelease && !other.IsPrerelease)
+ {
+ return -1;
+ }
+
+ return string.Compare(this.Prerelease, other.Prerelease, StringComparison.OrdinalIgnoreCase);
+ }
+
+ public override string ToString()
+ {
+ var version = $"{this.Major}.{this.Minor}.{this.Patch}";
+ return this.IsPrerelease ? $"{version}-{this.Prerelease}" : version;
+ }
+
+ public static bool operator >(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) > 0;
+
+ public static bool operator <(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) < 0;
+
+ public static bool operator >=(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) >= 0;
+
+ public static bool operator <=(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) <= 0;
+ }
+}
diff --git a/Services/ServiceConfiguration.cs b/Services/ServiceConfiguration.cs
index 23dc385..45a0a46 100644
--- a/Services/ServiceConfiguration.cs
+++ b/Services/ServiceConfiguration.cs
@@ -78,6 +78,16 @@ private static IServiceCollection ConfigureServiceInfrastructure(this IServiceCo
});
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
// Memory caching for performance - PERFORMANCE IMPROVEMENT
services.AddMemoryCache();
diff --git a/Services/SystemUpdateClock.cs b/Services/SystemUpdateClock.cs
new file mode 100644
index 0000000..35f8351
--- /dev/null
+++ b/Services/SystemUpdateClock.cs
@@ -0,0 +1,12 @@
+/*
+ * ThreadPilot - updater clock abstraction.
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+
+ public sealed class SystemUpdateClock : IUpdateClock
+ {
+ public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
+ }
+}
diff --git a/Services/UpdateAssetSelector.cs b/Services/UpdateAssetSelector.cs
new file mode 100644
index 0000000..10819f0
--- /dev/null
+++ b/Services/UpdateAssetSelector.cs
@@ -0,0 +1,81 @@
+/*
+ * ThreadPilot - release asset selection for safe installer updates.
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+ using System.IO;
+ using System.Linq;
+
+ public static class UpdateAssetSelector
+ {
+ public static bool TrySelectInstaller(UpdateReleaseInfo release, out UpdateAsset asset)
+ {
+ var selected = release.Assets
+ .Where(IsInstallerAsset)
+ .OrderByDescending(candidate => candidate.Name.Contains("setup", StringComparison.OrdinalIgnoreCase))
+ .FirstOrDefault();
+
+ asset = selected!;
+ return selected != null;
+ }
+
+ public static UpdateAsset? SelectChecksumAsset(UpdateReleaseInfo release)
+ {
+ return release.Assets.FirstOrDefault(asset =>
+ string.Equals(asset.Name, "SHA256SUMS.txt", StringComparison.OrdinalIgnoreCase));
+ }
+
+ public static bool IsSafeGitHubAssetUrl(Uri uri)
+ {
+ if (uri.Scheme != Uri.UriSchemeHttps)
+ {
+ return false;
+ }
+
+ return string.Equals(uri.Host, "github.com", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(uri.Host, "objects.githubusercontent.com", StringComparison.OrdinalIgnoreCase);
+ }
+
+ public static bool IsSafeAssetFileName(string assetName)
+ {
+ if (string.IsNullOrWhiteSpace(assetName))
+ {
+ return false;
+ }
+
+ if (!string.Equals(Path.GetFileName(assetName), assetName, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ return assetName.IndexOfAny(Path.GetInvalidFileNameChars()) < 0;
+ }
+
+ private static bool IsInstallerAsset(UpdateAsset asset)
+ {
+ if (!IsSafeGitHubAssetUrl(asset.DownloadUrl) || !IsSafeAssetFileName(asset.Name))
+ {
+ return false;
+ }
+
+ if (!asset.Name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ if (!asset.Name.StartsWith("ThreadPilot", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ if (asset.Name.Contains("portable", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ return asset.Name.Contains("setup", StringComparison.OrdinalIgnoreCase) ||
+ asset.Name.Contains("installer", StringComparison.OrdinalIgnoreCase);
+ }
+ }
+}
diff --git a/Services/UpdateChecksumVerifier.cs b/Services/UpdateChecksumVerifier.cs
new file mode 100644
index 0000000..82ff53e
--- /dev/null
+++ b/Services/UpdateChecksumVerifier.cs
@@ -0,0 +1,73 @@
+/*
+ * ThreadPilot - SHA256SUMS parsing and verification.
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+ using System.Globalization;
+ using System.IO;
+ using System.Linq;
+ using System.Security.Cryptography;
+
+ public static class UpdateChecksumVerifier
+ {
+ public static bool TryFindExpectedHash(string checksumsText, string fileName, out string expectedHash)
+ {
+ expectedHash = string.Empty;
+ foreach (var rawLine in checksumsText.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries))
+ {
+ var line = rawLine.Trim();
+ if (line.Length == 0 || line.StartsWith('#'))
+ {
+ continue;
+ }
+
+ if (line.StartsWith("SHA256(", StringComparison.OrdinalIgnoreCase))
+ {
+ var close = line.IndexOf(')');
+ var equals = line.IndexOf('=', StringComparison.Ordinal);
+ if (close > 7 && equals > close)
+ {
+ var listedName = line[7..close];
+ var hash = line[(equals + 1)..].Trim();
+ if (IsHash(hash) && string.Equals(listedName, fileName, StringComparison.OrdinalIgnoreCase))
+ {
+ expectedHash = hash.ToUpperInvariant();
+ return true;
+ }
+ }
+ }
+
+ var parts = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length >= 2 && IsHash(parts[0]))
+ {
+ var listedName = parts[^1].TrimStart('*');
+ if (string.Equals(listedName, fileName, StringComparison.OrdinalIgnoreCase))
+ {
+ expectedHash = parts[0].ToUpperInvariant();
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ public static string ComputeSha256(string filePath)
+ {
+ using var stream = File.OpenRead(filePath);
+ var hash = SHA256.HashData(stream);
+ return string.Concat(hash.Select(b => b.ToString("X2", CultureInfo.InvariantCulture)));
+ }
+
+ public static bool Verify(string filePath, string expectedHash)
+ {
+ return string.Equals(ComputeSha256(filePath), expectedHash, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool IsHash(string value)
+ {
+ return value.Length == 64 && value.All(Uri.IsHexDigit);
+ }
+ }
+}
diff --git a/Services/UpdateDownloadService.cs b/Services/UpdateDownloadService.cs
new file mode 100644
index 0000000..e14c343
--- /dev/null
+++ b/Services/UpdateDownloadService.cs
@@ -0,0 +1,103 @@
+/*
+ * ThreadPilot - secure update installer download and verification.
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+ using System.IO;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Extensions.Logging;
+
+ public sealed class UpdateDownloadService : IUpdateDownloadService
+ {
+ private readonly IUpdateDownloadClient downloadClient;
+ private readonly IUpdateTempDirectoryProvider tempDirectoryProvider;
+ private readonly IUpdateSignatureVerifier signatureVerifier;
+ private readonly ILogger logger;
+
+ public UpdateDownloadService(
+ IUpdateDownloadClient downloadClient,
+ IUpdateTempDirectoryProvider tempDirectoryProvider,
+ IUpdateSignatureVerifier signatureVerifier,
+ ILogger logger)
+ {
+ this.downloadClient = downloadClient ?? throw new ArgumentNullException(nameof(downloadClient));
+ this.tempDirectoryProvider = tempDirectoryProvider ?? throw new ArgumentNullException(nameof(tempDirectoryProvider));
+ this.signatureVerifier = signatureVerifier ?? throw new ArgumentNullException(nameof(signatureVerifier));
+ this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task DownloadInstallerAsync(UpdateReleaseInfo release, CancellationToken cancellationToken = default)
+ {
+ if (!UpdateAssetSelector.TrySelectInstaller(release, out var installerAsset))
+ {
+ throw new InvalidOperationException("Release does not contain a ThreadPilot installer asset.");
+ }
+
+ var tempDirectory = this.tempDirectoryProvider.CreateUpdateTempDirectory(release.Version);
+ try
+ {
+ var installerPath = Path.Combine(tempDirectory, installerAsset.Name);
+ await this.downloadClient.DownloadFileAsync(installerAsset.DownloadUrl, installerPath, cancellationToken)
+ .ConfigureAwait(false);
+
+ var checksumVerified = false;
+ var checksumAsset = UpdateAssetSelector.SelectChecksumAsset(release);
+ if (checksumAsset != null)
+ {
+ var checksumText = await this.downloadClient.TryDownloadStringAsync(checksumAsset.DownloadUrl, cancellationToken)
+ .ConfigureAwait(false);
+ if (string.IsNullOrWhiteSpace(checksumText) ||
+ !UpdateChecksumVerifier.TryFindExpectedHash(checksumText, installerAsset.Name, out var expectedHash))
+ {
+ throw new InvalidOperationException("SHA256SUMS.txt did not contain the installer checksum.");
+ }
+
+ if (!UpdateChecksumVerifier.Verify(installerPath, expectedHash))
+ {
+ throw new InvalidOperationException("Installer SHA256 checksum did not match SHA256SUMS.txt.");
+ }
+
+ checksumVerified = true;
+ }
+
+ var signatureStatus = this.signatureVerifier.Verify(installerPath);
+ if (signatureStatus == UpdateSignatureStatus.Invalid)
+ {
+ throw new InvalidOperationException("Installer Authenticode signature is invalid.");
+ }
+
+ this.logger.LogInformation(
+ "Downloaded ThreadPilot update installer {InstallerName}; checksum verified: {ChecksumVerified}; signature: {SignatureStatus}",
+ installerAsset.Name,
+ checksumVerified,
+ signatureStatus);
+
+ return new UpdateDownloadResult(
+ installerPath,
+ tempDirectory,
+ checksumVerified,
+ signatureStatus,
+ checksumVerified ? "Installer checksum verified." : "No SHA256SUMS.txt asset was available.");
+ }
+ catch
+ {
+ this.TryCleanup(tempDirectory);
+ throw;
+ }
+ }
+
+ private void TryCleanup(string tempDirectory)
+ {
+ try
+ {
+ this.tempDirectoryProvider.Cleanup(tempDirectory);
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogWarning(ex, "Failed to clean update temp directory {TempDirectory}", tempDirectory);
+ }
+ }
+ }
+}
diff --git a/Services/UpdateInstallerService.cs b/Services/UpdateInstallerService.cs
new file mode 100644
index 0000000..d710d65
--- /dev/null
+++ b/Services/UpdateInstallerService.cs
@@ -0,0 +1,64 @@
+/*
+ * ThreadPilot - elevated update installer launch.
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Diagnostics;
+ using System.IO;
+ using System.Threading;
+ using System.Threading.Tasks;
+
+ public sealed class UpdateInstallerService : IUpdateInstallerService
+ {
+ private readonly IUpdateTempDirectoryProvider tempDirectoryProvider;
+ private readonly IUpdateProcessLauncher processLauncher;
+
+ public UpdateInstallerService(
+ IUpdateTempDirectoryProvider tempDirectoryProvider,
+ IUpdateProcessLauncher processLauncher)
+ {
+ this.tempDirectoryProvider = tempDirectoryProvider ?? throw new ArgumentNullException(nameof(tempDirectoryProvider));
+ this.processLauncher = processLauncher ?? throw new ArgumentNullException(nameof(processLauncher));
+ }
+
+ public Task LaunchInstallerElevatedAsync(string installerPath, CancellationToken cancellationToken = default)
+ {
+ if (!File.Exists(installerPath))
+ {
+ throw new FileNotFoundException("Update installer was not found.", installerPath);
+ }
+
+ if (!string.Equals(Path.GetExtension(installerPath), ".exe", StringComparison.OrdinalIgnoreCase) ||
+ !this.tempDirectoryProvider.IsSafeUpdateTempPath(installerPath))
+ {
+ throw new InvalidOperationException("Update installer path is not trusted.");
+ }
+
+ return this.processLauncher.LaunchElevatedAsync(installerPath, Array.Empty(), cancellationToken);
+ }
+ }
+
+ public sealed class ShellUpdateProcessLauncher : IUpdateProcessLauncher
+ {
+ public Task LaunchElevatedAsync(string fileName, IReadOnlyList arguments, CancellationToken cancellationToken = default)
+ {
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = fileName,
+ UseShellExecute = true,
+ Verb = "runas",
+ WorkingDirectory = Path.GetDirectoryName(fileName) ?? Environment.CurrentDirectory,
+ };
+
+ foreach (var argument in arguments)
+ {
+ startInfo.ArgumentList.Add(argument);
+ }
+
+ Process.Start(startInfo);
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/Services/UpdateModels.cs b/Services/UpdateModels.cs
new file mode 100644
index 0000000..85cfe3e
--- /dev/null
+++ b/Services/UpdateModels.cs
@@ -0,0 +1,126 @@
+/*
+ * ThreadPilot - updater models and abstractions.
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Threading;
+ using System.Threading.Tasks;
+
+ public enum UpdateCheckTrigger
+ {
+ Startup,
+ Manual,
+ }
+
+ public enum UpdateCheckStatus
+ {
+ Skipped,
+ UpToDate,
+ UpdateAvailable,
+ Failed,
+ }
+
+ public enum UpdateInstallStatus
+ {
+ Started,
+ Failed,
+ }
+
+ public enum UpdateSignatureStatus
+ {
+ Valid,
+ Invalid,
+ Unknown,
+ }
+
+ public sealed record UpdateCheckRequest(UpdateCheckTrigger Trigger);
+
+ public sealed record UpdateAsset(string Name, Uri DownloadUrl, long Size);
+
+ public sealed record UpdateReleaseInfo(
+ SemanticVersion Version,
+ string TagName,
+ Uri ReleasePageUrl,
+ bool IsPrerelease,
+ IReadOnlyList Assets);
+
+ public sealed record UpdateCheckResult(
+ UpdateCheckStatus Status,
+ SemanticVersion CurrentVersion,
+ UpdateReleaseInfo? Release,
+ string Message)
+ {
+ public bool IsUpdateAvailable => this.Status == UpdateCheckStatus.UpdateAvailable && this.Release != null;
+ }
+
+ public sealed record UpdateDownloadResult(
+ string InstallerPath,
+ string TempDirectory,
+ bool ChecksumVerified,
+ UpdateSignatureStatus SignatureStatus,
+ string Message);
+
+ public sealed record UpdateInstallResult(UpdateInstallStatus Status, string Message);
+
+ public interface IApplicationVersionProvider
+ {
+ SemanticVersion CurrentVersion { get; }
+
+ string DisplayVersion { get; }
+ }
+
+ public interface IUpdateClock
+ {
+ DateTimeOffset UtcNow { get; }
+ }
+
+ public interface IUpdateService
+ {
+ Task CheckForUpdatesAsync(UpdateCheckRequest request, CancellationToken cancellationToken = default);
+
+ Task DownloadAndInstallAsync(UpdateReleaseInfo release, CancellationToken cancellationToken = default);
+ }
+
+ public interface IUpdateDownloadService
+ {
+ Task DownloadInstallerAsync(UpdateReleaseInfo release, CancellationToken cancellationToken = default);
+ }
+
+ public interface IUpdateInstallerService
+ {
+ Task LaunchInstallerElevatedAsync(string installerPath, CancellationToken cancellationToken = default);
+ }
+
+ public interface IUpdateDownloadClient
+ {
+ Task DownloadFileAsync(Uri uri, string destinationPath, CancellationToken cancellationToken = default);
+
+ Task TryDownloadStringAsync(Uri uri, CancellationToken cancellationToken = default);
+ }
+
+ public interface IUpdateTempDirectoryProvider
+ {
+ string CreateUpdateTempDirectory(SemanticVersion version);
+
+ bool IsSafeUpdateTempPath(string path);
+
+ void Cleanup(string path);
+ }
+
+ public interface IUpdateSignatureVerifier
+ {
+ UpdateSignatureStatus Verify(string installerPath);
+ }
+
+ public interface IUpdateProcessLauncher
+ {
+ Task LaunchElevatedAsync(string fileName, IReadOnlyList arguments, CancellationToken cancellationToken = default);
+ }
+
+ public interface IApplicationShutdownService
+ {
+ void RequestShutdownForUpdate();
+ }
+}
diff --git a/Services/UpdateService.cs b/Services/UpdateService.cs
new file mode 100644
index 0000000..5cc3256
--- /dev/null
+++ b/Services/UpdateService.cs
@@ -0,0 +1,158 @@
+/*
+ * ThreadPilot - safe in-app update orchestration.
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Extensions.Logging;
+
+ public sealed class UpdateService : IUpdateService
+ {
+ private const string OfficialOwner = "PrimeBuild-pc";
+ private const string OfficialRepository = "ThreadPilot";
+
+ private readonly GitHubUpdateChecker updateChecker;
+ private readonly IApplicationSettingsService settingsService;
+ private readonly IApplicationVersionProvider versionProvider;
+ private readonly IUpdateDownloadService downloadService;
+ private readonly IUpdateInstallerService installerService;
+ private readonly IUpdateTempDirectoryProvider tempDirectoryProvider;
+ private readonly IApplicationShutdownService shutdownService;
+ private readonly IUpdateClock clock;
+ private readonly ILogger logger;
+ private readonly SemaphoreSlim checkGate = new(1, 1);
+ private readonly SemaphoreSlim installGate = new(1, 1);
+
+ public UpdateService(
+ GitHubUpdateChecker updateChecker,
+ IApplicationSettingsService settingsService,
+ IApplicationVersionProvider versionProvider,
+ IUpdateDownloadService downloadService,
+ IUpdateInstallerService installerService,
+ IUpdateTempDirectoryProvider tempDirectoryProvider,
+ IApplicationShutdownService shutdownService,
+ IUpdateClock clock,
+ ILogger logger)
+ {
+ this.updateChecker = updateChecker ?? throw new ArgumentNullException(nameof(updateChecker));
+ this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
+ this.versionProvider = versionProvider ?? throw new ArgumentNullException(nameof(versionProvider));
+ this.downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService));
+ this.installerService = installerService ?? throw new ArgumentNullException(nameof(installerService));
+ this.tempDirectoryProvider = tempDirectoryProvider ?? throw new ArgumentNullException(nameof(tempDirectoryProvider));
+ this.shutdownService = shutdownService ?? throw new ArgumentNullException(nameof(shutdownService));
+ this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
+ this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task CheckForUpdatesAsync(UpdateCheckRequest request, CancellationToken cancellationToken = default)
+ {
+ var currentVersion = this.versionProvider.CurrentVersion;
+ var settings = this.settingsService.Settings;
+
+ if (request.Trigger == UpdateCheckTrigger.Startup)
+ {
+ if (!settings.EnableAutomaticUpdateChecks)
+ {
+ return new UpdateCheckResult(UpdateCheckStatus.Skipped, currentVersion, null, "Automatic update checks are disabled.");
+ }
+
+ var intervalDays = Math.Max(1, settings.UpdateCheckIntervalDays);
+ if (settings.LastUpdateCheckUtc.HasValue &&
+ this.clock.UtcNow - settings.LastUpdateCheckUtc.Value < TimeSpan.FromDays(intervalDays))
+ {
+ return new UpdateCheckResult(UpdateCheckStatus.Skipped, currentVersion, null, "Startup update check throttled.");
+ }
+ }
+
+ await this.checkGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await this.MarkUpdateCheckAttemptAsync(cancellationToken).ConfigureAwait(false);
+
+ var release = await this.updateChecker.GetLatestReleaseInfoAsync(
+ OfficialOwner,
+ OfficialRepository,
+ settings.IncludePrereleaseUpdates,
+ cancellationToken).ConfigureAwait(false);
+
+ if (release == null)
+ {
+ return new UpdateCheckResult(UpdateCheckStatus.Failed, currentVersion, null, "Unable to determine the latest ThreadPilot release.");
+ }
+
+ if (release.Version > currentVersion)
+ {
+ this.logger.LogInformation(
+ "ThreadPilot update available: current {CurrentVersion}, latest {LatestVersion}",
+ currentVersion,
+ release.Version);
+ return new UpdateCheckResult(UpdateCheckStatus.UpdateAvailable, currentVersion, release, "A newer ThreadPilot version is available.");
+ }
+
+ return new UpdateCheckResult(UpdateCheckStatus.UpToDate, currentVersion, release, "ThreadPilot is up to date.");
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ this.logger.LogWarning(ex, "ThreadPilot update check failed");
+ return new UpdateCheckResult(UpdateCheckStatus.Failed, currentVersion, null, ex.Message);
+ }
+ finally
+ {
+ this.checkGate.Release();
+ }
+ }
+
+ public async Task DownloadAndInstallAsync(UpdateReleaseInfo release, CancellationToken cancellationToken = default)
+ {
+ if (!await this.installGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
+ {
+ return new UpdateInstallResult(UpdateInstallStatus.Failed, "Another update is already in progress.");
+ }
+
+ UpdateDownloadResult? download = null;
+ try
+ {
+ download = await this.downloadService.DownloadInstallerAsync(release, cancellationToken).ConfigureAwait(false);
+ await this.installerService.LaunchInstallerElevatedAsync(download.InstallerPath, cancellationToken).ConfigureAwait(false);
+ this.shutdownService.RequestShutdownForUpdate();
+ return new UpdateInstallResult(UpdateInstallStatus.Started, "Update installer started.");
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ this.logger.LogWarning(ex, "ThreadPilot update install failed");
+ return new UpdateInstallResult(UpdateInstallStatus.Failed, ex.Message);
+ }
+ finally
+ {
+ if (download != null)
+ {
+ this.TryCleanup(download.TempDirectory);
+ }
+
+ this.installGate.Release();
+ }
+ }
+
+ private async Task MarkUpdateCheckAttemptAsync(CancellationToken cancellationToken)
+ {
+ var settings = this.settingsService.Settings;
+ settings.LastUpdateCheckUtc = this.clock.UtcNow;
+ await this.settingsService.UpdateSettingsAsync(settings).ConfigureAwait(false);
+ }
+
+ private void TryCleanup(string tempDirectory)
+ {
+ try
+ {
+ this.tempDirectoryProvider.Cleanup(tempDirectory);
+ }
+ catch (Exception ex)
+ {
+ this.logger.LogWarning(ex, "Failed to clean update temp directory {TempDirectory}", tempDirectory);
+ }
+ }
+ }
+}
diff --git a/Services/UpdateTempDirectoryProvider.cs b/Services/UpdateTempDirectoryProvider.cs
new file mode 100644
index 0000000..d2ac116
--- /dev/null
+++ b/Services/UpdateTempDirectoryProvider.cs
@@ -0,0 +1,68 @@
+/*
+ * ThreadPilot - safe temporary directory management for update downloads.
+ */
+namespace ThreadPilot.Services
+{
+ using System;
+ using System.IO;
+
+ public sealed class UpdateTempDirectoryProvider : IUpdateTempDirectoryProvider
+ {
+ private readonly string rootDirectory;
+
+ public UpdateTempDirectoryProvider()
+ : this(Path.Combine(Path.GetTempPath(), "ThreadPilot", "Updates"))
+ {
+ }
+
+ public UpdateTempDirectoryProvider(string rootDirectory)
+ {
+ this.rootDirectory = Path.GetFullPath(rootDirectory ?? throw new ArgumentNullException(nameof(rootDirectory)));
+ }
+
+ public string CreateUpdateTempDirectory(SemanticVersion version)
+ {
+ var directory = Path.Combine(this.rootDirectory, version.ToString(), Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(directory);
+ return directory;
+ }
+
+ public bool IsSafeUpdateTempPath(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return false;
+ }
+
+ var fullPath = Path.GetFullPath(path);
+ var rootWithSeparator = this.rootDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
+ + Path.DirectorySeparatorChar;
+ return fullPath.StartsWith(rootWithSeparator, StringComparison.OrdinalIgnoreCase);
+ }
+
+ public void Cleanup(string path)
+ {
+ if (!this.IsSafeUpdateTempPath(path) || !Directory.Exists(path))
+ {
+ return;
+ }
+
+ Directory.Delete(path, recursive: true);
+ this.DeleteEmptyParentsUntilRoot(Path.GetDirectoryName(Path.GetFullPath(path)));
+ }
+
+ private void DeleteEmptyParentsUntilRoot(string? directory)
+ {
+ while (!string.IsNullOrWhiteSpace(directory) && this.IsSafeUpdateTempPath(directory))
+ {
+ if (Directory.GetFileSystemEntries(directory).Length > 0)
+ {
+ return;
+ }
+
+ Directory.Delete(directory);
+ directory = Path.GetDirectoryName(directory);
+ }
+ }
+ }
+}
diff --git a/Services/WpfApplicationShutdownService.cs b/Services/WpfApplicationShutdownService.cs
new file mode 100644
index 0000000..2ae900d
--- /dev/null
+++ b/Services/WpfApplicationShutdownService.cs
@@ -0,0 +1,21 @@
+/*
+ * ThreadPilot - graceful shutdown hook after updater launch.
+ */
+namespace ThreadPilot.Services
+{
+ using System.Windows;
+
+ public sealed class WpfApplicationShutdownService : IApplicationShutdownService
+ {
+ public void RequestShutdownForUpdate()
+ {
+ var application = Application.Current;
+ if (application == null)
+ {
+ return;
+ }
+
+ application.Dispatcher.InvokeAsync(application.Shutdown);
+ }
+ }
+}
diff --git a/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs b/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs
index 214ac48..6eed3cb 100644
--- a/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs
+++ b/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs
@@ -14,6 +14,10 @@ public void Constructor_StartMinimizedDefaultsFalse_ForManualLaunchVisibility()
Assert.True(settings.ApplyPersistentRulesOnProcessStart);
Assert.False(settings.HasSeenStartupMinimizedSuggestion);
Assert.Equal("en-US", settings.Language);
+ Assert.True(settings.EnableAutomaticUpdateChecks);
+ Assert.Equal(7, settings.UpdateCheckIntervalDays);
+ Assert.False(settings.IncludePrereleaseUpdates);
+ Assert.Null(settings.LastUpdateCheckUtc);
}
[Fact]
diff --git a/Tests/ThreadPilot.Core.Tests/GitHubUpdateCheckerTests.cs b/Tests/ThreadPilot.Core.Tests/GitHubUpdateCheckerTests.cs
index 4af15aa..51143bc 100644
--- a/Tests/ThreadPilot.Core.Tests/GitHubUpdateCheckerTests.cs
+++ b/Tests/ThreadPilot.Core.Tests/GitHubUpdateCheckerTests.cs
@@ -1,84 +1,89 @@
-namespace ThreadPilot.Core.Tests
-{
- using System.Threading;
- using System.Threading.Tasks;
- using ThreadPilot.Services;
- using ThreadPilot.Services.Abstractions;
-
- public sealed class GitHubUpdateCheckerTests
- {
- [Fact]
- public async Task GetLatestVersionAsync_ReturnsStableRelease_WhenTagIsSemver()
- {
- var client = new FakeGitHubReleaseClient(
- """
- {
- "tag_name": "v1.2.3",
- "prerelease": false,
- "draft": false,
- "html_url": "https://example.test/releases/v1.2.3"
- }
- """);
- var checker = new GitHubUpdateChecker(client);
-
- var (latest, releaseUrl) = await checker.GetLatestVersionAsync("PrimeBuild-pc", "ThreadPilot");
-
- Assert.Equal(new System.Version(1, 2, 3), latest);
- Assert.Equal("https://example.test/releases/v1.2.3", releaseUrl);
- }
-
- [Fact]
- public async Task GetLatestVersionAsync_IgnoresDraftOrPrerelease()
- {
- var client = new FakeGitHubReleaseClient(
- """
- {
- "tag_name": "v1.2.3-beta1",
- "prerelease": true,
- "draft": false,
- "html_url": "https://example.test/releases/v1.2.3-beta1"
- }
- """);
- var checker = new GitHubUpdateChecker(client);
-
- var (latest, releaseUrl) = await checker.GetLatestVersionAsync("PrimeBuild-pc", "ThreadPilot");
-
- Assert.Null(latest);
- Assert.Null(releaseUrl);
- }
-
- [Fact]
- public async Task GetLatestVersionAsync_ReturnsNull_WhenTagCannotBeParsed()
- {
- var client = new FakeGitHubReleaseClient(
- """
- {
- "tag_name": "release-main",
- "prerelease": false,
- "draft": false,
- "html_url": "https://example.test/releases/release-main"
- }
- """);
- var checker = new GitHubUpdateChecker(client);
-
- var (latest, releaseUrl) = await checker.GetLatestVersionAsync("PrimeBuild-pc", "ThreadPilot");
-
- Assert.Null(latest);
- Assert.Equal("https://example.test/releases/release-main", releaseUrl);
- }
-
- private sealed class FakeGitHubReleaseClient : IGitHubReleaseClient
- {
- private readonly string responseJson;
-
- public FakeGitHubReleaseClient(string responseJson)
+namespace ThreadPilot.Core.Tests
+{
+ using System.Threading;
+ using System.Threading.Tasks;
+ using ThreadPilot.Services;
+ using ThreadPilot.Services.Abstractions;
+
+ public sealed class GitHubUpdateCheckerTests
+ {
+ [Fact]
+ public async Task GetLatestVersionAsync_ReturnsStableRelease_WhenTagIsSemver()
+ {
+ var client = new FakeGitHubReleaseClient(
+ """
+ {
+ "tag_name": "v1.2.3",
+ "prerelease": false,
+ "draft": false,
+ "html_url": "https://example.test/releases/v1.2.3"
+ }
+ """);
+ var checker = new GitHubUpdateChecker(client);
+
+ var (latest, releaseUrl) = await checker.GetLatestVersionAsync("PrimeBuild-pc", "ThreadPilot");
+
+ Assert.Equal(new System.Version(1, 2, 3), latest);
+ Assert.Equal("https://example.test/releases/v1.2.3", releaseUrl);
+ }
+
+ [Fact]
+ public async Task GetLatestVersionAsync_IgnoresDraftOrPrerelease()
+ {
+ var client = new FakeGitHubReleaseClient(
+ """
+ {
+ "tag_name": "v1.2.3-beta1",
+ "prerelease": true,
+ "draft": false,
+ "html_url": "https://example.test/releases/v1.2.3-beta1"
+ }
+ """);
+ var checker = new GitHubUpdateChecker(client);
+
+ var (latest, releaseUrl) = await checker.GetLatestVersionAsync("PrimeBuild-pc", "ThreadPilot");
+
+ Assert.Null(latest);
+ Assert.Null(releaseUrl);
+ }
+
+ [Fact]
+ public async Task GetLatestVersionAsync_ReturnsNull_WhenTagCannotBeParsed()
+ {
+ var client = new FakeGitHubReleaseClient(
+ """
+ {
+ "tag_name": "release-main",
+ "prerelease": false,
+ "draft": false,
+ "html_url": "https://example.test/releases/release-main"
+ }
+ """);
+ var checker = new GitHubUpdateChecker(client);
+
+ var (latest, releaseUrl) = await checker.GetLatestVersionAsync("PrimeBuild-pc", "ThreadPilot");
+
+ Assert.Null(latest);
+ Assert.Equal("https://example.test/releases/release-main", releaseUrl);
+ }
+
+ private sealed class FakeGitHubReleaseClient : IGitHubReleaseClient
+ {
+ private readonly string responseJson;
+
+ public FakeGitHubReleaseClient(string responseJson)
+ {
+ this.responseJson = responseJson;
+ }
+
+ public Task GetLatestReleaseJsonAsync(string owner, string repo, CancellationToken cancellationToken = default)
{
- this.responseJson = responseJson;
+ return Task.FromResult(this.responseJson);
}
- public Task GetLatestReleaseJsonAsync(string owner, string repo, CancellationToken cancellationToken = default)
+ public Task GetReleasesJsonAsync(string owner, string repo, CancellationToken cancellationToken = default)
{
- return Task.FromResult(this.responseJson);
+ return Task.FromResult($"[{this.responseJson}]");
}
}
}
diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs
index 4b344fe..9348f80 100644
--- a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs
+++ b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs
@@ -254,7 +254,8 @@ public void MainWindow_QueuesStartupUpdateCheckOnceWithoutBlockingStartup()
Assert.Contains("QueueStartupUpdateCheck();", source, StringComparison.Ordinal);
Assert.Contains("Interlocked.Exchange(ref this.startupUpdateCheckStarted, 1)", updateCheckSection, StringComparison.Ordinal);
Assert.Contains("TaskSafety.FireAndForget(this.CheckForUpdatesAtStartupAsync()", updateCheckSection, StringComparison.Ordinal);
- Assert.Contains("GetLatestVersionAsync(\"PrimeBuild-pc\", \"ThreadPilot\")", updateCheckSection, StringComparison.Ordinal);
+ Assert.Contains("GetRequiredService()", updateCheckSection, StringComparison.Ordinal);
+ Assert.Contains("CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Startup))", updateCheckSection, StringComparison.Ordinal);
Assert.Contains("Startup update check ignored failure", updateCheckSection, StringComparison.Ordinal);
Assert.DoesNotContain("System.Windows.MessageBox.Show", updateCheckSection, StringComparison.Ordinal);
}
diff --git a/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs b/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs
index 2d69ee3..4afd6e0 100644
--- a/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs
+++ b/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs
@@ -6,7 +6,6 @@ namespace ThreadPilot.Core.Tests
using Moq;
using ThreadPilot.Models;
using ThreadPilot.Services;
- using ThreadPilot.Services.Abstractions;
using ThreadPilot.ViewModels;
public sealed class SettingsViewModelThemeTests
@@ -201,6 +200,10 @@ private sealed class Harness
public Mock Localization { get; } = new(MockBehavior.Loose);
+ public Mock Updates { get; } = new(MockBehavior.Loose);
+
+ public Mock VersionProvider { get; } = new(MockBehavior.Loose);
+
public Mock Logging { get; } = new(MockBehavior.Loose);
public ActivityAuditService Audit { get; } = new(NullLogger.Instance);
@@ -228,6 +231,8 @@ public Harness(bool initialDarkTheme = false)
this.Associations
.Setup(service => service.GetDefaultPowerPlanAsync())
.ReturnsAsync((string.Empty, string.Empty));
+ this.VersionProvider.SetupGet(service => service.DisplayVersion).Returns("v1.3.1");
+ this.VersionProvider.SetupGet(service => service.CurrentVersion).Returns(new SemanticVersion(1, 3, 1));
}
public SettingsViewModel CreateViewModel() =>
@@ -241,7 +246,8 @@ public SettingsViewModel CreateViewModel() =>
this.ProcessMonitorManager.Object,
this.Theme.Object,
this.Tray.Object,
- new GitHubUpdateChecker(new Mock().Object),
+ this.Updates.Object,
+ this.VersionProvider.Object,
this.Localization.Object,
this.Logging.Object,
this.Audit);
diff --git a/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs b/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs
new file mode 100644
index 0000000..2f9729e
--- /dev/null
+++ b/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs
@@ -0,0 +1,323 @@
+namespace ThreadPilot.Core.Tests
+{
+ using System;
+ using System.Collections.Generic;
+ using System.IO;
+ using System.Text;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Microsoft.Extensions.Logging.Abstractions;
+ using Moq;
+ using ThreadPilot.Models;
+ using ThreadPilot.Services;
+ using ThreadPilot.Services.Abstractions;
+
+ public sealed class UpdateServiceTests
+ {
+ [Fact]
+ public void SemanticVersion_OrdersStableAbovePrerelease()
+ {
+ Assert.True(SemanticVersion.TryParse("v1.4.0-beta.1", out var prerelease));
+ Assert.True(SemanticVersion.TryParse("1.4.0", out var stable));
+
+ Assert.True(stable > prerelease);
+ }
+
+ [Fact]
+ public async Task GitHubUpdateChecker_ExcludesPrereleasesByDefault()
+ {
+ var checker = new GitHubUpdateChecker(new FakeGitHubReleaseClient(
+ """
+ [
+ { "tag_name": "v1.5.0-beta.1", "prerelease": true, "draft": false, "html_url": "https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.5.0-beta.1", "assets": [] },
+ { "tag_name": "v1.4.0", "prerelease": false, "draft": false, "html_url": "https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.4.0", "assets": [] }
+ ]
+ """));
+
+ var release = await checker.GetLatestReleaseInfoAsync("PrimeBuild-pc", "ThreadPilot");
+
+ Assert.NotNull(release);
+ Assert.Equal("1.4.0", release.Version.ToString());
+ }
+
+ [Fact]
+ public async Task CheckForUpdatesAsync_StartupSkipsWhenLastCheckInsideInterval()
+ {
+ var harness = new Harness();
+ harness.Settings.LastUpdateCheckUtc = harness.Clock.UtcNow.AddDays(-2);
+
+ var result = await harness.Service.CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Startup));
+
+ Assert.Equal(UpdateCheckStatus.Skipped, result.Status);
+ Assert.False(harness.ReleaseClient.RequestedReleases);
+ }
+
+ [Fact]
+ public async Task CheckForUpdatesAsync_ManualFindsNewerStableRelease()
+ {
+ var harness = new Harness();
+
+ var result = await harness.Service.CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Manual));
+
+ Assert.True(result.IsUpdateAvailable);
+ Assert.Equal("1.4.0", result.Release?.Version.ToString());
+ Assert.Equal(harness.Clock.UtcNow, harness.SavedSettings?.LastUpdateCheckUtc);
+ }
+
+ [Fact]
+ public void UpdateAssetSelector_SelectsInstallerAndRejectsPortable()
+ {
+ var release = CreateRelease(
+ new UpdateAsset("ThreadPilot_v1.4.0_Portable.zip", new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/ThreadPilot_v1.4.0_Portable.zip"), 1),
+ new UpdateAsset("ThreadPilot_v1.4.0_Setup.exe", new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/ThreadPilot_v1.4.0_Setup.exe"), 1));
+
+ var selected = UpdateAssetSelector.TrySelectInstaller(release, out var asset);
+
+ Assert.True(selected);
+ Assert.Equal("ThreadPilot_v1.4.0_Setup.exe", asset.Name);
+ }
+
+ [Fact]
+ public async Task DownloadInstallerAsync_VerifiesChecksum()
+ {
+ using var tempRoot = new TempDirectory();
+ var installerBytes = Encoding.UTF8.GetBytes("installer-content");
+ var expectedHash = ComputeSha256(installerBytes);
+ var client = new FakeUpdateDownloadClient(installerBytes, $"{expectedHash} ThreadPilot_v1.4.0_Setup.exe");
+ var service = CreateDownloadService(tempRoot.Path, client);
+
+ var result = await service.DownloadInstallerAsync(CreateReleaseWithInstallerAndChecksum());
+
+ Assert.True(result.ChecksumVerified);
+ Assert.True(File.Exists(result.InstallerPath));
+ }
+
+ [Fact]
+ public async Task DownloadInstallerAsync_RejectsInvalidChecksumAndCleansTemp()
+ {
+ using var tempRoot = new TempDirectory();
+ var client = new FakeUpdateDownloadClient(Encoding.UTF8.GetBytes("installer-content"), $"{new string('0', 64)} ThreadPilot_v1.4.0_Setup.exe");
+ var service = CreateDownloadService(tempRoot.Path, client);
+
+ await Assert.ThrowsAsync(() => service.DownloadInstallerAsync(CreateReleaseWithInstallerAndChecksum()));
+ Assert.Empty(Directory.GetDirectories(tempRoot.Path));
+ }
+
+ [Fact]
+ public void UpdateTempDirectoryProvider_DoesNotDeleteOutsideUpdateRoot()
+ {
+ using var tempRoot = new TempDirectory();
+ using var outside = new TempDirectory();
+ File.WriteAllText(Path.Combine(outside.Path, "settings.json"), "{}");
+ var provider = new UpdateTempDirectoryProvider(tempRoot.Path);
+
+ provider.Cleanup(outside.Path);
+
+ Assert.True(File.Exists(Path.Combine(outside.Path, "settings.json")));
+ }
+
+ [Fact]
+ public async Task StartupCheck_DoesNotDownloadOrInstallWithoutUserConsent()
+ {
+ var harness = new Harness();
+
+ var result = await harness.Service.CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Startup));
+
+ Assert.True(result.IsUpdateAvailable);
+ harness.Download.Verify(service => service.DownloadInstallerAsync(It.IsAny(), It.IsAny()), Times.Never);
+ harness.Installer.Verify(service => service.LaunchInstallerElevatedAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task DownloadAndInstallAsync_StartsInstallerAndRequestsShutdown()
+ {
+ var harness = new Harness();
+ harness.Download
+ .Setup(service => service.DownloadInstallerAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new UpdateDownloadResult(
+ Path.Combine(harness.TempDirectory, "ThreadPilot_v1.4.0_Setup.exe"),
+ harness.TempDirectory,
+ true,
+ UpdateSignatureStatus.Unknown,
+ "ok"));
+ File.WriteAllText(Path.Combine(harness.TempDirectory, "ThreadPilot_v1.4.0_Setup.exe"), "installer");
+
+ var result = await harness.Service.DownloadAndInstallAsync(CreateReleaseWithInstallerAndChecksum());
+
+ Assert.Equal(UpdateInstallStatus.Started, result.Status);
+ harness.Installer.Verify(service => service.LaunchInstallerElevatedAsync(It.IsAny(), It.IsAny()), Times.Once);
+ harness.Shutdown.Verify(service => service.RequestShutdownForUpdate(), Times.Once);
+ }
+
+ private static UpdateDownloadService CreateDownloadService(string tempRoot, IUpdateDownloadClient client)
+ {
+ var signature = new Mock();
+ signature.Setup(verifier => verifier.Verify(It.IsAny())).Returns(UpdateSignatureStatus.Unknown);
+ return new UpdateDownloadService(
+ client,
+ new UpdateTempDirectoryProvider(tempRoot),
+ signature.Object,
+ NullLogger.Instance);
+ }
+
+ private static UpdateReleaseInfo CreateReleaseWithInstallerAndChecksum()
+ {
+ return CreateRelease(
+ new UpdateAsset("ThreadPilot_v1.4.0_Setup.exe", new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/ThreadPilot_v1.4.0_Setup.exe"), 10),
+ new UpdateAsset("SHA256SUMS.txt", new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/SHA256SUMS.txt"), 10));
+ }
+
+ private static UpdateReleaseInfo CreateRelease(params UpdateAsset[] assets)
+ {
+ return new UpdateReleaseInfo(
+ new SemanticVersion(1, 4, 0),
+ "v1.4.0",
+ new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.4.0"),
+ false,
+ assets);
+ }
+
+ private static string ComputeSha256(byte[] bytes)
+ {
+ var hash = System.Security.Cryptography.SHA256.HashData(bytes);
+ return Convert.ToHexString(hash);
+ }
+
+ private sealed class Harness
+ {
+ public ApplicationSettingsModel Settings { get; } = new();
+
+ public ApplicationSettingsModel? SavedSettings { get; private set; }
+
+ public FakeClock Clock { get; } = new();
+
+ public FakeGitHubReleaseClient ReleaseClient { get; } = new(
+ """
+ [
+ {
+ "tag_name": "v1.4.0",
+ "prerelease": false,
+ "draft": false,
+ "html_url": "https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.4.0",
+ "assets": [
+ { "name": "ThreadPilot_v1.4.0_Setup.exe", "browser_download_url": "https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/ThreadPilot_v1.4.0_Setup.exe", "size": 100 },
+ { "name": "SHA256SUMS.txt", "browser_download_url": "https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/SHA256SUMS.txt", "size": 100 }
+ ]
+ }
+ ]
+ """);
+
+ public Mock Download { get; } = new(MockBehavior.Strict);
+
+ public Mock Installer { get; } = new(MockBehavior.Strict);
+
+ public Mock Shutdown { get; } = new(MockBehavior.Strict);
+
+ public string TempDirectory { get; }
+
+ public UpdateService Service { get; }
+
+ public Harness()
+ {
+ this.TempDirectory = Directory.CreateTempSubdirectory("ThreadPilotUpdateTest").FullName;
+ var settingsService = new Mock();
+ settingsService.SetupGet(service => service.Settings).Returns(() => (ApplicationSettingsModel)this.Settings.Clone());
+ settingsService
+ .Setup(service => service.UpdateSettingsAsync(It.IsAny()))
+ .Callback(settings =>
+ {
+ this.SavedSettings = (ApplicationSettingsModel)settings.Clone();
+ this.Settings.CopyFrom(settings);
+ })
+ .Returns(Task.CompletedTask);
+
+ var versionProvider = new Mock();
+ versionProvider.SetupGet(provider => provider.CurrentVersion).Returns(new SemanticVersion(1, 3, 1));
+ versionProvider.SetupGet(provider => provider.DisplayVersion).Returns("v1.3.1");
+
+ var tempProvider = new Mock();
+ tempProvider.Setup(provider => provider.Cleanup(It.IsAny()));
+
+ this.Shutdown.Setup(service => service.RequestShutdownForUpdate());
+ this.Installer
+ .Setup(service => service.LaunchInstallerElevatedAsync(It.IsAny(), It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ this.Service = new UpdateService(
+ new GitHubUpdateChecker(this.ReleaseClient),
+ settingsService.Object,
+ versionProvider.Object,
+ this.Download.Object,
+ this.Installer.Object,
+ tempProvider.Object,
+ this.Shutdown.Object,
+ this.Clock,
+ NullLogger.Instance);
+ }
+ }
+
+ private sealed class FakeClock : IUpdateClock
+ {
+ public DateTimeOffset UtcNow { get; } = new(2026, 6, 7, 12, 0, 0, TimeSpan.Zero);
+ }
+
+ private sealed class FakeGitHubReleaseClient : IGitHubReleaseClient
+ {
+ private readonly string releasesJson;
+
+ public bool RequestedReleases { get; private set; }
+
+ public FakeGitHubReleaseClient(string releasesJson)
+ {
+ this.releasesJson = releasesJson;
+ }
+
+ public Task GetLatestReleaseJsonAsync(string owner, string repo, CancellationToken cancellationToken = default)
+ {
+ throw new NotSupportedException();
+ }
+
+ public Task GetReleasesJsonAsync(string owner, string repo, CancellationToken cancellationToken = default)
+ {
+ this.RequestedReleases = true;
+ return Task.FromResult(this.releasesJson);
+ }
+ }
+
+ private sealed class FakeUpdateDownloadClient : IUpdateDownloadClient
+ {
+ private readonly byte[] fileBytes;
+ private readonly string? checksumsText;
+
+ public FakeUpdateDownloadClient(byte[] fileBytes, string? checksumsText)
+ {
+ this.fileBytes = fileBytes;
+ this.checksumsText = checksumsText;
+ }
+
+ public Task DownloadFileAsync(Uri uri, string destinationPath, CancellationToken cancellationToken = default)
+ {
+ File.WriteAllBytes(destinationPath, this.fileBytes);
+ return Task.CompletedTask;
+ }
+
+ public Task TryDownloadStringAsync(Uri uri, CancellationToken cancellationToken = default)
+ {
+ return Task.FromResult(this.checksumsText);
+ }
+ }
+
+ private sealed class TempDirectory : IDisposable
+ {
+ public string Path { get; } = Directory.CreateTempSubdirectory("ThreadPilotUpdateTest").FullName;
+
+ public void Dispose()
+ {
+ if (Directory.Exists(this.Path))
+ {
+ Directory.Delete(this.Path, recursive: true);
+ }
+ }
+ }
+ }
+}
diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs
index c764aad..fdbcde9 100644
--- a/ViewModels/SettingsViewModel.cs
+++ b/ViewModels/SettingsViewModel.cs
@@ -19,7 +19,6 @@ namespace ThreadPilot.ViewModels
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
- using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
@@ -48,13 +47,15 @@ public partial class SettingsViewModel : BaseViewModel
private readonly IProcessMonitorManagerService processMonitorManagerService;
private readonly IThemeService themeService;
private readonly ISystemTrayService systemTrayService;
- private readonly GitHubUpdateChecker gitHubUpdateChecker;
+ private readonly IUpdateService updateService;
+ private readonly IApplicationVersionProvider versionProvider;
private readonly ILocalizationService localizationService;
private ApplicationSettingsModel savedSettingsSnapshot;
private bool isSyncingFromService = false;
private bool? appliedThemePreference;
private string cachedDefaultPowerPlanGuid = string.Empty;
private string cachedDefaultPowerPlanName = string.Empty;
+ private UpdateReleaseInfo? availableUpdate;
private static readonly JsonSerializerOptions ImportExportJsonOptions = new()
{
WriteIndented = true,
@@ -96,6 +97,19 @@ public partial class SettingsViewModel : BaseViewModel
public ICommand CheckUpdatesCommand { get; }
+ public IAsyncRelayCommand DownloadAndInstallUpdateCommand { get; }
+
+ [ObservableProperty]
+ private string latestUpdateVersion = string.Empty;
+
+ [ObservableProperty]
+ private string lastUpdateCheckText = string.Empty;
+
+ [ObservableProperty]
+ private bool isUpdateAvailable = false;
+
+ public bool CanDownloadAndInstallUpdate => this.IsUpdateAvailable && !this.IsLoading;
+
public SettingsViewModel(
ILogger logger,
IApplicationSettingsService settingsService,
@@ -106,7 +120,8 @@ public SettingsViewModel(
IProcessMonitorManagerService processMonitorManagerService,
IThemeService themeService,
ISystemTrayService systemTrayService,
- GitHubUpdateChecker gitHubUpdateChecker,
+ IUpdateService updateService,
+ IApplicationVersionProvider versionProvider,
ILocalizationService localizationService,
IEnhancedLoggingService? enhancedLoggingService = null,
IActivityAuditService? activityAuditService = null)
@@ -120,24 +135,18 @@ public SettingsViewModel(
this.processMonitorManagerService = processMonitorManagerService ?? throw new ArgumentNullException(nameof(processMonitorManagerService));
this.themeService = themeService ?? throw new ArgumentNullException(nameof(themeService));
this.systemTrayService = systemTrayService ?? throw new ArgumentNullException(nameof(systemTrayService));
- this.gitHubUpdateChecker = gitHubUpdateChecker ?? throw new ArgumentNullException(nameof(gitHubUpdateChecker));
+ this.updateService = updateService ?? throw new ArgumentNullException(nameof(updateService));
+ this.versionProvider = versionProvider ?? throw new ArgumentNullException(nameof(versionProvider));
this.localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService));
- // Get version and strip the git commit hash (everything after '+')
- var rawVersion = typeof(App).Assembly
- .GetCustomAttribute()?
- .InformationalVersion
- ?? typeof(App).Assembly.GetName().Version?.ToString()
- ?? "0.0.0";
-
- // Remove commit hash suffix and add 'v' prefix
- var cleanVersion = rawVersion.Split('+')[0];
- this.ApplicationVersion = $"v{cleanVersion}";
+ this.ApplicationVersion = this.versionProvider.DisplayVersion;
// Initialize with current settings
this.settings = (ApplicationSettingsModel)this.settingsService.Settings.Clone();
this.savedSettingsSnapshot = (ApplicationSettingsModel)this.settings.Clone();
this.appliedThemePreference = this.settings.UseDarkTheme;
+ this.LatestUpdateVersion = this.GetLocalizedString("Settings_UpdateNotChecked", "Not checked");
+ this.UpdateLastCheckedText();
// Initialize commands
this.SaveSettingsCommand = new AsyncRelayCommand(this.SaveSettingsAsync);
@@ -147,6 +156,9 @@ public SettingsViewModel(
this.TestNotificationCommand = new AsyncRelayCommand(this.TestNotificationAsync);
this.RefreshPowerPlansCommand = new AsyncRelayCommand(this.RefreshPowerPlansAsync);
this.CheckUpdatesCommand = new AsyncRelayCommand(this.CheckUpdatesAsync);
+ this.DownloadAndInstallUpdateCommand = new AsyncRelayCommand(
+ this.DownloadAndInstallUpdateAsync,
+ () => this.CanDownloadAndInstallUpdate);
// Subscribe to property changes to track unsaved changes
this.Settings.PropertyChanged += this.OnSettingsPropertyChanged;
@@ -265,6 +277,14 @@ partial void OnHasUnsavedChangesChanged(bool value)
partial void OnIsLoadingChanged(bool value)
{
OnPropertyChanged(nameof(CanSaveSettings));
+ OnPropertyChanged(nameof(CanDownloadAndInstallUpdate));
+ this.DownloadAndInstallUpdateCommand.NotifyCanExecuteChanged();
+ }
+
+ partial void OnIsUpdateAvailableChanged(bool value)
+ {
+ OnPropertyChanged(nameof(CanDownloadAndInstallUpdate));
+ this.DownloadAndInstallUpdateCommand.NotifyCanExecuteChanged();
}
private async Task SaveSettingsAsync()
@@ -671,40 +691,34 @@ private async Task CheckUpdatesAsync()
this.IsLoading = true;
this.StatusMessage = this.GetLocalizedString("Settings_StatusCheckingUpdates", "Checking for updates...");
- var currentVersion = ParseVersion(this.ApplicationVersion);
- var (latest, releaseUrl) = await this.gitHubUpdateChecker.GetLatestVersionAsync("PrimeBuild-pc", "ThreadPilot");
+ var result = await this.updateService.CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Manual));
+ this.UpdateLastCheckedText();
- if (latest is null)
+ if (result.Status == UpdateCheckStatus.Failed)
{
this.StatusMessage = this.GetLocalizedString("Settings_StatusLatestUnknown", "Unable to determine the latest version.");
await this.notificationService.ShowErrorNotificationAsync(
"Update Check",
- "Unable to retrieve latest release information.");
+ result.Message);
return;
}
- if (latest > currentVersion)
+ if (result.IsUpdateAvailable && result.Release != null)
{
- this.StatusMessage = this.GetLocalizedString("Settings_StatusNewVersionFormat", "New version available: {0}", latest);
-
- var result = System.Windows.MessageBox.Show(
- $"Update available\nInstalled version: {this.ApplicationVersion}\nNew version: {latest}\n\nDo you want to open the download page?",
+ this.availableUpdate = result.Release;
+ this.LatestUpdateVersion = $"v{result.Release.Version}";
+ this.IsUpdateAvailable = true;
+ this.StatusMessage = this.GetLocalizedString("Settings_StatusNewVersionFormat", "New version available: {0}", result.Release.Version);
+ await this.notificationService.ShowNotificationAsync(
"Update available",
- MessageBoxButton.YesNo,
- MessageBoxImage.Information);
-
- if (result == MessageBoxResult.Yes)
- {
- var url = releaseUrl ?? "https://github.com/PrimeBuild-pc/ThreadPilot/releases/latest";
- Process.Start(new ProcessStartInfo
- {
- FileName = url,
- UseShellExecute = true,
- });
- }
+ $"ThreadPilot {result.Release.Version} is available.",
+ NotificationType.Information);
}
else
{
+ this.availableUpdate = null;
+ this.LatestUpdateVersion = result.Release != null ? $"v{result.Release.Version}" : this.GetLocalizedString("Settings_UpdateLatestUnknown", "Unknown");
+ this.IsUpdateAvailable = false;
this.StatusMessage = this.GetLocalizedString("Settings_StatusUpToDateFormat", "Application is up to date. Installed version: {0}", this.ApplicationVersion);
await this.notificationService.ShowSuccessNotificationAsync(
"Application up to date",
@@ -727,24 +741,73 @@ await this.notificationService.ShowErrorNotificationAsync(
}
}
- private static Version ParseVersion(string versionString)
+ private async Task DownloadAndInstallUpdateAsync()
{
- if (string.IsNullOrWhiteSpace(versionString))
+ if (this.availableUpdate == null)
{
- return new Version(0, 0, 0);
+ this.StatusMessage = this.GetLocalizedString("Settings_StatusLatestUnknown", "Unable to determine the latest version.");
+ return;
}
- var sanitized = versionString.Trim();
- if (sanitized.StartsWith("v", StringComparison.OrdinalIgnoreCase))
+ var message = this.GetLocalizedString(
+ "Settings_UpdateConfirmMessageFormat",
+ "ThreadPilot will download and verify version {0}, then ask Windows for permission to run the installer. Continue?",
+ this.availableUpdate.Version);
+ var confirmation = System.Windows.MessageBox.Show(
+ message,
+ this.GetLocalizedString("Settings_UpdateConfirmTitle", "Install ThreadPilot update"),
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Information);
+
+ if (confirmation != MessageBoxResult.Yes)
{
- sanitized = sanitized[1..];
+ this.StatusMessage = this.GetLocalizedString("Settings_StatusUpdateCanceled", "Update canceled.");
+ return;
}
- sanitized = sanitized.Split('-', '+')[0];
+ try
+ {
+ this.IsLoading = true;
+ this.StatusMessage = this.GetLocalizedString("Settings_StatusDownloadingUpdate", "Downloading and verifying update...");
- return Version.TryParse(sanitized, out var parsed)
- ? parsed
- : new Version(0, 0, 0);
+ var result = await this.updateService.DownloadAndInstallAsync(this.availableUpdate);
+ if (result.Status == UpdateInstallStatus.Started)
+ {
+ this.StatusMessage = this.GetLocalizedString("Settings_StatusUpdateInstallerStarted", "Update installer started.");
+ await this.notificationService.ShowNotificationAsync(
+ "Update installer started",
+ "ThreadPilot will close while the installer runs.",
+ NotificationType.Information);
+ }
+ else
+ {
+ this.StatusMessage = this.GetLocalizedString("Settings_StatusUpdateInstallFailedFormat", "Update install failed: {0}", result.Message);
+ await this.notificationService.ShowErrorNotificationAsync(
+ "Update install failed",
+ result.Message);
+ }
+ }
+ catch (Exception ex)
+ {
+ this.StatusMessage = this.GetLocalizedString("Settings_StatusUpdateInstallFailedFormat", "Update install failed: {0}", ex.Message);
+ this.Logger.LogError(ex, "Error downloading or installing update");
+ await this.notificationService.ShowErrorNotificationAsync(
+ "Update install failed",
+ "Unable to download or start the update installer",
+ ex);
+ }
+ finally
+ {
+ this.IsLoading = false;
+ }
+ }
+
+ private void UpdateLastCheckedText()
+ {
+ var lastCheck = this.settingsService.Settings.LastUpdateCheckUtc;
+ this.LastUpdateCheckText = lastCheck.HasValue
+ ? lastCheck.Value.LocalDateTime.ToString("g", System.Globalization.CultureInfo.CurrentCulture)
+ : this.GetLocalizedString("Settings_UpdateLastCheckedNever", "Never");
}
private string GetLocalizedString(string key, string fallback, params object[] args)
diff --git a/Views/SettingsView.xaml b/Views/SettingsView.xaml
index c98d358..bb09a94 100644
--- a/Views/SettingsView.xaml
+++ b/Views/SettingsView.xaml
@@ -350,6 +350,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -359,6 +391,12 @@
Command="{Binding CheckUpdatesCommand}"
Padding="15,6"
HorizontalAlignment="Left"/>
+
diff --git a/docs/UPDATES.md b/docs/UPDATES.md
new file mode 100644
index 0000000..3dd20c3
--- /dev/null
+++ b/docs/UPDATES.md
@@ -0,0 +1,50 @@
+# ThreadPilot In-App Updates
+
+ThreadPilot checks the official `PrimeBuild-pc/ThreadPilot` GitHub Releases feed for stable releases.
+
+## Check Behavior
+
+- Manual checks run from Settings.
+- Startup checks run in the background only when automatic checks are enabled and the last check was more than the configured interval ago.
+- The default interval is 7 days.
+- Prereleases are excluded by default.
+- Startup checks do not block app startup and never install updates.
+
+## Install Flow
+
+1. ThreadPilot reads release metadata from `PrimeBuild-pc/ThreadPilot`.
+2. The updater selects a ThreadPilot installer asset from that release.
+3. The installer downloads into a ThreadPilot-owned temp update directory.
+4. If `SHA256SUMS.txt` is present in the release, ThreadPilot verifies the installer hash before launch.
+5. ThreadPilot performs best-effort Authenticode verification. Invalid signatures are rejected; unsigned or unverifiable files are reported as unknown.
+6. The user must confirm the update from Settings before download/install starts.
+7. Windows shows the normal UAC prompt when the installer is launched elevated.
+8. ThreadPilot requests shutdown after the installer starts so the installer can replace app files.
+
+## Data Preservation
+
+Updates do not delete ThreadPilot user data. The updater only cleans its own temporary download directory.
+
+The following user-owned ThreadPilot data is preserved during update:
+
+- settings;
+- profiles;
+- CPU masks;
+- rules;
+- imported or custom power plans;
+- logs, subject to the app's normal log retention policy.
+
+Full uninstall remains the path that removes ThreadPilot-owned AppData/settings. Update code must not call uninstall cleanup or remove `%AppData%\ThreadPilot`.
+
+## Security Notes
+
+- Update metadata is fetched only for the official `PrimeBuild-pc/ThreadPilot` repository.
+- Asset URLs must be HTTPS GitHub release asset URLs.
+- Installer file names must be safe file names and must match ThreadPilot installer naming.
+- The updater does not invoke a shell command line to download files.
+- The installer is launched with `ProcessStartInfo.ArgumentList` and `UseShellExecute=true` for UAC elevation.
+- Concurrent update installation attempts are rejected.
+
+## Known Limitation
+
+If Windows keeps the elevated installer file locked after launch, immediate temp cleanup can fail. ThreadPilot logs the cleanup failure and leaves only the ThreadPilot update temp directory behind.