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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,23 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.48.3] - 2026-05-14

### Fixed
- **DuplicateFileGroup** — WastedBytes now raises PropertyChanged when Count or
FileSize changes (missing NotifyPropertyChangedFor attributes).
- **UpdateService** — pre-release and draft GitHub releases are now filtered out
in GetRecentAsync results.
- **ServiceManagerService** — StartService no longer throws when the service is
already in StartPending state.
- **DiskAnalyzerService** — empty directories are no longer incorrectly flagged
as access-denied; the flag now tracks actual UnauthorizedAccessException.
- **WindowsUpdateViewModel** — null-conditional on Application.Current before
calling Shutdown() prevents NullReferenceException during unit tests or
non-standard hosting.
- **TuneUpService** — SHEmptyRecycleBin HRESULT is now checked; returns false
on failure instead of always reporting success.

## [0.48.2] - 2026-05-14

### Fixed
Expand Down
13 changes: 13 additions & 0 deletions SysManager/SysManager.Tests/DiskAnalyzerServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,19 @@ public async Task Analyze_Percentages_ProportionalToSize()
Assert.True(big.Percentage > small.Percentage);
}

[Fact]
public async Task Analyze_EmptySubfolder_NotMarkedAsAccessDenied()
{
// An empty directory should NOT be flagged as access denied
CreateDir("emptydir");
var result = await _service.AnalyzeAsync(_root);
Assert.Single(result);
Assert.Equal("emptydir", result[0].Name);
Assert.False(result[0].IsAccessDenied);
Assert.Equal(0, result[0].SizeBytes);
Assert.Equal(0, result[0].FileCount);
}

// ── Invalid inputs ──

[Fact]
Expand Down
110 changes: 110 additions & 0 deletions SysManager/SysManager.Tests/DuplicateFileGroupTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// SysManager · DuplicateFileGroupTests
// Author: laurentiu021 · https://github.com/laurentiu021/SystemManager
// License: MIT

using SysManager.Models;

namespace SysManager.Tests;

/// <summary>
/// Tests for <see cref="DuplicateFileGroup"/> model — validates WastedBytes
/// calculation and property change notifications.
/// </summary>
public class DuplicateFileGroupTests
{
[Fact]
public void WastedBytes_CalculatesCorrectly()
{
var group = new DuplicateFileGroup();
group.FileSize = 1024;
group.Count = 3;
// Wasted = (3 - 1) * 1024 = 2048
Assert.Equal(2048, group.WastedBytes);
}

[Fact]
public void WastedBytes_ZeroWhenCountIsOne()
{
var group = new DuplicateFileGroup();
group.FileSize = 5000;
group.Count = 1;
Assert.Equal(0, group.WastedBytes);
}

[Fact]
public void WastedBytes_ZeroWhenCountIsZero()
{
var group = new DuplicateFileGroup();
group.FileSize = 5000;
group.Count = 0;
Assert.Equal(0, group.WastedBytes);
}

[Fact]
public void WastedBytes_NotifiesWhenFileSizeChanges()
{
var group = new DuplicateFileGroup();
group.Count = 3;
var changed = new List<string>();
group.PropertyChanged += (_, e) => changed.Add(e.PropertyName!);

group.FileSize = 2048;

Assert.Contains("WastedBytes", changed);
}

[Fact]
public void WastedBytes_NotifiesWhenCountChanges()
{
var group = new DuplicateFileGroup();
group.FileSize = 1024;
var changed = new List<string>();
group.PropertyChanged += (_, e) => changed.Add(e.PropertyName!);

group.Count = 5;

Assert.Contains("WastedBytes", changed);
}

[Fact]
public void Hash_PropertyNotifies()
{
var group = new DuplicateFileGroup();
var changed = new List<string>();
group.PropertyChanged += (_, e) => changed.Add(e.PropertyName!);

group.Hash = "abc123";

Assert.Contains("Hash", changed);
}

[Fact]
public void Files_Collection_IsInitialized()
{
var group = new DuplicateFileGroup();
Assert.NotNull(group.Files);
Assert.Empty(group.Files);
}

// ── DuplicateFileEntry tests ──

[Fact]
public void DuplicateFileEntry_Properties_Notify()
{
var entry = new DuplicateFileEntry();
var changed = new List<string>();
entry.PropertyChanged += (_, e) => changed.Add(e.PropertyName!);

entry.Path = @"C:\test\file.txt";
entry.Name = "file.txt";
entry.SizeBytes = 4096;
entry.LastModified = DateTime.Now;
entry.IsSelected = true;

Assert.Contains("Path", changed);
Assert.Contains("Name", changed);
Assert.Contains("SizeBytes", changed);
Assert.Contains("LastModified", changed);
Assert.Contains("IsSelected", changed);
}
}
9 changes: 7 additions & 2 deletions SysManager/SysManager/Models/DuplicateFileGroup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,13 @@ namespace SysManager.Models;
public partial class DuplicateFileGroup : ObservableObject
{
[ObservableProperty] private string _hash = "";
[ObservableProperty] private long _fileSize;
[ObservableProperty] private int _count;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(WastedBytes))]
private long _fileSize;

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(WastedBytes))]
private int _count;

public ObservableCollection<DuplicateFileEntry> Files { get; } = new();

Expand Down
17 changes: 9 additions & 8 deletions SysManager/SysManager/Services/DiskAnalyzerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ private static IReadOnlyList<DiskUsageEntry> Analyze(
scanned++;
progress?.Report(new AnalysisProgress(scanned, Path.GetFileName(dir)));

var (size, files, folders) = MeasureFolder(dir, ct);
var (size, files, folders, denied) = MeasureFolder(dir, ct);
var name = Path.GetFileName(dir);
if (string.IsNullOrEmpty(name)) name = dir;

Expand All @@ -93,7 +93,7 @@ private static IReadOnlyList<DiskUsageEntry> Analyze(
SizeBytes = size,
FileCount = files,
FolderCount = folders,
IsAccessDenied = size == 0 && files == 0 && folders == 0
IsAccessDenied = denied
});
}

Expand Down Expand Up @@ -124,11 +124,12 @@ private static IReadOnlyList<DiskUsageEntry> Analyze(
return results;
}

private static (long size, int files, int folders) MeasureFolder(string path, CancellationToken ct)
private static (long size, int files, int folders, bool accessDenied) MeasureFolder(string path, CancellationToken ct)
{
long totalSize = 0;
int fileCount = 0;
int folderCount = 0;
bool accessDenied = false;

var stack = new Stack<string>();
stack.Push(path);
Expand All @@ -140,10 +141,10 @@ private static (long size, int files, int folders) MeasureFolder(string path, Ca
string[] files = Array.Empty<string>();
string[] dirs = Array.Empty<string>();
try { files = Directory.GetFiles(current); }
catch (UnauthorizedAccessException) { /* skip protected folder */ }
catch (UnauthorizedAccessException) { accessDenied = true; }
catch (IOException) { /* skip inaccessible folder */ }
try { dirs = Directory.GetDirectories(current); }
catch (UnauthorizedAccessException) { /* skip protected folder */ }
catch (UnauthorizedAccessException) { accessDenied = true; }
catch (IOException) { /* skip inaccessible folder */ }

foreach (var f in files)
Expand All @@ -154,7 +155,7 @@ private static (long size, int files, int folders) MeasureFolder(string path, Ca
totalSize += new FileInfo(f).Length;
fileCount++;
}
catch (UnauthorizedAccessException) { /* skip inaccessible file */ }
catch (UnauthorizedAccessException) { accessDenied = true; }
catch (IOException) { /* skip inaccessible file */ }
}

Expand All @@ -167,15 +168,15 @@ private static (long size, int files, int folders) MeasureFolder(string path, Ca
var attr = File.GetAttributes(d);
if ((attr & FileAttributes.ReparsePoint) != 0) continue;
}
catch (UnauthorizedAccessException) { continue; }
catch (UnauthorizedAccessException) { accessDenied = true; continue; }
catch (IOException) { continue; }

folderCount++;
stack.Push(d);
}
}

return (totalSize, fileCount, folderCount);
return (totalSize, fileCount, folderCount, accessDenied);
}

private static bool ShouldSkip(string path)
Expand Down
23 changes: 12 additions & 11 deletions SysManager/SysManager/Services/ServiceManagerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,18 +86,19 @@ public static List<ServiceEntry> GetAllServices()
public static void StartService(string serviceName)
{
using var sc = new ServiceController(serviceName);
if (sc.Status != ServiceControllerStatus.Running)
if (sc.Status == ServiceControllerStatus.Running ||
sc.Status == ServiceControllerStatus.StartPending)
return;

sc.Start();
try
{
sc.Start();
try
{
sc.WaitForStatus(ServiceControllerStatus.Running, TimeSpan.FromSeconds(30));
}
catch (System.ServiceProcess.TimeoutException)
{
throw new InvalidOperationException(
$"Service '{serviceName}' did not start within 30 seconds. It may still be starting — check Services again in a moment.");
}
sc.WaitForStatus(ServiceControllerStatus.Running, TimeSpan.FromSeconds(30));
}
catch (System.ServiceProcess.TimeoutException)
{
throw new InvalidOperationException(
$"Service '{serviceName}' did not start within 30 seconds. It may still be starting — check Services again in a moment.");
}
}

Expand Down
5 changes: 3 additions & 2 deletions SysManager/SysManager/Services/TuneUpService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,9 @@
try
{
// SHEmptyRecycleBin with SHERB_NOCONFIRMATION | SHERB_NOPROGRESSUI | SHERB_NOSOUND
NativeMethods.SHEmptyRecycleBin(IntPtr.Zero, null, 0x00000007);
return true;
int hr = NativeMethods.SHEmptyRecycleBin(IntPtr.Zero, null, 0x00000007);

Check notice

Code scanning / CodeQL

Calls to unmanaged code Note

Replace this call with a call to managed code if possible.
Comment thread
laurentiu021 marked this conversation as resolved.
Dismissed
// S_OK (0) = success, 0x80070012 (ERROR_NO_MORE_FILES) = bin already empty
return hr >= 0 || unchecked((uint)hr) == 0x80070012;
}
catch (System.ComponentModel.Win32Exception ex)
{
Expand Down
1 change: 1 addition & 0 deletions SysManager/SysManager/Services/UpdateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ public static Version CurrentVersion
private static ReleaseInfo? Map(GhRelease dto)
{
if (dto.TagName == null) return null;
if (dto.Prerelease || dto.Draft) return null;
var version = ParseVersion(dto.TagName);
if (version == null) return null;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ private async Task AutoCheckOnStartAsync()
private void RelaunchAsAdmin()
{
if (AdminHelper.RelaunchAsAdmin())
System.Windows.Application.Current.Shutdown();
System.Windows.Application.Current?.Shutdown();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether any non-null-safe shutdown calls still exist.
# Expected: no matches for direct Application.Current.Shutdown(...)

rg -nP --type=cs 'Application\.Current\.Shutdown\s*\('
rg -nP --type=cs 'Application\.Current\?\.\s*Shutdown\s*\('

Repository: laurentiu021/SystemManager

Length of output: 1498


Apply null-guards to all relaunch shutdown paths.

Line 77 was fixed, but lines 114 and 350 in WindowsUpdateViewModel.cs still call System.Windows.Application.Current.Shutdown() directly. Additionally, the same null-reference vulnerability exists at:

  • DashboardViewModel.cs:126
  • AppUpdatesViewModel.cs:39
  • TrayIconService.cs:118

Apply the null-coalescing operator (?.) to all of these calls to prevent null-reference exceptions in tests or non-standard hosting scenarios.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SysManager/SysManager/ViewModels/WindowsUpdateViewModel.cs` at line 77,
Several shutdown calls directly invoke
System.Windows.Application.Current.Shutdown(), which can throw
NullReferenceException in tests or non-standard hosts; update every occurrence
to use the null-conditional operator
(System.Windows.Application.Current?.Shutdown())—specifically replace the direct
calls in WindowsUpdateViewModel's relaunch/shutdown paths, the shutdown call in
DashboardViewModel, the call in AppUpdatesViewModel, and the call in
TrayIconService so each uses ?.Shutdown() to safely no-op when Current is null.

}

[RelayCommand]
Expand Down
Loading