| | | 1 | | using System; |
| | | 2 | | using System.Threading; |
| | | 3 | | using System.Threading.Tasks; |
| | | 4 | | using SwitchBlade.Contracts; |
| | | 5 | | using SwitchBlade.Core; |
| | | 6 | | |
| | | 7 | | namespace SwitchBlade.Services |
| | | 8 | | { |
| | | 9 | | /// <summary> |
| | | 10 | | /// Service that polls all window providers in the background at a configurable interval. |
| | | 11 | | /// Uses Modern .NET 6+ PeriodicTimer for efficient async polling. |
| | | 12 | | /// </summary> |
| | | 13 | | public class BackgroundPollingService : IDisposable |
| | | 14 | | { |
| | | 15 | | private readonly ISettingsService _settingsService; |
| | | 16 | | private readonly IDispatcherService _dispatcherService; |
| | | 17 | | private readonly IWorkstationService _workstationService; |
| | | 18 | | private readonly Func<Task> _refreshAction; |
| | | 19 | | // Factory for creating timers, essential for unit testing to avoid real-time delays |
| | | 20 | | private readonly Func<TimeSpan, IPeriodicTimer> _periodicTimerFactory; |
| | | 21 | | |
| | | 22 | | private CancellationTokenSource? _cts; |
| | | 23 | | private Task? _pollingTask; |
| | | 24 | | private bool _disposed; |
| | | 25 | | |
| | 9 | 26 | | public BackgroundPollingService( |
| | 9 | 27 | | ISettingsService settingsService, |
| | 9 | 28 | | IDispatcherService dispatcherService, |
| | 9 | 29 | | Func<Task> refreshAction, |
| | 9 | 30 | | IWorkstationService? workstationService = null, |
| | 9 | 31 | | Func<TimeSpan, IPeriodicTimer>? periodicTimerFactory = null) |
| | 9 | 32 | | { |
| | 9 | 33 | | _settingsService = settingsService; |
| | 9 | 34 | | _dispatcherService = dispatcherService; |
| | 9 | 35 | | _refreshAction = refreshAction; |
| | 9 | 36 | | _workstationService = workstationService ?? new WorkstationService(); |
| | 9 | 37 | | _periodicTimerFactory = periodicTimerFactory ?? (interval => new SystemPeriodicTimer(interval)); |
| | | 38 | | |
| | | 39 | | // Subscribe to settings changes to dynamically update timer |
| | 9 | 40 | | _settingsService.SettingsChanged += OnSettingsChanged; |
| | | 41 | | |
| | | 42 | | // Initialize timer based on current settings |
| | 9 | 43 | | StartPolling(); |
| | 9 | 44 | | } |
| | | 45 | | |
| | | 46 | | private void StartPolling() |
| | 11 | 47 | | { |
| | | 48 | | // Cancel previous polling if any |
| | 11 | 49 | | StopPolling(); |
| | | 50 | | |
| | 11 | 51 | | if (!_settingsService.Settings.EnableBackgroundPolling) |
| | 3 | 52 | | { |
| | 3 | 53 | | Logger.Log("BackgroundPollingService: Polling disabled."); |
| | 3 | 54 | | return; |
| | | 55 | | } |
| | | 56 | | |
| | 8 | 57 | | int intervalMs = _settingsService.Settings.BackgroundPollingIntervalSeconds * 1000; |
| | 9 | 58 | | if (intervalMs < 1000) intervalMs = 1000; // Minimum 1 second |
| | | 59 | | |
| | 8 | 60 | | _cts = new CancellationTokenSource(); |
| | 8 | 61 | | _pollingTask = PollingLoop(TimeSpan.FromMilliseconds(intervalMs), _cts.Token); |
| | | 62 | | |
| | 8 | 63 | | Logger.Log($"BackgroundPollingService: Polling enabled with interval {intervalMs}ms."); |
| | 11 | 64 | | } |
| | | 65 | | |
| | | 66 | | private void StopPolling() |
| | 20 | 67 | | { |
| | 20 | 68 | | if (_cts != null) |
| | 8 | 69 | | { |
| | 8 | 70 | | _cts.Cancel(); |
| | 8 | 71 | | _cts.Dispose(); |
| | 8 | 72 | | _cts = null; |
| | 8 | 73 | | } |
| | | 74 | | // We don't await _pollingTask here to avoid blocking properties/events, |
| | | 75 | | // but the loop will exit on cancellation. |
| | 20 | 76 | | } |
| | | 77 | | |
| | | 78 | | private void OnSettingsChanged() |
| | 2 | 79 | | { |
| | | 80 | | // Re-configure timer when settings change |
| | 2 | 81 | | StartPolling(); |
| | 2 | 82 | | } |
| | | 83 | | |
| | | 84 | | private async Task PollingLoop(TimeSpan interval, CancellationToken token) |
| | 8 | 85 | | { |
| | | 86 | | // Modern "PeriodicTimer" pattern using factory for testability |
| | 8 | 87 | | using var timer = _periodicTimerFactory(interval); |
| | | 88 | | try |
| | 8 | 89 | | { |
| | 12 | 90 | | while (await timer.WaitForNextTickAsync(token)) |
| | 4 | 91 | | { |
| | | 92 | | try |
| | 4 | 93 | | { |
| | | 94 | | // Skip refresh when the workstation is locked. |
| | | 95 | | // UIA/COM calls against locked desktops can hang for 10-15s, |
| | | 96 | | // blocking the UI thread and making the app unresponsive on wake. |
| | 4 | 97 | | if (_workstationService.IsWorkstationLocked()) |
| | 1 | 98 | | { |
| | 1 | 99 | | Logger.Log("BackgroundPollingService: Workstation locked, skipping refresh."); |
| | 1 | 100 | | continue; |
| | | 101 | | } |
| | | 102 | | |
| | 3 | 103 | | Logger.Log("BackgroundPollingService: Running background refresh."); |
| | | 104 | | |
| | | 105 | | // Dispatch to UI thread since RefreshWindows updates ObservableCollection |
| | 3 | 106 | | await _dispatcherService.InvokeAsync(async () => |
| | 3 | 107 | | { |
| | 3 | 108 | | await _refreshAction(); |
| | 5 | 109 | | }); |
| | 2 | 110 | | } |
| | 1 | 111 | | catch (Exception ex) |
| | 1 | 112 | | { |
| | | 113 | | // Log but don't crash loop |
| | 1 | 114 | | Logger.LogError("BackgroundPollingService: Error during refresh", ex); |
| | 1 | 115 | | } |
| | 3 | 116 | | } |
| | 7 | 117 | | } |
| | 1 | 118 | | catch (OperationCanceledException) |
| | 1 | 119 | | { |
| | | 120 | | // Expected on stop |
| | 1 | 121 | | } |
| | 8 | 122 | | } |
| | | 123 | | |
| | | 124 | | public void Dispose() |
| | 11 | 125 | | { |
| | 13 | 126 | | if (_disposed) return; |
| | 9 | 127 | | _disposed = true; |
| | | 128 | | |
| | 9 | 129 | | _settingsService.SettingsChanged -= OnSettingsChanged; |
| | 9 | 130 | | StopPolling(); |
| | 11 | 131 | | } |
| | | 132 | | } |
| | | 133 | | } |