From a7892083480dea83bbf4053f75bee933da2b9054 Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Sat, 25 Jul 2026 12:29:46 +0800 Subject: [PATCH 1/2] feat: check GitHub Releases for app updates Checks the project's GitHub Releases feed on startup (throttled to once per 24h) and every time the About window is opened, notifying the user via a tray balloon or an inline status link when a newer stable release is available. --- src/PayBeat.App/Helpers/AppVersion.cs | 29 ++++++++ src/PayBeat.App/Models/SalarySettings.cs | 8 +++ src/PayBeat.App/Resources/Strings.en.xaml | 5 ++ src/PayBeat.App/Resources/Strings.zh-CN.xaml | 5 ++ .../Services/UpdateCheckService.cs | 68 +++++++++++++++++++ src/PayBeat.App/ViewModels/MainViewModel.cs | 8 ++- src/PayBeat.App/Views/AboutWindow.xaml | 15 ++++ src/PayBeat.App/Views/AboutWindow.xaml.cs | 43 +++++++----- 8 files changed, 162 insertions(+), 19 deletions(-) create mode 100644 src/PayBeat.App/Helpers/AppVersion.cs create mode 100644 src/PayBeat.App/Services/UpdateCheckService.cs diff --git a/src/PayBeat.App/Helpers/AppVersion.cs b/src/PayBeat.App/Helpers/AppVersion.cs new file mode 100644 index 0000000..5cf5d5c --- /dev/null +++ b/src/PayBeat.App/Helpers/AppVersion.cs @@ -0,0 +1,29 @@ +using System.Reflection; + +namespace PayBeat.App.Helpers; + +/// +/// Resolves the running app's version from assembly metadata, stripping MinVer's +/// build-metadata suffix (anything after '+'). +/// +public static class AppVersion +{ + /// The current version string, e.g. 1.2.0, or empty if unavailable. + public static string Current { get; } = Resolve(); + + private static string Resolve() + { + var version = Assembly.GetExecutingAssembly() + .GetCustomAttribute() + ?.InformationalVersion + ?? Assembly.GetExecutingAssembly().GetName().Version?.ToString(3); + + if (version is null) + { + return string.Empty; + } + + var plus = version.IndexOf('+'); + return plus >= 0 ? version[..plus] : version; + } +} diff --git a/src/PayBeat.App/Models/SalarySettings.cs b/src/PayBeat.App/Models/SalarySettings.cs index 1574432..4fe95cc 100644 --- a/src/PayBeat.App/Models/SalarySettings.cs +++ b/src/PayBeat.App/Models/SalarySettings.cs @@ -134,4 +134,12 @@ public WindowPosition? FlexPosition /// supported explicit values are "light" and "dark". /// public string Theme { get; init; } = "auto"; + + /// + /// Last time an automatic startup update check ran, used to throttle checks to once per day. + /// + public DateTimeOffset? LastUpdateCheckUtc + { + get; init; + } } \ No newline at end of file diff --git a/src/PayBeat.App/Resources/Strings.en.xaml b/src/PayBeat.App/Resources/Strings.en.xaml index 81106be..e96068f 100644 --- a/src/PayBeat.App/Resources/Strings.en.xaml +++ b/src/PayBeat.App/Resources/Strings.en.xaml @@ -79,6 +79,9 @@ License GitHub Close + Checking for updates... + You're up to date + Update available: v{0} Daily salary must be greater than 0 @@ -97,5 +100,7 @@ You've earned {0} today! Almost Done Work ends in {0} minutes. + Update Available + PayBeat v{0} is available. Open About to download it. diff --git a/src/PayBeat.App/Resources/Strings.zh-CN.xaml b/src/PayBeat.App/Resources/Strings.zh-CN.xaml index 52f9c9e..c4d6b68 100644 --- a/src/PayBeat.App/Resources/Strings.zh-CN.xaml +++ b/src/PayBeat.App/Resources/Strings.zh-CN.xaml @@ -79,6 +79,9 @@ 许可证 GitHub 关闭 + 正在检查更新... + 已是最新版本 + 发现新版本:v{0} 日薪须大于 0 @@ -97,5 +100,7 @@ 今天已赚到 {0}! 快下班了 还有 {0} 分钟下班。 + 发现新版本 + PayBeat v{0} 已发布,打开"关于"窗口下载。 diff --git a/src/PayBeat.App/Services/UpdateCheckService.cs b/src/PayBeat.App/Services/UpdateCheckService.cs new file mode 100644 index 0000000..73835b1 --- /dev/null +++ b/src/PayBeat.App/Services/UpdateCheckService.cs @@ -0,0 +1,68 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using PayBeat.App.Helpers; + +namespace PayBeat.App.Services; + +/// Latest available release, as reported by the GitHub Releases API. +public sealed record UpdateInfo(string Version, string HtmlUrl); + +/// +/// Checks the project's GitHub Releases feed for a newer stable version than the running build. +/// Best-effort only: any network, parsing, or version-comparison failure results in a null return. +/// +public sealed class UpdateCheckService +{ + private const string LatestReleaseUrl = "https://api.github.com/repos/coldhighsun/PayBeat/releases/latest"; + + private static readonly HttpClient HttpClient = CreateHttpClient(); + + /// + /// Returns the latest stable release if it is newer than , + /// or null if there is no newer release or the check failed. + /// + public async Task GetLatestReleaseAsync(CancellationToken cancellationToken = default) + { + try + { + using var response = await HttpClient.GetAsync(LatestReleaseUrl, cancellationToken); + if (!response.IsSuccessStatusCode) + { + return null; + } + + using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var json = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + + var tagName = json.RootElement.GetProperty("tag_name").GetString(); + var htmlUrl = json.RootElement.GetProperty("html_url").GetString(); + if (string.IsNullOrEmpty(tagName) || string.IsNullOrEmpty(htmlUrl)) + { + return null; + } + + var latestVersionText = tagName.StartsWith('v') ? tagName[1..] : tagName; + if (!Version.TryParse(latestVersionText, out var latestVersion) + || !Version.TryParse(AppVersion.Current, out var currentVersion) + || latestVersion <= currentVersion) + { + return null; + } + + return new UpdateInfo(latestVersionText, htmlUrl); + } + catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException or KeyNotFoundException or InvalidOperationException) + { + return null; + } + } + + private static HttpClient CreateHttpClient() + { + var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("PayBeat", AppVersion.Current)); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + return client; + } +} \ No newline at end of file diff --git a/src/PayBeat.App/ViewModels/MainViewModel.cs b/src/PayBeat.App/ViewModels/MainViewModel.cs index 7e3e393..863bf61 100644 --- a/src/PayBeat.App/ViewModels/MainViewModel.cs +++ b/src/PayBeat.App/ViewModels/MainViewModel.cs @@ -238,11 +238,17 @@ private void OpenAbout() } } - var win = new AboutWindow(); + var win = new AboutWindow(_settingsService); ApplyTopmostIfNeeded(win); win.Show(); } + /// Raises with a localized "update available" message. + public void NotifyUpdateAvailable(string version) => + NotificationRequested?.Invoke( + LocalizationService.Get("Notification.UpdateAvailableTitle"), + string.Format(LocalizationService.Get("Notification.UpdateAvailableBody"), version)); + private void OpenSettings() { foreach (Window w in Application.Current.Windows) diff --git a/src/PayBeat.App/Views/AboutWindow.xaml b/src/PayBeat.App/Views/AboutWindow.xaml index 3ced9a7..05e0fc8 100644 --- a/src/PayBeat.App/Views/AboutWindow.xaml +++ b/src/PayBeat.App/Views/AboutWindow.xaml @@ -74,6 +74,21 @@ FontSize="12" HorizontalAlignment="Center" Margin="0,4,0,0"/> + + + + diff --git a/src/PayBeat.App/Views/AboutWindow.xaml.cs b/src/PayBeat.App/Views/AboutWindow.xaml.cs index 2bf8189..fb41ab2 100644 --- a/src/PayBeat.App/Views/AboutWindow.xaml.cs +++ b/src/PayBeat.App/Views/AboutWindow.xaml.cs @@ -1,42 +1,49 @@ +using PayBeat.App.Helpers; +using PayBeat.App.Services; using System.Diagnostics; -using System.Reflection; using System.Windows.Navigation; namespace PayBeat.App.Views; /// -/// Displays app version, author, and license information. +/// Displays app version, author, and license information, and checks for updates on open. /// public partial class AboutWindow { + private readonly SettingsService _settingsService; + /// - /// Initializes the about window and populates the version label. + /// Initializes the about window, populates the version label, and starts an update check. /// - public AboutWindow() + public AboutWindow(SettingsService settingsService) { + _settingsService = settingsService; InitializeComponent(); - VersionText.Text = GetVersionString(); + VersionText.Text = $"v{AppVersion.Current}"; + UpdateStatusText.Text = LocalizationService.Get("About.Update.Checking"); + _ = CheckForUpdatesAsync(); } - private static string GetVersionString() + private async Task CheckForUpdatesAsync() { - var version = Assembly.GetExecutingAssembly() - .GetCustomAttribute() - ?.InformationalVersion - ?? Assembly.GetExecutingAssembly().GetName().Version?.ToString(3); + var info = await new UpdateCheckService().GetLatestReleaseAsync(); + _settingsService.Save(_settingsService.Load() with + { + LastUpdateCheckUtc = DateTimeOffset.UtcNow + }); - if (version is null) + if (info != null) { - return string.Empty; + UpdateStatusText.Text = string.Empty; + UpdateStatusLink.NavigateUri = new Uri(info.HtmlUrl); + UpdateStatusLink.Inlines.Clear(); + UpdateStatusLink.Inlines.Add(string.Format(LocalizationService.Get("About.Update.Available"), info.Version)); + UpdateStatusLinkContainer.Visibility = Visibility.Visible; } - - var plus = version.IndexOf('+'); - if (plus >= 0) + else { - version = version[..plus]; + UpdateStatusText.Text = LocalizationService.Get("About.Update.UpToDate"); } - - return $"v{version}"; } private void CloseButton_Click(object sender, RoutedEventArgs e) From 15a97d310be628108ed520363ea361d8ae0eca15 Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Sat, 25 Jul 2026 12:29:57 +0800 Subject: [PATCH 2/2] refactor: extract App.OnStartup into named startup phases OnStartup previously did settings load, single-instance check, view-model/window creation, hotkey registration, startup placement, tray creation, and the update check all inline. Split into LoadStartupSettings/TryAcquireSingleInstance/CreateMainViewModelAndWindow/ StartHiddenInTray/ShowMainWindow so each phase reads as one step, and deduplicated the tray/update-check calls that were repeated in both display-mode branches. No behavior or ordering changes. --- src/PayBeat.App/App.xaml.cs | 177 ++++++++++++++++++++++++------------ 1 file changed, 119 insertions(+), 58 deletions(-) diff --git a/src/PayBeat.App/App.xaml.cs b/src/PayBeat.App/App.xaml.cs index c157fcb..bd88c06 100644 --- a/src/PayBeat.App/App.xaml.cs +++ b/src/PayBeat.App/App.xaml.cs @@ -58,68 +58,27 @@ protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); - _settingsService = new SettingsService(); - var settings = _settingsService.Load(); - _startupSettings = settings; - LocalizationService.Apply(settings.Language); - ThemeService.Apply(settings.Theme); + var settings = LoadStartupSettings(); - _singleInstanceMutex = new Mutex(initiallyOwned: true, "PayBeat_SingleInstance", out var createdNew); - if (!createdNew) + if (!TryAcquireSingleInstance()) { - MessageBox.Show( - (string)FindResource("Error.AlreadyRunning")!, - "PayBeat", - MessageBoxButton.OK, - MessageBoxImage.Information); - Shutdown(); return; } - SystemEvents.SessionEnding += OnSessionEnding; - _mainVm = new MainViewModel(_settingsService); - _mainVm.HotkeySettingsChanged += OnHotkeySettingsChanged; - _mainWindow = new MainWindow { DataContext = _mainVm }; - _mainWindow.SourceInitialized += (_, _) => - { - var s = _settingsService.Load(); - _hotkeyService = new HotkeyService(); - var registered = _hotkeyService.Register(_mainWindow, s.HotkeyModifiers, s.HotkeyVirtualKey); - if (!registered) - { - var key = HotkeyService.Format(s.HotkeyModifiers, s.HotkeyVirtualKey); - MessageBox.Show( - string.Format((string)FindResource("Error.HotkeyConflict")!, key), - "PayBeat", - MessageBoxButton.OK, - MessageBoxImage.Warning); - } - _hotkeyService.Triggered += ToggleWindowVisibility; - }; - _mainWindow.ContentRendered += OnMainWindowContentRendered; + SystemEvents.SessionEnding += OnSessionEnding; + CreateMainViewModelAndWindow(); if (settings.DisplayMode == DisplayMode.None) { - // Only create the HWND (for hotkey registration) without showing the window. - new WindowInteropHelper(_mainWindow).EnsureHandle(); - _mainWindow.ContentRendered -= OnMainWindowContentRendered; - _trayIconService = new TrayIconService(_mainVm, ActivateMainWindow); - return; + StartHiddenInTray(); } - - _mainWindow.IsRestoringStartupPosition = settings.DisplayMode is DisplayMode.Normal or DisplayMode.Mini or DisplayMode.Flex; - if (settings.DisplayMode is DisplayMode.Normal or DisplayMode.Mini) + else { - var startupPos = GetSavedPosition(settings, settings.DisplayMode); - if (startupPos != null) - { - _mainWindow.Left = startupPos.Left; - _mainWindow.Top = startupPos.Top; - } + ShowMainWindow(settings); } - _mainWindow.Show(); - _trayIconService = new TrayIconService(_mainVm, ActivateMainWindow); + _trayIconService = new TrayIconService(_mainVm!, ActivateMainWindow); + CheckForUpdatesOnStartup(settings); } // Run restore after first render because clamping depends on measured window size. @@ -144,14 +103,14 @@ private static void ApplyStartupPlacement(MainWindow mainWindow, SalarySettings } private static WindowPosition? GetSavedPosition(SalarySettings settings, DisplayMode mode) => - mode switch - { - DisplayMode.Normal => settings.NormalPosition, - DisplayMode.Mini => settings.MiniPosition, - DisplayMode.None => null, - DisplayMode.Flex => null, - _ => null - }; + mode switch + { + DisplayMode.Normal => settings.NormalPosition, + DisplayMode.Mini => settings.MiniPosition, + DisplayMode.None => null, + DisplayMode.Flex => null, + _ => null + }; /// /// Restore Flex by preferred monitor name, falling back to nearest available monitor. @@ -201,6 +160,51 @@ private void ActivateMainWindow() _mainWindow.PlayAttentionAnimation(); } + /// + /// Fires a best-effort, fire-and-forget update check, throttled to once per 24 hours via + /// . + /// + private void CheckForUpdatesOnStartup(SalarySettings settings) + { + if (settings.LastUpdateCheckUtc is { } last && DateTimeOffset.UtcNow - last < TimeSpan.FromHours(24)) + { + return; + } + + _ = Task.Run(async () => + { + var info = await new UpdateCheckService().GetLatestReleaseAsync(); + _settingsService!.Save(_settingsService.Load() with + { + LastUpdateCheckUtc = DateTimeOffset.UtcNow + }); + if (info != null) + { + Dispatcher.Invoke(() => _mainVm!.NotifyUpdateAvailable(info.Version)); + } + }); + } + + private void CreateMainViewModelAndWindow() + { + _mainVm = new MainViewModel(_settingsService!); + _mainVm.HotkeySettingsChanged += OnHotkeySettingsChanged; + + _mainWindow = new MainWindow { DataContext = _mainVm }; + _mainWindow.SourceInitialized += OnMainWindowSourceInitialized; + _mainWindow.ContentRendered += OnMainWindowContentRendered; + } + + private SalarySettings LoadStartupSettings() + { + _settingsService = new SettingsService(); + var settings = _settingsService.Load(); + _startupSettings = settings; + LocalizationService.Apply(settings.Language); + ThemeService.Apply(settings.Theme); + return settings; + } + private void OnHotkeySettingsChanged() { var s = _settingsService!.Load(); @@ -231,6 +235,23 @@ private void OnMainWindowContentRendered(object? sender, EventArgs e) _mainWindow.IsRestoringStartupPosition = false; } + private void OnMainWindowSourceInitialized(object? sender, EventArgs e) + { + var s = _settingsService!.Load(); + _hotkeyService = new HotkeyService(); + var registered = _hotkeyService.Register(_mainWindow!, s.HotkeyModifiers, s.HotkeyVirtualKey); + if (!registered) + { + var key = HotkeyService.Format(s.HotkeyModifiers, s.HotkeyVirtualKey); + MessageBox.Show( + string.Format((string)FindResource("Error.HotkeyConflict")!, key), + "PayBeat", + MessageBoxButton.OK, + MessageBoxImage.Warning); + } + _hotkeyService.Triggered += ToggleWindowVisibility; + } + /// /// Proactively saves the window position when Windows is shutting down or the user is logging off. /// WPF does not guarantee that / run in response to @@ -267,6 +288,29 @@ private void SaveWindowPosition(WindowPosition pos) _settingsService.Save(updated); } + private void ShowMainWindow(SalarySettings settings) + { + _mainWindow!.IsRestoringStartupPosition = settings.DisplayMode is DisplayMode.Normal or DisplayMode.Mini or DisplayMode.Flex; + if (settings.DisplayMode is DisplayMode.Normal or DisplayMode.Mini) + { + var startupPos = GetSavedPosition(settings, settings.DisplayMode); + if (startupPos != null) + { + _mainWindow.Left = startupPos.Left; + _mainWindow.Top = startupPos.Top; + } + } + + _mainWindow.Show(); + } + + // Only create the HWND (for hotkey registration) without showing the window. + private void StartHiddenInTray() + { + new WindowInteropHelper(_mainWindow!).EnsureHandle(); + _mainWindow!.ContentRendered -= OnMainWindowContentRendered; + } + private void ToggleWindowVisibility() { if (_windowsHidden) @@ -295,4 +339,21 @@ private void ToggleWindowVisibility() _trayIconService?.SetHidden(true); } } + + private bool TryAcquireSingleInstance() + { + _singleInstanceMutex = new Mutex(initiallyOwned: true, "PayBeat_SingleInstance", out var createdNew); + if (createdNew) + { + return true; + } + + MessageBox.Show( + (string)FindResource("Error.AlreadyRunning")!, + "PayBeat", + MessageBoxButton.OK, + MessageBoxImage.Information); + Shutdown(); + return false; + } } \ No newline at end of file