diff --git a/CHANGELOG.md b/CHANGELOG.md
index 44c477f..ff45868 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,16 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
+## [1.17.4] - 2026-06-03
+
+### Fixed
+- **Windows Update install never applied updates** — install command sent KB numbers prefixed with `KB` (e.g. `KB5034441`) to PSWindowsUpdate's `-KBArticleID` parameter, which expects bare digits; the cmdlet matched zero updates and exited silently. Updates without a KB (Defender Definitions, drivers) and updates with multiple KBs were also excluded by the selection filter. The status bar reported a fabricated "Installed N update(s)" message based on the selection count rather than the cmdlet's actual result.
+- **Honest install reporting** — Install-WindowsUpdate output is now captured and parsed; the status bar shows real counts (`Installed X/Y. Failed: Z. Not applied: W.`) and each row's Status column reflects per-update outcome (`Installed`, `Failed`, `Not applied`).
+
+### Changed
+- **Unified update list** — "List updates" now returns Standard, Feature upgrades, and Hidden updates in a single grouped table; the separate "Feature upgrades" button has been removed. Category column distinguishes Security, Cumulative, Defender, Driver, Servicing, .NET, Feature upgrade, and Hidden entries.
+- **Title-based install pipeline** — selected updates are matched against the live PSWindowsUpdate feed by Title rather than KB, so updates without a KB (Defender, drivers) and updates with multiple KBs install correctly.
+
## [1.17.3] - 2026-05-29
### Fixed
diff --git a/README.md b/README.md
index da1ce17..198c8ef 100644
--- a/README.md
+++ b/README.md
@@ -122,11 +122,19 @@ deleting anything (uses the standard `LegacyDisable` registry mechanism):
### Windows Update (via PSWindowsUpdate)
- Auto-check for the PSWindowsUpdate module on tab open, with a one-click
install card if it's missing
-- Sortable DataGrid table for available updates, hidden updates, and history
-- Columns: Title, KB, Size, Status, Date, Category — click headers to sort
-- Check for standard and feature updates
-- Install selected updates, list history, check pending-reboot state
+- Unified DataGrid for **everything** in one scan — standard, feature
+ upgrades, hidden updates, and history
+- Categorized: Security, Cumulative, Defender, Driver, Servicing, .NET,
+ Feature upgrade, Hidden — click headers to sort
+- Per-update checkbox selection with Select all / Deselect all — install
+ exactly what you want, skip what you don't
+- Title-based install pipeline that works for **all** updates including
+ Defender Definitions and drivers (which have no KB)
+- Honest install reporting — captures Install-WindowsUpdate's actual
+ result and shows real counts (`Installed X/Y. Failed: Z. Not applied: W.`)
+- Per-row Status column updated with each update's outcome after install
- Live console output in a collapsible panel during install operations
+- Pending-reboot check, update history (last 30)
- Admin banner with a one-click "Run as Administrator" relaunch
### App updates (winget)
diff --git a/SysManager/SysManager.Tests/WindowsUpdateViewModelTests.cs b/SysManager/SysManager.Tests/WindowsUpdateViewModelTests.cs
index f587c46..3c141ad 100644
--- a/SysManager/SysManager.Tests/WindowsUpdateViewModelTests.cs
+++ b/SysManager/SysManager.Tests/WindowsUpdateViewModelTests.cs
@@ -59,7 +59,6 @@ public void Constructor_UpdateCountZero()
[Theory]
[InlineData("ListUpdatesCommand")]
[InlineData("ShowHistoryCommand")]
- [InlineData("ListFeatureUpdatesCommand")]
[InlineData("CheckPendingRebootCommand")]
[InlineData("InstallUpdatesCommand")]
[InlineData("InstallModuleCommand")]
@@ -205,6 +204,94 @@ public void FormatSize_StringValue_ReturnsAsIs()
Assert.Equal("50 MB", result);
}
+
+ // ---------- ParseInstallResults ----------
+
+ [Fact]
+ public void ParseInstallResults_EmptyString_ReturnsZeros()
+ {
+ var (installed, failed, results) = WindowsUpdateViewModel.ParseInstallResults("");
+ Assert.Equal(0, installed);
+ Assert.Equal(0, failed);
+ Assert.Empty(results);
+ }
+
+ [Fact]
+ public void ParseInstallResults_EmptyArray_ReturnsZeros()
+ {
+ var (installed, failed, results) = WindowsUpdateViewModel.ParseInstallResults("[]");
+ Assert.Equal(0, installed);
+ Assert.Equal(0, failed);
+ Assert.Empty(results);
+ }
+
+ [Fact]
+ public void ParseInstallResults_AllInstalled_CountsCorrectly()
+ {
+ var json = """
+ [
+ {"Title":"Cumulative Update for Windows","Result":"Installed"},
+ {"Title":"Defender Definition Update","Result":"Installed"}
+ ]
+ """;
+ var (installed, failed, results) = WindowsUpdateViewModel.ParseInstallResults(json);
+ Assert.Equal(2, installed);
+ Assert.Equal(0, failed);
+ Assert.Equal(2, results.Count);
+ Assert.Equal("Installed", results["Cumulative Update for Windows"]);
+ }
+
+ [Fact]
+ public void ParseInstallResults_MixedResults_SeparatesInstalledAndFailed()
+ {
+ var json = """
+ [
+ {"Title":"Update A","Result":"Installed"},
+ {"Title":"Update B","Result":"Failed"},
+ {"Title":"Update C","Result":"Downloaded"}
+ ]
+ """;
+ var (installed, failed, results) = WindowsUpdateViewModel.ParseInstallResults(json);
+ Assert.Equal(1, installed);
+ Assert.Equal(1, failed);
+ Assert.Equal(3, results.Count);
+ Assert.Equal("Downloaded", results["Update C"]);
+ }
+
+ [Fact]
+ public void ParseInstallResults_Succeeded_CountsAsInstalled()
+ {
+ var json = """[{"Title":"Update X","Result":"Succeeded"}]""";
+ var (installed, _, _) = WindowsUpdateViewModel.ParseInstallResults(json);
+ Assert.Equal(1, installed);
+ }
+
+ [Fact]
+ public void ParseInstallResults_ErrorString_CountsAsFailed()
+ {
+ var json = """[{"Title":"Update Y","Result":"Error: 0x80240020"}]""";
+ var (_, failed, _) = WindowsUpdateViewModel.ParseInstallResults(json);
+ Assert.Equal(1, failed);
+ }
+
+ [Fact]
+ public void ParseInstallResults_InvalidJson_ReturnsZeros()
+ {
+ var (installed, failed, results) = WindowsUpdateViewModel.ParseInstallResults("not json");
+ Assert.Equal(0, installed);
+ Assert.Equal(0, failed);
+ Assert.Empty(results);
+ }
+
+ [Fact]
+ public void ParseInstallResults_SingleObject_PopulatesOneResult()
+ {
+ var json = """{"Title":"Solo Update","Result":"Installed"}""";
+ var (installed, _, results) = WindowsUpdateViewModel.ParseInstallResults(json);
+ Assert.Equal(1, installed);
+ Assert.Single(results);
+ Assert.Equal("Installed", results["Solo Update"]);
+ }
}
// ---------- UpdateEntry model ----------
diff --git a/SysManager/SysManager/Models/UpdateEntry.cs b/SysManager/SysManager/Models/UpdateEntry.cs
index 28d9998..dfa2b37 100644
--- a/SysManager/SysManager/Models/UpdateEntry.cs
+++ b/SysManager/SysManager/Models/UpdateEntry.cs
@@ -12,11 +12,11 @@ namespace SysManager.Models;
public sealed partial class UpdateEntry : ObservableObject
{
[ObservableProperty] private bool _isSelected = true;
+ [ObservableProperty] private string _status = "";
public string Title { get; init; } = "";
public string KB { get; init; } = "";
public string Size { get; init; } = "";
- public string Status { get; init; } = "";
public DateTime? Date { get; init; }
public bool IsHidden { get; init; }
public string Category { get; init; } = "";
diff --git a/SysManager/SysManager/SysManager.csproj b/SysManager/SysManager/SysManager.csproj
index 5212f82..d54d09f 100644
--- a/SysManager/SysManager/SysManager.csproj
+++ b/SysManager/SysManager/SysManager.csproj
@@ -10,9 +10,9 @@
SysManager
true
NU1603;NU1701
- 1.17.3
- 1.17.3.0
- 1.17.3.0
+ 1.17.4
+ 1.17.4.0
+ 1.17.4.0
SysManager
SysManager — Windows system monitoring toolkit by laurentiu021. Network, updates, health, logs, safe deep cleanup.
https://github.com/laurentiu021/SystemManager
diff --git a/SysManager/SysManager/ViewModels/WindowsUpdateViewModel.cs b/SysManager/SysManager/ViewModels/WindowsUpdateViewModel.cs
index bb2808e..72ea5ec 100644
--- a/SysManager/SysManager/ViewModels/WindowsUpdateViewModel.cs
+++ b/SysManager/SysManager/ViewModels/WindowsUpdateViewModel.cs
@@ -163,6 +163,17 @@ void Capture(PowerShellLine l)
await _runner.RunScriptViaPwshAsync(@"
Import-Module PSWindowsUpdate -ErrorAction Stop
$updates = @()
+
+ $catExpr = {
+ if ($_.Title -match 'Defender|Definition Update|Antimalware') { 'Defender' }
+ elseif ($_.Title -match 'Driver') { 'Driver' }
+ elseif ($_.Title -match 'Cumulative Update') { 'Cumulative' }
+ elseif ($_.Title -match 'Security Update') { 'Security' }
+ elseif ($_.Title -match 'Servicing Stack') { 'Servicing' }
+ elseif ($_.Title -match '\.NET') { '.NET' }
+ else { 'Update' }
+ }
+
$std = Get-WindowsUpdate -MicrosoftUpdate -ErrorAction SilentlyContinue
if ($std) {
$updates += $std | Select-Object @{N='Title';E={$_.Title}},
@@ -171,8 +182,22 @@ await _runner.RunScriptViaPwshAsync(@"
@{N='Status';E={'Available'}},
@{N='Date';E={$null}},
@{N='IsHidden';E={$false}},
- @{N='Category';E={'Standard'}}
+ @{N='Category';E=$catExpr}
}
+
+ try {
+ $up = Get-WindowsUpdate -MicrosoftUpdate -UpdateType Software -Category 'Upgrades' -ErrorAction SilentlyContinue
+ if ($up) {
+ $updates += $up | Select-Object @{N='Title';E={$_.Title}},
+ @{N='KB';E={if($_.KBArticleIDs){('KB'+($_.KBArticleIDs -join ','))}else{''}}},
+ @{N='Size';E={$_.Size}},
+ @{N='Status';E={'Available'}},
+ @{N='Date';E={$null}},
+ @{N='IsHidden';E={$false}},
+ @{N='Category';E={'Feature upgrade'}}
+ }
+ } catch {}
+
try {
$hidden = Get-WindowsUpdate -MicrosoftUpdate -IsHidden -ErrorAction SilentlyContinue
if ($hidden) {
@@ -185,8 +210,10 @@ await _runner.RunScriptViaPwshAsync(@"
@{N='Category';E={'Hidden'}}
}
} catch {}
- if ($updates.Count -eq 0) { '[]' }
- else { $updates | ConvertTo-Json -Compress }
+
+ $updates = $updates | Sort-Object Title -Unique
+ if (-not $updates -or @($updates).Count -eq 0) { '[]' }
+ else { @($updates) | ConvertTo-Json -Compress -Depth 3 }
", cancellationToken: _cts.Token);
}
finally { _runner.LineReceived -= Capture; }
@@ -257,60 +284,6 @@ await _runner.RunScriptViaPwshAsync(@"
finally { IsBusy = false; IsProgressIndeterminate = false; }
}
- [RelayCommand]
- private async Task ListFeatureUpdatesAsync()
- {
- IsBusy = true;
- IsProgressIndeterminate = true;
- IsShowingHistory = false;
- StatusMessage = "Checking for feature upgrades…";
- Updates.Clear();
- ShowConsole = false;
- _cts?.Dispose();
- _cts = new CancellationTokenSource();
-
- try
- {
- var json = new System.Text.StringBuilder();
- void Capture(PowerShellLine l)
- {
- if (l.Kind == OutputKind.Output)
- json.AppendLine(l.Text);
- }
-
- _runner.LineReceived += Capture;
- try
- {
- await _runner.RunScriptViaPwshAsync(@"
- Import-Module PSWindowsUpdate -ErrorAction Stop
- $f = Get-WindowsUpdate -MicrosoftUpdate -UpdateType Software -Category 'Upgrades' -ErrorAction SilentlyContinue
- if (-not $f -or $f.Count -eq 0) { '[]' }
- else {
- $f | Select-Object @{N='Title';E={$_.Title}},
- @{N='KB';E={if($_.KBArticleIDs){('KB'+($_.KBArticleIDs -join ','))}else{''}}},
- @{N='Size';E={$_.Size}},
- @{N='Status';E={'Available'}},
- @{N='Date';E={$null}},
- @{N='IsHidden';E={$false}},
- @{N='Category';E={'Feature upgrade'}} |
- ConvertTo-Json -Compress
- }
- ", cancellationToken: _cts.Token);
- }
- finally { _runner.LineReceived -= Capture; }
-
- ParseUpdateJson(json.ToString());
- UpdateCount = Updates.Count;
- TableSummary = UpdateCount > 0
- ? $"{UpdateCount} feature upgrades available."
- : "No feature upgrades available.";
- StatusMessage = "Done";
- }
- catch (OperationCanceledException) { StatusMessage = "Cancelled."; }
- catch (InvalidOperationException ex) { StatusMessage = $"Error: {ex.Message}"; }
- finally { IsBusy = false; IsProgressIndeterminate = false; }
- }
-
[RelayCommand]
private async Task CheckPendingRebootAsync()
{
@@ -349,9 +322,7 @@ await _runner.RunScriptViaPwshAsync(@"
[RelayCommand]
private async Task InstallUpdatesAsync()
{
- var selected = Updates
- .Where(u => u.IsSelected && !string.IsNullOrWhiteSpace(u.KB) && u.KB.All(c => char.IsLetterOrDigit(c)))
- .ToList();
+ var selected = Updates.Where(u => u.IsSelected).ToList();
if (selected.Count == 0)
{
StatusMessage = "No updates selected.";
@@ -364,27 +335,147 @@ private async Task InstallUpdatesAsync()
if (AdminHelper.RelaunchAsAdmin()) System.Windows.Application.Current?.Shutdown();
return;
}
+
IsBusy = true;
IsProgressIndeterminate = true;
StatusMessage = $"Installing {selected.Count} update(s) (do not reboot)…";
ShowConsole = true;
Console.ClearCommand.Execute(null);
+ foreach (var u in selected) u.Status = "Installing…";
+
_cts?.Dispose();
_cts = new CancellationTokenSource();
+
+ var resultJson = new System.Text.StringBuilder();
+ void Capture(PowerShellLine l)
+ {
+ if (l.Kind == OutputKind.Output && l.Text.StartsWith("__RESULT__:", StringComparison.Ordinal))
+ resultJson.AppendLine(l.Text.Substring("__RESULT__:".Length));
+ }
+
try
{
- var kbFilter = string.Join("','", selected.Select(u => u.KB));
- await _runner.RunScriptViaPwshAsync($@"
- Import-Module PSWindowsUpdate -ErrorAction Stop
- Install-WindowsUpdate -MicrosoftUpdate -KBArticleID '{kbFilter}' -AcceptAll -IgnoreReboot -Verbose
- ", cancellationToken: _cts.Token);
- StatusMessage = $"Installed {selected.Count} update(s).";
+ // PowerShell single-quoted strings escape ' as ''. Build a literal array of titles.
+ var titleArray = string.Join(",", selected.Select(u => "'" + u.Title.Replace("'", "''") + "'"));
+
+ _runner.LineReceived += Capture;
+ try
+ {
+ await _runner.RunScriptViaPwshAsync($@"
+ Import-Module PSWindowsUpdate -ErrorAction Stop
+ $titles = @({titleArray})
+ $matched = Get-WindowsUpdate -MicrosoftUpdate -ErrorAction SilentlyContinue |
+ Where-Object {{ $titles -contains $_.Title }}
+ if (-not $matched -or @($matched).Count -eq 0) {{
+ $up = Get-WindowsUpdate -MicrosoftUpdate -UpdateType Software -Category 'Upgrades' -ErrorAction SilentlyContinue |
+ Where-Object {{ $titles -contains $_.Title }}
+ if ($up) {{ $matched = $up }}
+ }}
+ if (-not $matched -or @($matched).Count -eq 0) {{
+ Write-Host 'No matching updates found in the live update feed. They may already be installed or no longer offered.'
+ '__RESULT__:[]'
+ return
+ }}
+ $installed = $matched | Install-WindowsUpdate -AcceptAll -IgnoreReboot -Verbose
+ $report = $installed | Select-Object @{{N='Title';E={{$_.Title}}}},
+ @{{N='Result';E={{
+ if ($_.Result) {{ $_.Result.ToString() }}
+ elseif ($_.Status) {{ $_.Status.ToString() }}
+ else {{ 'Unknown' }}
+ }}}}
+ if (-not $report) {{ '__RESULT__:[]' }}
+ else {{ '__RESULT__:' + (@($report) | ConvertTo-Json -Compress -Depth 3) }}
+ ", cancellationToken: _cts.Token);
+ }
+ finally { _runner.LineReceived -= Capture; }
+
+ var (installed, failed, results) = ParseInstallResults(resultJson.ToString());
+ ApplyInstallResults(selected, results);
+
+ var notInstalled = selected.Count - installed - failed;
+ StatusMessage = failed > 0 || notInstalled > 0
+ ? $"Installed {installed}/{selected.Count}. Failed: {failed}. Not applied: {notInstalled}."
+ : $"Installed {installed}/{selected.Count}.";
+
+ if (installed > 0)
+ ToastService.Instance.Show("Windows Update", $"Installed {installed} update(s)");
+ }
+ catch (OperationCanceledException)
+ {
+ StatusMessage = "Cancelled.";
+ foreach (var u in selected.Where(s => s.Status == "Installing…")) u.Status = "Cancelled";
+ }
+ catch (InvalidOperationException ex)
+ {
+ StatusMessage = $"Error: {ex.Message}";
+ foreach (var u in selected.Where(s => s.Status == "Installing…")) u.Status = "Error";
}
- catch (OperationCanceledException) { StatusMessage = "Cancelled."; }
- catch (InvalidOperationException ex) { StatusMessage = $"Error: {ex.Message}"; }
finally { IsBusy = false; IsProgressIndeterminate = false; }
}
+ ///
+ /// Parse the JSON report emitted by Install-WindowsUpdate
+ /// (array of {Title, Result}). Returns counts and per-title results.
+ ///
+ internal static (int Installed, int Failed, IReadOnlyDictionary Results)
+ ParseInstallResults(string raw)
+ {
+ var map = new Dictionary(StringComparer.Ordinal);
+ if (string.IsNullOrWhiteSpace(raw)) return (0, 0, map);
+
+ try
+ {
+ using var doc = JsonDocument.Parse(raw);
+ var root = doc.RootElement;
+ var items = root.ValueKind == JsonValueKind.Array
+ ? root.EnumerateArray()
+ : new[] { root }.AsEnumerable();
+
+ foreach (var el in items)
+ {
+ var title = el.TryGetProperty("Title", out var t) ? t.GetString() ?? "" : "";
+ var result = el.TryGetProperty("Result", out var r) ? r.GetString() ?? "" : "";
+ if (!string.IsNullOrWhiteSpace(title))
+ map[title] = result;
+ }
+ }
+ catch (JsonException ex)
+ {
+ Log.Warning("Failed to parse install result JSON: {Error}", ex.Message);
+ return (0, 0, map);
+ }
+
+ var installed = map.Values.Count(IsInstalledResult);
+ var failed = map.Values.Count(IsFailedResult);
+ return (installed, failed, map);
+ }
+
+ private static bool IsInstalledResult(string result) =>
+ result.Contains("Installed", StringComparison.OrdinalIgnoreCase) ||
+ result.Equals("Succeeded", StringComparison.OrdinalIgnoreCase) ||
+ result.Equals("Success", StringComparison.OrdinalIgnoreCase);
+
+ private static bool IsFailedResult(string result) =>
+ result.Contains("Failed", StringComparison.OrdinalIgnoreCase) ||
+ result.Contains("Error", StringComparison.OrdinalIgnoreCase);
+
+ private void ApplyInstallResults(IReadOnlyList attempted, IReadOnlyDictionary results)
+ {
+ foreach (var entry in attempted)
+ {
+ if (results.TryGetValue(entry.Title, out var result) && !string.IsNullOrWhiteSpace(result))
+ {
+ entry.Status = IsInstalledResult(result) ? "Installed"
+ : IsFailedResult(result) ? "Failed"
+ : result;
+ }
+ else
+ {
+ entry.Status = "Not applied";
+ }
+ }
+ }
+
[RelayCommand]
private void SelectAll()
{
diff --git a/SysManager/SysManager/Views/WindowsUpdateView.xaml b/SysManager/SysManager/Views/WindowsUpdateView.xaml
index 4213612..8085c14 100644
--- a/SysManager/SysManager/Views/WindowsUpdateView.xaml
+++ b/SysManager/SysManager/Views/WindowsUpdateView.xaml
@@ -106,7 +106,6 @@
-