Runtime/SmartM365-DeviceRebootManager-GUI.ps1
|
#Requires -Version 5.1 <# .SYNOPSIS Opens the Smart Device Reboot Manager user GUI. .DESCRIPTION Shows a local WPF restart notification when a device uptime threshold is reached. The app provides a clear SmartM365 user interface, file-based reminder state, configurable postpone choices, preview mode, and structured local logging. .NOTES Version: 1.0 #> [CmdletBinding(SupportsShouldProcess = $true)] param( [int]$RecommendedRestartAfterDays = 5, [int]$RequiredRestartAfterDays = 7, [int]$RestartCountdownMinutes = 15, [int]$DefaultPostponeMinutes = 10, [int[]]$PostponeOptionsMinutes = @(), [int]$MaxPostponeCount = 2, [int]$ReminderCooldownHours = 20, [string]$WindowIconPath = 'WorkplaceCloudHub.ico', [string]$WindowTitle = '', [string]$CompanyName = '', [ValidateSet('auto','en','fr','de','es','nl','it','pt','pl','ar','tr','sv','da','nb','fi','ro','hu','ja','ko','zh-Hans','zh','uk')] [string]$DefaultLanguage = 'auto', [switch]$ForceLanguage, [string]$LanguageCatalogPath = '', [bool]$SplashEnabled = $true, [int]$SplashMinimumDurationMs = 6000, [string]$SplashProductName = '', [string]$SplashBadgeText = 'WORKPLACECLOUDHUB.COM', [string]$SplashSubtitle = 'Powered by WorkplaceCloudHub', [string]$SplashLogoPath = 'WorkplaceCloudHub-lockup-WPF.png', [switch]$EnableDebugLogging, [switch]$PreviewOnly, [switch]$TestRequiredRestart, [switch]$TestRecommendedRestart, [switch]$NeverForceRestart, [switch]$ResetRunState, [switch]$ValidateOnly, [string]$ConfigPath ) $ErrorActionPreference = 'Stop' $PSDefaultParameterValues['*:ErrorAction'] = 'Stop' $script:CliBoundParameters = @{} foreach ($key in $PSBoundParameters.Keys) { $script:CliBoundParameters[$key] = $true } $script:AppName = 'Smart Device Reboot Manager' $script:AppFolderName = 'SmartM365\DeviceRebootManager' $script:StartTopmost = $true $script:RestoreTopmostAfterPostpone = $true $script:RestoreTopmostAfterMinutes = 5 $script:PreviewOnly = [bool]$PreviewOnly $script:PostponeCount = 0 $script:AllowClose = $true $script:Ui = @{} $script:UserDataRoot = Join-Path -Path $env:APPDATA -ChildPath $script:AppFolderName $script:ProgramDataRoot = Join-Path -Path $env:ProgramData -ChildPath $script:AppFolderName $script:LogPath = Join-Path -Path $script:UserDataRoot -ChildPath 'SmartM365-DeviceRebootManager.log' $script:StatePath = Join-Path -Path $script:UserDataRoot -ChildPath 'SmartM365-DeviceRebootManager.state.json' $script:PreferencesPath = Join-Path -Path $script:UserDataRoot -ChildPath 'SmartM365-DeviceRebootManager.preferences.json' function Initialize-AppFolder { try { if (-not (Test-Path -LiteralPath $script:UserDataRoot)) { New-Item -ItemType Directory -Path $script:UserDataRoot -Force | Out-Null } $probePath = Join-Path -Path $script:UserDataRoot -ChildPath ('.write-test-{0}.tmp' -f ([guid]::NewGuid().ToString('N'))) [IO.File]::WriteAllText($probePath, 'test') Remove-Item -LiteralPath $probePath -Force -ErrorAction SilentlyContinue return } catch { $primaryError = $_.Exception.Message $fallbackRoot = Join-Path -Path $PSScriptRoot -ChildPath 'Logs' try { if (-not (Test-Path -LiteralPath $fallbackRoot)) { New-Item -ItemType Directory -Path $fallbackRoot -Force | Out-Null } } catch { $fallbackRoot = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath $script:AppFolderName if (-not (Test-Path -LiteralPath $fallbackRoot)) { New-Item -ItemType Directory -Path $fallbackRoot -Force | Out-Null } } $script:UserDataRoot = $fallbackRoot $script:LogPath = Join-Path -Path $script:UserDataRoot -ChildPath 'SmartM365-DeviceRebootManager.log' $script:StatePath = Join-Path -Path $script:UserDataRoot -ChildPath 'SmartM365-DeviceRebootManager.state.json' $script:PreferencesPath = Join-Path -Path $script:UserDataRoot -ChildPath 'SmartM365-DeviceRebootManager.preferences.json' if ($EnableDebugLogging) { Write-Warning "Unable to use AppData for Smart Device Reboot Manager ($primaryError). Falling back to '$script:UserDataRoot'." } } } function Invoke-LaunchLogRotation { Initialize-AppFolder try { if (Test-Path -LiteralPath $script:LogPath) { $item = Get-Item -LiteralPath $script:LogPath if ($item.Length -gt 0) { $stamp = Get-Date -Format 'yyyyMMdd_HHmmss_fff' $archivePath = Join-Path -Path $script:UserDataRoot -ChildPath ("SmartM365-DeviceRebootManager-{0}.log" -f $stamp) Move-Item -LiteralPath $script:LogPath -Destination $archivePath -Force } } New-Item -ItemType File -Path $script:LogPath -Force | Out-Null $keep = 10 Get-ChildItem -LiteralPath $script:UserDataRoot -Filter 'SmartM365-DeviceRebootManager-*.log' | Sort-Object LastWriteTime -Descending | Select-Object -Skip $keep | ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue } } catch { if ($EnableDebugLogging) { Write-Warning "Unable to rotate launch log: $($_.Exception.Message)" } } } function Write-AppLog { param( [Parameter(Mandatory = $true)][string]$Message, [ValidateSet('DEBUG','INFO','WARN','ERROR','SUCCESS')] [string]$Level = 'INFO' ) try { Initialize-AppFolder $utf8NoBom = New-Object System.Text.UTF8Encoding($false) $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff' $lines = @([regex]::Split(([string]$Message), '\r?\n') | ForEach-Object { '{0} [{1}] {2}' -f $timestamp, $Level, $_ }) [IO.File]::AppendAllText($script:LogPath, (($lines -join "`r`n") + "`r`n"), $utf8NoBom) if ($EnableDebugLogging) { $lines | ForEach-Object { Write-Host $_ } } if ($script:Ui.ContainsKey('ActivityText') -and $null -ne $script:Ui.ActivityText) { $uiLine = '[{0}] {1}' -f (Get-Date -Format 'HH:mm:ss'), $Message $script:Ui.ActivityText.AppendText("$uiLine`r`n") $script:Ui.ActivityText.ScrollToEnd() } } catch { if ($EnableDebugLogging) { Write-Warning "Unable to write app log: $($_.Exception.Message)" } } } function Stop-AppWithError { param( [Parameter(Mandatory = $true)][string]$Message, [int]$ExitCode = 1 ) try { Write-AppLog -Level ERROR -Message $Message } catch { Write-Verbose "Unable to write fatal log entry: $($_.Exception.Message)" } try { if ($script:Window -and $script:Window.IsVisible) { $script:Window.Close() } } catch { Write-Verbose "Unable to close GUI after fatal error: $($_.Exception.Message)" } [Environment]::Exit($ExitCode) } trap { $message = if ($_.Exception) { $_.Exception.ToString() } else { $_.ToString() } Stop-AppWithError -Message $message -ExitCode 1 } function Invoke-Safely { param([Parameter(Mandatory = $true)][scriptblock]$ScriptBlock) try { & $ScriptBlock } catch { $message = if ($_.Exception) { $_.Exception.ToString() } else { $_.ToString() } Stop-AppWithError -Message ("UI handler failed: {0}" -f $message) -ExitCode 3 } } function Get-FirstExistingPath { param([string[]]$Paths) foreach ($path in $Paths) { if (-not [string]::IsNullOrWhiteSpace($path) -and (Test-Path -LiteralPath $path)) { return $path } } return '' } function Resolve-AppRelativePath { param([string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { return '' } if ([IO.Path]::IsPathRooted($Path)) { return $Path } return Join-Path -Path $PSScriptRoot -ChildPath $Path } function Initialize-AppConfigFromTemplate { param( [Parameter(Mandatory = $true)][string]$ConfigPath, [Parameter(Mandatory = $true)][string]$TemplatePath ) if (Test-Path -LiteralPath $ConfigPath) { return $false } if (-not (Test-Path -LiteralPath $TemplatePath)) { return $false } $parent = Split-Path -Path $ConfigPath -Parent if (-not [string]::IsNullOrWhiteSpace($parent)) { New-Item -Path $parent -ItemType Directory -Force | Out-Null } Copy-Item -LiteralPath $TemplatePath -Destination $ConfigPath -ErrorAction Stop Write-AppLog -Level WARN -Message ("Created local configuration from template: {0}" -f $ConfigPath) Write-Host ((@( 'Created Device Reboot Manager local JSON from template.', "Local JSON: $ConfigPath", "Template: $TemplatePath", 'Review the generated local JSON values; continuing with default template values unless edited before next run.' )) -join [Environment]::NewLine) -ForegroundColor Yellow return $true } function Read-AppConfig { $scriptConfig = Join-Path -Path $PSScriptRoot -ChildPath 'SmartM365-DeviceRebootManager-GUI.config.json' $scriptTemplateConfig = Join-Path -Path $PSScriptRoot -ChildPath 'SmartM365-DeviceRebootManager-GUI.config.json.template' $programConfig = Join-Path -Path $script:ProgramDataRoot -ChildPath 'SmartM365-DeviceRebootManager-GUI.config.json' $userConfig = Join-Path -Path $script:UserDataRoot -ChildPath 'SmartM365-DeviceRebootManager-GUI.config.json' if (-not [string]::IsNullOrWhiteSpace($ConfigPath) -and -not (Test-Path -LiteralPath $ConfigPath)) { Initialize-AppConfigFromTemplate -ConfigPath $ConfigPath -TemplatePath $scriptTemplateConfig | Out-Null } elseif (-not (Test-Path -LiteralPath $scriptConfig) -and -not (Test-Path -LiteralPath $programConfig) -and -not (Test-Path -LiteralPath $userConfig)) { Initialize-AppConfigFromTemplate -ConfigPath $scriptConfig -TemplatePath $scriptTemplateConfig | Out-Null } $effectivePath = Get-FirstExistingPath -Paths @($ConfigPath, $scriptConfig, $programConfig, $userConfig) if ([string]::IsNullOrWhiteSpace($effectivePath)) { Write-AppLog -Level WARN -Message 'No configuration file found. CLI and built-in defaults will be used.' return $null } Write-AppLog -Message ("Loading configuration: {0}" -f $effectivePath) return Get-Content -LiteralPath $effectivePath -Raw -Encoding UTF8 | ConvertFrom-Json } function Get-ConfigValue { param( [object]$Config, [Parameter(Mandatory = $true)][string]$Name, [object]$CurrentValue, [ValidateSet('int','intArray','string','bool')] [string]$Type = 'string' ) if ($null -eq $Config) { return $CurrentValue } $property = $Config.PSObject.Properties[$Name] if ($null -eq $property -or $null -eq $property.Value) { return $CurrentValue } if ($script:CliBoundParameters.ContainsKey($Name)) { return $CurrentValue } switch ($Type) { 'int' { return [int]$property.Value } 'intArray' { return @($property.Value | ForEach-Object { [int]$_ }) } 'bool' { return [bool]$property.Value } default { return [string]$property.Value } } } function Apply-AppConfig { param([object]$Config) if ($null -eq $Config) { return } $script:StartTopmost = Get-ConfigValue -Config $Config -Name 'StartTopmost' -CurrentValue $script:StartTopmost -Type bool $script:RestoreTopmostAfterPostpone = Get-ConfigValue -Config $Config -Name 'RestoreTopmostAfterPostpone' -CurrentValue $script:RestoreTopmostAfterPostpone -Type bool $script:RestoreTopmostAfterMinutes = Get-ConfigValue -Config $Config -Name 'RestoreTopmostAfterMinutes' -CurrentValue $script:RestoreTopmostAfterMinutes -Type int $script:Effective = [ordered]@{ RecommendedRestartAfterDays = Get-ConfigValue -Config $Config -Name 'RecommendedRestartAfterDays' -CurrentValue $RecommendedRestartAfterDays -Type int RequiredRestartAfterDays = Get-ConfigValue -Config $Config -Name 'RequiredRestartAfterDays' -CurrentValue $RequiredRestartAfterDays -Type int RestartCountdownMinutes = Get-ConfigValue -Config $Config -Name 'RestartCountdownMinutes' -CurrentValue $RestartCountdownMinutes -Type int DefaultPostponeMinutes = Get-ConfigValue -Config $Config -Name 'DefaultPostponeMinutes' -CurrentValue $DefaultPostponeMinutes -Type int PostponeOptionsMinutes = Get-ConfigValue -Config $Config -Name 'PostponeOptionsMinutes' -CurrentValue $PostponeOptionsMinutes -Type intArray MaxPostponeCount = Get-ConfigValue -Config $Config -Name 'MaxPostponeCount' -CurrentValue $MaxPostponeCount -Type int ReminderCooldownHours = Get-ConfigValue -Config $Config -Name 'ReminderCooldownHours' -CurrentValue $ReminderCooldownHours -Type int WindowIconPath = Get-ConfigValue -Config $Config -Name 'WindowIconPath' -CurrentValue $WindowIconPath -Type string WindowTitle = Get-ConfigValue -Config $Config -Name 'WindowTitle' -CurrentValue $WindowTitle -Type string CompanyName = Get-ConfigValue -Config $Config -Name 'CompanyName' -CurrentValue $CompanyName -Type string DefaultLanguage = Get-ConfigValue -Config $Config -Name 'DefaultLanguage' -CurrentValue $DefaultLanguage -Type string ForceLanguage = Get-ConfigValue -Config $Config -Name 'ForceLanguage' -CurrentValue ([bool]$ForceLanguage) -Type bool LanguageCatalogPath = Get-ConfigValue -Config $Config -Name 'LanguageCatalogPath' -CurrentValue $LanguageCatalogPath -Type string SplashEnabled = Get-ConfigValue -Config $Config -Name 'SplashEnabled' -CurrentValue $SplashEnabled -Type bool SplashMinimumDurationMs = Get-ConfigValue -Config $Config -Name 'SplashMinimumDurationMs' -CurrentValue $SplashMinimumDurationMs -Type int SplashProductName = Get-ConfigValue -Config $Config -Name 'SplashProductName' -CurrentValue $SplashProductName -Type string SplashBadgeText = Get-ConfigValue -Config $Config -Name 'SplashBadgeText' -CurrentValue $SplashBadgeText -Type string SplashSubtitle = Get-ConfigValue -Config $Config -Name 'SplashSubtitle' -CurrentValue $SplashSubtitle -Type string SplashLogoPath = Get-ConfigValue -Config $Config -Name 'SplashLogoPath' -CurrentValue $SplashLogoPath -Type string EnableDebugLogging = Get-ConfigValue -Config $Config -Name 'EnableDebugLogging' -CurrentValue ([bool]$EnableDebugLogging) -Type bool PreviewOnly = Get-ConfigValue -Config $Config -Name 'PreviewOnly' -CurrentValue ([bool]$PreviewOnly) -Type bool TestRequiredRestart = Get-ConfigValue -Config $Config -Name 'TestRequiredRestart' -CurrentValue ([bool]$TestRequiredRestart) -Type bool TestRecommendedRestart = Get-ConfigValue -Config $Config -Name 'TestRecommendedRestart' -CurrentValue ([bool]$TestRecommendedRestart) -Type bool NeverForceRestart = Get-ConfigValue -Config $Config -Name 'NeverForceRestart' -CurrentValue ([bool]$NeverForceRestart) -Type bool } } function Initialize-EffectiveSettings { if ($null -eq $script:Effective) { $script:Effective = [ordered]@{ RecommendedRestartAfterDays = $RecommendedRestartAfterDays RequiredRestartAfterDays = $RequiredRestartAfterDays RestartCountdownMinutes = $RestartCountdownMinutes DefaultPostponeMinutes = $DefaultPostponeMinutes PostponeOptionsMinutes = $PostponeOptionsMinutes MaxPostponeCount = $MaxPostponeCount ReminderCooldownHours = $ReminderCooldownHours WindowIconPath = $WindowIconPath WindowTitle = $WindowTitle CompanyName = $CompanyName DefaultLanguage = $DefaultLanguage ForceLanguage = [bool]$ForceLanguage LanguageCatalogPath = $LanguageCatalogPath SplashEnabled = $SplashEnabled SplashMinimumDurationMs = $SplashMinimumDurationMs SplashProductName = $SplashProductName SplashBadgeText = $SplashBadgeText SplashSubtitle = $SplashSubtitle SplashLogoPath = $SplashLogoPath EnableDebugLogging = [bool]$EnableDebugLogging PreviewOnly = [bool]$PreviewOnly TestRequiredRestart = [bool]$TestRequiredRestart TestRecommendedRestart = [bool]$TestRecommendedRestart NeverForceRestart = [bool]$NeverForceRestart } } if (-not $script:Effective.PostponeOptionsMinutes -or $script:Effective.PostponeOptionsMinutes.Count -eq 0) { $script:Effective.PostponeOptionsMinutes = @([int]$script:Effective.DefaultPostponeMinutes) } $script:Effective.RecommendedRestartAfterDays = [Math]::Max(0, [int]$script:Effective.RecommendedRestartAfterDays) $script:Effective.RequiredRestartAfterDays = [Math]::Max($script:Effective.RecommendedRestartAfterDays + 1, [int]$script:Effective.RequiredRestartAfterDays) $script:Effective.RestartCountdownMinutes = [Math]::Max(1, [int]$script:Effective.RestartCountdownMinutes) $script:Effective.MaxPostponeCount = [Math]::Max(0, [int]$script:Effective.MaxPostponeCount) $script:Effective.ReminderCooldownHours = [Math]::Max(1, [int]$script:Effective.ReminderCooldownHours) $script:Effective.SplashMinimumDurationMs = [Math]::Max(0, [int]$script:Effective.SplashMinimumDurationMs) $script:PreviewOnly = [bool]$script:Effective.PreviewOnly } function Read-ReminderState { if (-not (Test-Path -LiteralPath $script:StatePath)) { return $null } try { return Get-Content -LiteralPath $script:StatePath -Raw -Encoding UTF8 | ConvertFrom-Json } catch { Write-AppLog -Level WARN -Message ("Unable to read reminder state. It will be ignored. {0}" -f $_.Exception.Message) return $null } } function Save-ReminderState { param( [Parameter(Mandatory = $true)][string]$Reason, [int]$DaysSinceReboot, [datetime]$LastBoot ) $state = [ordered]@{ LastReminderUtc = (Get-Date).ToUniversalTime().ToString('o') LastReason = $Reason DaysSinceReboot = $DaysSinceReboot LastBootUtc = $LastBoot.ToUniversalTime().ToString('o') ComputerName = $env:COMPUTERNAME UserName = $env:USERNAME } Initialize-AppFolder $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [IO.File]::WriteAllText($script:StatePath, ($state | ConvertTo-Json -Depth 4), $utf8NoBom) Write-AppLog -Message ("Reminder state saved. Reason={0}" -f $Reason) } function Read-UserPreferences { if (-not (Test-Path -LiteralPath $script:PreferencesPath)) { return $null } try { return Get-Content -LiteralPath $script:PreferencesPath -Raw -Encoding UTF8 | ConvertFrom-Json } catch { Write-AppLog -Level WARN -Message ("Unable to read user preferences. They will be ignored. {0}" -f $_.Exception.Message) return $null } } function Save-UserLanguagePreference { param([Parameter(Mandatory = $true)][string]$LanguageCode) $preferences = [ordered]@{ DefaultLanguage = $LanguageCode UpdatedUtc = (Get-Date).ToUniversalTime().ToString('o') UserName = $env:USERNAME } Initialize-AppFolder $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [IO.File]::WriteAllText($script:PreferencesPath, ($preferences | ConvertTo-Json -Depth 4), $utf8NoBom) Write-AppLog -Message ("User language preference saved: {0}" -f $LanguageCode) } function Apply-UserLanguagePreference { if ($script:Effective.ForceLanguage) { return } if ($script:CliBoundParameters.ContainsKey('DefaultLanguage')) { return } $preferences = Read-UserPreferences if ($null -eq $preferences -or [string]::IsNullOrWhiteSpace([string]$preferences.DefaultLanguage)) { return } $script:Effective.DefaultLanguage = [string]$preferences.DefaultLanguage Write-AppLog -Message ("User language preference loaded: {0}" -f $script:Effective.DefaultLanguage) } function Test-ReminderCooldown { param([object]$State) if ($null -eq $State) { return $false } if (-not $State.LastReminderUtc) { return $false } try { $lastReminder = [datetime]::Parse($State.LastReminderUtc).ToUniversalTime() $ageHours = ((Get-Date).ToUniversalTime() - $lastReminder).TotalHours return ($ageHours -lt [double]$script:Effective.ReminderCooldownHours) } catch { return $false } } function Get-LastBootTime { try { $os = Get-CimInstance Win32_OperatingSystem if ($os.LastBootUpTime -is [datetime]) { return $os.LastBootUpTime } return [Management.ManagementDateTimeConverter]::ToDateTime($os.LastBootUpTime) } catch { Write-AppLog -Level WARN -Message ("Last boot detection failed, falling back to max threshold. {0}" -f $_.Exception.Message) return (Get-Date).AddDays(-[int]$script:Effective.RequiredRestartAfterDays) } } function Get-PrimaryIPv4Address { try { $address = Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.IPAddress -and $_.IPAddress -notlike '169.254.*' -and $_.IPAddress -ne '127.0.0.1' -and $_.PrefixOrigin -ne 'WellKnown' } | Sort-Object InterfaceMetric, InterfaceIndex | Select-Object -First 1 -ExpandProperty IPAddress if (-not [string]::IsNullOrWhiteSpace([string]$address)) { return [string]$address } } catch { Write-AppLog -Level WARN -Message ("Primary IPv4 detection failed. {0}" -f $_.Exception.Message) } return 'N/A' } function Get-DetectedCompanyName { if (-not [string]::IsNullOrWhiteSpace([string]$script:Effective.CompanyName)) { return [string]$script:Effective.CompanyName } $registryPaths = @( 'HKLM:\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\TenantInfo\*', 'HKLM:\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo\*', 'HKCU:\Software\Microsoft\Windows NT\CurrentVersion\WorkplaceJoin\TenantInfo\*', 'HKCU:\Software\Microsoft\Windows NT\CurrentVersion\WorkplaceJoin\JoinInfo\*' ) $candidateProperties = @('DisplayName','TenantDisplayName','TenantName','TenantDomain','IdpDomain') foreach ($path in $registryPaths) { try { foreach ($item in @(Get-ItemProperty -Path $path -ErrorAction SilentlyContinue)) { foreach ($propertyName in $candidateProperties) { $property = $item.PSObject.Properties[$propertyName] if ($property -and -not [string]::IsNullOrWhiteSpace([string]$property.Value)) { return [string]$property.Value } } } } catch { Write-AppLog -Level DEBUG -Message ("Company registry detection failed for {0}. {1}" -f $path, $_.Exception.Message) } } try { $tenantLine = & dsregcmd.exe /status 2>$null | Where-Object { $_ -match '^\s*(TenantName|WorkplaceTenantName)\s*:' } | Select-Object -First 1 if ($tenantLine -match ':\s*(.+)$') { $tenantName = $matches[1].Trim() if (-not [string]::IsNullOrWhiteSpace($tenantName)) { return $tenantName } } } catch { Write-AppLog -Level DEBUG -Message ("Company dsregcmd detection failed. {0}" -f $_.Exception.Message) } try { $computerSystem = Get-CimInstance Win32_ComputerSystem if ($computerSystem.PartOfDomain -and -not [string]::IsNullOrWhiteSpace([string]$computerSystem.Domain)) { return [string]$computerSystem.Domain } } catch { Write-AppLog -Level DEBUG -Message ("Company domain detection failed. {0}" -f $_.Exception.Message) } return '' } function Test-UserSessionVisible { if ($script:Effective.TestRequiredRestart -or $script:Effective.TestRecommendedRestart) { return $true } try { $sessionLines = & query session $env:USERNAME 2>$null $sessionMatch = $sessionLines | Where-Object { $_ -match [regex]::Escape($env:USERNAME) } | Select-Object -First 1 if ($sessionMatch -match 'Disc') { Write-AppLog -Level WARN -Message ("Session for {0} appears disconnected. Exiting without showing UI." -f $env:USERNAME) return $false } } catch { Write-AppLog -Level WARN -Message ("Session visibility check failed, continuing. {0}" -f $_.Exception.Message) } return $true } function Invoke-RelaunchSta { $exe = (Get-Process -Id $PID).Path $arguments = New-Object System.Collections.Generic.List[string] $arguments.Add('-NoProfile') $arguments.Add('-ExecutionPolicy') $arguments.Add('Bypass') $arguments.Add('-Sta') $arguments.Add('-File') $arguments.Add(('"{0}"' -f $PSCommandPath)) foreach ($key in @('RecommendedRestartAfterDays','RequiredRestartAfterDays','RestartCountdownMinutes','DefaultPostponeMinutes','MaxPostponeCount','ReminderCooldownHours','DefaultLanguage','WindowIconPath','WindowTitle','CompanyName','LanguageCatalogPath')) { $value = $script:Effective[$key] if ($null -ne $value -and "$value" -ne '') { $arguments.Add("-$key") $arguments.Add(('"{0}"' -f $value)) } } if ($script:Effective.PostponeOptionsMinutes -and $script:Effective.PostponeOptionsMinutes.Count -gt 0) { $arguments.Add('-PostponeOptionsMinutes') foreach ($value in $script:Effective.PostponeOptionsMinutes) { $arguments.Add([string][int]$value) } } if ($script:Effective.EnableDebugLogging) { $arguments.Add('-EnableDebugLogging') } if ($script:Effective.PreviewOnly) { $arguments.Add('-PreviewOnly') } if ($script:Effective.TestRequiredRestart) { $arguments.Add('-TestRequiredRestart') } if ($script:Effective.TestRecommendedRestart) { $arguments.Add('-TestRecommendedRestart') } if ($script:Effective.NeverForceRestart) { $arguments.Add('-NeverForceRestart') } if ($script:Effective.ForceLanguage) { $arguments.Add('-ForceLanguage') } if ($ConfigPath) { $arguments.Add('-ConfigPath') $arguments.Add(('"{0}"' -f $ConfigPath)) } Start-Process -FilePath $exe -ArgumentList ($arguments -join ' ') -WindowStyle Hidden | Out-Null [Environment]::Exit(0) } function Invoke-SafeRestart { [CmdletBinding(SupportsShouldProcess = $true)] param([string]$Reason = 'User action') if ($script:PreviewOnly) { Write-AppLog -Level WARN -Message ("PreviewOnly is enabled. Restart skipped. Reason={0}" -f $Reason) [System.Windows.MessageBox]::Show('Preview mode is enabled. No restart was triggered.', $script:AppName, 'OK', 'Information') | Out-Null return } if (-not $PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Restart computer')) { Write-AppLog -Level WARN -Message 'Restart skipped by ShouldProcess.' return } Write-AppLog -Level WARN -Message ("Restart requested. Reason={0}" -f $Reason) try { $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if ($isAdmin) { Restart-Computer -Force } else { Start-Process -FilePath 'shutdown.exe' -ArgumentList '/r /t 0' -WindowStyle Hidden } } catch { Stop-AppWithError -Message ("Restart failed: {0}" -f $_.Exception.Message) -ExitCode 20 } } function Import-AppStringsCatalog { $defaultPath = Join-Path -Path $PSScriptRoot -ChildPath 'SmartM365-DeviceRebootManager-GUI.strings.psd1' $path = if (-not [string]::IsNullOrWhiteSpace($script:Effective.LanguageCatalogPath)) { Resolve-AppRelativePath -Path $script:Effective.LanguageCatalogPath } else { $defaultPath } if (-not (Test-Path -LiteralPath $path)) { throw "Strings resource file not found: $path" } $tokens = $null $parseErrors = $null $ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$tokens, [ref]$parseErrors) if ($parseErrors -and $parseErrors.Count -gt 0) { throw ("Strings resource file could not be parsed: {0}" -f (($parseErrors | ForEach-Object { $_.Message }) -join '; ')) } $statement = $ast.EndBlock.Statements | Select-Object -First 1 if ($null -eq $statement -or $null -eq $statement.PipelineElements -or $statement.PipelineElements.Count -eq 0) { throw "Strings resource file is empty: $path" } $catalog = $statement.PipelineElements[0].Expression.SafeGetValue() if (-not $catalog.ContainsKey('Strings') -or -not $catalog.Strings.ContainsKey('en')) { throw "Strings resource file is invalid or missing the English fallback: $path" } return $catalog } function Resolve-AppLanguage { param([Parameter(Mandatory = $true)][hashtable]$Catalog) $candidates = New-Object System.Collections.Generic.List[string] if ($script:Effective.DefaultLanguage -eq 'auto') { try { $culture = [Globalization.CultureInfo]::CurrentUICulture $candidates.Add($culture.Name) $candidates.Add($culture.TwoLetterISOLanguageName) } catch { $candidates.Add('en') } } else { $candidates.Add([string]$script:Effective.DefaultLanguage) } $aliases = if ($Catalog.ContainsKey('Aliases')) { $Catalog.Aliases } else { @{} } foreach ($candidate in $candidates) { if ([string]::IsNullOrWhiteSpace($candidate)) { continue } $normalized = $candidate.Trim() if ($aliases.ContainsKey($normalized)) { $normalized = [string]$aliases[$normalized] } elseif ($aliases.ContainsKey($normalized.ToLowerInvariant())) { $normalized = [string]$aliases[$normalized.ToLowerInvariant()] } if ($Catalog.Strings.ContainsKey($normalized)) { return $normalized } $baseName = ($normalized -split '-')[0] if ($aliases.ContainsKey($baseName)) { $baseName = [string]$aliases[$baseName] } if ($Catalog.Strings.ContainsKey($baseName)) { return $baseName } } if ($Catalog.ContainsKey('DefaultLanguage')) { return [string]$Catalog.DefaultLanguage } return 'en' } function Get-AppStrings { $catalog = Import-AppStringsCatalog $script:StringsCatalog = $catalog $selected = Resolve-AppLanguage -Catalog $catalog $script:SelectedLanguage = $selected $english = @{} foreach ($key in $catalog.Strings.en.Keys) { $english[$key] = $catalog.Strings.en[$key] } $localized = $catalog.Strings[$selected] foreach ($key in $localized.Keys) { $english[$key] = $localized[$key] } if (-not $english.ContainsKey('FlowDirection')) { $english.FlowDirection = 'LeftToRight' } if (-not $english.ContainsKey('Close')) { $english.Close = 'Close' } if (-not $english.ContainsKey('DaysUnit')) { $english.DaysUnit = 'days' } if (-not $english.ContainsKey('ShowingGui')) { $english.ShowingGui = 'Showing Smart Device Reboot Manager GUI.' } if (-not $english.ContainsKey('ClosedGui')) { $english.ClosedGui = 'Smart Device Reboot Manager GUI closed.' } if (-not $english.ContainsKey('LanguageLabel')) { $english.LanguageLabel = 'Language' } if (-not $english.ContainsKey('LanguageAuto')) { $english.LanguageAuto = 'Automatic' } Write-AppLog -Message ("Language selected: {0}" -f $selected) return [pscustomobject]$english } function Add-WpfAssemblies { Add-Type -AssemblyName PresentationFramework Add-Type -AssemblyName PresentationCore Add-Type -AssemblyName WindowsBase } function Import-SmartM365GuiSplash { $current = if (-not [string]::IsNullOrWhiteSpace($PSScriptRoot)) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path } while ($current) { $splashPath = Join-Path -Path $current -ChildPath 'SmartM365.GuiSplash.ps1' if (Test-Path -LiteralPath $splashPath) { return $splashPath } if ((Split-Path -Path $current -Leaf) -eq 'SmartM365') { return $null } $parent = Split-Path -Path $current -Parent if ([string]::IsNullOrWhiteSpace($parent) -or $parent -eq $current) { break } $current = $parent } return $null } function Set-UiValue { param([string]$Name, [object]$Value) if ($script:Ui.ContainsKey($Name) -and $null -ne $script:Ui[$Name]) { $script:Ui[$Name].Text = [string]$Value } } function Update-CountdownUi { $remaining = [Math]::Max(0, [int]$script:SecondsRemaining) $minutes = [Convert]::ToInt32([Math]::Floor($remaining / 60)) $seconds = $remaining % 60 Set-UiValue -Name 'CountdownText' -Value ([string]::Format($script:Strings.Countdown, $minutes, $seconds)) if ($script:Ui.ContainsKey('CountdownProgress') -and $script:Ui.CountdownProgress) { $elapsed = [Math]::Max(0, $script:InitialSeconds - $remaining) $script:Ui.CountdownProgress.Value = [Math]::Min(100, [Math]::Round(($elapsed / [double]$script:InitialSeconds) * 100, 0)) } } function Set-PostponeState { if (-not $script:ForceMode) { $script:Ui.PostponeButton.IsEnabled = $false $script:Ui.PostponeCombo.IsEnabled = $false $script:Ui.PostponeButton.Content = $script:Strings.Postpone return } $remainingPostpones = [Math]::Max(0, [int]$script:Effective.MaxPostponeCount - [int]$script:PostponeCount) $script:Ui.PostponeButton.IsEnabled = ($remainingPostpones -gt 0) $script:Ui.PostponeCombo.IsEnabled = ($remainingPostpones -gt 0) $script:Ui.PostponeButton.Content = ('{0} ({1})' -f $script:Strings.Postpone, $remainingPostpones) } function Get-EffectiveWindowTitle { if ($script:Effective.Contains('WindowTitle') -and -not [string]::IsNullOrWhiteSpace([string]$script:Effective.WindowTitle)) { return [string]$script:Effective.WindowTitle } $baseTitle = [string]$script:Strings.WindowTitle if (-not [string]::IsNullOrWhiteSpace([string]$script:DetectedCompanyName)) { return ('{0} - {1}' -f $script:DetectedCompanyName, $baseTitle) } return $baseTitle } function Get-LanguageChoices { $fallbackNames = @{ en = 'English' fr = 'French' de = 'German' es = 'Spanish' nl = 'Dutch' it = 'Italian' pt = 'Portuguese' pl = 'Polish' ar = 'Arabic' tr = 'Turkish' sv = 'Swedish' da = 'Danish' nb = 'Norwegian' fi = 'Finnish' ro = 'Romanian' hu = 'Hungarian' ja = 'Japanese' ko = 'Korean' 'zh-Hans' = 'Chinese' uk = 'Ukrainian' } $catalogNames = if ($script:StringsCatalog -and $script:StringsCatalog.ContainsKey('LanguageNames')) { $script:StringsCatalog.LanguageNames } else { @{} } $choices = New-Object System.Collections.Generic.List[object] $choices.Add([pscustomobject]@{ Code = 'auto'; Display = $script:Strings.LanguageAuto }) foreach ($code in @('en','fr','de','es','nl','it','pt','pl','ar','tr','sv','da','nb','fi','ro','hu','ja','ko','zh-Hans','uk')) { $display = if ($catalogNames.ContainsKey($code)) { [string]$catalogNames[$code] } else { [string]$fallbackNames[$code] } $choices.Add([pscustomobject]@{ Code = $code; Display = $display }) } return $choices } function Update-LanguageSelector { if (-not $script:Ui.ContainsKey('LanguageCombo') -or $null -eq $script:Ui.LanguageCombo) { return } $script:IsUpdatingLanguageSelector = $true try { $selectedCode = [string]$script:Effective.DefaultLanguage if ([string]::IsNullOrWhiteSpace($selectedCode)) { $selectedCode = 'auto' } $script:Ui.LanguageCombo.Items.Clear() $index = 0 $selectedIndex = 0 foreach ($choice in Get-LanguageChoices) { [void]$script:Ui.LanguageCombo.Items.Add($choice) if ($choice.Code -eq $selectedCode) { $selectedIndex = $index } $index++ } $script:Ui.LanguageCombo.DisplayMemberPath = 'Display' $script:Ui.LanguageCombo.SelectedIndex = $selectedIndex $script:Ui.LanguageCombo.IsEnabled = (-not [bool]$script:Effective.ForceLanguage) } finally { $script:IsUpdatingLanguageSelector = $false } } function Apply-LocalizedUi { if ($script:Window) { $script:Window.Title = Get-EffectiveWindowTitle if ($script:Strings.FlowDirection -eq 'RightToLeft') { $script:Window.FlowDirection = [System.Windows.FlowDirection]::RightToLeft } else { $script:Window.FlowDirection = [System.Windows.FlowDirection]::LeftToRight } } if ($script:ForceMode) { $script:Ui.StatusPillText.Text = $script:Strings.PillForced $script:Ui.StatusPill.Background = '#FFF1F0' $script:Ui.StatusPillText.Foreground = '#B42318' $script:Ui.TitleText.Text = $script:Strings.ForcedTitle $script:Ui.SummaryText.Text = $script:Strings.ForcedSummary $script:Ui.NextActionText.Text = $script:Strings.NextActionForced } else { $script:Ui.StatusPillText.Text = $script:Strings.PillRecommended $script:Ui.StatusPill.Background = '#E6F4FF' $script:Ui.StatusPillText.Foreground = '#005A9E' $script:Ui.TitleText.Text = $script:Strings.RecommendedTitle $script:Ui.SummaryText.Text = $script:Strings.RecommendedSummary $script:Ui.NextActionText.Text = $script:Strings.NextActionRecommended $script:Ui.CountdownText.Text = $script:Strings.NoCountdown $script:Ui.CountdownProgress.Value = 0 } if ($script:PreviewOnly) { $script:Ui.SummaryText.Text = ('{0} ({1})' -f $script:Ui.SummaryText.Text, $script:Strings.Preview) } $script:Ui.DaysBadgeText.Text = [string]$script:DaysSinceReboot $script:Ui.DaysUnitText.Text = $script:Strings.DaysUnit $script:Ui.UptimeLabel.Text = $script:Strings.Uptime $script:Ui.UptimeValue.Text = ('{0} d' -f $script:DaysSinceReboot) $script:Ui.ThresholdLabel.Text = $script:Strings.Threshold $script:Ui.ThresholdValue.Text = ('{0} d' -f $script:Effective.RecommendedRestartAfterDays) $script:Ui.MaximumLabel.Text = $script:Strings.Maximum $script:Ui.MaximumValue.Text = ('{0} d' -f $script:Effective.RequiredRestartAfterDays) $script:Ui.LastBootLabel.Text = $script:Strings.LastBoot $script:Ui.LastBootValue.Text = $script:LastBoot.ToString('yyyy-MM-dd HH:mm') $script:Ui.ActivityTitleText.Text = $script:Strings.Activity $script:Ui.LanguageLabelText.Text = $script:Strings.LanguageLabel $script:Ui.DeviceIdentityText.Text = ('{0} ({1})' -f $env:COMPUTERNAME, $script:PrimaryIPv4Address) $script:Ui.RestartButton.Content = $script:Strings.RestartNow $script:Ui.LaterButton.Content = $script:Strings.RemindLater $script:Ui.CloseButton.Content = $script:Strings.Close Set-PostponeState Update-LanguageSelector if ($script:ForceMode) { Update-CountdownUi } } function New-RebootManagerWindow { Add-WpfAssemblies [xml]$xaml = @" <Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="Smart Device Reboot Manager" Width="800" Height="600" MinWidth="760" MinHeight="560" WindowStartupLocation="CenterScreen" ResizeMode="CanResize" Background="#F4F7FB" FontFamily="Segoe UI"> <Window.Resources> <SolidColorBrush x:Key="InkBrush" Color="#102033"/> <SolidColorBrush x:Key="MutedBrush" Color="#5D6B7C"/> <SolidColorBrush x:Key="PanelBrush" Color="#FFFFFF"/> <SolidColorBrush x:Key="BorderBrushSoft" Color="#D9E2EC"/> <SolidColorBrush x:Key="AccentBrush" Color="#0078D4"/> <SolidColorBrush x:Key="DangerBrush" Color="#B42318"/> <Style TargetType="Button"> <Setter Property="MinWidth" Value="118"/> <Setter Property="Padding" Value="14,8"/> <Setter Property="Margin" Value="0,0,8,0"/> <Setter Property="FontWeight" Value="SemiBold"/> <Setter Property="Cursor" Value="Hand"/> <Setter Property="Background" Value="#FFFFFF"/> <Setter Property="Foreground" Value="{StaticResource InkBrush}"/> <Setter Property="BorderBrush" Value="#B9C8D7"/> <Setter Property="BorderThickness" Value="1"/> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="Button"> <Border x:Name="ButtonBorder" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="6"> <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" Margin="{TemplateBinding Padding}"/> </Border> <ControlTemplate.Triggers> <Trigger Property="IsMouseOver" Value="True"> <Setter TargetName="ButtonBorder" Property="Background" Value="#EAF6FF"/> <Setter TargetName="ButtonBorder" Property="BorderBrush" Value="{StaticResource AccentBrush}"/> </Trigger> <Trigger Property="IsPressed" Value="True"> <Setter TargetName="ButtonBorder" Property="Background" Value="#D8ECFA"/> </Trigger> <Trigger Property="IsEnabled" Value="False"> <Setter Property="Opacity" Value="0.45"/> <Setter Property="Cursor" Value="Arrow"/> </Trigger> </ControlTemplate.Triggers> </ControlTemplate> </Setter.Value> </Setter> </Style> <Style x:Key="PrimaryButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}"> <Setter Property="Background" Value="{StaticResource AccentBrush}"/> <Setter Property="Foreground" Value="#FFFFFF"/> <Setter Property="BorderBrush" Value="{StaticResource AccentBrush}"/> </Style> <Style x:Key="DangerButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}"> <Setter Property="Background" Value="#FFF1F0"/> <Setter Property="Foreground" Value="{StaticResource DangerBrush}"/> <Setter Property="BorderBrush" Value="#FDA29B"/> </Style> <Style TargetType="ProgressBar"> <Setter Property="Height" Value="10"/> <Setter Property="Foreground" Value="{StaticResource AccentBrush}"/> <Setter Property="Background" Value="#E5EAF0"/> </Style> <Style TargetType="ComboBox"> <Setter Property="Height" Value="34"/> <Setter Property="Padding" Value="8,4"/> <Setter Property="Margin" Value="0,0,8,0"/> <Setter Property="Background" Value="#FFFFFF"/> <Setter Property="Foreground" Value="{StaticResource InkBrush}"/> <Setter Property="BorderBrush" Value="#B9C8D7"/> <Setter Property="BorderThickness" Value="1"/> </Style> <Style x:Key="MetricLabel" TargetType="TextBlock"> <Setter Property="Foreground" Value="{StaticResource MutedBrush}"/> <Setter Property="FontSize" Value="12"/> </Style> <Style x:Key="MetricValue" TargetType="TextBlock"> <Setter Property="Foreground" Value="{StaticResource InkBrush}"/> <Setter Property="FontSize" Value="20"/> <Setter Property="FontWeight" Value="SemiBold"/> <Setter Property="Margin" Value="0,3,0,0"/> </Style> </Window.Resources> <Grid Margin="18"> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> <RowDefinition Height="*"/> <RowDefinition Height="Auto"/> </Grid.RowDefinitions> <Border Grid.Row="0" Background="{StaticResource PanelBrush}" BorderBrush="{StaticResource BorderBrushSoft}" BorderThickness="1" CornerRadius="8" Padding="18"> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="*"/> <ColumnDefinition Width="Auto"/> </Grid.ColumnDefinitions> <StackPanel> <Border x:Name="StatusPill" HorizontalAlignment="Left" CornerRadius="12" Padding="10,4" Background="#E6F4FF"> <TextBlock x:Name="StatusPillText" Foreground="#005A9E" FontWeight="SemiBold" FontSize="12"/> </Border> <TextBlock x:Name="TitleText" Margin="0,12,0,0" FontSize="28" FontWeight="SemiBold" Foreground="{StaticResource InkBrush}" TextWrapping="Wrap"/> <TextBlock x:Name="SummaryText" Margin="0,8,0,0" FontSize="14" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap"/> </StackPanel> <Border Grid.Column="1" Width="116" Height="116" CornerRadius="58" Background="#EAF6FF" Margin="18,0,0,0"> <StackPanel VerticalAlignment="Center"> <TextBlock x:Name="DaysBadgeText" HorizontalAlignment="Center" FontSize="36" FontWeight="Bold" Foreground="{StaticResource AccentBrush}"/> <TextBlock x:Name="DaysUnitText" HorizontalAlignment="Center" Foreground="{StaticResource MutedBrush}" FontSize="12"/> </StackPanel> </Border> </Grid> </Border> <Border Grid.Row="1" Background="{StaticResource PanelBrush}" BorderBrush="{StaticResource BorderBrushSoft}" BorderThickness="1" CornerRadius="8" Padding="14" Margin="0,12,0,0"> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/> <ColumnDefinition Width="1.3*"/> </Grid.ColumnDefinitions> <StackPanel Grid.Column="0"> <TextBlock x:Name="UptimeLabel" Style="{StaticResource MetricLabel}"/> <TextBlock x:Name="UptimeValue" Style="{StaticResource MetricValue}"/> </StackPanel> <StackPanel Grid.Column="1"> <TextBlock x:Name="ThresholdLabel" Style="{StaticResource MetricLabel}"/> <TextBlock x:Name="ThresholdValue" Style="{StaticResource MetricValue}"/> </StackPanel> <StackPanel Grid.Column="2"> <TextBlock x:Name="MaximumLabel" Style="{StaticResource MetricLabel}"/> <TextBlock x:Name="MaximumValue" Style="{StaticResource MetricValue}"/> </StackPanel> <StackPanel Grid.Column="3"> <TextBlock x:Name="LastBootLabel" Style="{StaticResource MetricLabel}"/> <TextBlock x:Name="LastBootValue" Style="{StaticResource MetricValue}" FontSize="15"/> </StackPanel> </Grid> </Border> <Grid Grid.Row="2" Margin="0,12,0,12"> <Grid.ColumnDefinitions> <ColumnDefinition Width="1.1*"/> <ColumnDefinition Width="0.9*"/> </Grid.ColumnDefinitions> <Border Grid.Column="0" Background="{StaticResource PanelBrush}" BorderBrush="{StaticResource BorderBrushSoft}" BorderThickness="1" CornerRadius="8" Padding="16" Margin="0,0,12,0"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> <TextBlock x:Name="CountdownText" Grid.Row="0" FontSize="24" FontWeight="SemiBold" Foreground="{StaticResource InkBrush}" TextWrapping="Wrap"/> <ProgressBar x:Name="CountdownProgress" Grid.Row="1" Margin="0,14,0,12" Minimum="0" Maximum="100"/> <TextBlock x:Name="NextActionText" Grid.Row="2" Foreground="{StaticResource MutedBrush}" FontSize="14" TextWrapping="Wrap"/> <Separator Grid.Row="3" Margin="0,14,0,10"/> <TextBlock x:Name="DeviceIdentityText" Grid.Row="4" Foreground="{StaticResource MutedBrush}" FontSize="13" TextWrapping="Wrap"/> </Grid> </Border> <Border Grid.Column="1" Background="{StaticResource PanelBrush}" BorderBrush="{StaticResource BorderBrushSoft}" BorderThickness="1" CornerRadius="8" Padding="16"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> <TextBlock x:Name="ActivityTitleText" Grid.Row="0" FontWeight="SemiBold" Foreground="{StaticResource InkBrush}" Margin="0,0,0,6"/> <Grid Grid.Row="1" Margin="0,0,0,8"> <Grid.ColumnDefinitions> <ColumnDefinition Width="Auto"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <TextBlock x:Name="LanguageLabelText" Grid.Column="0" Foreground="{StaticResource MutedBrush}" FontSize="12" VerticalAlignment="Center" Margin="0,0,8,0"/> <ComboBox x:Name="LanguageCombo" Grid.Column="1" MinWidth="150" HorizontalAlignment="Stretch" Height="28" Margin="0"/> </Grid> <TextBox x:Name="ActivityText" Grid.Row="2" IsReadOnly="True" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto" BorderBrush="#E4EAF1" Background="#FAFCFE"/> </Grid> </Border> </Grid> <Border Grid.Row="3" Background="{StaticResource PanelBrush}" BorderBrush="{StaticResource BorderBrushSoft}" BorderThickness="1" CornerRadius="8" Padding="12"> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="Auto"/> <ColumnDefinition Width="120"/> <ColumnDefinition Width="*"/> <ColumnDefinition Width="Auto"/> </Grid.ColumnDefinitions> <Button x:Name="RestartButton" Grid.Column="0" Width="158" Style="{StaticResource DangerButton}"/> <ComboBox x:Name="PostponeCombo" Grid.Column="1" Width="112"/> <Button x:Name="PostponeButton" Grid.Column="2" Width="126" HorizontalAlignment="Left"/> <StackPanel Grid.Column="3" Orientation="Horizontal" HorizontalAlignment="Right"> <Button x:Name="LaterButton" Width="160"/> <Button x:Name="CloseButton" Width="118" Margin="0"/> </StackPanel> </Grid> </Border> </Grid> </Window> "@ $reader = New-Object System.Xml.XmlNodeReader $xaml $window = [Windows.Markup.XamlReader]::Load($reader) foreach ($name in @( 'StatusPill','StatusPillText','TitleText','SummaryText','DaysBadgeText','DaysUnitText', 'UptimeLabel','UptimeValue','ThresholdLabel','ThresholdValue','MaximumLabel','MaximumValue', 'LastBootLabel','LastBootValue','CountdownText','CountdownProgress','NextActionText', 'DeviceIdentityText','ActivityTitleText','ActivityText','LanguageLabelText', 'LanguageCombo','RestartButton','PostponeCombo','PostponeButton','LaterButton','CloseButton' )) { $script:Ui[$name] = $window.FindName($name) } return $window } Initialize-AppFolder Invoke-LaunchLogRotation $config = Read-AppConfig Apply-AppConfig -Config $config Initialize-EffectiveSettings $script:DetectedCompanyName = Get-DetectedCompanyName Apply-UserLanguagePreference if ($ValidateOnly) { if ([Threading.Thread]::CurrentThread.GetApartmentState() -ne [Threading.ApartmentState]::STA) { throw "Validation requires an STA runspace. Start with: powershell.exe -STA -NoProfile -File `"$PSCommandPath`" -ValidateOnly" } $script:Strings = Get-AppStrings $script:ForceMode = $false $script:DaysSinceReboot = [int]$script:Effective.RecommendedRestartAfterDays $script:LastBoot = (Get-Date).AddDays(-[int]$script:DaysSinceReboot) $script:PrimaryIPv4Address = Get-PrimaryIPv4Address $script:InitialSeconds = [Math]::Max(60, [int]$script:Effective.RestartCountdownMinutes * 60) $script:SecondsRemaining = $script:InitialSeconds $script:Window = New-RebootManagerWindow Apply-LocalizedUi Write-AppLog -Message 'Smart Device Reboot Manager GUI validation completed.' Write-Output 'Smart Device Reboot Manager GUI validation completed.' [Environment]::Exit(0) } if ($ResetRunState -and (Test-Path -LiteralPath $script:StatePath)) { Remove-Item -LiteralPath $script:StatePath -Force Write-AppLog -Message 'Reminder state reset by CLI.' } Write-AppLog -Message ("Starting. RecommendedRestartAfterDays={0}; RequiredRestartAfterDays={1}; RestartCountdownMinutes={2}; PreviewOnly={3}; NeverForceRestart={4}" -f $script:Effective.RecommendedRestartAfterDays, $script:Effective.RequiredRestartAfterDays, $script:Effective.RestartCountdownMinutes, $script:PreviewOnly, $script:Effective.NeverForceRestart) $lastBoot = Get-LastBootTime $daysSinceReboot = (New-TimeSpan -Start $lastBoot -End (Get-Date)).Days if ($script:Effective.TestRequiredRestart) { $daysSinceReboot = [int]$script:Effective.RequiredRestartAfterDays + 1 $lastBoot = (Get-Date).AddDays(-$daysSinceReboot) } elseif ($script:Effective.TestRecommendedRestart) { $daysSinceReboot = [Math]::Min([int]$script:Effective.RequiredRestartAfterDays - 1, [int]$script:Effective.RecommendedRestartAfterDays + 1) $lastBoot = (Get-Date).AddDays(-$daysSinceReboot) } $script:ForceMode = (-not [bool]$script:Effective.NeverForceRestart -and $daysSinceReboot -ge [int]$script:Effective.RequiredRestartAfterDays) Write-AppLog -Message ("LastBoot={0}; DaysSinceReboot={1}; ForceMode={2}" -f $lastBoot.ToString('s'), $daysSinceReboot, $script:ForceMode) if ($daysSinceReboot -lt [int]$script:Effective.RecommendedRestartAfterDays) { Write-AppLog -Message 'Device is below the reminder threshold. Exiting.' [Environment]::Exit(0) } if (-not $script:ForceMode -and -not ($script:Effective.TestRecommendedRestart -or $script:Effective.TestRequiredRestart)) { $state = Read-ReminderState if (Test-ReminderCooldown -State $state) { Write-AppLog -Message ("Reminder cooldown is still active ({0} hours). Exiting." -f $script:Effective.ReminderCooldownHours) [Environment]::Exit(0) } } if (-not (Test-UserSessionVisible)) { [Environment]::Exit(0) } if ([Threading.Thread]::CurrentThread.GetApartmentState() -ne [Threading.ApartmentState]::STA) { Invoke-RelaunchSta } $script:GuiSplash = $null $splashHelperPath = if ([bool]$script:Effective.SplashEnabled) { Import-SmartM365GuiSplash } else { $null } if ($splashHelperPath) { . $splashHelperPath $splashProductName = if ([string]::IsNullOrWhiteSpace([string]$script:Effective.SplashProductName)) { 'Device Reboot Manager' } else { [string]$script:Effective.SplashProductName } $script:GuiSplash = Start-SmartM365GuiSplash ` -Framework Wpf ` -ProductName $splashProductName ` -MinimumDurationMs ([int]$script:Effective.SplashMinimumDurationMs) ` -BadgeText ([string]$script:Effective.SplashBadgeText) ` -Subtitle ([string]$script:Effective.SplashSubtitle) ` -LogoPath ([string]$script:Effective.SplashLogoPath) ` -WindowIconPath ([string]$script:Effective.WindowIconPath) } $script:DaysSinceReboot = $daysSinceReboot $script:LastBoot = $lastBoot $script:PrimaryIPv4Address = Get-PrimaryIPv4Address $script:InitialSeconds = [Math]::Max(60, [int]$script:Effective.RestartCountdownMinutes * 60) $script:SecondsRemaining = $script:InitialSeconds $script:Strings = Get-AppStrings $script:Window = New-RebootManagerWindow $script:Window.Topmost = [bool]$script:StartTopmost $resolvedIconPath = Resolve-AppRelativePath -Path $script:Effective.WindowIconPath if (-not [string]::IsNullOrWhiteSpace($resolvedIconPath) -and (Test-Path -LiteralPath $resolvedIconPath)) { try { $script:Window.Icon = New-Object System.Windows.Media.Imaging.BitmapImage([Uri]$resolvedIconPath) } catch { Write-AppLog -Level WARN -Message ("Unable to load icon: {0}" -f $_.Exception.Message) } } if ($script:ForceMode) { $script:AllowClose = [bool]$script:PreviewOnly } else { $script:AllowClose = $true } foreach ($minutes in $script:Effective.PostponeOptionsMinutes) { [void]$script:Ui.PostponeCombo.Items.Add(('{0} min' -f [int]$minutes)) } $script:Ui.PostponeCombo.SelectedIndex = 0 if (-not $script:ForceMode) { $script:Ui.LaterButton.IsEnabled = $true } else { $script:Ui.LaterButton.IsEnabled = $false } $script:Ui.CloseButton.IsEnabled = (-not $script:ForceMode -or $script:PreviewOnly) Apply-LocalizedUi $script:Ui.LanguageCombo.Add_SelectionChanged({ Invoke-Safely { if ($script:IsUpdatingLanguageSelector) { return } if ($script:Effective.ForceLanguage) { return } $selected = $script:Ui.LanguageCombo.SelectedItem if ($null -eq $selected -or [string]::IsNullOrWhiteSpace([string]$selected.Code)) { return } $script:Effective.DefaultLanguage = [string]$selected.Code Save-UserLanguagePreference -LanguageCode $script:Effective.DefaultLanguage $script:Strings = Get-AppStrings Apply-LocalizedUi } }) $script:Window.Add_Closing({ if (-not $script:AllowClose) { $_.Cancel = $true Write-AppLog -Level WARN -Message 'Window close blocked while restart is required.' } }) $script:Ui.RestartButton.Add_Click({ Invoke-Safely { $choice = [System.Windows.MessageBox]::Show($script:Strings.ConfirmRestart, $script:AppName, 'OKCancel', 'Warning') if ($choice -ne 'OK') { Write-AppLog -Message 'Restart confirmation cancelled by user.' return } if ($script:Timer) { $script:Timer.Stop() } Save-ReminderState -Reason 'RestartNow' -DaysSinceReboot $daysSinceReboot -LastBoot $lastBoot $script:AllowClose = $true $script:Window.Close() Invoke-SafeRestart -Reason 'RestartNow' } }) $script:Ui.PostponeButton.Add_Click({ Invoke-Safely { if (-not $script:ForceMode) { return } if ($script:PostponeCount -ge [int]$script:Effective.MaxPostponeCount) { return } $selectedText = [string]$script:Ui.PostponeCombo.SelectedItem $minutes = [int](([regex]::Match($selectedText, '\d+')).Value) $script:SecondsRemaining += ($minutes * 60) $script:InitialSeconds += ($minutes * 60) $script:PostponeCount++ Save-ReminderState -Reason ("Postpone{0}Minutes" -f $minutes) -DaysSinceReboot $daysSinceReboot -LastBoot $lastBoot Write-AppLog -Message ("Restart postponed by {0} minutes. Count={1}/{2}" -f $minutes, $script:PostponeCount, $script:Effective.MaxPostponeCount) Set-PostponeState Update-CountdownUi if ($script:Window.Topmost) { $script:Window.Topmost = $false } } }) $script:Ui.LaterButton.Add_Click({ Invoke-Safely { if ($script:ForceMode) { return } Save-ReminderState -Reason 'RemindLater' -DaysSinceReboot $daysSinceReboot -LastBoot $lastBoot Write-AppLog -Message 'User selected remind me later.' $script:AllowClose = $true $script:Window.Close() } }) $script:Ui.CloseButton.Add_Click({ Invoke-Safely { if ($script:ForceMode -and -not $script:PreviewOnly) { Write-AppLog -Level WARN -Message 'Close button ignored while restart is required.' return } Save-ReminderState -Reason 'Close' -DaysSinceReboot $daysSinceReboot -LastBoot $lastBoot $script:AllowClose = $true $script:Window.Close() } }) if ($script:ForceMode) { $script:Timer = New-Object System.Windows.Threading.DispatcherTimer $script:Timer.Interval = [TimeSpan]::FromSeconds(1) $script:Timer.Add_Tick({ Invoke-Safely { $script:SecondsRemaining-- if ($script:SecondsRemaining -le 0) { $script:Timer.Stop() Save-ReminderState -Reason 'CountdownExpired' -DaysSinceReboot $daysSinceReboot -LastBoot $lastBoot Write-AppLog -Level WARN -Message 'Countdown expired. Restart will be triggered.' $script:AllowClose = $true $script:Window.Close() Invoke-SafeRestart -Reason 'CountdownExpired' return } if ($script:RestoreTopmostAfterPostpone) { $thresholdSeconds = [int]$script:RestoreTopmostAfterMinutes * 60 if ($script:SecondsRemaining -le $thresholdSeconds -and -not $script:Window.Topmost) { $script:Window.Topmost = $true Write-AppLog -Message ("Topmost restored under {0} minutes." -f $script:RestoreTopmostAfterMinutes) } } Update-CountdownUi } }) $script:Timer.Start() } $script:Window.Add_ContentRendered({ if ($script:GuiSplash) { Hide-SmartM365GuiSplash -Splash $script:GuiSplash } }) Write-AppLog -Message $script:Strings.ShowingGui [void]$script:Window.ShowDialog() if ($script:GuiSplash) { Close-SmartM365GuiSplash -Splash $script:GuiSplash } Write-AppLog -Message $script:Strings.ClosedGui [Environment]::Exit(0) # SIG # Begin signature block # MIIeYwYJKoZIhvcNAQcCoIIeVDCCHlACAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCBEF3eyO0mB2KLd # D+Ef1hbIBGYn89uQNhixmw1coSaGpqCCF/swggS9MIIDJaADAgECAhAebu87xzjh # s0Q4yPEDH+JoMA0GCSqGSIb3DQEBCwUAME4xHjAcBgNVBAMMFXdvcmtwbGFjZWNs # b3VkaHViLmNvbTEsMCoGCSqGSIb3DQEJARYdY29udGFjdEB3b3JrcGxhY2VjbG91 # ZGh1Yi5jb20wHhcNMjYwNzEzMDgyMjM1WhcNMjkwNzEzMDgzMjI5WjBOMR4wHAYD # VQQDDBV3b3JrcGxhY2VjbG91ZGh1Yi5jb20xLDAqBgkqhkiG9w0BCQEWHWNvbnRh # Y3RAd29ya3BsYWNlY2xvdWRodWIuY29tMIIBojANBgkqhkiG9w0BAQEFAAOCAY8A # MIIBigKCAYEAse6XztERSyHn9DVqj8Rdv0qjc5owqvgAIGaYxBmfiQuoM48Fo4Xt # 1ovi9brLUtf55G4XgthNPCoanxfCRRg30IVRxaDfdPXJzYmgsM5tXlsuNU49lE7E # PJk3+jEOgSCt8NKzmVPKpNRG0NmK0a8wm12cceYZOZlSYE0+ZtT6wy5PQQjMUqIx # XnGjt4H0nfgZZa7D4FyARKOVg/Xr9sUq5jIn3zszvg4jjeb4b0DKJtfbHukhWc2Y # oVFgswxVBXCWIaBnfF/cjqMfK/CaToT2trVb4hG4qcQ31s1nR4keoRaOw/vyd6ap # rEtCsT22N/Jx0dz7fIo1tVyvIaVcHdN9LW3chn0en0OKZ6Ke1OH9wf2prl4KA6Ww # VzrAZrOlXTAItdK7D9kKO/HeJd4PZvO53oy1LdmMGLSz3OLB9e5q7yo8rfqi5Ka9 # KzM2CrSzz1yphn/H90wz7Q2pm4FIlWdcj86A/0kmhYg+5Wqqbg1drrPXu4nEBwWN # /dzoGtKZKHTdAgMBAAGjgZYwgZMwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG # CCsGAQUFBwMDMD8GA1UdEQQ4MDaBHWNvbnRhY3RAd29ya3BsYWNlY2xvdWRodWIu # Y29tghV3b3JrcGxhY2VjbG91ZGh1Yi5jb20wDAYDVR0TAQH/BAIwADAdBgNVHQ4E # FgQUXIOOADQM78XfPAncirgCECedg9gwDQYJKoZIhvcNAQELBQADggGBADhZUB2R # 5J/Jw030xodhEWeCQ0vnJRaiEsjOxuArQREKH3lCrQ3UsUVl292d6LnQUSTH/jF7 # rovEZ+JN2GQ/LCrXRaCuwCEGZKzlSEbtYWhfwDyj6GpIPq8Y4SeXyjdq4/rrI1bm # iTK4Sq7EoBlGJuX6l2nfvx1tTioSr11FoDfllJR7EYawRj9hBFJ0gG0b2SuYZMgW # gaDKefcnJDmOwcRNAZUII0ss8EeyANukWSkNN5ILZ+iKDpQgZxgDLPTiRguCyx45 # PI5wrVTjV/pR7IrtSIfq8UladlrSZJyyDn3NV2ATvIZ6wNxbTmPFcE0uMg/EYzwd # Tek+CgXL3TxUKeldJM4YDWPimNBRhOPXzBDiOQIj6WNswt/KM1oDLnA00CNtciPN # dn+dXlneMvTEUah9wyt8o8tkLpoBw+KN+Bq/K0O1qPtS7umi70l45pPiej+mwbwq # ztcaoVD7a8ggHP1Vdp/rnafM4GtyCAE6b7U9Yzgvp1/a1kh7XffmqVhRRjCCBY0w # ggR1oAMCAQICEA6bGI750C3n79tQ4ghAGFowDQYJKoZIhvcNAQEMBQAwZTELMAkG # A1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3LmRp # Z2ljZXJ0LmNvbTEkMCIGA1UEAxMbRGlnaUNlcnQgQXNzdXJlZCBJRCBSb290IENB # MB4XDTIyMDgwMTAwMDAwMFoXDTMxMTEwOTIzNTk1OVowYjELMAkGA1UEBhMCVVMx # FTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3LmRpZ2ljZXJ0LmNv # bTEhMB8GA1UEAxMYRGlnaUNlcnQgVHJ1c3RlZCBSb290IEc0MIICIjANBgkqhkiG # 9w0BAQEFAAOCAg8AMIICCgKCAgEAv+aQc2jeu+RdSjwwIjBpM+zCpyUuySE98orY # WcLhKac9WKt2ms2uexuEDcQwH/MbpDgW61bGl20dq7J58soR0uRf1gU8Ug9SH8ae # FaV+vp+pVxZZVXKvaJNwwrK6dZlqczKU0RBEEC7fgvMHhOZ0O21x4i0MG+4g1ckg # HWMpLc7sXk7Ik/ghYZs06wXGXuxbGrzryc/NrDRAX7F6Zu53yEioZldXn1RYjgwr # t0+nMNlW7sp7XeOtyU9e5TXnMcvak17cjo+A2raRmECQecN4x7axxLVqGDgDEI3Y # 1DekLgV9iPWCPhCRcKtVgkEy19sEcypukQF8IUzUvK4bA3VdeGbZOjFEmjNAvwjX # WkmkwuapoGfdpCe8oU85tRFYF/ckXEaPZPfBaYh2mHY9WV1CdoeJl2l6SPDgohIb # Zpp0yt5LHucOY67m1O+SkjqePdwA5EUlibaaRBkrfsCUtNJhbesz2cXfSwQAzH0c # lcOP9yGyshG3u3/y1YxwLEFgqrFjGESVGnZifvaAsPvoZKYz0YkH4b235kOkGLim # dwHhD5QMIR2yVCkliWzlDlJRR3S+Jqy2QXXeeqxfjT/JvNNBERJb5RBQ6zHFynIW # IgnffEx1P2PsIV/EIFFrb7GrhotPwtZFX50g/KEexcCPorF+CiaZ9eRpL5gdLfXZ # qbId5RsCAwEAAaOCATowggE2MA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFOzX # 44LScV1kTN8uZz/nupiuHA9PMB8GA1UdIwQYMBaAFEXroq/0ksuCMS1Ri6enIZ3z # bcgPMA4GA1UdDwEB/wQEAwIBhjB5BggrBgEFBQcBAQRtMGswJAYIKwYBBQUHMAGG # GGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBDBggrBgEFBQcwAoY3aHR0cDovL2Nh # Y2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9vdENBLmNydDBF # BgNVHR8EPjA8MDqgOKA2hjRodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNl # cnRBc3N1cmVkSURSb290Q0EuY3JsMBEGA1UdIAQKMAgwBgYEVR0gADANBgkqhkiG # 9w0BAQwFAAOCAQEAcKC/Q1xV5zhfoKN0Gz22Ftf3v1cHvZqsoYcs7IVeqRq7IviH # GmlUIu2kiHdtvRoU9BNKei8ttzjv9P+Aufih9/Jy3iS8UgPITtAq3votVs/59Pes # MHqai7Je1M/RQ0SbQyHrlnKhSLSZy51PpwYDE3cnRNTnf+hZqPC/Lwum6fI0POz3 # A8eHqNJMQBk1RmppVLC4oVaO7KTVPeix3P0c2PR3WlxUjG/voVA9/HYJaISfb8rb # II01YBwCA8sgsKxYoA5AY8WYIsGyWfVVa88nq2x2zm8jLfR+cWojayL/ErhULSd+ # 2DrZ8LaHlv1b0VysGMNNn3O3AamfV6peKOK5lDCCBrQwggScoAMCAQICEA3HrFcF # /yGZLkBDIgw6SYYwDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCVVMxFTATBgNV # BAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3LmRpZ2ljZXJ0LmNvbTEhMB8G # A1UEAxMYRGlnaUNlcnQgVHJ1c3RlZCBSb290IEc0MB4XDTI1MDUwNzAwMDAwMFoX # DTM4MDExNDIzNTk1OVowaTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0 # LCBJbmMuMUEwPwYDVQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0IFRpbWVTdGFtcGlu # ZyBSU0E0MDk2IFNIQTI1NiAyMDI1IENBMTCCAiIwDQYJKoZIhvcNAQEBBQADggIP # ADCCAgoCggIBALR4MdMKmEFyvjxGwBysddujRmh0tFEXnU2tjQ2UtZmWgyxU7UNq # EY81FzJsQqr5G7A6c+Gh/qm8Xi4aPCOo2N8S9SLrC6Kbltqn7SWCWgzbNfiR+2fk # HUiljNOqnIVD/gG3SYDEAd4dg2dDGpeZGKe+42DFUF0mR/vtLa4+gKPsYfwEu7EE # bkC9+0F2w4QJLVSTEG8yAR2CQWIM1iI5PHg62IVwxKSpO0XaF9DPfNBKS7Zazch8 # NF5vp7eaZ2CVNxpqumzTCNSOxm+SAWSuIr21Qomb+zzQWKhxKTVVgtmUPAW35xUU # FREmDrMxSNlr/NsJyUXzdtFUUt4aS4CEeIY8y9IaaGBpPNXKFifinT7zL2gdFpBP # 9qh8SdLnEut/GcalNeJQ55IuwnKCgs+nrpuQNfVmUB5KlCX3ZA4x5HHKS+rqBvKW # xdCyQEEGcbLe1b8Aw4wJkhU1JrPsFfxW1gaou30yZ46t4Y9F20HHfIY4/6vHespY # MQmUiote8ladjS/nJ0+k6MvqzfpzPDOy5y6gqztiT96Fv/9bH7mQyogxG9QEPHrP # V6/7umw052AkyiLA6tQbZl1KhBtTasySkuJDpsZGKdlsjg4u70EwgWbVRSX1Wd4+ # zoFpp4Ra+MlKM2baoD6x0VR4RjSpWM8o5a6D8bpfm4CLKczsG7ZrIGNTAgMBAAGj # ggFdMIIBWTASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBTvb1NK6eQGfHrK # 4pBW9i/USezLTjAfBgNVHSMEGDAWgBTs1+OC0nFdZEzfLmc/57qYrhwPTzAOBgNV # HQ8BAf8EBAMCAYYwEwYDVR0lBAwwCgYIKwYBBQUHAwgwdwYIKwYBBQUHAQEEazBp # MCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wQQYIKwYBBQUH # MAKGNWh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRS # b290RzQuY3J0MEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwzLmRpZ2ljZXJ0 # LmNvbS9EaWdpQ2VydFRydXN0ZWRSb290RzQuY3JsMCAGA1UdIAQZMBcwCAYGZ4EM # AQQCMAsGCWCGSAGG/WwHATANBgkqhkiG9w0BAQsFAAOCAgEAF877FoAc/gc9EXZx # ML2+C8i1NKZ/zdCHxYgaMH9Pw5tcBnPw6O6FTGNpoV2V4wzSUGvI9NAzaoQk97fr # PBtIj+ZLzdp+yXdhOP4hCFATuNT+ReOPK0mCefSG+tXqGpYZ3essBS3q8nL2UwM+ # NMvEuBd/2vmdYxDCvwzJv2sRUoKEfJ+nN57mQfQXwcAEGCvRR2qKtntujB71WPYA # gwPyWLKu6RnaID/B0ba2H3LUiwDRAXx1Neq9ydOal95CHfmTnM4I+ZI2rVQfjXQA # 1WSjjf4J2a7jLzWGNqNX+DF0SQzHU0pTi4dBwp9nEC8EAqoxW6q17r0z0noDjs6+ # BFo+z7bKSBwZXTRNivYuve3L2oiKNqetRHdqfMTCW/NmKLJ9M+MtucVGyOxiDf06 # VXxyKkOirv6o02OoXN4bFzK0vlNMsvhlqgF2puE6FndlENSmE+9JGYxOGLS/D284 # NHNboDGcmWXfwXRy4kbu4QFhOm0xJuF2EZAOk5eCkhSxZON3rGlHqhpB/8MluDez # ooIs8CVnrpHMiD2wL40mm53+/j7tFaxYKIqL0Q4ssd8xHZnIn/7GELH3IdvG2XlM # 9q7WP/UwgOkw/HQtyRN62JK4S1C8uw3PdBunvAZapsiI5YKdvlarEvf8EA+8hcpS # M9LHJmyrxaFtoza2zNaQ9k+5t1wwggbtMIIE1aADAgECAhAKgO8YS43xBYLRxHan # lXRoMA0GCSqGSIb3DQEBCwUAMGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdp # Q2VydCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1lU3Rh # bXBpbmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTEwHhcNMjUwNjA0MDAwMDAwWhcN # MzYwOTAzMjM1OTU5WjBjMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQs # IEluYy4xOzA5BgNVBAMTMkRpZ2lDZXJ0IFNIQTI1NiBSU0E0MDk2IFRpbWVzdGFt # cCBSZXNwb25kZXIgMjAyNSAxMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKC # AgEA0EasLRLGntDqrmBWsytXum9R/4ZwCgHfyjfMGUIwYzKomd8U1nH7C8Dr0cVM # F3BsfAFI54um8+dnxk36+jx0Tb+k+87H9WPxNyFPJIDZHhAqlUPt281mHrBbZHqR # K71Em3/hCGC5KyyneqiZ7syvFXJ9A72wzHpkBaMUNg7MOLxI6E9RaUueHTQKWXym # OtRwJXcrcTTPPT2V1D/+cFllESviH8YjoPFvZSjKs3SKO1QNUdFd2adw44wDcKgH # +JRJE5Qg0NP3yiSyi5MxgU6cehGHr7zou1znOM8odbkqoK+lJ25LCHBSai25CFyD # 23DZgPfDrJJJK77epTwMP6eKA0kWa3osAe8fcpK40uhktzUd/Yk0xUvhDU6lvJuk # x7jphx40DQt82yepyekl4i0r8OEps/FNO4ahfvAk12hE5FVs9HVVWcO5J4dVmVzi # x4A77p3awLbr89A90/nWGjXMGn7FQhmSlIUDy9Z2hSgctaepZTd0ILIUbWuhKuAe # NIeWrzHKYueMJtItnj2Q+aTyLLKLM0MheP/9w6CtjuuVHJOVoIJ/DtpJRE7Ce7vM # RHoRon4CWIvuiNN1Lk9Y+xZ66lazs2kKFSTnnkrT3pXWETTJkhd76CIDBbTRofOs # NyEhzZtCGmnQigpFHti58CSmvEyJcAlDVcKacJ+A9/z7eacCAwEAAaOCAZUwggGR # MAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFOQ7/PIx7f391/ORcWMZUEPPYYzoMB8G # A1UdIwQYMBaAFO9vU0rp5AZ8esrikFb2L9RJ7MtOMA4GA1UdDwEB/wQEAwIHgDAW # BgNVHSUBAf8EDDAKBggrBgEFBQcDCDCBlQYIKwYBBQUHAQEEgYgwgYUwJAYIKwYB # BQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBdBggrBgEFBQcwAoZRaHR0 # cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0VGltZVN0 # YW1waW5nUlNBNDA5NlNIQTI1NjIwMjVDQTEuY3J0MF8GA1UdHwRYMFYwVKBSoFCG # Tmh0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFRpbWVT # dGFtcGluZ1JTQTQwOTZTSEEyNTYyMDI1Q0ExLmNybDAgBgNVHSAEGTAXMAgGBmeB # DAEEAjALBglghkgBhv1sBwEwDQYJKoZIhvcNAQELBQADggIBAGUqrfEcJwS5rmBB # 7NEIRJ5jQHIh+OT2Ik/bNYulCrVvhREafBYF0RkP2AGr181o2YWPoSHz9iZEN/FP # sLSTwVQWo2H62yGBvg7ouCODwrx6ULj6hYKqdT8wv2UV+Kbz/3ImZlJ7YXwBD9R0 # oU62PtgxOao872bOySCILdBghQ/ZLcdC8cbUUO75ZSpbh1oipOhcUT8lD8QAGB9l # ctZTTOJM3pHfKBAEcxQFoHlt2s9sXoxFizTeHihsQyfFg5fxUFEp7W42fNBVN4ue # LaceRf9Cq9ec1v5iQMWTFQa0xNqItH3CPFTG7aEQJmmrJTV3Qhtfparz+BW60OiM # EgV5GWoBy4RVPRwqxv7Mk0Sy4QHs7v9y69NBqycz0BZwhB9WOfOu/CIJnzkQTwtS # SpGGhLdjnQ4eBpjtP+XB3pQCtv4E5UCSDag6+iX8MmB10nfldPF9SVD7weCC3yXZ # i/uuhqdwkgVxuiMFzGVFwYbQsiGnoa9F5AaAyBjFBtXVLcKtapnMG3VH3EmAp/js # J3FVF3+d1SVDTmjFjLbNFZUWMXuZyvgLfgyPehwJVxwC+UpX2MSey2ueIu9THFVk # T+um1vshETaWyQo8gmBto/m3acaP9QsuLj3FNwFlTxq25+T4QwX9xa6ILs84ZPvm # povq90K8eWyG2N01c4IhSOxqt81nMYIFvjCCBboCAQEwYjBOMR4wHAYDVQQDDBV3 # b3JrcGxhY2VjbG91ZGh1Yi5jb20xLDAqBgkqhkiG9w0BCQEWHWNvbnRhY3RAd29y # a3BsYWNlY2xvdWRodWIuY29tAhAebu87xzjhs0Q4yPEDH+JoMA0GCWCGSAFlAwQC # AQUAoIGEMBgGCisGAQQBgjcCAQwxCjAIoAKAAKECgAAwGQYJKoZIhvcNAQkDMQwG # CisGAQQBgjcCAQQwHAYKKwYBBAGCNwIBCzEOMAwGCisGAQQBgjcCARUwLwYJKoZI # hvcNAQkEMSIEIG/VgMNu2rjHE+HHoGKwzy86ciKcp+kbXrOoKftYdCe1MA0GCSqG # SIb3DQEBAQUABIIBgH1fg+4dU2uuEAmoqSI2qt2g5M885yRR9WcXxwSMFSe+0W7T # FjOdvSMA4L0DBnVYKGJIQnCn2TNnZAo1CzQm6zpyfPLakmRW6U+MeetNWj/w0/60 # t4d3N6+OtV/hAvtKr7YagNEM9s1YfsQ9xUlA/ZKVp/ygVuB5u9NXDlmRvQ1aGJ5r # Rh6q+2hivjOxay08o8KamPQnMeFvWL+uYVA2aMOFSLX51v4qcty7jMkzCurQsmFq # VLeyWNn4HVnv74QZrdYe/z0WmlZMjoknsGNHuA3HqM8rRI+NqlOO/qz3KPEEztgO # tbo+6EagHQnoY3LyjwKKz7rs8yQeUOd+1MyL0TPZdmoxdWxW98JzJyZthEJNGlQO # rSCuDMMa+Ifnelrl+8QEUr8/X2x1CEUeAkmTb0YqyUy+V9TEJvhCzUtArgCDCkQZ # y8zg2ly5xhDn5e1dyjBIYaFnlDEby3A1YNq9EjkGmFbBNuDatOwr+1TfkKDvrOr2 # q1h/iBHhR7PWLUTL96GCAyYwggMiBgkqhkiG9w0BCQYxggMTMIIDDwIBATB9MGkx # CzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4 # RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1lU3RhbXBpbmcgUlNBNDA5NiBTSEEyNTYg # MjAyNSBDQTECEAqA7xhLjfEFgtHEdqeVdGgwDQYJYIZIAWUDBAIBBQCgaTAYBgkq # hkiG9w0BCQMxCwYJKoZIhvcNAQcBMBwGCSqGSIb3DQEJBTEPFw0yNjA3MTMwODQ5 # MjhaMC8GCSqGSIb3DQEJBDEiBCBT2GHedURuRQ14b793cjbH8r6qtG42WCZSkJx9 # /6JWcjANBgkqhkiG9w0BAQEFAASCAgCTdm71RKKPvUT6pv6eIt9Kbet6t7+PCDK1 # A3+DEd0O4USn01apojkOyp2zWwRdGtC5bF6GT8MF11l4WNGY0Sxgq6AjAJqpoLyU # gV8Orw2osScc8kvdvYEbUHxShltzCAmzPOxZlonWqQ+u0ejBW0Wf5qzzh2lNOB90 # sFDc7Z0NmOa4NgrF1efKzSCG0MG9OnKxiV6PY59keOfKTaNVSkzMADSvONJ8U5Fo # gArOcI8IbL6VKaIiJ1fuZ/cIqXqSvAwmCXyzn8M13AZh/ttFnUnRoIJfpy5ads/E # cEHxaVuL9XatjpEJtv7sksUWlptU+Voahqt/9TfH2FzxtpbkvHoL43Ev1ccRY8wg # mtKJAWxCifTe9ttLy7ebHE5MVo+cS00Ch47cIilZ/hg2C8I7J1TaPF5H+1YxcIi2 # JzVDYf3g55CC+OJNPLH8XdV0uwZ5QBqErb9tCpAyuRSDGsu8FRKwXM+7YzCDWQo/ # aZl2cmG0iIP8+1CpxZ+rBhR/W60sc79Z9JC8hhpScE6hLlRZFJ08If4ZxxYsX7py # 3n/vXZ9r1A8MldGfwDEPmtpcVSq7JI9hH/lLGim1gPtyDlZ//sFHTtvsAhIpWBEG # TdRfYVmoaZi8rnP5GpJt2GRWiTthrxyEapX6pbfv5fJj3O3bscVpdgHTLig8/SPP # 40DxPV/8Sg== # SIG # End signature block |