Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ Key services:
Recycle Bin, shortcut scan, disk SMART, uptime/RAM checks. Non-destructive.
- `HealthScoreService` — aggregates disk health, RAM, uptime, and battery
wear into a single 0–100 score with color-coded verdict and recommendations.
- `TrayIconService` — system tray icon with background monitoring (60s),
tooltip updates, context menu, and Windows toast notifications.
- `LogService` — Serilog wrapper with rolling file sink.
- `FixedDriveService` — enumerate fixed NTFS/ReFS volumes.
- `DeepCleanupService` — scan-first safe cleanup (vendor caches, gaming
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
with "Scan system". Closes #259.
- **HealthScoreService** — aggregates SystemInfoService, DiskHealthService,
and BatteryService into a weighted health score.
- **System Tray mode** — minimize-to-tray on window close, background
health monitoring every 60 seconds, CPU/RAM/uptime tooltip on hover,
Windows toast notifications when RAM > 90%, uptime > 14 days, or disk
health degrades. Right-click context menu with Show / Exit. Uses
H.NotifyIcon.Wpf 2.2.1. Closes #262.
- **IntGreaterThanZeroConverter** — value converter for conditional
visibility when an integer is greater than zero.
- **IDialogService** — abstraction for user confirmation dialogs, replacing
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,9 @@ long-running operation, so you always know which tab is working.
SMART, RAM usage, uptime, and battery wear. Color-coded ring (green /
amber / red) with up to 3 actionable recommendations. Auto-computes on
load and refreshes with "Scan system".
- **System Tray** — minimize-to-tray on close, background health monitoring
(60s polling), CPU/RAM tooltip, Windows notifications when RAM > 90%,
uptime > 14 days, or disk health degrades. Context menu: Show / Exit.

### Updates (for SysManager itself)
- Auto-check on startup against the GitHub Releases API, plus a manual
Expand Down
135 changes: 135 additions & 0 deletions SysManager/SysManager.Tests/TrayIconServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// SysManager · TrayIconServiceTests
// Author: laurentiu021 · https://github.com/laurentiu021/SystemManager
// License: MIT

using SysManager.Models;
using SysManager.Services;

namespace SysManager.Tests;

/// <summary>
/// Unit tests for <see cref="TrayIconService"/> — validates notification
/// threshold logic and property defaults. Actual tray icon display is
/// integration-level (requires UI thread + Windows shell).
/// </summary>
public class TrayIconServiceTests
{
// ---------- construction & defaults ----------

[Fact]
public void Constructor_DoesNotThrow()
{
var svc = new TrayIconService(new SystemInfoService());
Assert.NotNull(svc);
svc.Dispose();
}

[Fact]
public void MinimizeToTray_DefaultTrue()
{
var svc = new TrayIconService(new SystemInfoService());
Assert.True(svc.MinimizeToTray);
svc.Dispose();
}

[Fact]
public void NotificationsEnabled_DefaultTrue()
{
var svc = new TrayIconService(new SystemInfoService());
Assert.True(svc.NotificationsEnabled);
svc.Dispose();
}

[Fact]
public void MinimizeToTray_CanBeDisabled()
{
var svc = new TrayIconService(new SystemInfoService());
svc.MinimizeToTray = false;
Assert.False(svc.MinimizeToTray);
svc.Dispose();
}

[Fact]
public void NotificationsEnabled_CanBeDisabled()
{
var svc = new TrayIconService(new SystemInfoService());
svc.NotificationsEnabled = false;
Assert.False(svc.NotificationsEnabled);
svc.Dispose();
}

[Fact]
public void Dispose_CalledTwice_DoesNotThrow()
{
var svc = new TrayIconService(new SystemInfoService());
svc.Dispose();
var ex = Record.Exception(() => svc.Dispose());
Assert.Null(ex);
}

// ---------- notification threshold logic ----------

[Fact]
public void CheckAndNotify_LowRam_DoesNotThrow()
{
// RAM at 50% — should NOT trigger notification
var svc = new TrayIconService(new SystemInfoService());
var snapshot = MakeSnapshot(ramUsedPct: 50, uptimeDays: 1);
var ex = Record.Exception(() => svc.CheckAndNotify(snapshot));
Assert.Null(ex);
svc.Dispose();
}

[Fact]
public void CheckAndNotify_HighRam_DoesNotThrow()
{
// RAM at 95% — should trigger notification (but no tray icon = no crash)
var svc = new TrayIconService(new SystemInfoService());
var snapshot = MakeSnapshot(ramUsedPct: 95, uptimeDays: 1);
var ex = Record.Exception(() => svc.CheckAndNotify(snapshot));
Assert.Null(ex);
svc.Dispose();
}

[Fact]
public void CheckAndNotify_HighUptime_DoesNotThrow()
{
var svc = new TrayIconService(new SystemInfoService());
var snapshot = MakeSnapshot(ramUsedPct: 50, uptimeDays: 20);
var ex = Record.Exception(() => svc.CheckAndNotify(snapshot));
Assert.Null(ex);
svc.Dispose();
}

[Fact]
public void CheckAndNotify_UnhealthyDisk_DoesNotThrow()
{
var svc = new TrayIconService(new SystemInfoService());
var snapshot = new SystemSnapshot(
new OsInfo("Windows 11", "10.0", "22631", TimeSpan.FromDays(1), "64-bit"),
new CpuInfo("Test CPU", 8, 16, 3600, 10),
new MemoryInfo(16, 8, 8, 50, new List<MemoryModule>()),
new List<DiskInfo>
{
new("TestDisk", "SSD", "NVMe", 500, "Warning", "OK", 45, 10)
},
DateTime.Now);
var ex = Record.Exception(() => svc.CheckAndNotify(snapshot));
Assert.Null(ex);
svc.Dispose();
}

// ---------- helpers ----------

private static SystemSnapshot MakeSnapshot(double ramUsedPct, int uptimeDays)
{
double totalGB = 16;
double usedGB = totalGB * ramUsedPct / 100;
return new SystemSnapshot(
new OsInfo("Windows 11", "10.0", "22631", TimeSpan.FromDays(uptimeDays), "64-bit"),
new CpuInfo("Test CPU", 8, 16, 3600, 10),
new MemoryInfo(totalGB, totalGB - usedGB, usedGB, ramUsedPct, new List<MemoryModule>()),
new List<DiskInfo>(),
DateTime.Now);
}
}
12 changes: 12 additions & 0 deletions SysManager/SysManager/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@ public partial class App : Application
{
private const string MutexName = "Global\\SysManager_SingleInstance_laurentiu021";
private Mutex? _instanceMutex;
private TrayIconService? _trayService;

// Guard against cascading error dialogs — show at most one at a time.
private static int _errorDialogActive;

/// <summary>The shared tray icon service instance.</summary>
public TrayIconService? TrayService => _trayService;

[DllImport("user32.dll")]
private static extern bool SetForegroundWindow(IntPtr hWnd);

Expand Down Expand Up @@ -45,6 +49,13 @@ protected override void OnStartup(StartupEventArgs e)
}

LogService.Init();

// Don't shutdown when main window is hidden (tray mode)
ShutdownMode = ShutdownMode.OnExplicitShutdown;

// Initialize tray icon service
_trayService = new TrayIconService(new SystemInfoService());

DispatcherUnhandledException += OnUi;
AppDomain.CurrentDomain.UnhandledException += OnDomain;
TaskScheduler.UnobservedTaskException += OnTask;
Expand All @@ -53,6 +64,7 @@ protected override void OnStartup(StartupEventArgs e)

protected override void OnExit(ExitEventArgs e)
{
_trayService?.Dispose();
LogService.Shutdown();
_instanceMutex?.ReleaseMutex();
_instanceMutex?.Dispose();
Expand Down
17 changes: 17 additions & 0 deletions SysManager/SysManager/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Windows;
using System.Windows.Input;
using System.Windows.Interop;
using SysManager.Services;
using SysManager.ViewModels;

namespace SysManager;
Expand All @@ -29,6 +30,10 @@ protected override void OnSourceInitialized(EventArgs e)
base.OnSourceInitialized(e);
if (PresentationSource.FromVisual(this) is HwndSource source)
source.AddHook(WndProc);

// Initialize tray icon after window handle is available
if (Application.Current is App app && app.TrayService != null)
app.TrayService.Initialize(this);
}

private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
Expand Down Expand Up @@ -63,6 +68,18 @@ private void NavChild_Click(object sender, MouseButtonEventArgs e)
vm.SelectedNav = item;
}

protected override void OnClosing(System.ComponentModel.CancelEventArgs e)
{
// Minimize to tray instead of closing (if enabled)
if (Application.Current is App app && app.TrayService is { MinimizeToTray: true })
{
e.Cancel = true;
TrayIconService.HideWindow(this);
return;
}
base.OnClosing(e);
}

protected override void OnClosed(EventArgs e)
{
(DataContext as MainWindowViewModel)?.Dispose();
Expand Down
Loading
Loading