- Use 32-bit Environment.TickCount with unchecked uint arithmetic so GetIdleTime stays correct after the ~49.7 day uint wrap boundary - Only clear _pausedTaskId after successful resume, not before the API call - Add retry path: if resume failed, retry on next poll cycle while user is still active Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
216 lines
7.5 KiB
C#
216 lines
7.5 KiB
C#
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<WindowWatcherOptions> options,
|
|
ILogger<Worker> 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<ApiResponse<ActiveTaskDto>>(
|
|
"/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<ApiResponse<ActiveTaskDto>>(
|
|
$"/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<T>(bool Success, T? Data, string? Error);
|