using System.Diagnostics; using System.Net.Http.Json; using System.Text; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace WindowWatcher; public class Worker( IHttpClientFactory httpClientFactory, IOptions options, ILogger logger) : BackgroundService { private string _lastAppName = string.Empty; private string _lastWindowTitle = string.Empty; private DateTime _lastChangeTime = DateTime.MinValue; private bool _isIdle; private int? _pausedTaskId; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var config = options.Value; logger.LogInformation( "WindowWatcher started. Polling every {Interval}ms, debounce {Debounce}ms, idle timeout {IdleTimeout}ms", config.PollIntervalMs, config.DebounceMs, config.IdleTimeoutMs); while (!stoppingToken.IsCancellationRequested) { try { var hwnd = NativeMethods.GetForegroundWindow(); if (hwnd == IntPtr.Zero) { await Task.Delay(config.PollIntervalMs, stoppingToken); continue; } // Get window title var sb = new StringBuilder(512); NativeMethods.GetWindowText(hwnd, sb, sb.Capacity); var windowTitle = sb.ToString(); // Get process name NativeMethods.GetWindowThreadProcessId(hwnd, out var pid); string appName; try { var process = Process.GetProcessById((int)pid); appName = process.ProcessName; } catch { appName = "Unknown"; } // Check if changed if (appName != _lastAppName || windowTitle != _lastWindowTitle) { var now = DateTime.UtcNow; // Debounce: only report if last change was more than DebounceMs ago if ((now - _lastChangeTime).TotalMilliseconds >= config.DebounceMs && !string.IsNullOrWhiteSpace(windowTitle)) { await ReportContextEvent(appName, windowTitle, stoppingToken); } _lastAppName = appName; _lastWindowTitle = windowTitle; _lastChangeTime = now; } // Idle detection var idleTime = NativeMethods.GetIdleTime(); if (!_isIdle && _pausedTaskId is not null && idleTime.TotalMilliseconds < config.IdleTimeoutMs) { // Retry resume from a previous failed attempt await ResumeIdlePausedTaskAsync(ct: stoppingToken); } else if (!_isIdle && idleTime.TotalMilliseconds >= config.IdleTimeoutMs) { _isIdle = true; logger.LogInformation("User idle for {IdleTime}, pausing active task", idleTime); await PauseActiveTaskAsync(ct: stoppingToken); } else if (_isIdle && idleTime.TotalMilliseconds < config.IdleTimeoutMs) { _isIdle = false; logger.LogInformation("User returned from idle"); await ResumeIdlePausedTaskAsync(ct: stoppingToken); } } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } catch (Exception ex) { logger.LogError(ex, "Error polling foreground window"); } await Task.Delay(config.PollIntervalMs, stoppingToken); } } private async Task ReportContextEvent(string appName, string windowTitle, CancellationToken ct) { try { var client = httpClientFactory.CreateClient("TaskTrackerApi"); var payload = new { source = "WindowWatcher", appName, windowTitle }; var response = await client.PostAsJsonAsync("/api/context", payload, ct); if (response.IsSuccessStatusCode) { logger.LogDebug("Reported: {App} - {Title}", appName, windowTitle); } else { logger.LogWarning("API returned {StatusCode} for context event", response.StatusCode); } } catch (Exception ex) { logger.LogWarning(ex, "Failed to report context event to API"); } } private async Task PauseActiveTaskAsync(CancellationToken ct) { try { var client = httpClientFactory.CreateClient("TaskTrackerApi"); // Get the active task var response = await client.GetFromJsonAsync>( "/api/tasks/active", ct); if (response?.Data is null) { logger.LogDebug("No active task to pause"); return; } _pausedTaskId = response.Data.Id; // Pause it var pauseResponse = await client.PutAsJsonAsync( $"/api/tasks/{_pausedTaskId}/pause", new { note = "Auto-paused: idle timeout" }, ct); if (pauseResponse.IsSuccessStatusCode) logger.LogInformation("Auto-paused task {TaskId}", _pausedTaskId); else logger.LogWarning("Failed to pause task {TaskId}: {Status}", _pausedTaskId, pauseResponse.StatusCode); } catch (Exception ex) { logger.LogWarning(ex, "Failed to pause active task on idle"); } } private async Task ResumeIdlePausedTaskAsync(CancellationToken ct) { if (_pausedTaskId is null) return; var taskId = _pausedTaskId.Value; try { var client = httpClientFactory.CreateClient("TaskTrackerApi"); // Check the task is still paused (user may have manually switched tasks) var response = await client.GetFromJsonAsync>( $"/api/tasks/{taskId}", ct); if (response?.Data is null || response.Data.Status != "Paused") { logger.LogDebug("Task {TaskId} is no longer paused, skipping auto-resume", taskId); _pausedTaskId = null; return; } // Resume it var resumeResponse = await client.PutAsJsonAsync( $"/api/tasks/{taskId}/resume", new { note = "Auto-resumed: user returned" }, ct); if (resumeResponse.IsSuccessStatusCode) { logger.LogInformation("Auto-resumed task {TaskId}", taskId); _pausedTaskId = null; } else { logger.LogWarning("Failed to resume task {TaskId}: {Status}, will retry", taskId, resumeResponse.StatusCode); } } catch (Exception ex) { logger.LogWarning(ex, "Failed to resume task {TaskId} after idle, will retry", taskId); } } } internal record ActiveTaskDto(int Id, string Status); internal record ApiResponse(bool Success, T? Data, string? Error);