Files
windows-updater/windows-updater.ps1
2026-07-07 10:14:27 +02:00

1378 lines
50 KiB
PowerShell

#Requires -Version 5.1
<#
Script: windows-updater.ps1
Description: Automated Windows Update management with
notifications, winget support, logging,
history tracking, and reboot control.
Platforms: Windows 10/11, Server 2016/2019/2022/2025
Author: Patrick Asmus
Web: https://www.cleveradmin.de
Repository: https://git.techniverse.net/scriptos/windows-updater.git
License: MIT
Version: 2.8.0
Date: 07.07.2026
Modifications: Add -DefenderOnly, -DryRun, -ConfigFile parameters and UpdateType config option
#####################################################
#>
param(
[switch]$DefenderOnly,
[switch]$DryRun,
[string]$ConfigFile
)
# ===================================================
#region Administrator Check
# ===================================================
if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Host "[ERROR] This script must be run as Administrator." -ForegroundColor Red
exit 1
}
try {
if ([enum]::GetNames([Net.SecurityProtocolType]) -contains 'Tls13') {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls13 -bor [Net.SecurityProtocolType]::Tls12
}
else {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
}
}
catch {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
}
#endregion
# ===================================================
#region Global Variables
# ===================================================
$script:LogFile = $null
$script:Config = $null
$script:OSInfo = $null
$script:WSUSInfo = $null
$script:HasErrors = $false
$script:RebootPending = $false
$script:HistoryEntries = [System.Collections.ArrayList]::new()
$script:UseXlsx = $false
$script:UpdateResults = [System.Collections.ArrayList]::new()
$script:WingetResults = [System.Collections.ArrayList]::new()
$script:ScriptStartTime = Get-Date
$script:DefenderOnlyMode = $DefenderOnly.IsPresent
$script:DryRunParam = $DryRun.IsPresent
$script:ConfigFilePath = $ConfigFile
#endregion
# ===================================================
#region INI Config Parser
# ===================================================
function Read-IniFile {
param([string]$Path)
if (-not (Test-Path -Path $Path)) {
Write-Host "[ERROR] Configuration file not found: $Path" -ForegroundColor Red
Write-Host "[ERROR] Copy config.example.ini to config.ini and adjust values." -ForegroundColor Red
exit 1
}
$config = @{}
$currentSection = $null
foreach ($line in (Get-Content -Path $Path -Encoding UTF8)) {
$trimmed = $line.Trim()
if ([string]::IsNullOrWhiteSpace($trimmed)) { continue }
if ($trimmed.StartsWith('#') -or $trimmed.StartsWith(';')) { continue }
if ($trimmed -match '^\[(.+)\]$') {
$currentSection = $Matches[1]
if (-not $config.ContainsKey($currentSection)) {
$config[$currentSection] = @{}
}
continue
}
if ($null -ne $currentSection -and $trimmed -match '^([^=]+)=(.*)$') {
$key = $Matches[1].Trim()
$value = $Matches[2].Trim()
$config[$currentSection][$key] = $value
}
}
return $config
}
function Get-ConfigValue {
param(
[hashtable]$Config,
[string]$Section,
[string]$Key,
[object]$Default = $null,
[ValidateSet('string', 'bool', 'int')]
[string]$Type = 'string'
)
$value = $null
if ($Config.ContainsKey($Section) -and $Config[$Section].ContainsKey($Key)) {
$value = $Config[$Section][$Key]
}
if ([string]::IsNullOrWhiteSpace($value)) {
return $Default
}
switch ($Type) {
'bool' {
return $value -match '^(true|1|yes)$'
}
'int' {
$parsed = 0
if ([int]::TryParse($value, [ref]$parsed)) {
return $parsed
}
return $Default
}
default {
return $value
}
}
}
#endregion
# ===================================================
#region Logging
# ===================================================
function Initialize-Logging {
param([string]$LogPath, [int]$RetentionDays)
if (-not (Test-Path -Path $LogPath)) {
New-Item -ItemType Directory -Path $LogPath -Force | Out-Null
}
$dateStr = (Get-Date).ToString("yyyy-MM-dd")
$script:LogFile = Join-Path -Path $LogPath -ChildPath "windows-updater_$dateStr.log"
Remove-OldLogs -LogPath $LogPath -RetentionDays $RetentionDays
}
function Remove-OldLogs {
param([string]$LogPath, [int]$RetentionDays)
$cutoff = (Get-Date).AddDays(-$RetentionDays)
$oldLogs = Get-ChildItem -Path $LogPath -Filter "windows-updater_*.log" -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -lt $cutoff }
if ($oldLogs) {
$count = ($oldLogs | Measure-Object).Count
$oldLogs | Remove-Item -Force
Write-Log "Removed $count log file(s) older than $RetentionDays days" -Level INFO
}
}
function Write-Log {
param(
[string]$Message,
[ValidateSet('INFO', 'WARN', 'ERROR')]
[string]$Level = 'INFO'
)
$timestamp = (Get-Date).ToString("dd.MM.yyyy HH:mm:ss")
$logLine = "[$timestamp] [$Level] $Message"
switch ($Level) {
'INFO' { Write-Host $logLine -ForegroundColor Cyan }
'WARN' { Write-Host $logLine -ForegroundColor Yellow }
'ERROR' { Write-Host $logLine -ForegroundColor Red }
}
if ($script:LogFile) {
$logLine | Out-File -Append -FilePath $script:LogFile -Encoding UTF8
}
}
#endregion
# ===================================================
#region OS Detection
# ===================================================
function Get-OSInfo {
$os = Get-CimInstance -ClassName Win32_OperatingSystem
$info = @{
Caption = $os.Caption
Version = $os.Version
BuildNumber = [int]$os.BuildNumber
IsServer = ($os.ProductType -ne 1)
OSType = if ($os.ProductType -eq 1) { "Client" } else { "Server" }
}
return $info
}
#endregion
# ===================================================
#region Module Management
# ===================================================
function Initialize-PSWindowsUpdate {
$moduleName = "PSWindowsUpdate"
$existing = Get-Module -Name $moduleName -ListAvailable -ErrorAction SilentlyContinue
if ($existing) {
Import-Module -Name $moduleName -Force -ErrorAction SilentlyContinue
if (Get-Module -Name $moduleName) {
Write-Log "PSWindowsUpdate module loaded (already installed, v$($existing[0].Version))"
return $true
}
}
Write-Log "Attempting online installation of PSWindowsUpdate..."
try {
$nuget = Get-PackageProvider -Name NuGet -ListAvailable -ErrorAction SilentlyContinue
if (-not $nuget -or $nuget[0].Version -lt [version]"2.8.5.201") {
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -ErrorAction Stop | Out-Null
}
Install-Module -Name $moduleName -Force -Scope AllUsers -AllowClobber -ErrorAction Stop
Import-Module -Name $moduleName -Force -ErrorAction Stop
Write-Log "PSWindowsUpdate module installed and loaded from PSGallery"
return $true
}
catch {
Write-Log "Online installation failed: $($_.Exception.Message)" -Level WARN
}
Write-Log "Falling back to offline module from repository..."
$offlinePath = Join-Path -Path $PSScriptRoot -ChildPath "Modules"
if (Test-Path -Path $offlinePath) {
$env:PSModulePath = "$offlinePath;$env:PSModulePath"
try {
Import-Module -Name $moduleName -Force -ErrorAction Stop
Write-Log "PSWindowsUpdate module loaded from offline fallback"
return $true
}
catch {
Write-Log "Offline module import failed: $($_.Exception.Message)" -Level ERROR
}
}
else {
Write-Log "Offline module path not found: $offlinePath" -Level ERROR
}
Write-Log "Failed to load PSWindowsUpdate module from any source" -Level ERROR
return $false
}
function Initialize-ImportExcel {
$moduleName = "ImportExcel"
$existing = Get-Module -Name $moduleName -ListAvailable -ErrorAction SilentlyContinue
if ($existing) {
Import-Module -Name $moduleName -Force -ErrorAction SilentlyContinue
if (Get-Module -Name $moduleName) {
Write-Log "ImportExcel module loaded (v$($existing[0].Version))"
return $true
}
}
Write-Log "Attempting online installation of ImportExcel..."
try {
Install-Module -Name $moduleName -Force -Scope AllUsers -AllowClobber -ErrorAction Stop
Import-Module -Name $moduleName -Force -ErrorAction Stop
Write-Log "ImportExcel module installed and loaded from PSGallery"
return $true
}
catch {
Write-Log "ImportExcel not available, falling back to CSV for history export" -Level WARN
return $false
}
}
#endregion
# ===================================================
#region WSUS Detection
# ===================================================
function Get-WSUSConfig {
$result = @{
IsConfigured = $false
ServerUrl = ""
}
$wuRegPath = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate"
$auRegPath = "$wuRegPath\AU"
if (-not (Test-Path -Path $wuRegPath)) {
return $result
}
try {
$useWU = Get-ItemProperty -Path $auRegPath -Name UseWUServer -ErrorAction SilentlyContinue
if ($useWU -and $useWU.UseWUServer -eq 1) {
$wuServer = Get-ItemProperty -Path $wuRegPath -Name WUServer -ErrorAction SilentlyContinue
if ($wuServer -and -not [string]::IsNullOrWhiteSpace($wuServer.WUServer)) {
$result.IsConfigured = $true
$result.ServerUrl = $wuServer.WUServer
}
}
}
catch {
Write-Log "Error reading WSUS configuration: $($_.Exception.Message)" -Level WARN
}
return $result
}
#endregion
# ===================================================
#region Notifications
# ===================================================
function Send-NtfyNotification {
param(
[string]$Title,
[string]$Body,
[string]$Priority = "default"
)
$url = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "NtfyUrl" -Default ""
$topic = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "NtfyTopic" -Default ""
$token = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "NtfyToken" -Default ""
if ([string]::IsNullOrWhiteSpace($url) -or [string]::IsNullOrWhiteSpace($topic)) {
Write-Log "ntfy URL or topic not configured, skipping ntfy notification" -Level WARN
return
}
$endpoint = "$url/$topic"
$headers = @{
"Title" = $Title
"Priority" = $Priority
}
if (-not [string]::IsNullOrWhiteSpace($token)) {
$headers["Authorization"] = "Bearer $token"
}
try {
Invoke-RestMethod -Uri $endpoint -Method Post -Body $Body -Headers $headers -ContentType "text/plain; charset=utf-8" -ErrorAction Stop | Out-Null
Write-Log "ntfy notification sent: $Title"
}
catch {
Write-Log "Failed to send ntfy notification: $($_.Exception.Message)" -Level WARN
}
}
function New-HtmlUpdateReport {
param([array]$Results, [string]$Hostname, [string]$OSCaption)
$rows = ""
foreach ($r in $Results) {
$color = if ($r.Result -match "^Installed|^Succeeded|^Success") { "#2d7d2d" } else { "#c0392b" }
$bgColor = if ($r.Result -match "^Installed|^Succeeded|^Success") { "#e8f5e9" } else { "#fde8e8" }
$eDate = [System.Net.WebUtility]::HtmlEncode($r.Date)
$eKB = [System.Net.WebUtility]::HtmlEncode($r.KB)
$eTitle = [System.Net.WebUtility]::HtmlEncode($r.Title)
$eSize = [System.Net.WebUtility]::HtmlEncode($r.Size)
$eResult = [System.Net.WebUtility]::HtmlEncode($r.Result)
$rows += @"
<tr style="background-color: $bgColor;">
<td style="padding: 8px; border: 1px solid #ddd;">$eDate</td>
<td style="padding: 8px; border: 1px solid #ddd;">$eKB</td>
<td style="padding: 8px; border: 1px solid #ddd;">$eTitle</td>
<td style="padding: 8px; border: 1px solid #ddd;">$eSize</td>
<td style="padding: 8px; border: 1px solid #ddd; color: $color; font-weight: bold;">$eResult</td>
</tr>
"@
}
$successCount = ($Results | Where-Object { $_.Result -match "^Installed|^Succeeded|^Success" } | Measure-Object).Count
$failCount = ($Results | Where-Object { $_.Result -notmatch "^Installed|^Succeeded|^Success" } | Measure-Object).Count
$eHostname = [System.Net.WebUtility]::HtmlEncode($Hostname)
$eOSCaption = [System.Net.WebUtility]::HtmlEncode($OSCaption)
$html = @"
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: Segoe UI, Arial, sans-serif; color: #333; max-width: 900px; margin: 0 auto; padding: 20px;">
<h2 style="color: #1a5276;">Windows Update Report</h2>
<p><strong>Host:</strong> $eHostname<br>
<strong>OS:</strong> $eOSCaption<br>
<strong>Date:</strong> $(Get-Date -Format "dd.MM.yyyy HH:mm:ss")<br>
<strong>Successful:</strong> $successCount | <strong>Failed:</strong> $failCount</p>
<table style="border-collapse: collapse; width: 100%; font-size: 14px;">
<thead>
<tr style="background-color: #1a5276; color: white;">
<th style="padding: 10px; border: 1px solid #ddd; text-align: left;">Date</th>
<th style="padding: 10px; border: 1px solid #ddd; text-align: left;">KB</th>
<th style="padding: 10px; border: 1px solid #ddd; text-align: left;">Title</th>
<th style="padding: 10px; border: 1px solid #ddd; text-align: left;">Size</th>
<th style="padding: 10px; border: 1px solid #ddd; text-align: left;">Result</th>
</tr>
</thead>
<tbody>
$rows
</tbody>
</table>
<p style="color: #888; font-size: 12px; margin-top: 20px;">Generated by Windows Updater Script</p>
</body>
</html>
"@
return $html
}
function Send-EmailNotification {
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '')]
param(
[string]$Subject,
[string]$Body,
[switch]$IsHtml,
[array]$UpdateResults = @()
)
$smtpServer = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "SmtpServer" -Default ""
$smtpPort = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "SmtpPort" -Default 587 -Type int
$useSsl = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "SmtpUseSsl" -Default $true -Type bool
$username = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "SmtpUsername" -Default ""
$password = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "SmtpPassword" -Default ""
$from = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "EmailFrom" -Default ""
$to = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "EmailTo" -Default ""
$htmlReport = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "EmailHtmlReport" -Default $true -Type bool
if ([string]::IsNullOrWhiteSpace($smtpServer) -or [string]::IsNullOrWhiteSpace($from) -or [string]::IsNullOrWhiteSpace($to)) {
Write-Log "Email not fully configured (SmtpServer/EmailFrom/EmailTo), skipping" -Level WARN
return
}
$recipients = $to -split ',' | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
$mailParams = @{
SmtpServer = $smtpServer
Port = $smtpPort
From = $from
To = $recipients
Subject = $Subject
Encoding = [System.Text.Encoding]::UTF8
}
if ($useSsl) {
$mailParams["UseSsl"] = $true
}
if (-not [string]::IsNullOrWhiteSpace($username) -and -not [string]::IsNullOrWhiteSpace($password)) {
$secPass = ConvertTo-SecureString -String $password -AsPlainText -Force
$cred = New-Object System.Management.Automation.PSCredential($username, $secPass)
$mailParams["Credential"] = $cred
}
if ($IsHtml -or ($htmlReport -and $UpdateResults.Count -gt 0)) {
if ($UpdateResults.Count -gt 0) {
$htmlBody = New-HtmlUpdateReport -Results $UpdateResults -Hostname $env:COMPUTERNAME -OSCaption $script:OSInfo.Caption
$mailParams["Body"] = $htmlBody
}
else {
$mailParams["Body"] = $Body
}
$mailParams["BodyAsHtml"] = $true
}
else {
$mailParams["Body"] = $Body
}
try {
Send-MailMessage @mailParams -ErrorAction Stop
Write-Log "Email notification sent: $Subject"
}
catch {
Write-Log "Failed to send email: $($_.Exception.Message)" -Level WARN
}
}
function Send-TeamsNotification {
param(
[string]$Title,
[string]$Body,
[array]$UpdateResults = @()
)
$webhookUrl = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "TeamsWebhookUrl" -Default ""
if ([string]::IsNullOrWhiteSpace($webhookUrl)) {
Write-Log "Teams webhook URL not configured, skipping Teams notification" -Level WARN
return
}
$bodyItems = @(
@{
type = "TextBlock"
text = $Title
size = "Large"
weight = "Bolder"
color = "Accent"
},
@{
type = "FactSet"
facts = @(
@{ title = "Host"; value = $env:COMPUTERNAME },
@{ title = "Date"; value = (Get-Date -Format "dd.MM.yyyy HH:mm:ss") }
)
},
@{
type = "TextBlock"
text = $Body
wrap = $true
}
)
if ($UpdateResults.Count -gt 0) {
$bodyItems += @{
type = "TextBlock"
text = "Update Details"
weight = "Bolder"
separator = $true
spacing = "Medium"
}
foreach ($r in $UpdateResults) {
$icon = if ($r.Result -match "^Installed|^Succeeded|^Success") { "" } else { "" }
$bodyItems += @{
type = "TextBlock"
text = "$icon **$($r.KB)** — $($r.Title) ($($r.Size)) — $($r.Result)"
wrap = $true
spacing = "Small"
}
}
}
$card = @{
type = "message"
attachments = @(
@{
contentType = "application/vnd.microsoft.card.adaptive"
contentUrl = $null
content = @{
'$schema' = "http://adaptivecards.io/schemas/adaptive-card.json"
type = "AdaptiveCard"
version = "1.4"
body = $bodyItems
}
}
)
}
$json = $card | ConvertTo-Json -Depth 10 -Compress
try {
Invoke-RestMethod -Uri $webhookUrl -Method Post -Body $json -ContentType "application/json; charset=utf-8" -ErrorAction Stop | Out-Null
Write-Log "Teams notification sent: $Title"
}
catch {
Write-Log "Failed to send Teams notification: $($_.Exception.Message)" -Level WARN
}
}
function Test-NotificationEvent {
param(
[ValidateSet('ModuleError', 'HookFailure', 'NoUpdates', 'DryRun', 'UpdateComplete', 'Reboot', 'RebootRequired', 'ServiceRestartFailure', 'ScanError')]
[string]$EventName
)
$key = "NotifyOn$EventName"
return Get-ConfigValue -Config $script:Config -Section "NotificationEvents" -Key $key -Default $true -Type bool
}
function Send-Notification {
param(
[string]$Subject,
[string]$Body,
[string]$Priority = "default",
[array]$UpdateResults = @(),
[string]$EventName = ""
)
if (-not [string]::IsNullOrWhiteSpace($EventName)) {
if (-not (Test-NotificationEvent -EventName $EventName)) {
Write-Log "Notification event '$EventName' is disabled, skipping"
return
}
}
$ntfyEnabled = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "NtfyEnabled" -Default $false -Type bool
$emailEnabled = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "EmailEnabled" -Default $false -Type bool
$teamsEnabled = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "TeamsEnabled" -Default $false -Type bool
$ntfyPriority = Get-ConfigValue -Config $script:Config -Section "Notification" -Key "NtfyPriority" -Default "default"
if (-not $ntfyEnabled -and -not $emailEnabled -and -not $teamsEnabled) {
return
}
if ($ntfyEnabled) {
$effectivePriority = if ($Priority -ne "default") { $Priority } else { $ntfyPriority }
Send-NtfyNotification -Title $Subject -Body $Body -Priority $effectivePriority
}
if ($emailEnabled) {
Send-EmailNotification -Subject $Subject -Body $Body -UpdateResults $UpdateResults
}
if ($teamsEnabled) {
Send-TeamsNotification -Title $Subject -Body $Body -UpdateResults $UpdateResults
}
}
#endregion
# ===================================================
#region Update History
# ===================================================
function Add-UpdateHistoryEntry {
param(
[string]$KB,
[string]$Title,
[string]$Size,
[string]$Result,
[string]$UpdateType = "WindowsUpdate"
)
$entry = [PSCustomObject]@{
Date = (Get-Date).ToString("dd.MM.yyyy HH:mm:ss")
ComputerName = $env:COMPUTERNAME
KB = $KB
Title = $Title
Size = $Size
Result = $Result
UpdateType = $UpdateType
}
$script:HistoryEntries.Add($entry) | Out-Null
}
function Export-UpdateHistory {
$historyEnabled = Get-ConfigValue -Config $script:Config -Section "History" -Key "Enabled" -Default $false -Type bool
if (-not $historyEnabled) { return }
if ($script:HistoryEntries.Count -eq 0) {
Write-Log "No history entries to export"
return
}
$basePath = Get-ConfigValue -Config $script:Config -Section "History" -Key "HistoryPath" -Default "C:\logs\windows-updater\update-history"
$directory = Split-Path -Path $basePath -Parent
if (-not (Test-Path -Path $directory)) {
New-Item -ItemType Directory -Path $directory -Force | Out-Null
}
if ($script:UseXlsx) {
$xlsxPath = "$basePath.xlsx"
try {
$script:HistoryEntries | Export-Excel -Path $xlsxPath -Append -WorksheetName "UpdateHistory" -AutoSize -TableStyle Medium2 -ErrorAction Stop
Write-Log "Update history exported to: $xlsxPath"
return
}
catch {
Write-Log "XLSX export failed, falling back to CSV: $($_.Exception.Message)" -Level WARN
}
}
$csvPath = "$basePath.csv"
try {
if (Test-Path -Path $csvPath) {
$existing = Import-Csv -Path $csvPath -Encoding UTF8
$all = @($existing) + @($script:HistoryEntries)
$all | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8 -Force
}
else {
$script:HistoryEntries | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8
}
Write-Log "Update history exported to: $csvPath"
}
catch {
Write-Log "Failed to export history: $($_.Exception.Message)" -Level ERROR
}
}
#endregion
# ===================================================
#region Pre/Post Hooks
# ===================================================
function Invoke-UpdateHook {
param(
[ValidateSet('Pre', 'Post')]
[string]$Phase
)
$configKey = "${Phase}UpdateScript"
$scriptPath = Get-ConfigValue -Config $script:Config -Section "Hooks" -Key $configKey -Default ""
if ([string]::IsNullOrWhiteSpace($scriptPath)) { return $true }
if (-not (Test-Path -Path $scriptPath)) {
Write-Log "$Phase-update hook script not found: $scriptPath" -Level ERROR
if ($Phase -eq "Pre") {
$abort = Get-ConfigValue -Config $script:Config -Section "Hooks" -Key "AbortOnPreHookFailure" -Default $false -Type bool
return (-not $abort)
}
return $true
}
Write-Log "Executing $Phase-update hook: $scriptPath"
try {
$hookProcess = Start-Process -FilePath "powershell.exe" `
-ArgumentList "-ExecutionPolicy Bypass -File `"$scriptPath`"" `
-Wait -PassThru -NoNewWindow -ErrorAction Stop
if ($hookProcess.ExitCode -eq 0) {
Write-Log "$Phase-update hook completed successfully"
return $true
}
else {
Write-Log "$Phase-update hook failed with exit code: $($hookProcess.ExitCode)" -Level ERROR
if ($Phase -eq "Pre") {
$abort = Get-ConfigValue -Config $script:Config -Section "Hooks" -Key "AbortOnPreHookFailure" -Default $false -Type bool
if ($abort) {
Write-Log "Aborting update process due to pre-hook failure" -Level ERROR
Send-Notification -Subject "Windows Update ABORTED - $env:COMPUTERNAME" `
-Body "Pre-update hook failed with exit code $($hookProcess.ExitCode). Update process aborted.`nHook: $scriptPath" `
-Priority "high" -EventName "HookFailure"
return $false
}
}
return $true
}
}
catch {
Write-Log "$Phase-update hook execution error: $($_.Exception.Message)" -Level ERROR
return $true
}
}
#endregion
# ===================================================
#region Windows Update Logic
# ===================================================
function Get-AvailableUpdates {
$params = @{}
$useMU = Get-ConfigValue -Config $script:Config -Section "WindowsUpdate" -Key "UseMicrosoftUpdate" -Default $true -Type bool
$excludeKBs = Get-ConfigValue -Config $script:Config -Section "WindowsUpdate" -Key "ExcludeKBs" -Default ""
$excludeDrivers = Get-ConfigValue -Config $script:Config -Section "WindowsUpdate" -Key "ExcludeDrivers" -Default $false -Type bool
if ($useMU -and -not $script:WSUSInfo.IsConfigured) {
$params["MicrosoftUpdate"] = $true
}
if ($script:DefenderOnlyMode) {
$params["Category"] = @("Definition Updates")
Write-Log "Filtering for Defender definition updates only"
}
if (-not [string]::IsNullOrWhiteSpace($excludeKBs)) {
$kbList = $excludeKBs -split ',' | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
if ($kbList.Count -gt 0) {
$params["NotKBArticleID"] = $kbList
Write-Log "Excluding KBs: $($kbList -join ', ')"
}
}
if ($excludeDrivers -and -not $script:DefenderOnlyMode) {
$params["NotCategory"] = "Drivers"
Write-Log "Excluding driver updates"
}
Write-Log "Scanning for available updates..."
try {
$updates = Get-WindowsUpdate @params -ErrorAction Stop
return $updates
}
catch {
Write-Log "Error scanning for updates: $($_.Exception.Message)" -Level ERROR
$script:HasErrors = $true
return $null
}
}
function Install-PendingUpdates {
$params = @{
AcceptAll = $true
}
$useMU = Get-ConfigValue -Config $script:Config -Section "WindowsUpdate" -Key "UseMicrosoftUpdate" -Default $true -Type bool
$excludeKBs = Get-ConfigValue -Config $script:Config -Section "WindowsUpdate" -Key "ExcludeKBs" -Default ""
$excludeDrivers = Get-ConfigValue -Config $script:Config -Section "WindowsUpdate" -Key "ExcludeDrivers" -Default $false -Type bool
$retryCount = Get-ConfigValue -Config $script:Config -Section "WindowsUpdate" -Key "RetryCount" -Default 3 -Type int
if ($useMU -and -not $script:WSUSInfo.IsConfigured) {
$params["MicrosoftUpdate"] = $true
}
if ($script:DefenderOnlyMode) {
$params["Category"] = @("Definition Updates")
}
if (-not [string]::IsNullOrWhiteSpace($excludeKBs)) {
$kbList = $excludeKBs -split ',' | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
if ($kbList.Count -gt 0) {
$params["NotKBArticleID"] = $kbList
}
}
if ($excludeDrivers -and -not $script:DefenderOnlyMode) {
$params["NotCategory"] = "Drivers"
}
Write-Log "Installing Windows updates..."
try {
$results = Install-WindowsUpdate @params -ErrorAction Stop
}
catch {
Write-Log "Error during update installation: $($_.Exception.Message)" -Level ERROR
$script:HasErrors = $true
return @()
}
if (-not $results) {
Write-Log "No updates were installed"
return @()
}
$resultArray = @($results)
# PSWindowsUpdate returns multiple objects per update (one per phase: search, download, install).
# Group by KB (or Title as fallback for driver/feature updates without KB) and keep only the last (final) result per update.
$grouped = $resultArray | Group-Object -Property { if ($_.KB) { $_.KB } else { $_.Title } }
$deduplicated = foreach ($group in $grouped) {
$group.Group | Select-Object -Last 1
}
foreach ($r in $deduplicated) {
$sizeStr = if ($r.Size) { "{0:N2} MB" -f ($r.Size / 1MB) } else { "N/A" }
$resultStr = if ($r.Result) { $r.Result.ToString() } else { $r.Status.ToString() }
$kbStr = if ($r.KB) { $r.KB } else { "N/A" }
Write-Log " $resultStr - $kbStr - $($r.Title) ($sizeStr)"
$entry = [PSCustomObject]@{
Date = (Get-Date).ToString("dd.MM.yyyy HH:mm:ss")
KB = $kbStr
Title = $r.Title
Size = $sizeStr
Result = $resultStr
}
$script:UpdateResults.Add($entry) | Out-Null
Add-UpdateHistoryEntry -KB $kbStr -Title $r.Title -Size $sizeStr -Result $resultStr
}
$failed = $deduplicated | Where-Object { $_.Result -and $_.Result.ToString() -notmatch "^Installed|^Succeeded|^Success" }
if ($failed -and $retryCount -gt 0) {
Write-Log "Retrying $(@($failed).Count) failed update(s)..." -Level WARN
foreach ($f in $failed) {
$kbId = if ($f.KB) { $f.KB } else { $script:HasErrors = $true; continue }
$retrySuccess = $false
for ($i = 1; $i -le $retryCount; $i++) {
Write-Log " Retry $i/$retryCount for $kbId - $($f.Title)" -Level WARN
try {
$retryResult = Install-WindowsUpdate -KBArticleID $kbId -AcceptAll -ErrorAction Stop
$finalRetryResult = if ($retryResult) { @($retryResult) | Select-Object -Last 1 } else { $null }
if ($finalRetryResult -and $finalRetryResult.Result -and $finalRetryResult.Result.ToString() -match "^Installed|^Succeeded|^Success") {
Write-Log " Retry successful for $kbId" -Level INFO
$retrySuccess = $true
$idx = -1
for ($j = 0; $j -lt $script:UpdateResults.Count; $j++) {
if ($script:UpdateResults[$j].KB -eq $kbId) { $idx = $j; break }
}
if ($idx -ge 0) {
$script:UpdateResults[$idx] = [PSCustomObject]@{
Date = (Get-Date).ToString("dd.MM.yyyy HH:mm:ss")
KB = $kbId
Title = $f.Title
Size = if ($f.Size) { "{0:N2} MB" -f ($f.Size / 1MB) } else { "N/A" }
Result = "Installed (Retry)"
}
}
break
}
}
catch {
Write-Log " Retry $i failed: $($_.Exception.Message)" -Level ERROR
}
}
if (-not $retrySuccess) {
Write-Log " All retries exhausted for $kbId" -Level ERROR
$script:HasErrors = $true
}
}
}
elseif ($failed) {
$script:HasErrors = $true
}
return $resultArray
}
#endregion
# ===================================================
#region Winget Updates
# ===================================================
function Invoke-WingetUpgrade {
$wingetEnabled = Get-ConfigValue -Config $script:Config -Section "Winget" -Key "Enabled" -Default $false -Type bool
if (-not $wingetEnabled) { return }
$wingetCmd = Get-Command winget -ErrorAction SilentlyContinue
if (-not $wingetCmd) {
try {
$appxPkg = Get-AppxPackage -AllUsers -Name "Microsoft.DesktopAppInstaller" -ErrorAction SilentlyContinue
if ($appxPkg) {
$wingetPath = Join-Path -Path $appxPkg.InstallLocation -ChildPath "winget.exe"
if (Test-Path -Path $wingetPath) {
$wingetCmd = $wingetPath
}
}
}
catch {
Write-Log "Appx-based winget detection failed: $($_.Exception.Message)" -Level WARN
}
}
if (-not $wingetCmd) {
Write-Log "Winget is not available on this system, skipping winget updates" -Level WARN
return
}
Write-Log "Running winget upgrade --all..."
try {
$wingetExe = if ($wingetCmd -is [System.Management.Automation.ApplicationInfo]) { $wingetCmd.Source } else { $wingetCmd }
$output = & $wingetExe upgrade --all --accept-source-agreements --accept-package-agreements --silent 2>&1
$exitCode = $LASTEXITCODE
$outputStr = $output | Out-String
Write-Log "Winget output:`n$outputStr"
if ($exitCode -eq 0) {
Write-Log "Winget upgrade completed successfully"
}
else {
Write-Log "Winget upgrade finished with exit code: $exitCode" -Level WARN
$script:HasErrors = $true
}
Add-UpdateHistoryEntry -KB "winget" -Title "Winget Upgrade --all" -Size "N/A" -Result "ExitCode: $exitCode" -UpdateType "Winget"
}
catch {
Write-Log "Winget upgrade error: $($_.Exception.Message)" -Level ERROR
$script:HasErrors = $true
}
}
#endregion
# ===================================================
#region Reboot Management
# ===================================================
function Test-PendingReboot {
$rebootRequired = $false
try {
$wuReboot = Get-WURebootStatus -Silent -ErrorAction SilentlyContinue
if ($wuReboot) { $rebootRequired = $true }
}
catch {
Write-Log "WURebootStatus check failed: $($_.Exception.Message)" -Level WARN
}
$regPaths = @(
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending",
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired"
)
foreach ($path in $regPaths) {
if (Test-Path -Path $path) {
$rebootRequired = $true
break
}
}
try {
$sessionMgr = Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager" -Name PendingFileRenameOperations -ErrorAction SilentlyContinue
if ($sessionMgr -and $sessionMgr.PendingFileRenameOperations) {
$rebootRequired = $true
}
}
catch {
Write-Log "PendingFileRenameOperations check failed: $($_.Exception.Message)" -Level WARN
}
return $rebootRequired
}
function Invoke-ScheduledReboot {
$rebootEnabled = Get-ConfigValue -Config $script:Config -Section "Reboot" -Key "Enabled" -Default $false -Type bool
if (-not $rebootEnabled) { return $false }
$mode = Get-ConfigValue -Config $script:Config -Section "Reboot" -Key "Mode" -Default "immediate"
switch ($mode.ToLower()) {
"immediate" {
Write-Log "Initiating immediate reboot..."
Send-Notification -Subject "Reboot - $env:COMPUTERNAME" `
-Body "System is rebooting now after Windows Updates." `
-Priority "high" -UpdateResults @($script:UpdateResults) -EventName "Reboot"
Start-Sleep -Seconds 5
Restart-Computer -Force
}
"delayed" {
$delayMinutes = Get-ConfigValue -Config $script:Config -Section "Reboot" -Key "DelayMinutes" -Default 5 -Type int
$delaySec = $delayMinutes * 60
Write-Log "Scheduling reboot in $delayMinutes minute(s)..."
Send-Notification -Subject "Reboot in $delayMinutes min - $env:COMPUTERNAME" `
-Body "System will reboot in $delayMinutes minute(s) after Windows Updates." `
-Priority "high" -UpdateResults @($script:UpdateResults) -EventName "Reboot"
& shutdown /r /t $delaySec /f /c "Windows Updater: Scheduled reboot in $delayMinutes minute(s)"
}
"scheduled" {
$scheduledTime = Get-ConfigValue -Config $script:Config -Section "Reboot" -Key "ScheduledTime" -Default "03:00"
if ($scheduledTime -notmatch '^\d{1,2}:\d{2}$') {
Write-Log "Invalid ScheduledTime format '$scheduledTime' (expected HH:mm), falling back to 03:00" -Level WARN
$scheduledTime = "03:00"
}
$parts = $scheduledTime.Split(':')
$hour = [int]$parts[0]
$minute = [int]$parts[1]
if ($hour -lt 0 -or $hour -gt 23 -or $minute -lt 0 -or $minute -gt 59) {
Write-Log "ScheduledTime '$scheduledTime' out of range, falling back to 03:00" -Level WARN
$hour = 3; $minute = 0; $scheduledTime = "03:00"
}
$now = Get-Date
$target = Get-Date -Hour $hour -Minute $minute -Second 0
if ($target -le $now) {
$target = $target.AddDays(1)
}
$delaySec = [int]($target - $now).TotalSeconds
Write-Log "Scheduling reboot at $scheduledTime (in $([math]::Round($delaySec / 60)) minutes)..."
Send-Notification -Subject "Reboot scheduled at $scheduledTime - $env:COMPUTERNAME" `
-Body "System will reboot at $scheduledTime after Windows Updates." `
-Priority "high" -UpdateResults @($script:UpdateResults) -EventName "Reboot"
& shutdown /r /t $delaySec /f /c "Windows Updater: Scheduled reboot at $scheduledTime"
}
default {
Write-Log "Unknown reboot mode: $mode" -Level WARN
return $false
}
}
return $true
}
#endregion
# ===================================================
#region Service Watchdog
# ===================================================
function Invoke-ServiceWatchdog {
$watchdogEnabled = Get-ConfigValue -Config $script:Config -Section "ServiceWatchdog" -Key "Enabled" -Default $false -Type bool
if (-not $watchdogEnabled) { return }
$serviceList = Get-ConfigValue -Config $script:Config -Section "ServiceWatchdog" -Key "Services" -Default ""
if ([string]::IsNullOrWhiteSpace($serviceList)) {
Write-Log "ServiceWatchdog enabled but no services configured" -Level WARN
return
}
$services = $serviceList -split ',' | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
if ($services.Count -eq 0) { return }
$waitSeconds = Get-ConfigValue -Config $script:Config -Section "ServiceWatchdog" -Key "WaitSeconds" -Default 30 -Type int
$retryCount = Get-ConfigValue -Config $script:Config -Section "ServiceWatchdog" -Key "RetryCount" -Default 3 -Type int
Write-Log "Service Watchdog: Restarting $($services.Count) service(s)..."
$failedServices = [System.Collections.ArrayList]::new()
foreach ($svcName in $services) {
Write-Log " Restarting service: $svcName"
$svc = Get-Service -Name $svcName -ErrorAction SilentlyContinue
if (-not $svc) {
Write-Log " Service '$svcName' not found on this system" -Level ERROR
$failedServices.Add([PSCustomObject]@{ Name = $svcName; Reason = "Service not found" }) | Out-Null
continue
}
$started = $false
for ($attempt = 1; $attempt -le $retryCount; $attempt++) {
try {
if ($attempt -eq 1) {
Restart-Service -Name $svcName -Force -ErrorAction Stop
}
else {
Start-Service -Name $svcName -ErrorAction Stop
}
$svc.WaitForStatus([System.ServiceProcess.ServiceControllerStatus]::Running, [TimeSpan]::FromSeconds($waitSeconds))
$svc.Refresh()
if ($svc.Status -eq 'Running') {
Write-Log " Service '$svcName' is running (attempt $attempt/$retryCount)"
$started = $true
break
}
}
catch {
Write-Log " Attempt $attempt/$retryCount for '$svcName' failed: $($_.Exception.Message)" -Level WARN
if ($attempt -lt $retryCount) {
Start-Sleep -Seconds 5
}
}
}
if (-not $started) {
$svc.Refresh()
Write-Log " Service '$svcName' could not be started (status: $($svc.Status))" -Level ERROR
$failedServices.Add([PSCustomObject]@{ Name = $svcName; Reason = "Failed after $retryCount attempts (status: $($svc.Status))" }) | Out-Null
$script:HasErrors = $true
}
}
if ($failedServices.Count -gt 0) {
$failList = ($failedServices | ForEach-Object { " - $($_.Name): $($_.Reason)" }) -join "`n"
$body = "Service Watchdog on $env:COMPUTERNAME failed to restart $($failedServices.Count) service(s):`n$failList"
Write-Log "Service Watchdog: $($failedServices.Count) service(s) failed" -Level ERROR
Send-Notification -Subject "Service Restart FAILED - $env:COMPUTERNAME" `
-Body $body -Priority "high" -EventName "ServiceRestartFailure"
}
else {
Write-Log "Service Watchdog: All services restarted successfully"
}
}
#endregion
# ===================================================
#region Main Execution
# ===================================================
function Start-WindowsUpdater {
# --- Load Config ---
if (-not [string]::IsNullOrWhiteSpace($script:ConfigFilePath)) {
$configPath = $script:ConfigFilePath
}
else {
$configPath = Join-Path -Path $PSScriptRoot -ChildPath "config.ini"
}
$script:Config = Read-IniFile -Path $configPath
# --- Resolve DefenderOnly mode (parameter overrides config) ---
if (-not $script:DefenderOnlyMode) {
$configUpdateType = Get-ConfigValue -Config $script:Config -Section "General" -Key "UpdateType" -Default "All"
if ($configUpdateType -eq "DefenderOnly") {
$script:DefenderOnlyMode = $true
}
}
# --- Initialize Logging ---
$logPath = Get-ConfigValue -Config $script:Config -Section "General" -Key "LogPath" -Default "C:\logs\windows-updater"
$logRetention = Get-ConfigValue -Config $script:Config -Section "General" -Key "LogRetentionDays" -Default 365 -Type int
Initialize-Logging -LogPath $logPath -RetentionDays $logRetention
$modeLabel = if ($script:DefenderOnlyMode) { "Defender Only" } else { "Full Update" }
Write-Log "=========================================="
Write-Log "Windows Updater started (Mode: $modeLabel)"
Write-Log "=========================================="
# --- OS Detection ---
$script:OSInfo = Get-OSInfo
Write-Log "OS: $($script:OSInfo.Caption) ($($script:OSInfo.OSType))"
Write-Log "Version: $($script:OSInfo.Version) (Build $($script:OSInfo.BuildNumber))"
Write-Log "Hostname: $env:COMPUTERNAME"
# --- Load PSWindowsUpdate ---
if (-not (Initialize-PSWindowsUpdate)) {
Write-Log "Cannot continue without PSWindowsUpdate module" -Level ERROR
Send-Notification -Subject "Windows Update FAILED - $env:COMPUTERNAME" `
-Body "Failed to load PSWindowsUpdate module. No updates were installed." `
-Priority "high" -EventName "ModuleError"
exit 1
}
# --- Initialize ImportExcel (for history) ---
$historyEnabled = Get-ConfigValue -Config $script:Config -Section "History" -Key "Enabled" -Default $false -Type bool
if ($historyEnabled) {
$script:UseXlsx = Initialize-ImportExcel
}
# --- WSUS Detection ---
$script:WSUSInfo = Get-WSUSConfig
if ($script:WSUSInfo.IsConfigured) {
Write-Log "WSUS server detected: $($script:WSUSInfo.ServerUrl)"
}
else {
Write-Log "No WSUS configured, using default update sources"
}
# --- Pre-Update Hook ---
$preHookResult = Invoke-UpdateHook -Phase Pre
if (-not $preHookResult) {
exit 1
}
# --- Scan for Updates ---
$dryRun = Get-ConfigValue -Config $script:Config -Section "General" -Key "DryRun" -Default $false -Type bool
if ($script:DryRunParam) { $dryRun = $true }
$availableUpdates = Get-AvailableUpdates
if ($null -eq $availableUpdates) {
Write-Log "Update scan failed, aborting" -Level ERROR
Send-Notification -Subject "Scan Error - $env:COMPUTERNAME" `
-Body "Windows update scan failed on $env:COMPUTERNAME. Check logs for details." `
-Priority "high" -EventName "ScanError"
Invoke-UpdateHook -Phase Post | Out-Null
Write-Log "Windows Updater finished (scan error)"
exit 1
}
$updateLabel = if ($script:DefenderOnlyMode) { "Defender" } else { "Windows" }
if (@($availableUpdates).Count -eq 0) {
Write-Log "No updates available"
Send-Notification -Subject "No $updateLabel Updates - $env:COMPUTERNAME" `
-Body "No $updateLabel updates are currently available for $env:COMPUTERNAME." `
-EventName "NoUpdates"
if (-not $dryRun) {
if (-not $script:DefenderOnlyMode) { Invoke-WingetUpgrade }
Export-UpdateHistory
}
Invoke-UpdateHook -Phase Post | Out-Null
if ($script:HasErrors) {
Write-Log "Windows Updater finished (no updates, but errors occurred)" -Level WARN
exit 1
}
Write-Log "Windows Updater finished (no updates)"
exit 0
}
$updateCount = @($availableUpdates).Count
Write-Log "Found $updateCount update(s):"
foreach ($u in $availableUpdates) {
$sizeStr = if ($u.Size) { "{0:N2} MB" -f ($u.Size / 1MB) } else { "N/A" }
$kbStr = if ($u.KB) { $u.KB } else { "N/A" }
Write-Log " [$kbStr] $($u.Title) ($sizeStr)"
}
# --- Dry-Run Mode ---
if ($dryRun) {
Write-Log "DRY-RUN mode active - no updates will be installed"
$reportLines = @("Available $updateLabel Updates for $env:COMPUTERNAME ($updateCount):`n")
foreach ($u in $availableUpdates) {
$sizeStr = if ($u.Size) { "{0:N2} MB" -f ($u.Size / 1MB) } else { "N/A" }
$kbStr = if ($u.KB) { $u.KB } else { "N/A" }
$reportLines += " [$kbStr] $($u.Title) ($sizeStr)"
}
$reportBody = $reportLines -join "`n"
Send-Notification -Subject "$updateLabel Dry-Run Report - $env:COMPUTERNAME" -Body $reportBody -EventName "DryRun"
Invoke-UpdateHook -Phase Post | Out-Null
Write-Log "Windows Updater finished (dry-run)"
exit 0
}
# --- Install Updates ---
Install-PendingUpdates
# --- Winget (skip in DefenderOnly mode) ---
if (-not $script:DefenderOnlyMode) {
Invoke-WingetUpgrade
}
# --- Export History ---
Export-UpdateHistory
# --- Send Summary Notification ---
$successCount = ($script:UpdateResults | Where-Object { $_.Result -match "^Installed|^Succeeded|^Success" } | Measure-Object).Count
$failCount = ($script:UpdateResults | Where-Object { $_.Result -notmatch "^Installed|^Succeeded|^Success" } | Measure-Object).Count
$summaryBody = @(
"$updateLabel Update Summary for $env:COMPUTERNAME"
"OS: $($script:OSInfo.Caption)"
"Date: $(Get-Date -Format 'dd.MM.yyyy HH:mm:ss')"
""
"Successful: $successCount"
"Failed: $failCount"
""
)
foreach ($r in $script:UpdateResults) {
$summaryBody += " [$($r.Result)] $($r.KB) - $($r.Title)"
}
$summaryText = $summaryBody -join "`n"
$hasFailures = $failCount -gt 0 -or $script:HasErrors
$priority = if ($hasFailures) { "high" } else { "default" }
$subjectPrefix = if ($hasFailures) { "$updateLabel Updates (with errors)" } else { "$updateLabel Updates OK" }
Send-Notification -Subject "$subjectPrefix - $env:COMPUTERNAME" `
-Body $summaryText -Priority $priority `
-UpdateResults @($script:UpdateResults) -EventName "UpdateComplete"
# --- Post-Update Hook ---
Invoke-UpdateHook -Phase Post | Out-Null
# --- Reboot Handling (skip in DefenderOnly mode) ---
if (-not $script:DefenderOnlyMode) {
$script:RebootPending = Test-PendingReboot
if ($script:RebootPending) {
Write-Log "System reboot is pending"
$rebootHandled = Invoke-ScheduledReboot
if (-not $rebootHandled) {
Write-Log "Auto-reboot is disabled, manual reboot required" -Level WARN
Send-Notification -Subject "Reboot Required - $env:COMPUTERNAME" `
-Body "Windows updates have been installed on $env:COMPUTERNAME but a reboot is required. Auto-reboot is disabled." `
-Priority "high" -EventName "RebootRequired"
}
}
else {
Write-Log "No reboot required"
# --- Service Watchdog (only when no reboot is pending) ---
Invoke-ServiceWatchdog
}
}
# --- Finish ---
$duration = (Get-Date) - $script:ScriptStartTime
Write-Log "Windows Updater finished in $([math]::Round($duration.TotalMinutes, 1)) minutes"
Write-Log "=========================================="
if ($script:HasErrors) { exit 1 }
if (-not $script:DefenderOnlyMode -and $script:RebootPending -and -not (Get-ConfigValue -Config $script:Config -Section "Reboot" -Key "Enabled" -Default $false -Type bool)) { exit 2 }
exit 0
}
# --- Entry Point ---
Start-WindowsUpdater
#endregion