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
177 changes: 119 additions & 58 deletions src/PayBeat.App/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
};

/// <summary>
/// Restore Flex by preferred monitor name, falling back to nearest available monitor.
Expand Down Expand Up @@ -201,6 +160,51 @@ private void ActivateMainWindow()
_mainWindow.PlayAttentionAnimation();
}

/// <summary>
/// Fires a best-effort, fire-and-forget update check, throttled to once per 24 hours via
/// <see cref="SalarySettings.LastUpdateCheckUtc"/>.
/// </summary>
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();
Expand Down Expand Up @@ -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;
}

/// <summary>
/// Proactively saves the window position when Windows is shutting down or the user is logging off.
/// WPF does not guarantee that <see cref="Window.Closing"/>/<see cref="OnExit"/> run in response to
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;
}
}
29 changes: 29 additions & 0 deletions src/PayBeat.App/Helpers/AppVersion.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System.Reflection;

namespace PayBeat.App.Helpers;

/// <summary>
/// Resolves the running app's version from assembly metadata, stripping MinVer's
/// build-metadata suffix (anything after '+').
/// </summary>
public static class AppVersion
{
/// <summary>The current version string, e.g. <c>1.2.0</c>, or empty if unavailable.</summary>
public static string Current { get; } = Resolve();

private static string Resolve()
{
var version = Assembly.GetExecutingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion
?? Assembly.GetExecutingAssembly().GetName().Version?.ToString(3);

if (version is null)
{
return string.Empty;
}

var plus = version.IndexOf('+');
return plus >= 0 ? version[..plus] : version;
}
}
8 changes: 8 additions & 0 deletions src/PayBeat.App/Models/SalarySettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,12 @@ public WindowPosition? FlexPosition
/// supported explicit values are <c>"light"</c> and <c>"dark"</c>.
/// </summary>
public string Theme { get; init; } = "auto";

/// <summary>
/// Last time an automatic startup update check ran, used to throttle checks to once per day.
/// </summary>
public DateTimeOffset? LastUpdateCheckUtc
{
get; init;
}
}
5 changes: 5 additions & 0 deletions src/PayBeat.App/Resources/Strings.en.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@
<sys:String x:Key="About.License">License</sys:String>
<sys:String x:Key="About.GitHub">GitHub</sys:String>
<sys:String x:Key="About.Close">Close</sys:String>
<sys:String x:Key="About.Update.Checking">Checking for updates...</sys:String>
<sys:String x:Key="About.Update.UpToDate">You're up to date</sys:String>
<sys:String x:Key="About.Update.Available">Update available: v{0}</sys:String>

<!-- Validation errors -->
<sys:String x:Key="Error.SalaryPositive">Daily salary must be greater than 0</sys:String>
Expand All @@ -97,5 +100,7 @@
<sys:String x:Key="Notification.MilestoneBody">You've earned {0} today!</sys:String>
<sys:String x:Key="Notification.EndOfDayTitle">Almost Done</sys:String>
<sys:String x:Key="Notification.EndOfDayBody">Work ends in {0} minutes.</sys:String>
<sys:String x:Key="Notification.UpdateAvailableTitle">Update Available</sys:String>
<sys:String x:Key="Notification.UpdateAvailableBody">PayBeat v{0} is available. Open About to download it.</sys:String>

</ResourceDictionary>
5 changes: 5 additions & 0 deletions src/PayBeat.App/Resources/Strings.zh-CN.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@
<sys:String x:Key="About.License">许可证</sys:String>
<sys:String x:Key="About.GitHub">GitHub</sys:String>
<sys:String x:Key="About.Close">关闭</sys:String>
<sys:String x:Key="About.Update.Checking">正在检查更新...</sys:String>
<sys:String x:Key="About.Update.UpToDate">已是最新版本</sys:String>
<sys:String x:Key="About.Update.Available">发现新版本:v{0}</sys:String>

<!-- Validation errors -->
<sys:String x:Key="Error.SalaryPositive">日薪须大于 0</sys:String>
Expand All @@ -97,5 +100,7 @@
<sys:String x:Key="Notification.MilestoneBody">今天已赚到 {0}!</sys:String>
<sys:String x:Key="Notification.EndOfDayTitle">快下班了</sys:String>
<sys:String x:Key="Notification.EndOfDayBody">还有 {0} 分钟下班。</sys:String>
<sys:String x:Key="Notification.UpdateAvailableTitle">发现新版本</sys:String>
<sys:String x:Key="Notification.UpdateAvailableBody">PayBeat v{0} 已发布,打开"关于"窗口下载。</sys:String>

</ResourceDictionary>
68 changes: 68 additions & 0 deletions src/PayBeat.App/Services/UpdateCheckService.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Latest available release, as reported by the GitHub Releases API.</summary>
public sealed record UpdateInfo(string Version, string HtmlUrl);

/// <summary>
/// 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.
/// </summary>
public sealed class UpdateCheckService
{
private const string LatestReleaseUrl = "https://api.github.com/repos/coldhighsun/PayBeat/releases/latest";

private static readonly HttpClient HttpClient = CreateHttpClient();

/// <summary>
/// Returns the latest stable release if it is newer than <see cref="AppVersion.Current"/>,
/// or <c>null</c> if there is no newer release or the check failed.
/// </summary>
public async Task<UpdateInfo?> 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;
}
}
8 changes: 7 additions & 1 deletion src/PayBeat.App/ViewModels/MainViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -238,11 +238,17 @@ private void OpenAbout()
}
}

var win = new AboutWindow();
var win = new AboutWindow(_settingsService);
ApplyTopmostIfNeeded(win);
win.Show();
}

/// <summary>Raises <see cref="NotificationRequested"/> with a localized "update available" message.</summary>
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)
Expand Down
Loading
Loading