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:
16
WindowWatcher/NativeMethods.cs
Normal file
16
WindowWatcher/NativeMethods.cs
Normal 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
32
WindowWatcher/Program.cs
Normal 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;
|
||||
12
WindowWatcher/Properties/launchSettings.json
Normal file
12
WindowWatcher/Properties/launchSettings.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"WindowWatcher": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
58
WindowWatcher/TrayApplicationContext.cs
Normal file
58
WindowWatcher/TrayApplicationContext.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
17
WindowWatcher/WindowWatcher.csproj
Normal file
17
WindowWatcher/WindowWatcher.csproj
Normal 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>
|
||||
10
WindowWatcher/WindowWatcherOptions.cs
Normal file
10
WindowWatcher/WindowWatcherOptions.cs
Normal 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
111
WindowWatcher/Worker.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
8
WindowWatcher/appsettings.Development.json
Normal file
8
WindowWatcher/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
13
WindowWatcher/appsettings.json
Normal file
13
WindowWatcher/appsettings.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
},
|
||||
"WindowWatcher": {
|
||||
"ApiBaseUrl": "http://localhost:5200",
|
||||
"PollIntervalMs": 2000,
|
||||
"DebounceMs": 3000
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user