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 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)