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

## [Unreleased]

## [1.20.2] - 2026-06-08

### Fixed
- **Restore point creation no longer reports false success.** `CreateRestorePointAsync` returned `true` whenever the PowerShell call didn't throw, but `Checkpoint-Computer` fails *non-terminating* in common cases (notably the once-per-24h rate limit), so failures were reported as success — undermining the "everything is reversible" guarantee. It now forces the error to terminate and only returns `true` when an explicit success sentinel is emitted.
- **In-app updater can now find its download asset.** The release-asset matcher looked for a fixed `SysManager.exe`, but releases publish `SysManager-v<version>.exe`, so `AssetUrl`/`AssetSize` were always null. Replaced with `IsMainExeAsset`, which matches the versioned executable and excludes the `.sha256` companion.
- **Windows Update scan no longer leaks COM objects on failure.** `WindowsUpdateService.ScanAsync` released its COM objects only on the success path, so a cancellation or mapping error mid-scan leaked them. The releases now run in a `finally` block.

## [1.20.1] - 2026-06-08

### Fixed
Expand Down
10 changes: 9 additions & 1 deletion SysManager/SysManager.IntegrationTests/UpdateServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,15 @@ public void Constants_AreSet()
{
Assert.Equal("laurentiu021", UpdateService.Owner);
Assert.Equal("SystemManager", UpdateService.Repo);
Assert.Equal("SysManager.exe", UpdateService.AssetName);
}

[Fact]
public void IsMainExeAsset_MatchesVersionedReleaseExe()
{
// Release assets are SysManager-v<version>.exe, not a fixed SysManager.exe.
Assert.True(UpdateService.IsMainExeAsset("SysManager-v1.20.1.exe"));
Assert.False(UpdateService.IsMainExeAsset("SysManager-v1.20.1.exe.sha256"));
Assert.False(UpdateService.IsMainExeAsset("SysManager.exe"));
}

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

using SysManager.Services;

namespace SysManager.Tests;

/// <summary>
/// Regression: the release asset matcher must accept the real versioned exe name
/// (SysManager-v&lt;version&gt;.exe) and reject the .sha256 companion. Previously it
/// looked for a fixed "SysManager.exe" that no release ever published, so the
/// in-app updater could never resolve its download asset.
/// </summary>
public class UpdateServiceAssetMatchTests
{
[Theory]
[InlineData("SysManager-v1.20.1.exe")]
[InlineData("SysManager-v1.7.0.exe")]
[InlineData("SysManager-v2.0.0.exe")]
[InlineData("sysmanager-v1.20.1.EXE")] // case-insensitive
public void IsMainExeAsset_AcceptsVersionedExe(string name)
=> Assert.True(UpdateService.IsMainExeAsset(name));

[Theory]
[InlineData("SysManager-v1.20.1.exe.sha256")] // checksum companion
[InlineData("SysManager.exe")] // the old fixed name no release uses
[InlineData("something-else.exe")]
[InlineData("SysManager-v1.20.1.zip")]
[InlineData("")]
[InlineData(null)]
public void IsMainExeAsset_RejectsNonMatching(string? name)
=> Assert.False(UpdateService.IsMainExeAsset(name));
}
20 changes: 15 additions & 5 deletions SysManager/SysManager/Services/PerformanceService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -529,11 +529,21 @@ public async Task<bool> CreateRestorePointAsync(string description, Cancellation
// directly in the script body — with single-quote escaping to avoid
// injection via the user-supplied string.
var safeDesc = (description ?? "SysManager Restore Point").Replace("'", "''");
var script = $"Checkpoint-Computer -Description '{safeDesc}' -RestorePointType 'MODIFY_SETTINGS'";
await _ps.RunAsync(script, null, ct).ConfigureAwait(false);
// If RunAsync completes without throwing, the command succeeded.
// Checkpoint-Computer produces no output on success but throws on failure.
return true;
// Checkpoint-Computer does NOT always throw on failure — e.g. the once-per-24h
// rate limit writes a non-terminating error and produces no exception. Force the
// error to terminate and emit an explicit success sentinel only when it really
// succeeded, so a silent failure can no longer be reported as success.
var script =
"try { " +
$"Checkpoint-Computer -Description '{safeDesc}' -RestorePointType 'MODIFY_SETTINGS' -ErrorAction Stop; " +
"'__SM_RESTORE_OK__' " +
"} catch { Write-Error $_; exit 1 }";
var results = await _ps.RunAsync(script, null, ct).ConfigureAwait(false);
var succeeded = results.Any(o =>
string.Equals(o?.BaseObject?.ToString(), "__SM_RESTORE_OK__", StringComparison.Ordinal));
if (!succeeded)
Log.Warning("CreateRestorePoint: Checkpoint-Computer did not confirm success (it may be rate-limited to one per 24h).");
return succeeded;
}

// ═══════════════════════════════════════════════════════════════
Expand Down
15 changes: 12 additions & 3 deletions SysManager/SysManager/Services/UpdateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,17 @@ public sealed class UpdateService
{
public const string Owner = "laurentiu021";
public const string Repo = "SystemManager";
public const string AssetName = "SysManager.exe";

/// <summary>
/// True for the release's main executable asset. Release assets are named
/// <c>SysManager-v&lt;version&gt;.exe</c> (e.g. <c>SysManager-v1.20.1.exe</c>),
/// not a fixed <c>SysManager.exe</c>, so match by pattern and exclude the
/// companion <c>.sha256</c> checksum file.
/// </summary>
public static bool IsMainExeAsset(string? assetName) =>
!string.IsNullOrEmpty(assetName) &&
assetName.StartsWith("SysManager-", StringComparison.OrdinalIgnoreCase) &&
assetName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase);

private static readonly HttpClient Http = CreateClient();

Expand Down Expand Up @@ -345,8 +355,7 @@ private static void CleanupFile(string path)
var version = ParseVersion(dto.TagName);
if (version is null) return null;

var asset = dto.Assets?.FirstOrDefault(a =>
string.Equals(a.Name, AssetName, StringComparison.OrdinalIgnoreCase));
var asset = dto.Assets?.FirstOrDefault(a => IsMainExeAsset(a.Name));

return new ReleaseInfo(
Version: version,
Expand Down
54 changes: 32 additions & 22 deletions SysManager/SysManager/Services/WindowsUpdateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,33 +32,43 @@ public Task<IReadOnlyList<UpdateEntry>> ScanAsync(CancellationToken ct = default
{
ct.ThrowIfCancellationRequested();
Emit("Connecting to Windows Update…");
var session = CreateSession();
var searcher = session.CreateUpdateSearcher();
searcher.IncludePotentiallySupersededUpdates = false;

// "IsInstalled=0" returns everything not yet installed,
// including optional drivers and feature upgrades.
var result = searcher.Search("IsInstalled=0");
ct.ThrowIfCancellationRequested();
// Declared outside the try so the finally can release every COM object
// even when cancellation or MapToEntry throws mid-scan (previously these
// releases sat on the happy path only, leaking COM objects on any throw).
dynamic? session = null, searcher = null, result = null, updates = null;
try
{
session = CreateSession();
searcher = session.CreateUpdateSearcher();
searcher.IncludePotentiallySupersededUpdates = false;

// "IsInstalled=0" returns everything not yet installed,
// including optional drivers and feature upgrades.
result = searcher.Search("IsInstalled=0");
ct.ThrowIfCancellationRequested();

var updates = result.Updates;
var count = (int)updates.Count;
Emit($"Found {count} update(s).");
updates = result.Updates;
var count = (int)updates.Count;
Emit($"Found {count} update(s).");

var list = new List<UpdateEntry>(count);
for (int i = 0; i < count; i++)
var list = new List<UpdateEntry>(count);
for (int i = 0; i < count; i++)
{
ct.ThrowIfCancellationRequested();
var u = updates.Item(i);
list.Add(MapToEntry(u));
Marshal.FinalReleaseComObject(u);
}
return list;
}
finally
{
ct.ThrowIfCancellationRequested();
var u = updates.Item(i);
list.Add(MapToEntry(u));
Marshal.FinalReleaseComObject(u);
if (updates is not null) Marshal.FinalReleaseComObject(updates);
if (result is not null) Marshal.FinalReleaseComObject(result);
if (searcher is not null) Marshal.FinalReleaseComObject(searcher);
if (session is not null) Marshal.FinalReleaseComObject(session);
}

Marshal.FinalReleaseComObject(updates);
Marshal.FinalReleaseComObject(result);
Marshal.FinalReleaseComObject(searcher);
Marshal.FinalReleaseComObject(session);
return list;
}, ct);

/// <summary>
Expand Down
6 changes: 3 additions & 3 deletions SysManager/SysManager/SysManager.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
<RootNamespace>SysManager</RootNamespace>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<NoWarn>NU1603;NU1701</NoWarn>
<Version>1.20.1</Version>
<FileVersion>1.20.1.0</FileVersion>
<AssemblyVersion>1.20.1.0</AssemblyVersion>
<Version>1.20.2</Version>
<FileVersion>1.20.2.0</FileVersion>
<AssemblyVersion>1.20.2.0</AssemblyVersion>
<Product>SysManager</Product>
<Description>SysManager — Windows system monitoring toolkit by laurentiu021. Network, updates, health, logs, safe deep cleanup.</Description>
<PackageProjectUrl>https://github.com/laurentiu021/SystemManager</PackageProjectUrl>
Expand Down
Loading