Files
windows-builder/includes/$OEM$/$$/OEM/scripts/windowsupdate/WindowsUpdateManager.ps1
2026-06-02 03:37:09 -07:00

649 lines
24 KiB
PowerShell

<#
.SYNOPSIS
Windows Update Manager with Rich Notifications
.DESCRIPTION
Automated Windows Update manager with Windows 10/11 Action Center notifications
and detailed update popup windows
.NOTES
Author: oxmc
Version: 2.5
Requires: PowerShell 5.1+, Administrator privileges
#>
#Requires -RunAsAdministrator
param(
[switch]$Silent,
[switch]$AutoInstall,
[switch]$CheckOnly
)
# Import notification module
Import-Module "$PSScriptRoot\UpdateNotifications.psm1" -Force
$script:DataDir = "$env:ProgramData\WindowsUpdateManager"
$script:LogPath = "$script:DataDir\WindowsUpdate.log"
$script:ConfigPath = "$PSScriptRoot\UpdateConfig.json"
#region Configuration
function Get-UpdateConfiguration {
if (Test-Path $script:ConfigPath) {
try {
$config = Get-Content $script:ConfigPath -Raw | ConvertFrom-Json
Write-Log "Configuration loaded from $script:ConfigPath" -Level "INFO"
return $config
}
catch {
Write-Log "Failed to load config, using defaults" -Level "WARNING"
}
}
# Create default config
$defaultConfig = @{
AutoInstall = $false
AutoReboot = $false
IncludeDrivers = $false
IncludeOptional = $false
ShowNotifications = $true
CheckIntervalHours = 12
LogPath = "C:\Logs\WindowsUpdate.log"
}
# Save default config to file
try {
$defaultConfig | ConvertTo-Json -Depth 5 | Out-File $script:ConfigPath -Encoding UTF8
Write-Log "Created default configuration at $script:ConfigPath" -Level "SUCCESS"
}
catch {
Write-Log "Could not create config file: $($_.Exception.Message)" -Level "WARNING"
}
return $defaultConfig
}
#endregion
#region Logging
function Write-Log {
param(
[string]$Message,
[ValidateSet("INFO", "WARNING", "ERROR", "SUCCESS")]
[string]$Level = "INFO"
)
$logDir = Split-Path $script:LogPath -Parent
if (!(Test-Path $logDir)) {
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
}
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logEntry = "[$timestamp] [$Level] $Message"
Add-Content -Path $script:LogPath -Value $logEntry
$color = switch ($Level) {
"ERROR" { "Red" }
"WARNING" { "Yellow" }
"SUCCESS" { "Green" }
default { "White" }
}
Write-Host $logEntry -ForegroundColor $color
}
#endregion
#region Update Functions
function Start-UpdateService {
param([string]$Name)
$svc = Get-Service -Name $Name -ErrorAction SilentlyContinue
if (-not $svc) { Write-Log "Service $Name not found" "ERROR"; return $false }
if ($svc.Status -eq 'Running') { return $true }
try {
Start-Service -Name $Name -ErrorAction Stop
return $true
} catch {
Write-Log "Could not start $Name`: $($_.Exception.Message)" "ERROR"
return $false
}
}
function Test-UpdateServices {
Write-Log "Ensuring Windows Update services are available..." -Level "INFO"
$ok = $true
foreach ($svc in @("wuauserv", "BITS", "cryptsvc")) {
if (-not (Start-UpdateService $svc)) { $ok = $false }
}
return $ok
}
function Repair-UpdateServices {
Write-Log "Repair: stopping services and clearing corrupt cache..." -Level "WARNING"
foreach ($svc in @("BITS", "wuauserv", "cryptsvc")) {
Stop-Service -Name $svc -Force -ErrorAction SilentlyContinue
}
if (Test-Path "C:\Windows\SoftwareDistribution\Download") {
Remove-Item "C:\Windows\SoftwareDistribution\Download\*" -Recurse -Force -ErrorAction SilentlyContinue
Write-Log "Cleared SoftwareDistribution download cache" -Level "SUCCESS"
}
$ok = $true
foreach ($svc in @("BITS", "wuauserv", "cryptsvc")) {
if (-not (Start-UpdateService $svc)) { $ok = $false }
}
return $ok
}
function Get-AvailableUpdates {
param([PSCustomObject]$Config)
Write-Log "Searching for available updates..." -Level "INFO"
try {
$session = New-Object -ComObject "Microsoft.Update.Session"
$searcher = $session.CreateUpdateSearcher()
$criteria = "IsInstalled=0"
Write-Log "Search criteria: $criteria" -Level "INFO"
$searchResult = $searcher.Search($criteria)
Write-Log "Found $($searchResult.Updates.Count) total updates" -Level "INFO"
if ($searchResult.Updates.Count -eq 0) {
return @()
}
# Filter updates based on config
$filteredUpdates = @()
foreach ($update in $searchResult.Updates) {
$shouldInclude = $true
# Debug: Show update type
Write-Log "Evaluating: $($update.Title) - Type: $($update.Type)" -Level "INFO"
# Filter drivers
if (-not $Config.IncludeDrivers -and $update.Type -eq 2) {
Write-Log "FILTERED: $($update.Title) - Driver update excluded by config" -Level "WARNING"
$shouldInclude = $false
}
# Filter optional updates
if ($shouldInclude -and -not $Config.IncludeOptional -and -not $update.IsMandatory) {
# Check if it's a critical/security/important update
$isImportant = $false
foreach ($category in $update.Categories) {
if ($category.Name -match "Critical|Security|Important|Definition") {
$isImportant = $true
break
}
}
if (-not $isImportant) {
Write-Log "FILTERED: $($update.Title) - Optional update excluded by config" -Level "WARNING"
$shouldInclude = $false
}
}
if ($shouldInclude) {
Write-Log "INCLUDED: $($update.Title)" -Level "SUCCESS"
$filteredUpdates += $update
}
}
Write-Log "After filtering: $($filteredUpdates.Count) updates remaining" -Level "SUCCESS"
return $filteredUpdates
}
catch {
Write-Log "Error searching for updates: $($_.Exception.Message)" -Level "ERROR"
return @()
}
}
function Send-PipeMessage {
param([System.IO.StreamWriter]$Writer, [hashtable]$Msg)
try { $Writer.WriteLine(($Msg | ConvertTo-Json -Compress -Depth 4)) } catch {}
}
function Install-WindowsUpdates {
param(
[array]$Updates,
[scriptblock]$OnMessage = $null # if set, progress goes to pipe instead of WinForms
)
if (@($Updates).Count -eq 0) {
Write-Log "No updates to install" -Level "INFO"
return [PSCustomObject]@{ Succeeded = 0; Failed = 0; RebootRequired = $false }
}
Write-Log "Installing $($Updates.Count) update(s)..." -Level "INFO"
$useWinForms = ($null -eq $OnMessage)
$progressForm = $null
if ($useWinForms) {
Show-UpdateInstallingToast -UpdateCount $Updates.Count
$progressForm = Show-InstallationProgressWindow -Updates $Updates
}
$emit = {
param($msg)
if ($OnMessage) { & $OnMessage $msg }
}
try {
$session = New-Object -ComObject "Microsoft.Update.Session"
$collection = New-Object -ComObject "Microsoft.Update.UpdateColl"
$i = 1
foreach ($u in $Updates) {
Write-Log "Queuing: $($u.Title)" -Level "INFO"
if ($useWinForms) { Update-InstallationProgress -Form $progressForm -Current $i -Total $Updates.Count -CurrentUpdate $u.Title -Status "Preparing" }
& $emit @{ cmd = "progress"; phase = "Preparing"; index = $i; total = $Updates.Count; title = $u.Title; percent = 0 }
$collection.Add($u) | Out-Null
$i++
}
# Download
Write-Log "Downloading..." -Level "INFO"
if ($useWinForms) { Update-InstallationProgress -Form $progressForm -Current 0 -Total $Updates.Count -CurrentUpdate "Downloading..." -Status "Downloading" }
& $emit @{ cmd = "progress"; phase = "Downloading"; index = 0; total = $Updates.Count; title = "Downloading updates..."; percent = 0.1 }
$downloader = $session.CreateUpdateDownloader()
$downloader.Updates = $collection
$dlResult = $downloader.Download()
if ($dlResult.ResultCode -ne 2) {
Write-Log "Download failed (code $($dlResult.ResultCode))" -Level "ERROR"
& $emit @{ cmd = "progress"; phase = "Error"; index = 0; total = $Updates.Count; title = "Download failed"; percent = 0 }
if ($useWinForms) { if ($progressForm -and -not $progressForm.IsDisposed) { $progressForm.Close(); $progressForm.Dispose() } }
return [PSCustomObject]@{ Succeeded = 0; Failed = $Updates.Count; RebootRequired = $false }
}
Write-Log "Download complete. Installing..." -Level "SUCCESS"
if ($useWinForms) { Update-InstallationProgressBar -Form $progressForm -PercentComplete 50 -Status "Installing..." }
& $emit @{ cmd = "progress"; phase = "Installing"; index = 0; total = $Updates.Count; title = "Installing updates..."; percent = 0.5 }
$installer = $session.CreateUpdateInstaller()
$installer.Updates = $collection
$instResult = $installer.Install()
if ($useWinForms -and $progressForm -and -not $progressForm.IsDisposed) { $progressForm.Close(); $progressForm.Dispose() }
Write-Log "Install result code: $($instResult.ResultCode)" -Level "INFO"
$succeeded = 0; $failed = 0
for ($j = 0; $j -lt $Updates.Count; $j++) {
$ur = $instResult.GetUpdateResult($j)
$u = $Updates[$j]
$ok = $ur.ResultCode -in @(2, 3)
if ($ok) { $succeeded++ } else { $failed++ }
Write-Log "$(if ($ok) { 'OK' } else { 'FAIL' }): $($u.Title)" -Level $(if ($ok) { "SUCCESS" } else { "ERROR" })
& $emit @{
cmd = "progress"
phase = if ($ok) { "Installed" } else { "Failed" }
index = $j + 1
total = $Updates.Count
title = $u.Title
percent = ($j + 1) / $Updates.Count
}
}
if ($instResult.RebootRequired) { Write-Log "Reboot required." -Level "WARNING" }
return [PSCustomObject]@{ Succeeded = $succeeded; Failed = $failed; RebootRequired = $instResult.RebootRequired }
}
catch {
if ($useWinForms -and $progressForm -and -not $progressForm.IsDisposed) { $progressForm.Close(); $progressForm.Dispose() }
Write-Log "Installation error: $($_.Exception.Message)" -Level "ERROR"
return [PSCustomObject]@{ Succeeded = 0; Failed = $Updates.Count; RebootRequired = $false }
}
}
function Get-UpdateErrorMessage {
param([int]$HResult)
$errorMessages = @{
0x80240001 = "WU_E_NO_SERVICE - Windows Update Agent was unable to provide the service"
0x80240002 = "WU_E_MAX_CAPACITY_REACHED - The maximum capacity of the service was exceeded"
0x80240016 = "WU_E_INSTALL_NOT_ALLOWED - Operation tried to install while another installation was in progress"
0x80240017 = "WU_E_NOT_APPLICABLE - Operation was not performed because there are no applicable updates"
0x80240022 = "WU_E_REBOOT_REQUIRED - The operation could not be completed because a reboot is required"
0x80246007 = "WU_E_DM_NOTDOWNLOADED - The update has not been downloaded (driver updates may require manual download)"
0x80070BC9 = "ERROR_FAIL_REBOOT_REQUIRED - The requested operation failed. A system reboot is required"
0x80070643 = "ERROR_INSTALL_FAILURE - Fatal error during installation"
0x80070490 = "ERROR_NOT_FOUND - Element not found"
0x800F0922 = "CBS_E_INSTALLERS_FAILED - Processing advanced installers failed"
0x80242014 = "WU_E_UH_POSTREBOOTSTILLPENDING - The post-reboot operation for the update is still in progress"
0x80242006 = "WU_E_UH_INVALIDMETADATA - Update contains invalid metadata"
}
if ($errorMessages.ContainsKey($HResult)) {
return $errorMessages[$HResult]
}
else {
$hexCode = "0x{0:X8}" -f $HResult
return "Unknown error ($hexCode)"
}
}
function Invoke-RebootRequest {
param([bool]$RebootRequired)
if (-not $RebootRequired) { return }
$config = Get-UpdateConfiguration
if ($config.AutoReboot -or $AutoInstall) {
Write-Log "Auto-reboot enabled. System will restart in 5 minutes..." -Level "WARNING"
# Show 5 minute warning
Show-RebootRequiredToast -MinutesUntilReboot 5
Show-BalloonNotification -Title "Restart Required" `
-Message "Your computer will restart in 5 minutes to complete update installation" `
-Icon "Warning"
Start-Sleep -Seconds 300
Restart-Computer -Force
}
else {
# Show reboot notification without auto-restart
Show-RebootRequiredToast -MinutesUntilReboot 0
Show-BalloonNotification -Title "Restart Required" `
-Message "Please restart your computer to complete update installation" `
-Icon "Warning"
}
}
#endregion
#region Main Logic
function Start-UpdateCheck {
Write-Log "=== Windows Update Manager v2.5 Starting ===" -Level "INFO"
# Check services
if (-not (Test-UpdateServices)) {
Write-Log "Update services not running. Attempting repair..." -Level "WARNING"
if (-not (Repair-UpdateServices)) {
Write-Log "Failed to repair services. Exiting." -Level "ERROR"
Show-BalloonNotification -Title "Service Error" `
-Message "Windows Update services could not be started" `
-Icon "Error"
exit 1
}
}
# Load configuration
$config = Get-UpdateConfiguration
# Get available updates - force PS array so .Count is always reliable
$updates = @(Get-AvailableUpdates -Config $config)
if (@($updates).Count -eq 0) {
Write-Log "No updates available" -Level "SUCCESS"
if (-not $Silent) {
Show-BalloonNotification -Title "Windows Update" `
-Message "Your system is up to date. No updates available." `
-Icon "Info"
}
return
}
Write-Log "Found $($updates.Count) available updates" -Level "SUCCESS"
if ($CheckOnly) {
Write-Log "Check-only mode. Exiting." -Level "INFO"
return
}
$config = Get-UpdateConfiguration
$pipeName = "WUM_IPC_$(Get-Date -Format 'yyyyMMddHHmmss')"
if ($AutoInstall -or $config.AutoInstall) {
$userLoggedIn = [bool](Get-Process explorer -ErrorAction SilentlyContinue)
if ($userLoggedIn) {
Write-Log "Auto-install: notifying user and installing via pipe..." -Level "INFO"
Invoke-UpdatePipe -Updates $updates -PipeName $pipeName -AutoInstall
} else {
Write-Log "Auto-install: no user session, installing silently..." -Level "INFO"
$result = Install-WindowsUpdates -Updates $updates
if ($result.RebootRequired) { Invoke-RebootRequest -RebootRequired $true }
}
} else {
Write-Log "Waiting for user approval via pipe..." -Level "INFO"
Invoke-UpdatePipe -Updates $updates -PipeName $pipeName
}
}
function Invoke-UpdatePipe {
param(
[array]$Updates,
[string]$PipeName,
[switch]$AutoInstall # skip approval, go straight to install+progress
)
$pipeSecurity = [System.IO.Pipes.PipeSecurity]::new()
$sid = [System.Security.Principal.SecurityIdentifier]::new(
[System.Security.Principal.WellKnownSidType]::AuthenticatedUserSid, $null)
$rule = [System.IO.Pipes.PipeAccessRule]::new(
$sid, [System.IO.Pipes.PipeAccessRights]::ReadWrite,
[System.Security.AccessControl.AccessControlType]::Allow)
$pipeSecurity.AddAccessRule($rule)
$pipe = [System.IO.Pipes.NamedPipeServerStream]::new(
$PipeName,
[System.IO.Pipes.PipeDirection]::InOut, 1,
[System.IO.Pipes.PipeTransmissionMode]::Byte,
[System.IO.Pipes.PipeOptions]::None,
65536, 65536, $pipeSecurity)
$reader = $null
$writer = $null
try {
$spawned = Invoke-UserInteractiveSession -PipeName $PipeName
if (-not $spawned) { return }
Write-Log "Pipe server listening on $PipeName ..."
$cts = [System.Threading.CancellationTokenSource]::new([System.TimeSpan]::FromHours(2))
$task = $pipe.WaitForConnectionAsync($cts.Token)
while (-not $task.IsCompleted) { Start-Sleep -Milliseconds 200 }
if ($task.IsFaulted -or $task.IsCanceled) { Write-Log "Pipe wait cancelled." "WARN"; return }
Write-Log "UI connected."
$enc = [System.Text.UTF8Encoding]::new($false)
$writer = [System.IO.StreamWriter]::new($pipe, $enc, 65536, $true)
$reader = [System.IO.StreamReader]::new($pipe, $enc, $false, 65536, $true)
$writer.AutoFlush = $true
# Build the update metadata payload (IDs + titles for toast)
$meta = @($Updates | ForEach-Object {
[PSCustomObject]@{ UpdateID = $_.Identity.UpdateID; Title = $_.Title }
})
$toInstall = $Updates
if ($AutoInstall) {
# Tell UI to show "installing" notification - no approval needed
Send-PipeMessage $writer @{ cmd = "auto_install"; count = $Updates.Count; updates = @($meta | ForEach-Object { $_.Title }) }
} else {
# Send for user approval
$writer.WriteLine(($meta | ConvertTo-Json -Compress))
Write-Log "Waiting for user approval..."
$response = $reader.ReadLine()
Write-Log "UI response received."
if ([string]::IsNullOrEmpty($response) -or $response -eq "[]") {
Write-Log "User dismissed - nothing to install."
return
}
$approvedIds = @($response | ConvertFrom-Json)
Write-Log "User approved $($approvedIds.Count) update(s). Re-querying WU..."
$wuSession = New-Object -ComObject "Microsoft.Update.Session"
$wuSearcher = $wuSession.CreateUpdateSearcher()
$wuResult = $wuSearcher.Search("IsInstalled=0")
$toInstall = @($wuResult.Updates | Where-Object { $_.Identity.UpdateID -in @($approvedIds) })
if ($toInstall.Count -eq 0) { Write-Log "Approved updates no longer in WU." "WARN"; return }
}
# Notify UI that install is starting
Send-PipeMessage $writer @{ cmd = "install_start"; count = $toInstall.Count }
# Install with progress pushed back to UI via pipe
$progressCb = {
param($msg)
Send-PipeMessage $writer $msg
}
$result = Install-WindowsUpdates -Updates $toInstall -OnMessage $progressCb
# Send completion
Send-PipeMessage $writer @{
cmd = "complete"
succeeded = $result.Succeeded
failed = $result.Failed
rebootRequired = $result.RebootRequired
}
Write-Log "Done. Succeeded: $($result.Succeeded) Failed: $($result.Failed) Reboot: $($result.RebootRequired)"
if ($result.RebootRequired) { Invoke-RebootRequest -RebootRequired $true }
}
catch {
Write-Log "Pipe error: $($_.Exception.Message)" "ERROR"
try { Send-PipeMessage $writer @{ cmd = "error"; message = $_.Exception.Message } } catch {}
}
finally {
if ($reader) { try { $reader.Dispose() } catch {} }
if ($writer) { try { $writer.Dispose() } catch {} }
try { $pipe.Close(); $pipe.Dispose() } catch {}
}
}
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
public class WumSession {
[DllImport("wtsapi32.dll", SetLastError=true)]
static extern bool WTSQueryUserToken(uint sessionId, out IntPtr token);
[DllImport("advapi32.dll", SetLastError=true, CharSet=CharSet.Auto)]
static extern bool CreateProcessAsUser(
IntPtr hToken, string app, string cmdLine,
IntPtr procAttr, IntPtr threadAttr, bool inherit,
uint flags, IntPtr env, string dir,
ref STARTUPINFO si, out PROCESS_INFORMATION pi);
[DllImport("userenv.dll", SetLastError=true)]
static extern bool CreateEnvironmentBlock(out IntPtr env, IntPtr token, bool inherit);
[DllImport("userenv.dll", SetLastError=true)]
static extern bool DestroyEnvironmentBlock(IntPtr env);
[DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr h);
[StructLayout(LayoutKind.Sequential, CharSet=CharSet.Auto)]
struct STARTUPINFO {
public int cb, _r1; public string lpReserved, lpDesktop, lpTitle;
public int dwX, dwY, dwXSize, dwYSize, dwXCountChars, dwYCountChars;
public int dwFillAttribute, dwFlags; public short wShowWindow, cbReserved2;
public IntPtr lpReserved2, hStdInput, hStdOutput, hStdError;
}
[StructLayout(LayoutKind.Sequential)]
struct PROCESS_INFORMATION {
public IntPtr hProcess, hThread;
public int dwProcessId, dwThreadId;
}
public static bool SpawnInUserSession(uint sessionId, string commandLine) {
IntPtr token = IntPtr.Zero, env = IntPtr.Zero;
if (!WTSQueryUserToken(sessionId, out token)) return false;
try {
CreateEnvironmentBlock(out env, token, false);
var si = new STARTUPINFO();
si.cb = Marshal.SizeOf(si);
si.lpDesktop = "winsta0\\default";
PROCESS_INFORMATION pi;
// CREATE_UNICODE_ENVIRONMENT | CREATE_NEW_CONSOLE
bool ok = CreateProcessAsUser(token, null, commandLine,
IntPtr.Zero, IntPtr.Zero, false, 0x410, env, null, ref si, out pi);
if (ok) { CloseHandle(pi.hProcess); CloseHandle(pi.hThread); }
return ok;
} finally {
if (env != IntPtr.Zero) DestroyEnvironmentBlock(env);
CloseHandle(token);
}
}
}
'@
function Invoke-UserInteractiveSession {
param([string]$PipeName)
$uiScript = Join-Path $PSScriptRoot "WindowsUpdateManager-UI.ps1"
if (-not (Test-Path $uiScript)) {
Write-Log "UI script not found: $uiScript" "ERROR"
return $false
}
$cmdLine = "PowerShell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Normal -File `"$uiScript`" -PipeName `"$PipeName`""
$isSystem = [System.Security.Principal.WindowsIdentity]::GetCurrent().IsSystem
if ($isSystem) {
# Running as SYSTEM (scheduled task) - spawn directly into the user's desktop session
$explorerProc = Get-Process explorer -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $explorerProc) {
Write-Log "No interactive user session found - updates will be offered at next scheduled run." "WARN"
return $false
}
$sessionId = [uint32]$explorerProc.SessionId
$ok = [WumSession]::SpawnInUserSession($sessionId, $cmdLine)
if ($ok) {
Write-Log "UI process spawned in session $sessionId (pipe: $PipeName)"
} else {
Write-Log "SpawnInUserSession failed (error $([System.Runtime.InteropServices.Marshal]::GetLastWin32Error()))" "ERROR"
}
return $ok
} else {
# Running as interactive admin (dev/test) - keep window open so errors are visible
Write-Log "Not SYSTEM - spawning UI in current session for testing (pipe: $PipeName)" "INFO"
Start-Process -FilePath "PowerShell.exe" `
-ArgumentList "-NoProfile -ExecutionPolicy Bypass -WindowStyle Normal -File `"$uiScript`" -PipeName `"$PipeName`""
return $true
}
}
# Main entry point
try {
Start-UpdateCheck
Write-Log "=== Windows Update Manager Completed ===" -Level "SUCCESS"
}
catch {
Write-Log "Fatal error: $($_.Exception.Message)" -Level "ERROR"
Show-BalloonNotification -Title "Update Manager Error" `
-Message "An error occurred: $($_.Exception.Message)" `
-Icon "Error"
exit 1
}
#endregion