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 @@