updates.ps1

#Requires -Version 5.0

[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'AcceptAll', Justification = 'Used as a parameter set selector by Install-WindowsUpdate.')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'All', Justification = 'Used as a parameter set selector by Install-MSStoreUpdate.')]

# ---- WinRT interop cache (both PS5.1 and PS7+) ---------------------------------
$script:AppInstallManager = $null
$_asTaskGeneric = $null

function _getAppInstallManager {
  if ($null -ne $script:AppInstallManager) { return $script:AppInstallManager }
  Add-Type -AssemblyName System.Runtime.WindowsRuntime -ErrorAction SilentlyContinue
  [Windows.ApplicationModel.Store.Preview.InstallControl.AppInstallManager, Windows.ApplicationModel.Store.Preview, ContentType = WindowsRuntime] | Out-Null
  $script:AppInstallManager = New-Object Windows.ApplicationModel.Store.Preview.InstallControl.AppInstallManager
  return $script:AppInstallManager
}

function _awaitWinRt($WinRtTask, $ResultType) {
  if ($null -eq $script:_asTaskGeneric) {
    $script:_asTaskGeneric = ([System.WindowsRuntimeSystemExtensions].GetMethods() |
        Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1' })[0]
  }
  $asTask = $script:_asTaskGeneric.MakeGenericMethod($ResultType)
  $netTask = $asTask.Invoke($null, @($WinRtTask))
  $netTask.Wait(-1) | Out-Null
  return $netTask.Result
}

# ---- Test-PSWindowsUpdateAvailable ---------------------------------------------
function Test-PSWindowsUpdateAvailable {
  <#
    .SYNOPSIS
      Returns $true if the PSWindowsUpdate module is installed.
    .DESCRIPTION
      Checks Get-Module -ListAvailable. Does not import the module.
    .EXAMPLE
      PS> Test-PSWindowsUpdateAvailable
    .LINK
      https://github.com/adnoctem/winkit/lib/updates.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [CmdletBinding()]
  [OutputType([bool])]
  param()

  return $null -ne (Get-Module -ListAvailable -Name PSWindowsUpdate -ErrorAction SilentlyContinue)
}

# ---- Get-WindowsUpdate ---------------------------------------------------------
function Get-WindowsUpdate {
  <#
    .SYNOPSIS
      Lists available Windows updates.
 
    .DESCRIPTION
      Wraps Get-WUList from the PSWindowsUpdate module. Filters by category,
      KB article ID, or title wildcard. Requires admin elevation and the
      PSWindowsUpdate module.
 
    .PARAMETER Category
      One or more update categories to include. Common values: Security,
      Critical, Definition, Update, UpdateRollup, Drivers.
 
    .PARAMETER KBArticleID
      Filter to a specific KB article.
 
    .PARAMETER Title
      Wildcard title filter forwarded to -Title.
 
    .EXAMPLE
      PS> Get-WindowsUpdate -Category Security, Critical
 
    .EXAMPLE
      PS> Get-WindowsUpdate -KBArticleID 'KB5021233'
 
    .LINK
      https://github.com/adnoctem/winkit/lib/updates.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [CmdletBinding()]
  [OutputType([PSCustomObject[]])]
  param (
    [Parameter(Mandatory = $false)]
    [string[]]
    $Category,

    [Parameter(Mandatory = $false)]
    [string]
    $KBArticleID,

    [Parameter(Mandatory = $false)]
    [string]
    $Title
  )

  if (-not (Test-PSWindowsUpdateAvailable)) {
    Write-Error 'PSWindowsUpdate module is not installed. Install it with: Install-Module -Name PSWindowsUpdate -Force -Scope CurrentUser'
    return
  }

  Import-Module PSWindowsUpdate -Force -ErrorAction Stop

  $_params = @{ ErrorAction = 'Stop' }
  if ($PSBoundParameters.ContainsKey('KBArticleID')) { $_params['KBArticleID'] = $KBArticleID }
  if ($PSBoundParameters.ContainsKey('Title')) { $_params['Title'] = $Title }

  try {
    $_updates = Get-WUList @_params

    if ($Category) {
      $_updates = $_updates | Where-Object { $_.Categories -and ($Category | Where-Object { $_ -in $_.Categories.Name }) }
    }

    return $_updates
  }
  catch {
    Write-Error "Failed to query available updates: $_"
    return
  }
}

# ---- Install-WindowsUpdate -----------------------------------------------------
function Install-WindowsUpdate {
  <#
    .SYNOPSIS
      Installs one or more Windows updates.
 
    .DESCRIPTION
      Accepts pipeline input from Get-WindowsUpdate, a specific KBArticleID,
      or the -AcceptAll switch to install everything available.
 
      Returns an object per update with KB, Title, HResult, and RebootRequired.
      When -AutoReboot is used and an update requires a restart, the system
      reboots after completion.
 
    .PARAMETER InputObject
      Update object from Get-WindowsUpdate (pipeline).
 
    .PARAMETER KBArticleID
      Install a specific KB by article ID.
 
    .PARAMETER AcceptAll
      Install all available updates without filtering.
 
    .PARAMETER AutoReboot
      Automatically restart the system if an update requires it.
 
    .PARAMETER IgnoreReboot
      Suppress the reboot-required check after install.
 
    .EXAMPLE
      PS> Get-WindowsUpdate -Category Security | Install-WindowsUpdate
 
    .EXAMPLE
      PS> Install-WindowsUpdate -KBArticleID 'KB5021233'
 
    .EXAMPLE
      PS> Install-WindowsUpdate -AcceptAll -AutoReboot
 
    .LINK
      https://github.com/adnoctem/winkit/lib/updates.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [CmdletBinding(DefaultParameterSetName = 'Pipeline')]
  [OutputType([PSCustomObject[]])]
  param (
    [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = 'Pipeline')]
    [psobject[]]
    $InputObject,

    [Parameter(Mandatory = $true, ParameterSetName = 'KB')]
    [string]
    $KBArticleID,

    [Parameter(Mandatory = $true, ParameterSetName = 'All')]
    [switch]
    $AcceptAll,

    [Parameter(Mandatory = $false)]
    [switch]
    $AutoReboot,

    [Parameter(Mandatory = $false)]
    [switch]
    $IgnoreReboot
  )

  begin {
    if (-not (Test-PSWindowsUpdateAvailable)) {
      Write-Error 'PSWindowsUpdate module is not installed.'
      return
    }
    Import-Module PSWindowsUpdate -Force -ErrorAction Stop
    $_results = New-Object System.Collections.ArrayList
    $_updateObjects = New-Object System.Collections.ArrayList
    $null = $PSBoundParameters['AcceptAll']
  }

  process {
    if ($PSCmdlet.ParameterSetName -eq 'Pipeline') {
      foreach ($_u in $InputObject) {
        [void]$_updateObjects.Add($_u)
      }
    }
  }

  end {
    if ($PSCmdlet.ParameterSetName -eq 'KB') {
      $_found = Get-WindowsUpdate -KBArticleID $KBArticleID
      if (-not $_found) {
        Write-Error "No update found for KB: $KBArticleID"
        return
      }
      [void]$_updateObjects.Add($_found)
    }

    if ($PSCmdlet.ParameterSetName -eq 'All') {
      $_all = Get-WindowsUpdate
      if (-not $_all) {
        Write-Error 'No updates available.'
        return
      }
      foreach ($_u in $_all) { [void]$_updateObjects.Add($_u) }
    }

    if ($_updateObjects.Count -eq 0) {
      Write-Error 'No updates to install.'
      return
    }

    try {
      $installResult = Install-WUUpdates -Updates $_updateObjects -AcceptAll -AutoReboot:$AutoReboot -IgnoreReboot:$IgnoreReboot -ErrorAction Stop

      foreach ($_item in $installResult) {
        $obj = [PSCustomObject]@{
          KB = $_item.KB
          Title = $_item.Title
          HResult = $_item.HResult
          Result = $_item.Result
          RebootRequired = $_item.RebootRequired
        }
        [void]$_results.Add($obj)
      }

      return $_results
    }
    catch {
      Write-Error "Failed to install updates: $_"
      return
    }
  }
}

# ---- Hide-WindowsUpdate --------------------------------------------------------
function Hide-WindowsUpdate {
  <#
    .SYNOPSIS
      Hides an available update so it does not appear in future scans.
 
    .DESCRIPTION
      Wraps Hide-WUUpdate. Useful for suppressing known-problematic updates
      or driver packages. Accepts pipeline input from Get-WindowsUpdate.
 
    .PARAMETER InputObject
      Update object from Get-WindowsUpdate (pipeline).
 
    .PARAMETER KBArticleID
      Hide a specific KB.
 
    .EXAMPLE
      PS> Get-WindowsUpdate -Title '*Driver*' | Hide-WindowsUpdate
 
    .EXAMPLE
      PS> Hide-WindowsUpdate -KBArticleID 'KB5021233'
 
    .LINK
      https://github.com/adnoctem/winkit/lib/updates.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [CmdletBinding(SupportsShouldProcess = $true)]
  param (
    [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = 'Pipeline')]
    [psobject[]]
    $InputObject,

    [Parameter(Mandatory = $true, ParameterSetName = 'KB')]
    [string]
    $KBArticleID
  )

  begin {
    if (-not (Test-PSWindowsUpdateAvailable)) {
      Write-Error 'PSWindowsUpdate module is not installed.'
      return
    }
    Import-Module PSWindowsUpdate -Force -ErrorAction Stop
    $_updates = New-Object System.Collections.ArrayList
  }

  process {
    if ($PSCmdlet.ParameterSetName -eq 'Pipeline') {
      foreach ($_u in $InputObject) {
        [void]$_updates.Add($_u)
      }
    }
  }

  end {
    if ($PSCmdlet.ParameterSetName -eq 'KB') {
      $_found = Get-WindowsUpdate -KBArticleID $KBArticleID
      if (-not $_found) { return }
      [void]$_updates.Add($_found)
    }

    if ($_updates.Count -eq 0) { return }

    foreach ($_u in $_updates) {
      $_label = "$($_u.KB) - $($_u.Title)"
      if (-not $PSCmdlet.ShouldProcess($_label, 'Hide update')) { continue }

      try {
        $null = Hide-WUUpdate -Update $_u -Confirm:$false -ErrorAction Stop
        Write-Verbose "Hidden: $_label"
      }
      catch {
        Write-Error "Failed to hide update '$_label': $_"
      }
    }
  }
}

# ---- Get-WindowsUpdateHistory --------------------------------------------------
function Get-WindowsUpdateHistory {
  <#
    .SYNOPSIS
      Returns installed Windows update history.
 
    .DESCRIPTION
      Wraps Get-WUHistory. Optionally filtered by -Last N recent entries
      or a specific -KBArticleID.
 
    .PARAMETER Last
      Number of most recent history entries to return.
 
    .PARAMETER KBArticleID
      Filter to a specific KB article.
 
    .EXAMPLE
      PS> Get-WindowsUpdateHistory -Last 10
 
    .EXAMPLE
      PS> Get-WindowsUpdateHistory -KBArticleID 'KB5021233'
 
    .LINK
      https://github.com/adnoctem/winkit/lib/updates.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [CmdletBinding()]
  [OutputType([PSCustomObject[]])]
  param (
    [Parameter(Mandatory = $false)]
    [int]
    $Last,

    [Parameter(Mandatory = $false)]
    [string]
    $KBArticleID
  )

  if (-not (Test-PSWindowsUpdateAvailable)) {
    Write-Error 'PSWindowsUpdate module is not installed.'
    return
  }

  Import-Module PSWindowsUpdate -Force -ErrorAction Stop

  $_params = @{ ErrorAction = 'Stop' }
  if ($PSBoundParameters.ContainsKey('Last')) { $_params['Last'] = $Last }
  if ($PSBoundParameters.ContainsKey('KBArticleID')) { $_params['KBArticleID'] = $KBArticleID }

  try {
    return Get-WUHistory @_params
  }
  catch {
    Write-Error "Failed to get update history: $_"
    return
  }
}

# ---- Uninstall-WindowsUpdate ---------------------------------------------------
function Uninstall-WindowsUpdate {
  <#
    .SYNOPSIS
      Uninstalls a previously installed Windows update.
 
    .DESCRIPTION
      Wraps Remove-WUUpdate. Requires a KBArticleID. Asks for confirmation
      by default since removing security updates can leave the system
      vulnerable.
 
    .PARAMETER KBArticleID
      KB article ID of the update to uninstall.
 
    .EXAMPLE
      PS> Uninstall-WindowsUpdate -KBArticleID 'KB5021233'
 
    .LINK
      https://github.com/adnoctem/winkit/lib/updates.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
  param (
    [Parameter(Mandatory = $true)]
    [string]
    $KBArticleID
  )

  if (-not (Test-PSWindowsUpdateAvailable)) {
    Write-Error 'PSWindowsUpdate module is not installed.'
    return
  }

  Import-Module PSWindowsUpdate -Force -ErrorAction Stop

  $_history = Get-WindowsUpdateHistory -KBArticleID $KBArticleID
  if (-not $_history) {
    Write-Error "No installed update found for KB: $KBArticleID"
    return
  }

  if (-not $PSCmdlet.ShouldProcess($KBArticleID, 'Uninstall Windows update')) {
    return
  }

  try {
    Remove-WUUpdate -KBArticleID $KBArticleID -Confirm:$false -ErrorAction Stop
    Write-Verbose "Uninstalled: $KBArticleID"
  }
  catch {
    Write-Error "Failed to uninstall ${KBArticleID}: $_"
  }
}

# ---- Test-WindowsUpdateRebootRequired ------------------------------------------
function Test-WindowsUpdateRebootRequired {
  <#
    .SYNOPSIS
      Returns $true if a reboot is pending after a Windows update install.
 
    .EXAMPLE
      PS> if (Test-WindowsUpdateRebootRequired) { Restart-Computer }
 
    .LINK
      https://github.com/adnoctem/winkit/lib/updates.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [CmdletBinding()]
  [OutputType([bool])]
  param()

  if (-not (Test-PSWindowsUpdateAvailable)) {
    Write-Error 'PSWindowsUpdate module is not installed.'
    return $false
  }

  Import-Module PSWindowsUpdate -Force -ErrorAction Stop

  try {
    $status = Get-WURebootStatus -ErrorAction Stop
    return $status.RebootRequired
  }
  catch {
    Write-Error "Failed to get reboot status: $_"
    return $false
  }
}

# ---- Get-WindowsUpdateConfiguration --------------------------------------------
function Get-WindowsUpdateConfiguration {
  <#
    .SYNOPSIS
      Returns current Windows Update service configuration.
 
    .DESCRIPTION
      Wraps Get-WUSettings. Returns an object with ServerSelection,
      ServiceID, TargetGroup, and NotificationLevel properties.
 
    .EXAMPLE
      PS> Get-WindowsUpdateConfiguration
 
    .LINK
      https://github.com/adnoctem/winkit/lib/updates.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [CmdletBinding()]
  [OutputType([PSCustomObject])]
  param()

  if (-not (Test-PSWindowsUpdateAvailable)) {
    Write-Error 'PSWindowsUpdate module is not installed.'
    return
  }

  Import-Module PSWindowsUpdate -Force -ErrorAction Stop

  try {
    return Get-WUSettings -ErrorAction Stop
  }
  catch {
    Write-Error "Failed to get Windows Update configuration: $_"
    return
  }
}

# ---- Get-MSStoreUpdate ---------------------------------------------------------
function Get-MSStoreUpdate {
  <#
    .SYNOPSIS
      Lists available Microsoft Store app updates.
 
    .DESCRIPTION
      Uses the WinRT AppInstallManager API to search for available Store
      app updates. When no -PackageFamilyName is supplied, returns updates
      for all installed Store-packaged apps.
 
      Does not require administrator elevation for user-scope updates.
 
    .PARAMETER PackageFamilyName
      Specific package family name to check, e.g. Microsoft.WindowsStore_8wekyb3d8bbwe.
      Supports wildcard matching.
 
    .EXAMPLE
      PS> Get-MSStoreUpdate
 
    .EXAMPLE
      PS> Get-MSStoreUpdate -PackageFamilyName '*WindowsTerminal*'
 
    .LINK
      https://github.com/adnoctem/winkit/lib/updates.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [CmdletBinding()]
  [OutputType([PSCustomObject[]])]
  param (
    [Parameter(Mandatory = $false)]
    [string]
    $PackageFamilyName
  )

  try {
    $mgr = _getAppInstallManager
    $searchOp = $mgr.SearchForUpdatesAsync()
    $appUpdates = _awaitWinRt $searchOp ([System.Collections.Generic.IReadOnlyList[Windows.ApplicationModel.Store.Preview.InstallControl.AppInstallItem]])

    if (-not $appUpdates -or $appUpdates.Count -eq 0) {
      return @()
    }

    $results = foreach ($item in $appUpdates) {
      $obj = [PSCustomObject]@{
        PackageFamilyName = $item.PackageFamilyName
        ProductId = $item.ProductId
        ItemKind = $item.ItemKind
        ErrorCode = $item.ErrorCode
        InstallType = $item.InstallType
        CompletedInstallCount = $item.CompletedInstallCount
        TotalInstallCount = $item.TotalInstallCount
      }
      if ($PackageFamilyName) {
        if ($obj.PackageFamilyName -like $PackageFamilyName) { $obj }
      }
      else { $obj }
    }

    return $results
  }
  catch {
    Write-Error "Failed to search for Store updates: $_"
    return
  }
}

# ---- Install-MSStoreUpdate -----------------------------------------------------
function Install-MSStoreUpdate {
  <#
    .SYNOPSIS
      Installs available Microsoft Store app updates.
 
    .DESCRIPTION
      Uses the WinRT AppInstallManager API to trigger Store app updates.
      Accepts pipeline input from Get-MSStoreUpdate or a specific
      -PackageFamilyName. Progress is reported via Write-Log.
 
      Does not require administrator elevation for user-scope updates.
      Non-installed apps that match -PackageFamilyName are skipped silently.
 
    .PARAMETER InputObject
      Store update object from Get-MSStoreUpdate (pipeline).
 
    .PARAMETER PackageFamilyName
      Specific package family name to update.
 
    .PARAMETER All
      Install all available Store updates.
 
    .EXAMPLE
      PS> Get-MSStoreUpdate | Install-MSStoreUpdate
 
    .EXAMPLE
      PS> Install-MSStoreUpdate -PackageFamilyName '*WindowsStore*'
 
    .EXAMPLE
      PS> Install-MSStoreUpdate -All
 
    .LINK
      https://github.com/adnoctem/winkit/lib/updates.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [CmdletBinding(DefaultParameterSetName = 'Pipeline')]
  [OutputType([PSCustomObject[]])]
  param (
    [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = 'Pipeline')]
    [psobject[]]
    $InputObject,

    [Parameter(Mandatory = $true, ParameterSetName = 'Name')]
    [string]
    $PackageFamilyName,

    [Parameter(Mandatory = $true, ParameterSetName = 'All')]
    [switch]
    $All
  )

  begin {
    $_items = New-Object System.Collections.ArrayList
    $_results = New-Object System.Collections.ArrayList
    $null = $PSBoundParameters['All']
  }

  process {
    if ($PSCmdlet.ParameterSetName -eq 'Pipeline') {
      foreach ($_i in $InputObject) {
        [void]$_items.Add($_i)
      }
    }
  }

  end {
    if ($PSCmdlet.ParameterSetName -eq 'Name') {
      $_found = Get-MSStoreUpdate -PackageFamilyName $PackageFamilyName
      if (-not $_found) {
        Write-Error "No Store updates found for: $PackageFamilyName"
        return
      }
      foreach ($_f in $_found) { [void]$_items.Add($_f) }
    }

    if ($PSCmdlet.ParameterSetName -eq 'All') {
      $_found = Get-MSStoreUpdate
      if (-not $_found) {
        Write-Error 'No Store updates available.'
        return
      }
      foreach ($_f in $_found) { [void]$_items.Add($_f) }
    }

    if ($_items.Count -eq 0) {
      Write-Error 'No Store updates to install.'
      return
    }

    $mgr = _getAppInstallManager

    foreach ($_item in $_items) {
      $_pfn = $_item.PackageFamilyName
      Write-Log -Message " Installing: $_pfn" -Color Yellow

      try {
        $updateOp = $mgr.UpdateAppByPackageFamilyNameAsync($_pfn)
        $updateResult = _awaitWinRt $updateOp ([Windows.ApplicationModel.Store.Preview.InstallControl.AppInstallItem])

        if ($null -eq $updateResult) {
          Write-Log -Message " No update available or already up to date." -Color Gray
          $_item = [PSCustomObject]@{
            PackageFamilyName = $_pfn
            Status = 'NoUpdate'
          }
          [void]$_results.Add($_item)
          continue
        }

        while ($true) {
          $currentStatus = $updateResult.GetCurrentStatus()
          if ($null -eq $currentStatus) { break }

          $pct = $currentStatus.PercentComplete
          if ($pct -eq 100) {
            Write-Log -Message " Install completed ($_pfn)" -Color Green
            $_item = [PSCustomObject]@{
              PackageFamilyName = $_pfn
              Status = 'Completed'
              ErrorCode = $currentStatus.ErrorCode
            }
            [void]$_results.Add($_item)
            break
          }

          if ($currentStatus.ErrorCode -and $currentStatus.ErrorCode -ne 0) {
            Write-Log -Message " Failed: $_pfn (ErrorCode $($currentStatus.ErrorCode))" -Color Red
            $_item = [PSCustomObject]@{
              PackageFamilyName = $_pfn
              Status = 'Failed'
              ErrorCode = $currentStatus.ErrorCode
            }
            [void]$_results.Add($_item)
            break
          }

          if ($pct % 25 -eq 0 -or $pct -eq 15) {
            Write-Log -Message " ${_pfn}: $pct%" -Color Gray
          }
          Start-Sleep -Seconds 3
        }
      }
      catch [System.AggregateException] {
        $inner = $_.Exception.InnerException
        Write-Log -Message " Skipped: $_pfn (not installed or not applicable: $inner)" -Color Gray
        $_item = [PSCustomObject]@{
          PackageFamilyName = $_pfn
          Status = 'Skipped'
        }
        [void]$_results.Add($_item)
      }
      catch {
        Write-Log -Message " Unexpected error for ${_pfn}: $_" -Color Red
        $_item = [PSCustomObject]@{
          PackageFamilyName = $_pfn
          Status = 'Failed'
        }
        [void]$_results.Add($_item)
      }
    }

    return $_results
  }
}