using Microsoft.Extensions.Logging; using PatchProbe.Shared.Contracts; using PatchProbe.Shared.Models; namespace PatchProbe.Cli.Collectors; public sealed class WindowsUpdateCollector(ILogger logger) : ICollector { public Task CollectAsync(CancellationToken cancellationToken = default) { logger.LogInformation("Collecting Windows Update information via WUA COM"); var applicable = new List(); var history = new List(); string? searchError = null; try { var sessionType = Type.GetTypeFromProgID("Microsoft.Update.Session") ?? throw new InvalidOperationException("Microsoft.Update.Session ProgID not found"); dynamic session = Activator.CreateInstance(sessionType)!; dynamic searcher = session.CreateUpdateSearcher(); logger.LogInformation("Searching for applicable updates (IsInstalled=0 and IsHidden=0)"); dynamic result = searcher.Search("IsInstalled=0 and IsHidden=0"); for (int i = 0; i < result.Updates.Count; i++) { dynamic u = result.Updates.Item(i); string kbId = ExtractKbId(u); string category = ExtractFirstCategory(u); applicable.Add(new ApplicableUpdate { UpdateId = TryGet(() => u.Identity.UpdateID), Title = TryGet(() => u.Title), KbArticleId = kbId, Category = category, Severity = TryGet(() => u.MsrcSeverity), RebootRequired = TryGet(() => u.RebootRequired), IsDownloaded = TryGet(() => u.IsDownloaded), Description = TryGet(() => u.Description), SupportUrl = TryGet(() => u.SupportUrl), }); } logger.LogInformation("Found {Count} applicable updates", applicable.Count); int totalHistoryCount = searcher.GetTotalHistoryCount(); int historyLimit = Math.Min(totalHistoryCount, 100); if (historyLimit > 0) { dynamic historyEntries = searcher.QueryHistory(0, historyLimit); for (int i = 0; i < historyEntries.Count; i++) { dynamic e = historyEntries.Item(i); history.Add(new UpdateHistoryEntry { UpdateId = TryGet(() => e.UpdateIdentity.UpdateID), Title = TryGet(() => e.Title), KbArticleId = ExtractKbIdFromTitle(TryGet(() => e.Title)), ResultCode = TryGet(() => (int)e.ResultCode), HResult = TryGet(() => e.HResult), Date = TryGetDate(() => e.Date), Operation = TryGet(() => (int)e.Operation) switch { 1 => "Installation", 2 => "Uninstallation", _ => "Unknown" }, ServerSelection = TryGet(() => (int)e.ServerSelection) switch { 0 => "Default", 1 => "ManagedServer", 2 => "WindowsUpdate", 3 => "Others", _ => "Unknown" }, }); } } } catch (Exception ex) { logger.LogWarning(ex, "WUA search failed"); searchError = ex.Message; } return Task.FromResult(new WindowsUpdateInfo { ApplicableUpdates = applicable, History = history, SearchError = searchError, }); } private static string ExtractKbId(dynamic update) { try { if (update.KBArticleIDs.Count > 0) return "KB" + update.KBArticleIDs.Item(0); } catch { /* fall through to title parse */ } return ExtractKbIdFromTitle(TryGet(() => update.Title)); } private static string ExtractFirstCategory(dynamic update) { try { if (update.Categories.Count > 0) return update.Categories.Item(0).Name; } catch { } return string.Empty; } private static string ExtractKbIdFromTitle(string? title) { if (string.IsNullOrEmpty(title)) return string.Empty; var match = System.Text.RegularExpressions.Regex.Match(title, @"KB\d+", System.Text.RegularExpressions.RegexOptions.IgnoreCase); return match.Success ? match.Value.ToUpperInvariant() : string.Empty; } private static T TryGet(Func getter) { try { return getter(); } catch { return default!; } } private static DateTimeOffset? TryGetDate(Func getter) { try { var raw = getter(); if (raw is DateTime dt) return new DateTimeOffset(dt, TimeSpan.Zero); if (raw is DateTimeOffset dto) return dto; if (DateTime.TryParse(raw?.ToString(), out var parsed)) return new DateTimeOffset(parsed, TimeSpan.Zero); } catch { } return null; } }