chore: initial commit of TaskTracker project

Existing ASP.NET API with vanilla JS SPA, WindowWatcher, Chrome extension, and MCP server.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 22:08:45 -05:00
commit e12f78c479
66 changed files with 5170 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
using System.Runtime.InteropServices;
using System.Text;
namespace WindowWatcher;
internal static partial class NativeMethods
{
[LibraryImport("user32.dll")]
internal static partial IntPtr GetForegroundWindow();
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
internal static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
[LibraryImport("user32.dll", SetLastError = true)]
internal static partial uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
}

32
WindowWatcher/Program.cs Normal file
View File

@@ -0,0 +1,32 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using WindowWatcher;
var hostCts = new CancellationTokenSource();
var builder = Host.CreateApplicationBuilder(args);
builder.Services.Configure<WindowWatcherOptions>(
builder.Configuration.GetSection(WindowWatcherOptions.SectionName));
builder.Services.AddHttpClient("TaskTrackerApi", (sp, client) =>
{
var config = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<WindowWatcherOptions>>().Value;
client.BaseAddress = new Uri(config.ApiBaseUrl);
});
builder.Services.AddHostedService<Worker>();
var host = builder.Build();
// Run the host in a background thread
var hostTask = Task.Run(() => host.RunAsync(hostCts.Token));
// Run WinForms tray icon on the main thread
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new TrayApplicationContext(hostCts));
// Wait for host to finish
await hostTask;

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"WindowWatcher": {
"commandName": "Project",
"dotnetRunMessages": true,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,58 @@
namespace WindowWatcher;
public class TrayApplicationContext : ApplicationContext
{
private readonly NotifyIcon _trayIcon;
private readonly CancellationTokenSource _hostCts;
private bool _trackingPaused;
public TrayApplicationContext(CancellationTokenSource hostCts)
{
_hostCts = hostCts;
_trayIcon = new NotifyIcon
{
Icon = SystemIcons.Application,
Text = "Work Context Tracker",
Visible = true,
ContextMenuStrip = CreateMenu()
};
}
private ContextMenuStrip CreateMenu()
{
var menu = new ContextMenuStrip();
var pauseItem = new ToolStripMenuItem("Pause Tracking");
pauseItem.Click += (_, _) =>
{
_trackingPaused = !_trackingPaused;
pauseItem.Text = _trackingPaused ? "Resume Tracking" : "Pause Tracking";
_trayIcon.Text = _trackingPaused ? "Work Context Tracker (Paused)" : "Work Context Tracker";
};
var exitItem = new ToolStripMenuItem("Exit");
exitItem.Click += (_, _) =>
{
_trayIcon.Visible = false;
_hostCts.Cancel();
Application.Exit();
};
menu.Items.Add(pauseItem);
menu.Items.Add(new ToolStripSeparator());
menu.Items.Add(exitItem);
return menu;
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_trayIcon.Visible = false;
_trayIcon.Dispose();
}
base.Dispose(disposing);
}
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWindowsForms>true</UseWindowsForms>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.3" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,10 @@
namespace WindowWatcher;
public class WindowWatcherOptions
{
public const string SectionName = "WindowWatcher";
public string ApiBaseUrl { get; set; } = "http://localhost:5200";
public int PollIntervalMs { get; set; } = 2000;
public int DebounceMs { get; set; } = 3000;
}

111
WindowWatcher/Worker.cs Normal file
View File

@@ -0,0 +1,111 @@
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;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var config = options.Value;
logger.LogInformation("WindowWatcher started. Polling every {Interval}ms, debounce {Debounce}ms",
config.PollIntervalMs, config.DebounceMs);
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;
}
}
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");
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

View File

@@ -0,0 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"WindowWatcher": {
"ApiBaseUrl": "http://localhost:5200",
"PollIntervalMs": 2000,
"DebounceMs": 3000
}
}