added usage status record in awake

Signed-off-by: Shawn Yuan <shuaiyuan@microsoft.com>
This commit is contained in:
Shawn Yuan
2025-09-17 10:58:30 +08:00
parent 1aeed1699e
commit be334fa0df
8 changed files with 694 additions and 2 deletions

View File

@@ -2,7 +2,13 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.PowerToys.Settings.UI.Library;
using ModelContextProtocol.Server;
namespace PowerToys.MCPServer.Tools
@@ -13,5 +19,103 @@ namespace PowerToys.MCPServer.Tools
[McpServerTool]
[Description("Echoes the message back to the client.")]
public static string SetTimeTest(string message) => $"Hello {message}";
private sealed class AppUsageRecord
{
[JsonPropertyName("process")]
public string ProcessName { get; set; } = string.Empty;
[JsonPropertyName("totalSeconds")]
public double TotalSeconds { get; set; }
[JsonPropertyName("lastUpdatedUtc")]
public DateTime LastUpdatedUtc { get; set; }
[JsonPropertyName("firstSeenUtc")]
public DateTime FirstSeenUtc { get; set; }
}
[McpServerTool]
[Description("Get top N foreground app usage entries recorded by Awake (reads usage.json). Parameters: top (default 10), days (default 7). Returns JSON.")]
public static string GetAwakeUsageSummary(int top = 10, int days = 7)
{
try
{
SettingsUtils utils = new();
string settingsPath = utils.GetSettingsFilePath("Awake");
string directory = Path.GetDirectoryName(settingsPath)!;
string usageFile = Path.Combine(directory, "usage.json");
if (!File.Exists(usageFile))
{
return JsonSerializer.Serialize(new { error = "usage.json not found", path = usageFile });
}
string json = File.ReadAllText(usageFile);
using JsonDocument doc = JsonDocument.Parse(json);
DateTime cutoff = DateTime.UtcNow.AddDays(-Math.Max(1, days));
var result = doc.RootElement
.EnumerateArray()
.Select(e => new
{
process = e.GetPropertyOrDefault("process", string.Empty),
totalSeconds = Math.Round(e.GetPropertyOrDefault("totalSeconds", 0.0), 1),
lastUpdatedUtc = e.GetPropertyOrDefaultDateTime("lastUpdatedUtc"),
firstSeenUtc = e.GetPropertyOrDefaultDateTime("firstSeenUtc"),
})
.Where(r => r.lastUpdatedUtc >= cutoff)
.OrderByDescending(r => r.totalSeconds)
.Take(top)
.Select(r => new
{
r.process,
r.totalSeconds,
totalHours = Math.Round(r.totalSeconds / 3600.0, 2),
r.firstSeenUtc,
r.lastUpdatedUtc,
});
return JsonSerializer.Serialize(result);
}
catch (Exception ex)
{
return JsonSerializer.Serialize(new { error = ex.Message });
}
}
private static string GetPropertyOrDefault(this JsonElement element, string name, string defaultValue)
{
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String)
{
return value.GetString() ?? defaultValue;
}
return defaultValue;
}
private static double GetPropertyOrDefault(this JsonElement element, string name, double defaultValue)
{
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out double d))
{
return d;
}
return defaultValue;
}
private static DateTime GetPropertyOrDefaultDateTime(this JsonElement element, string name)
{
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out JsonElement value))
{
if (value.ValueKind == JsonValueKind.String && value.TryGetDateTime(out DateTime dt))
{
return dt;
}
}
return DateTime.MinValue;
}
}
}

View File

@@ -17,6 +17,9 @@ using System.Text.Json;
using System.Threading;
using Awake.Core.Models;
using Awake.Core.Native;
// New usage tracking namespace
using Awake.Core.Usage;
using Awake.Properties;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Library;
@@ -56,6 +59,9 @@ namespace Awake.Core
private static readonly BlockingCollection<ExecutionState> _stateQueue;
private static CancellationTokenSource _tokenSource;
// Foreground usage tracker instance (lifecycle managed by Program)
internal static ForegroundUsageTracker? UsageTracker { get; set; }
static Manager()
{
_tokenSource = new CancellationTokenSource();
@@ -412,6 +418,16 @@ namespace Awake.Core
Bridge.DestroyWindow(TrayHelper.WindowHandle);
}
// Dispose usage tracker (flushes data)
try
{
UsageTracker?.Dispose();
}
catch (Exception ex)
{
Logger.LogWarning($"Failed disposing UsageTracker: {ex.Message}");
}
Bridge.PostQuitMessage(exitCode);
Environment.Exit(exitCode);
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Runtime.InteropServices;
namespace Awake.Core.Native
{
internal static class IdleTime
{
// Keep original native field names but suppress StyleCop (interop requires exact names).
[StructLayout(LayoutKind.Sequential)]
private struct LASTINPUTINFO
{
#pragma warning disable SA1307 // Interop field naming
public uint cbSize;
public uint dwTime;
#pragma warning restore SA1307
}
[DllImport("user32.dll")]
private static extern bool GetLastInputInfo(ref LASTINPUTINFO plii);
public static TimeSpan GetIdleTime()
{
LASTINPUTINFO info = new()
{
cbSize = (uint)Marshal.SizeOf<LASTINPUTINFO>(),
};
if (!GetLastInputInfo(ref info))
{
return TimeSpan.Zero;
}
// Calculate elapsed milliseconds since last input considering Environment.TickCount wrap.
uint lastInputTicks = info.dwTime;
uint nowTicks = (uint)Environment.TickCount;
uint delta = nowTicks >= lastInputTicks ? nowTicks - lastInputTicks : (uint.MaxValue - lastInputTicks) + nowTicks + 1;
return TimeSpan.FromMilliseconds(delta);
}
}
}

View File

@@ -0,0 +1,459 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Timers;
using Awake.Core.Native;
using Awake.Core.Usage.Models;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Library;
namespace Awake.Core.Usage
{
/// <summary>
/// Tracks foreground application usage time (simple active window focus durations) with idle suppression.
/// </summary>
internal sealed class ForegroundUsageTracker : IDisposable
{
private const uint EventSystemForeground = 0x0003;
private const uint WinEventOutOfContext = 0x0000;
private static readonly JsonSerializerOptions SerializerOptions = new() { WriteIndented = true };
private delegate void WinEventDelegate(
IntPtr hWinEventHook,
uint eventType,
IntPtr hwnd,
int idObject,
int idChild,
uint dwEventThread,
uint dwmsEventTime);
[DllImport("user32.dll")]
private static extern IntPtr SetWinEventHook(
uint eventMin,
uint eventMax,
IntPtr hmodWinEventProc,
WinEventDelegate lpfnWinEventProc,
uint idProcess,
uint idThread,
uint dwFlags);
[DllImport("user32.dll")]
private static extern bool UnhookWinEvent(IntPtr hWinEventHook);
[DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
private readonly object _lock = new();
private readonly Dictionary<string, AppUsageRecord> _usage = new(StringComparer.OrdinalIgnoreCase);
private readonly string _storePath;
private readonly Timer _flushTimer;
private readonly TimeSpan _idleThreshold = TimeSpan.FromSeconds(60);
private readonly Timer _pollTimer; // Fallback polling when WinEvent hook not firing
private string? _activeProcess;
private DateTime _activeSince;
private bool _disposed;
private IntPtr _hook = IntPtr.Zero;
private WinEventDelegate? _hookDelegate;
private int _retentionDays;
private IntPtr _lastHwnd = IntPtr.Zero;
private const double CommitThresholdSeconds = 0.25;
internal bool Enabled { get; private set; }
public ForegroundUsageTracker(string storePath, int retentionDays)
{
_storePath = storePath;
_retentionDays = retentionDays;
Directory.CreateDirectory(Path.GetDirectoryName(storePath)!);
LoadState();
_flushTimer = new Timer(30000)
{
AutoReset = true,
};
_flushTimer.Elapsed += (_, _) => FlushInternal();
_pollTimer = new Timer(1000)
{
AutoReset = true,
};
_pollTimer.Elapsed += (_, _) => PollForeground();
}
public void Configure(bool enabled, int retentionDays)
{
_retentionDays = Math.Max(1, retentionDays);
if (enabled == Enabled)
{
return;
}
Enabled = enabled;
if (Enabled)
{
_activeSince = DateTime.UtcNow;
_hookDelegate = WinEventCallback;
_hook = SetWinEventHook(EventSystemForeground, EventSystemForeground, IntPtr.Zero, _hookDelegate, 0, 0, WinEventOutOfContext);
Logger.LogInfo(_hook != IntPtr.Zero ? "[AwakeUsage] WinEvent hook installed." : "[AwakeUsage] WinEvent hook failed (fallback polling only).");
CaptureInitialForeground();
_flushTimer.Start();
_pollTimer.Start();
Logger.LogInfo("[AwakeUsage] Started foreground usage tracking.");
}
else
{
_flushTimer.Stop();
_pollTimer.Stop();
if (_hook != IntPtr.Zero)
{
UnhookWinEvent(_hook);
_hook = IntPtr.Zero;
}
CommitActiveSpan();
FlushInternal();
Logger.LogInfo("[AwakeUsage] Stopped foreground usage tracking.");
}
}
private void CaptureInitialForeground()
{
try
{
IntPtr hwnd = GetForegroundWindow();
if (hwnd == IntPtr.Zero)
{
Logger.LogDebug("[AwakeUsage] Initial foreground hwnd == 0.");
return;
}
if (TryResolveProcess(hwnd, out string? procName))
{
_activeProcess = procName;
_activeSince = DateTime.UtcNow;
_lastHwnd = hwnd;
EnsurePlaceholder(_activeProcess);
Logger.LogInfo("[AwakeUsage] Initial foreground captured: " + _activeProcess);
}
}
catch (Exception ex)
{
Logger.LogWarning("[AwakeUsage] Failed to capture initial foreground: " + ex.Message);
}
}
private static string SafeResolveProcessName(Process proc)
{
try
{
return Path.GetFileName(proc.MainModule?.FileName) ?? proc.ProcessName;
}
catch
{
return proc.ProcessName;
}
}
private bool TryResolveProcess(IntPtr hwnd, out string? processName)
{
processName = null;
if (hwnd == IntPtr.Zero)
{
return false;
}
try
{
uint pid;
uint tid = GetWindowThreadProcessId(hwnd, out pid);
if (tid == 0 || pid == 0)
{
return false;
}
using Process p = Process.GetProcessById((int)pid);
processName = SafeResolveProcessName(p);
return !string.IsNullOrWhiteSpace(processName);
}
catch (Exception ex)
{
Logger.LogDebug("[AwakeUsage] TryResolveProcess failed: " + ex.Message);
return false;
}
}
private void EnsurePlaceholder(string? process)
{
if (string.IsNullOrWhiteSpace(process))
{
return;
}
lock (_lock)
{
if (!_usage.ContainsKey(process))
{
_usage[process] = new AppUsageRecord
{
ProcessName = process,
FirstSeenUtc = DateTime.UtcNow,
LastUpdatedUtc = DateTime.UtcNow,
TotalSeconds = 0,
};
}
}
}
private void WinEventCallback(IntPtr hWinEventHook, uint evt, IntPtr hwnd, int idObj, int idChild, uint thread, uint time)
{
if (_disposed || !Enabled || evt != EventSystemForeground)
{
return;
}
Logger.LogDebug($"[AwakeUsage] WinEvent foreground change: hwnd=0x{hwnd.ToInt64():X}");
HandleForegroundChange(hwnd, source: "hook");
}
private void PollForeground()
{
if (_disposed || !Enabled)
{
return;
}
IntPtr hwnd = GetForegroundWindow();
if (hwnd == IntPtr.Zero || hwnd == _lastHwnd)
{
return;
}
Logger.LogDebug($"[AwakeUsage] Poll detected change: hwnd=0x{hwnd.ToInt64():X}");
HandleForegroundChange(hwnd, source: "poll");
}
private void HandleForegroundChange(IntPtr hwnd, string source)
{
try
{
CommitActiveSpan();
if (!TryResolveProcess(hwnd, out string? procName))
{
_activeProcess = null;
return;
}
_activeProcess = procName;
_activeSince = DateTime.UtcNow;
_lastHwnd = hwnd;
EnsurePlaceholder(_activeProcess);
Logger.LogDebug($"[AwakeUsage] Active process set ({source}): {_activeProcess}");
}
catch (Exception ex)
{
Logger.LogWarning("[AwakeUsage] HandleForegroundChange failed: " + ex.Message);
}
}
private void CommitActiveSpan()
{
if (string.IsNullOrEmpty(_activeProcess))
{
return;
}
if (IdleTime.GetIdleTime() > _idleThreshold)
{
_activeProcess = null;
return;
}
double seconds = (DateTime.UtcNow - _activeSince).TotalSeconds;
if (seconds < CommitThresholdSeconds)
{
return;
}
lock (_lock)
{
if (!_usage.TryGetValue(_activeProcess!, out AppUsageRecord? rec))
{
rec = new AppUsageRecord
{
ProcessName = _activeProcess!,
FirstSeenUtc = DateTime.UtcNow,
LastUpdatedUtc = DateTime.UtcNow,
TotalSeconds = 0,
};
_usage[_activeProcess!] = rec;
}
rec.TotalSeconds += seconds;
rec.LastUpdatedUtc = DateTime.UtcNow;
}
_activeSince = DateTime.UtcNow;
}
private void LoadState()
{
try
{
if (!File.Exists(_storePath))
{
return;
}
string json = File.ReadAllText(_storePath);
List<AppUsageRecord>? list = JsonSerializer.Deserialize<List<AppUsageRecord>>(json);
if (list == null)
{
return;
}
DateTime cutoff = DateTime.UtcNow.AddDays(-_retentionDays);
foreach (AppUsageRecord rec in list.Where(r => r.LastUpdatedUtc >= cutoff))
{
_usage[rec.ProcessName] = rec;
}
}
catch (Exception ex)
{
Logger.LogWarning("[AwakeUsage] Failed to load usage store: " + ex.Message);
}
}
private double GetLiveActiveSeconds()
{
if (string.IsNullOrEmpty(_activeProcess))
{
return 0;
}
if (IdleTime.GetIdleTime() > _idleThreshold)
{
return 0;
}
return Math.Max(0, (DateTime.UtcNow - _activeSince).TotalSeconds);
}
private void FlushInternal()
{
try
{
CommitActiveSpan();
List<AppUsageRecord> snapshot;
double liveSeconds = GetLiveActiveSeconds();
string? liveProcess = _activeProcess;
lock (_lock)
{
DateTime cutoff = DateTime.UtcNow.AddDays(-_retentionDays);
foreach (string key in _usage.Values.Where(v => v.LastUpdatedUtc < cutoff).Select(v => v.ProcessName).ToList())
{
_usage.Remove(key);
}
snapshot = _usage.Values
.Select(r => new AppUsageRecord
{
ProcessName = r.ProcessName,
TotalSeconds = r.ProcessName.Equals(liveProcess, StringComparison.OrdinalIgnoreCase) ? r.TotalSeconds + liveSeconds : r.TotalSeconds,
FirstSeenUtc = r.FirstSeenUtc,
LastUpdatedUtc = r.LastUpdatedUtc,
})
.OrderByDescending(r => r.TotalSeconds)
.ToList();
if (liveProcess != null && !_usage.ContainsKey(liveProcess) && liveSeconds > 0)
{
snapshot.Add(new AppUsageRecord
{
ProcessName = liveProcess,
TotalSeconds = liveSeconds,
FirstSeenUtc = DateTime.UtcNow,
LastUpdatedUtc = DateTime.UtcNow,
});
snapshot = snapshot.OrderByDescending(r => r.TotalSeconds).ToList();
}
}
string json = JsonSerializer.Serialize(snapshot, SerializerOptions);
File.WriteAllText(_storePath, json);
}
catch (Exception ex)
{
Logger.LogWarning("[AwakeUsage] Flush failed: " + ex.Message);
}
}
public IReadOnlyList<AppUsageRecord> GetSummary(int top, int days)
{
CommitActiveSpan();
double liveSeconds = GetLiveActiveSeconds();
string? liveProcess = _activeProcess;
lock (_lock)
{
DateTime cutoff = DateTime.UtcNow.AddDays(-Math.Max(1, days));
List<AppUsageRecord> list = _usage.Values
.Where(r => r.LastUpdatedUtc >= cutoff)
.Select(r => new AppUsageRecord
{
ProcessName = r.ProcessName,
TotalSeconds = r.ProcessName.Equals(liveProcess, StringComparison.OrdinalIgnoreCase) ? r.TotalSeconds + liveSeconds : r.TotalSeconds,
FirstSeenUtc = r.FirstSeenUtc,
LastUpdatedUtc = r.LastUpdatedUtc,
})
.ToList();
if (liveProcess != null && list.All(r => !r.ProcessName.Equals(liveProcess, StringComparison.OrdinalIgnoreCase)) && liveSeconds > 0)
{
list.Add(new AppUsageRecord
{
ProcessName = liveProcess,
TotalSeconds = liveSeconds,
FirstSeenUtc = DateTime.UtcNow,
LastUpdatedUtc = DateTime.UtcNow,
});
}
return list
.OrderByDescending(r => r.TotalSeconds)
.Take(top)
.ToList();
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
Configure(false, _retentionDays);
_flushTimer.Dispose();
_pollTimer.Dispose();
}
}
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Text.Json.Serialization;
namespace Awake.Core.Usage.Models
{
internal sealed class AppUsageRecord
{
[JsonPropertyName("process")]
public string ProcessName { get; set; } = string.Empty;
[JsonPropertyName("totalSeconds")]
public double TotalSeconds { get; set; }
[JsonPropertyName("lastUpdatedUtc")]
public DateTime LastUpdatedUtc { get; set; }
[JsonPropertyName("firstSeenUtc")]
public DateTime FirstSeenUtc { get; set; }
}
}

View File

@@ -18,6 +18,9 @@ using System.Threading.Tasks;
using Awake.Core;
using Awake.Core.Models;
using Awake.Core.Native;
// Usage tracking
using Awake.Core.Usage;
using Awake.Properties;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Library;
@@ -342,6 +345,33 @@ namespace Awake
}
}
}
// Initialize usage tracking
InitializeUsageTracking();
}
private static void InitializeUsageTracking()
{
try
{
string settingsPath = _settingsUtils!.GetSettingsFilePath(Core.Constants.AppName);
string directory = Path.GetDirectoryName(settingsPath)!;
string usageFile = Path.Combine(directory, "usage.json");
AwakeSettings settings = _settingsUtils.GetSettings<AwakeSettings>(Core.Constants.AppName) ?? new AwakeSettings();
if (Manager.UsageTracker == null)
{
Manager.UsageTracker = new ForegroundUsageTracker(usageFile, settings.Properties.UsageRetentionDays);
}
Manager.UsageTracker.Configure(settings.Properties.TrackUsageEnabled, settings.Properties.UsageRetentionDays);
Logger.LogInfo($"Usage tracking configured (enabled={settings.Properties.TrackUsageEnabled}, retentionDays={settings.Properties.UsageRetentionDays}).");
}
catch (Exception ex)
{
Logger.LogWarning($"Failed to initialize usage tracking: {ex.Message}");
}
}
private static void AllocateLocalConsole()
@@ -361,6 +391,7 @@ namespace Awake
SetupFileSystemWatcher(settingsPath);
InitializeSettings();
ProcessSettings();
InitializeUsageTracking(); // after initial settings load
}
catch (Exception ex)
{
@@ -406,6 +437,7 @@ namespace Awake
{
Logger.LogInfo("Detected a settings file change. Updating configuration...");
ProcessSettings();
InitializeUsageTracking(); // re-evaluate usage tracking on config change
}
catch (Exception e)
{

View File

@@ -20,6 +20,11 @@ namespace Microsoft.PowerToys.Settings.UI.Library
IntervalMinutes = 1;
ExpirationDateTime = DateTimeOffset.Now;
CustomTrayTimes = [];
// New usage tracking defaults (opt-in, disabled by default)
// Need to add this to settings, set it to true for testing
TrackUsageEnabled = true;
UsageRetentionDays = 14; // two weeks default retention
}
[JsonPropertyName("keepDisplayOn")]
@@ -40,5 +45,13 @@ namespace Microsoft.PowerToys.Settings.UI.Library
[JsonPropertyName("customTrayTimes")]
[CmdConfigureIgnore]
public Dictionary<string, uint> CustomTrayTimes { get; set; }
// New opt-in usage tracking flag
[JsonPropertyName("trackUsageEnabled")]
public bool TrackUsageEnabled { get; set; }
// Retention window for usage data (days)
[JsonPropertyName("usageRetentionDays")]
public int UsageRetentionDays { get; set; }
}
}

View File

@@ -38,9 +38,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library
KeepDisplayOn = Properties.KeepDisplayOn,
IntervalMinutes = Properties.IntervalMinutes,
IntervalHours = Properties.IntervalHours,
// Fix old buggy default value that might be saved in Settings. Some components don't deal well with negative time zones and minimum time offsets.
ExpirationDateTime = Properties.ExpirationDateTime.Year < 2 ? DateTimeOffset.Now : Properties.ExpirationDateTime,
TrackUsageEnabled = Properties.TrackUsageEnabled,
UsageRetentionDays = Properties.UsageRetentionDays,
},
};
}