Skip to content

Commit 952200c

Browse files
feat: add tracking diagnostics and parent alerts
1 parent 197de39 commit 952200c

18 files changed

Lines changed: 742 additions & 25 deletions

NativeMethods.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ internal static class NativeMethods
1414
[DllImport("user32.dll")]
1515
internal static extern bool GetLastInputInfo(ref LASTINPUTINFO plii);
1616

17+
[DllImport("user32.dll", SetLastError = true)]
18+
internal static extern IntPtr OpenInputDesktop(uint dwFlags, bool fInherit, uint dwDesiredAccess);
19+
20+
[DllImport("user32.dll", SetLastError = true)]
21+
internal static extern bool CloseDesktop(IntPtr hDesktop);
22+
1723
[DllImport("user32.dll", SetLastError = true)]
1824
internal static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
1925

@@ -52,6 +58,7 @@ internal static class NativeMethods
5258
internal const uint MOD_SHIFT = 0x0004;
5359
internal const uint MOD_WIN = 0x0008;
5460
internal const int WM_CLOSE = 0x0010;
61+
internal const uint DESKTOP_SWITCHDESKTOP = 0x0100;
5562

5663
[StructLayout(LayoutKind.Sequential)]
5764
internal struct LASTINPUTINFO

Program.cs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ internal static class Program
2626
private static SchedulerService? _scheduler;
2727
private static NotificationService? _notifier;
2828
private static EmailService? _emailService;
29+
private static ParentReportService? _parentReportService;
2930
private static HiddenForm? _hiddenForm;
3031
private static CancellationTokenSource? _cts;
3132

@@ -91,9 +92,7 @@ static void Main(string[] args)
9192

9293
private static AppConfig LoadConfig()
9394
{
94-
var path = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
95-
if (!File.Exists(path))
96-
path = "appsettings.json";
95+
var path = GetConfigPath();
9796

9897
if (File.Exists(path))
9998
{
@@ -105,6 +104,12 @@ private static AppConfig LoadConfig()
105104
return _cachedConfig;
106105
}
107106

107+
private static string GetConfigPath()
108+
{
109+
var path = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
110+
return File.Exists(path) ? path : Path.GetFullPath("appsettings.json");
111+
}
112+
108113
private static void RegisterAutoStart()
109114
{
110115
SetAutoStart(true);
@@ -365,6 +370,12 @@ public static async Task<bool> UpdateDashboardHotKeyAsync(string modifiers, stri
365370
return await _hiddenForm.UpdateHotKeyAsync(mods, vk);
366371
}
367372

373+
public static async Task ReloadParentReportingAsync()
374+
{
375+
if (_parentReportService != null)
376+
await _parentReportService.ReloadAndCheckAsync();
377+
}
378+
368379
private static void StartServices(AppConfig config)
369380
{
370381
_tracker = new WindowTracker();
@@ -373,6 +384,11 @@ private static void StartServices(AppConfig config)
373384
var mappings = _db!.GetAppMappingsAsync().GetAwaiter().GetResult();
374385
foreach (var m in mappings)
375386
_tracker.AddKnownApp(m.ProcessName, m.AppName, m.CountInBackground, m.IgnoreOverlayFocus);
387+
var pauseWhenIdleText = _db.GetSettingAsync("PauseTrackingWhenIdle", "false").GetAwaiter().GetResult();
388+
var idleThresholdText = _db.GetSettingAsync("IdleThresholdMinutes", "10").GetAwaiter().GetResult();
389+
_tracker.ConfigureIdleTracking(
390+
bool.TryParse(pauseWhenIdleText, out var pauseWhenIdle) && pauseWhenIdle,
391+
int.TryParse(idleThresholdText, out var idleThresholdMinutes) ? idleThresholdMinutes : 10);
376392
_tracker.Start(config.PollIntervalMs);
377393

378394
// Copy PopupHost to hidden appdata location
@@ -397,6 +413,9 @@ private static void StartServices(AppConfig config)
397413
_emailService = new EmailService(_db!, _tracker!, _enforcer!, _scheduler!);
398414
_emailService.LoadSettingsAsync().GetAwaiter().GetResult();
399415
_emailService.StartPolling();
416+
_parentReportService = new ParentReportService(_db!, _emailService, _notifier!, GetConfigPath());
417+
DashboardServer.LoginLockoutDetected = _parentReportService.ReportLoginLockout;
418+
_parentReportService.Start();
400419
_enforcer.OnBreachAlert += (app, delay, proc) => _ = _emailService.SendAlertAsync($"Limit Breach: {app}", $"{app} exceeded its daily limit. Closing in {delay}s.");
401420
_enforcer.OnAppKilled += (app) => _ = _emailService.SendAlertAsync($"App Closed: {app}", $"{app} was closed after exceeding its limit.");
402421
_enforcer.OnAppTerminatedBySchedule += (app) => _ = _emailService.SendAlertAsync($"Schedule Block: {app}", $"{app} was closed by schedule rule.");
@@ -662,6 +681,8 @@ private static async Task Cleanup()
662681
Logger.Instance.Info("Monitor shutting down");
663682
_usageTracker?.Stop();
664683
_tracker?.Stop();
684+
DashboardServer.LoginLockoutDetected = null;
685+
_parentReportService?.Dispose();
665686
_emailService?.Dispose();
666687
await DashboardServer.StopAsync();
667688
_db?.Dispose();

README.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ and monitor usage — all from a beautiful web dashboard.
2626
- **Real‑time usage tracking** — counts foreground use by default, with optional per-app background tracking
2727
- **Concurrent app accounting** — a foreground game and opted-in background communication apps can accumulate time simultaneously
2828
- **Overlay focus filtering** — prevents voice and notification overlays from being mistaken for deliberate app switches
29+
- **Lock and idle awareness** — tracking always pauses on the Windows lock screen, with optional configurable idle suspension
30+
- **Tracking diagnostics** — shows which processes are counted, ignored, paused, or running without background permission
31+
- **Scheduled parent summaries** — optional daily, weekly, or monthly email reports with automatic next-start catch-up
32+
- **Tamper alerts** — optional email/webhook alerts for deleted data/config, clock changes, watchdog failure, and repeated login failures
2933
- **Daily time limits** — set per‑app limits (e.g. Fortnite 120 min/day); apps are auto‑closed when exceeded
3034
- **Graceful countdown** — full‑screen warning popup with countdown before an app is killed
3135
- **Bonus time** — grant extra minutes from the dashboard without changing limits
@@ -95,10 +99,24 @@ Tracking behavior is configured independently for every process using the **Back
9599

96100
Background accounting is concurrent. For example, if a game is in the foreground while Discord, Telegram, Signal, Skype, Teams, Zoom, or another opted-in application is running, the same elapsed second can be added to both applications. An application is counted only once per sample even if it is both foreground and enabled for background tracking.
97101

102+
When several executables map to the same app name, they share one usage total and limit. Reaching that limit closes every running executable mapped to the app, not only the first process.
103+
98104
> **Important:** Background mode measures process runtime, not microphone activity. An application left open but unused continues accumulating time, and its daily limit can be reached while it remains in the background.
99105
100106
Usage is sampled approximately once per second using a monotonic clock. Large timing gaps caused by sleep, hibernation, or a stalled process are discarded instead of being charged as usage. Accumulated time is persisted according to `FlushIntervalSec`.
101107

108+
Tracking always pauses while the Windows desktop is locked. **Settings → Pause all usage tracking when idle** can optionally pause foreground and background accounting after 1–240 minutes without keyboard or mouse input; it is disabled by default because controllers, video playback, and voice calls may not generate normal Windows input.
109+
110+
The Live tab's **Tracking Diagnostics** card explains each running configured process: counted as foreground, counted in background, not counted because background mode is disabled, or paused because Windows is locked/the user is idle.
111+
112+
### Scheduled summaries and tamper alerts
113+
114+
Both features are disabled by default and configured under **Settings → Scheduled Reports & Security Alerts**. Summaries support daily, weekly, or monthly delivery using the computer's local time. Weekly reports have a weekday selector; monthly reports accept days 1–31 and automatically use the month's last valid day when necessary.
115+
116+
If the computer is off at the scheduled time, the app sends only the latest missed period after the next startup. The due period is marked complete only after email delivery succeeds. Summary email requires Gmail credentials but does not require breach-notification email to be enabled.
117+
118+
Tamper alerts use every configured channel available: email and webhook. Alerts are deduplicated while a condition remains active and cover missing database/config files, clock jumps of at least five minutes, an unavailable/stopped watchdog, and dashboard login lockouts after repeated failures.
119+
102120
The **Today** and **History** tabs include a **Show foreground/background breakdown** checkbox. When enabled, charts use stacked foreground and background segments and tables show Foreground, Background, and Total columns. If an opted-in background app becomes the accepted foreground app, that interval is classified only as foreground and is never double-counted.
103121

104122
Usage recorded by an older release has only a total and cannot be reconstructed by source. After upgrading, that time is shown as **Legacy** / **unclassified** in breakdown mode while its original total remains unchanged. New usage is classified fully.
@@ -279,14 +297,14 @@ Once installed, the `GameHost` service runs under the SYSTEM account and automat
279297

280298
| Tab | Description |
281299
|-----|-------------|
282-
| **Live** | Currently active app, quick actions (pause, resume, block all, extend time) |
300+
| **Live** | Currently active app, tracking diagnostics, quick actions (pause, resume, block all, extend time) |
283301
| **Today** | Bar chart and table of today's total usage, with optional foreground/background breakdown |
284302
| **History** | Historical totals and foreground/background breakdown with filters, charts, and stats (7/14/30/90 days or custom range) |
285303
| **Limits** | One combined table with per-process background/overlay checkboxes, optional daily limits, bonus time, status, and app management |
286304
| **Discover** | Scan system for installed games/apps, add them with default limit |
287305
| **Schedule** | Time‑based allowed hours rules per app |
288306
| **Logs** | Real‑time event log with filtering |
289-
| **Settings** | Kill delay, language, dashboard hotkey, auto-start, webhook, email, app update, admin password, backup/restore, health |
307+
| **Settings** | Kill delay, optional idle suspension, language, dashboard hotkey, auto-start, webhook, email, app update, admin password, backup/restore, health |
290308

291309
**Admin password:** On first setup, create it from the trusted local dashboard on the child PC. Remote browsers are then redirected to `login.html` and must authenticate before dashboard assets are served. The authenticated session is stored in an HTTP-only, SameSite cookie. Use **Settings → Logout** to clear it. The password also protects dashboard changes (pause, resume, reset, kill, settings edits) and shutdown.
292310

@@ -394,6 +412,12 @@ A: No. It counts whenever the configured process is running. This avoids applica
394412
**Q: Can background tracking cause an application to reach its daily limit while minimized?**
395413
A: Yes. Background-counted time uses the same daily usage total and limit enforcement as foreground time. Close the application when it is not being used, increase its limit, or disable background tracking.
396414

415+
**Q: Why is a running app not accumulating time?**
416+
A: Open **Live → Tracking Diagnostics**. It shows whether the process is foreground, allowed in background, paused by the lock screen/idle setting, or simply running without background tracking enabled.
417+
418+
**Q: Does tracking continue while Windows is locked or the PC is idle?**
419+
A: It always pauses while Windows is locked. Idle pausing is optional and disabled by default; enable it and choose a threshold in **Settings** if desired. Be cautious on PCs used with controllers, videos, or voice calls because those activities may not reset the Windows keyboard/mouse idle timer.
420+
397421
**Q: How do I set up email notifications?**
398422
A: Enable 2FA on your Gmail account, generate an [App Password](https://support.google.com/accounts/answer/185833), and enter your Gmail address and the app password in **Settings → Email Notifications & Control**. See the [Email control](#email-control-optional) section for all commands.
399423

Services/EmailService.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,13 @@ private string[] GetAllowedSenders()
252252
return null;
253253
}
254254

255+
public async Task<string?> SendSystemEmailAsync(string subject, string body)
256+
{
257+
if (string.IsNullOrEmpty(_email) || string.IsNullOrEmpty(_password))
258+
return "Email credentials not configured";
259+
return await SendMailAsync(subject, body, _email);
260+
}
261+
255262
private bool IsCommandForThisDevice(string commandText, out string effectiveCommand)
256263
{
257264
effectiveCommand = commandText.Trim();

Services/LimitEnforcer.cs

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,14 @@ private void OnAppChanged(string app, string proc)
6767
}
6868

6969
// If this app was already killed today for exceeding its limit, block immediately
70-
if (_exceededToday.ContainsKey(app) && IsProcessRunning(proc))
70+
if (_exceededToday.ContainsKey(app) && _tracker.IsProcessRunning(proc))
7171
{
7272
KillAppProcesses(app);
7373
OnAppKilled?.Invoke(app);
7474
}
7575

7676
// Child closed and reopened app during countdown - kill immediately.
77-
if (!_exceededToday.ContainsKey(app) && _activeCountdowns.ContainsKey(app.ToLowerInvariant()) && IsProcessRunning(proc))
77+
if (!_exceededToday.ContainsKey(app) && _activeCountdowns.ContainsKey(app.ToLowerInvariant()) && _tracker.IsProcessRunning(proc))
7878
{
7979
CancelCountdown(app);
8080
_exceededToday[app] = today;
@@ -129,8 +129,7 @@ public async Task EnforceAsync(
129129
{
130130
if (!_activeCountdowns.ContainsKey(key))
131131
{
132-
var procName = _tracker.GetProcessNameForApp(appName) ?? appName;
133-
if (IsProcessRunning(procName))
132+
if (IsAnyAppProcessRunning(appName))
134133
{
135134
Logger.Instance.Info($"Re-kill exceeded app: {appName}");
136135
KillAppProcesses(appName);
@@ -150,7 +149,9 @@ private async Task StartCountdownAsync(string appName)
150149
var delay = await _db.GetKillDelayAsync();
151150
var cts = new CancellationTokenSource();
152151
var key = appName.ToLowerInvariant();
153-
var procName = _tracker.GetProcessNameForApp(appName) ?? appName;
152+
var procName = _tracker.GetRunningProcessNamesForApp(appName).FirstOrDefault()
153+
?? _tracker.GetProcessNameForApp(appName)
154+
?? appName;
154155

155156
if (!_activeCountdowns.TryAdd(key, cts))
156157
return;
@@ -242,15 +243,26 @@ private void KillAppProcesses(string appName)
242243
{
243244
try
244245
{
245-
var procName = _tracker.GetProcessNameForApp(appName) ?? appName;
246-
KillProcessByName(procName);
246+
var processNames = _tracker.GetProcessNamesForApp(appName);
247+
if (processNames.Length == 0)
248+
processNames = new[] { appName };
249+
foreach (var processName in processNames)
250+
KillProcessByName(processName);
247251
}
248252
catch (Exception ex)
249253
{
250254
Logger.Instance.Error($"Failed to kill app processes for {appName}: {ex.Message}");
251255
}
252256
}
253257

258+
private bool IsAnyAppProcessRunning(string appName)
259+
{
260+
var processNames = _tracker.GetProcessNamesForApp(appName);
261+
return processNames.Length > 0
262+
? _tracker.GetRunningProcessNamesForApp(appName).Length > 0
263+
: _tracker.IsProcessRunning(appName);
264+
}
265+
254266
private void KillProcessByName(string processName)
255267
{
256268
try
@@ -276,13 +288,4 @@ private void KillProcessByName(string processName)
276288
}
277289
}
278290

279-
private bool IsProcessRunning(string processName)
280-
{
281-
try
282-
{
283-
var name = Path.GetFileNameWithoutExtension(processName);
284-
return Process.GetProcessesByName(name).Length > 0;
285-
}
286-
catch { return false; }
287-
}
288291
}

Services/NotificationService.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,17 @@ public async Task TestWebhookAsync()
9393
});
9494
}
9595

96+
public async Task NotifySystemAsync(string eventType, string message)
97+
{
98+
await FireWebhook(eventType, "System", new
99+
{
100+
type = eventType,
101+
app = "System",
102+
message,
103+
timestamp = DateTime.Now
104+
});
105+
}
106+
96107
private async Task<string?> GetWebhookUrlAsync()
97108
{
98109
if ((DateTime.UtcNow - _lastUrlCheck).TotalSeconds > 30)

0 commit comments

Comments
 (0)