Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Locales/en-US.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,12 @@
<sys:String x:Key="SettingsView_Translator">Translator: Ylimhs</sys:String>
<sys:String x:Key="SettingsView_License">License: AGPLv3</sys:String>
<sys:String x:Key="SettingsView_CheckUpdates">Check for updates</sys:String>
<sys:String x:Key="SettingsView_DownloadInstallUpdate">Download and install update</sys:String>
<sys:String x:Key="SettingsView_EnableAutomaticUpdateChecks">Check automatically on startup</sys:String>
<sys:String x:Key="SettingsView_UpdateIntervalDays">Check interval (days):</sys:String>
<sys:String x:Key="SettingsView_IncludePrereleaseUpdates">Include prerelease updates</sys:String>
<sys:String x:Key="SettingsView_LatestVersion">Latest version:</sys:String>
<sys:String x:Key="SettingsView_LastUpdateCheck">Last checked:</sys:String>
<sys:String x:Key="SettingsView_UpdatesDescription">Checks on demand using the official GitHub Releases.</sys:String>

<sys:String x:Key="SettingsView_ResetDefaults">Reset to Defaults</sys:String>
Expand Down Expand Up @@ -474,6 +480,15 @@
<sys:String x:Key="Settings_StatusNewVersionFormat">New version available: {0}</sys:String>
<sys:String x:Key="Settings_StatusUpToDateFormat">Application is up to date. Installed version: {0}</sys:String>
<sys:String x:Key="Settings_StatusUpdateErrorFormat">Error while checking updates: {0}</sys:String>
<sys:String x:Key="Settings_UpdateLatestUnknown">Unknown</sys:String>
<sys:String x:Key="Settings_UpdateNotChecked">Not checked</sys:String>
<sys:String x:Key="Settings_UpdateLastCheckedNever">Never</sys:String>
<sys:String x:Key="Settings_UpdateConfirmTitle">Install ThreadPilot update</sys:String>
<sys:String x:Key="Settings_UpdateConfirmMessageFormat">ThreadPilot will download and verify version {0}, then ask Windows for permission to run the installer. Continue?</sys:String>
<sys:String x:Key="Settings_StatusUpdateCanceled">Update canceled.</sys:String>
<sys:String x:Key="Settings_StatusDownloadingUpdate">Downloading and verifying update...</sys:String>
<sys:String x:Key="Settings_StatusUpdateInstallerStarted">Update installer started.</sys:String>
<sys:String x:Key="Settings_StatusUpdateInstallFailedFormat">Update install failed: {0}</sys:String>
<sys:String x:Key="Settings_StatusModified">Settings have been modified</sys:String>
<sys:String x:Key="Settings_StatusMatchSaved">Settings match the saved configuration</sys:String>
<sys:String x:Key="Settings_LanguageSimplifiedChinese">Simplified Chinese</sys:String>
Expand Down
15 changes: 15 additions & 0 deletions Locales/zh-CN.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,12 @@
<sys:String x:Key="SettingsView_Translator">翻译人员:Ylimhs</sys:String>
<sys:String x:Key="SettingsView_License">软件授权: AGPLv3</sys:String>
<sys:String x:Key="SettingsView_CheckUpdates">检查新版本</sys:String>
<sys:String x:Key="SettingsView_DownloadInstallUpdate">下载并安装更新</sys:String>
<sys:String x:Key="SettingsView_EnableAutomaticUpdateChecks">启动时自动检查更新</sys:String>
<sys:String x:Key="SettingsView_UpdateIntervalDays">检查间隔(天):</sys:String>
<sys:String x:Key="SettingsView_IncludePrereleaseUpdates">包含预发布版本</sys:String>
<sys:String x:Key="SettingsView_LatestVersion">最新版本:</sys:String>
<sys:String x:Key="SettingsView_LastUpdateCheck">上次检查:</sys:String>
<sys:String x:Key="SettingsView_UpdatesDescription">按需通过官方 GitHub Releases API 检索最新稳定版本。</sys:String>

<sys:String x:Key="SettingsView_ResetDefaults">重置为默认值</sys:String>
Expand Down Expand Up @@ -474,6 +480,15 @@
<sys:String x:Key="Settings_StatusNewVersionFormat">发现新版本: {0}</sys:String>
<sys:String x:Key="Settings_StatusUpToDateFormat">应用已是最新版本。已安装版本: {0}</sys:String>
<sys:String x:Key="Settings_StatusUpdateErrorFormat">检查更新时出错: {0}</sys:String>
<sys:String x:Key="Settings_UpdateLatestUnknown">未知</sys:String>
<sys:String x:Key="Settings_UpdateNotChecked">尚未检查</sys:String>
<sys:String x:Key="Settings_UpdateLastCheckedNever">从未检查</sys:String>
<sys:String x:Key="Settings_UpdateConfirmTitle">安装 ThreadPilot 更新</sys:String>
<sys:String x:Key="Settings_UpdateConfirmMessageFormat">ThreadPilot 将下载并验证版本 {0},然后请求 Windows 权限运行安装程序。是否继续?</sys:String>
<sys:String x:Key="Settings_StatusUpdateCanceled">更新已取消。</sys:String>
<sys:String x:Key="Settings_StatusDownloadingUpdate">正在下载并验证更新...</sys:String>
<sys:String x:Key="Settings_StatusUpdateInstallerStarted">更新安装程序已启动。</sys:String>
<sys:String x:Key="Settings_StatusUpdateInstallFailedFormat">更新安装失败: {0}</sys:String>
<sys:String x:Key="Settings_StatusModified">设置已被修改</sys:String>
<sys:String x:Key="Settings_StatusMatchSaved">设置与已保存的配置一致</sys:String>
<sys:String x:Key="Settings_LanguageSimplifiedChinese">简体中文</sys:String>
Expand Down
17 changes: 8 additions & 9 deletions MainWindow.Behaviors.partial.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,27 +157,26 @@ private async Task CheckForUpdatesAtStartupAsync()
try
{
this.LogDebug("Startup update check started");
var checker = this.serviceProvider.GetRequiredService<GitHubUpdateChecker>();
var currentVersion = GetCurrentApplicationVersion();
var (latest, _) = await checker.GetLatestVersionAsync("PrimeBuild-pc", "ThreadPilot");
var updateService = this.serviceProvider.GetRequiredService<IUpdateService>();
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)
{
Expand Down
21 changes: 21 additions & 0 deletions Models/ApplicationSettingsModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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());
}

Expand Down
2 changes: 2 additions & 0 deletions Services/Abstractions/IGitHubReleaseClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,7 @@ namespace ThreadPilot.Services.Abstractions
public interface IGitHubReleaseClient
{
Task<string> GetLatestReleaseJsonAsync(string owner, string repo, CancellationToken cancellationToken = default);

Task<string> GetReleasesJsonAsync(string owner, string repo, CancellationToken cancellationToken = default);
}
}
32 changes: 21 additions & 11 deletions Services/ApplicationSettingsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
36 changes: 36 additions & 0 deletions Services/ApplicationVersionProvider.cs
Original file line number Diff line number Diff line change
@@ -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<AssemblyInformationalVersionAttribute>()
.FirstOrDefault()?
.InformationalVersion
?? typeof(App).Assembly.GetName().Version?.ToString()
?? "0.0.0";
}
}
}
46 changes: 46 additions & 0 deletions Services/AuthenticodeSignatureVerifier.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
}
6 changes: 6 additions & 0 deletions Services/GitHubReleaseClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,11 @@ public Task<string> 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<string> GetReleasesJsonAsync(string owner, string repo, CancellationToken cancellationToken = default)
{
var url = $"https://api.github.com/repos/{owner}/{repo}/releases";
return this.httpClient.GetStringAsync(url, cancellationToken);
}
}
}
69 changes: 68 additions & 1 deletion Services/GitHubUpdateChecker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<LatestReleaseAsset>? Assets);

private record LatestReleaseAsset(string Name, string Browser_download_url, long Size);

public GitHubUpdateChecker(IGitHubReleaseClient gitHubReleaseClient)
{
Expand Down Expand Up @@ -71,6 +80,64 @@ public GitHubUpdateChecker(IGitHubReleaseClient gitHubReleaseClient)
? (version, release.Html_url)
: (null, release.Html_url);
}

public async Task<UpdateReleaseInfo?> 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<List<LatestRelease>>(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<UpdateReleaseInfo>()
.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<LatestReleaseAsset>())
.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);
}
}
}

Loading
Loading