mirror of
https://github.com/ChrisTitusTech/winutil.git
synced 2026-10-08 11:39:10 +11:00
feat: add automated environment report export (#5025)
* feat: add automated environment report export * refactor: address code review comments * fix: refresh toggle state in environment report * fix: surface environment report read failures * test: make registry failure explicit --------- Co-authored-by: Chris Titus <contact@christitus.com>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
function Get-WinUtilEnvironmentReport {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Collects the allowlisted data used by the WinUtil environment report.
|
||||
#>
|
||||
|
||||
$windows = [ordered]@{
|
||||
edition = $null
|
||||
version = $null
|
||||
buildNumber = $null
|
||||
architecture = $null
|
||||
}
|
||||
$hardware = [ordered]@{
|
||||
cpuModel = $null
|
||||
logicalProcessorCount = $null
|
||||
totalMemoryGB = $null
|
||||
}
|
||||
|
||||
try {
|
||||
$operatingSystem = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
|
||||
$windows.edition = $operatingSystem.Caption
|
||||
$windows.version = $operatingSystem.Version
|
||||
$windows.buildNumber = $operatingSystem.BuildNumber
|
||||
$windows.architecture = $operatingSystem.OSArchitecture
|
||||
|
||||
if ($null -ne $operatingSystem.TotalVisibleMemorySize) {
|
||||
$hardware.totalMemoryGB = [math]::Round(([double]$operatingSystem.TotalVisibleMemorySize / 1MB), 2)
|
||||
}
|
||||
} catch {
|
||||
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message "Failed to collect Windows/memory info from Win32_OperatingSystem: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
try {
|
||||
$processors = @(Get-CimInstance -ClassName Win32_Processor -ErrorAction Stop)
|
||||
if ($processors.Count -gt 0) {
|
||||
$hardware.cpuModel = $processors[0].Name
|
||||
$hardware.logicalProcessorCount = [int](($processors | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum)
|
||||
}
|
||||
} catch {
|
||||
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message "Failed to collect CPU info from Win32_Processor: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
$powershell = [ordered]@{
|
||||
edition = $PSVersionTable.PSEdition
|
||||
version = $PSVersionTable.PSVersion.ToString()
|
||||
executionPolicy = $null
|
||||
}
|
||||
|
||||
try {
|
||||
$powershell.executionPolicy = (Get-ExecutionPolicy).ToString()
|
||||
} catch {
|
||||
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message "Failed to read PowerShell execution policy: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# Re-use built-in functionality
|
||||
$chocolatey = [ordered]@{ installed = $false; version = $null }
|
||||
try {
|
||||
$chocolatey.installed = (Test-WinUtilPackageManager -choco 6>$null) -eq "installed"
|
||||
} catch {
|
||||
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message "Failed to check Chocolatey availability: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
if ($chocolatey.installed) {
|
||||
try {
|
||||
$chocolatey.version = (choco -v 2>&1 | Select-Object -First 1).ToString().Trim()
|
||||
} catch {
|
||||
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message "Failed to read Chocolatey version: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
$winget = [ordered]@{ installed = $false; version = $null }
|
||||
try {
|
||||
$winget.installed = (Test-WinUtilPackageManager -winget 6>$null) -eq "installed"
|
||||
} catch {
|
||||
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message "Failed to check WinGet availability: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
if ($winget.installed) {
|
||||
try {
|
||||
$winget.version = (winget -v 2>&1 | Select-Object -First 1).ToString().Trim()
|
||||
} catch {
|
||||
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message "Failed to read WinGet version: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
$system = [ordered]@{ pendingRebootRequired = $false }
|
||||
try {
|
||||
$rebootPaths = @(
|
||||
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending",
|
||||
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired"
|
||||
)
|
||||
|
||||
# A present-but-empty PendingFileRenameOperations value still returns a non-null object, so
|
||||
# check the actual entries rather than just whether the property exists.
|
||||
$pendingFileRenameOperations = @(
|
||||
(Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager" `
|
||||
-Name "PendingFileRenameOperations" -ErrorAction SilentlyContinue).PendingFileRenameOperations |
|
||||
Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }
|
||||
)
|
||||
$system.pendingRebootRequired = ($rebootPaths | Where-Object { Test-Path $_ }).Count -gt 0 -or
|
||||
$pendingFileRenameOperations.Count -gt 0
|
||||
} catch {
|
||||
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message "Failed to check pending-reboot registry state: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
$tweaksState = Get-WinUtilTweaksStateReport
|
||||
|
||||
return [pscustomobject][ordered]@{
|
||||
schemaVersion = "1.0"
|
||||
generatedAtUtc = [DateTime]::UtcNow.ToString("o")
|
||||
windows = [pscustomobject]$windows
|
||||
hardware = [pscustomobject]$hardware
|
||||
powershell = [pscustomobject]$powershell
|
||||
packageManagers = [pscustomobject][ordered]@{
|
||||
winget = [pscustomobject]$winget
|
||||
chocolatey = [pscustomobject]$chocolatey
|
||||
}
|
||||
system = [pscustomobject]$system
|
||||
tweaksState = $tweaksState
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
function Get-WinUtilEnvironmentReportLogsPath {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Derives the companion logs .txt path from the environment report's JSON save path.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$JsonPath
|
||||
)
|
||||
|
||||
# ChangeExtension($JsonPath, $null) leaves a trailing dot instead of stripping it, so build the
|
||||
# name from its parts instead.
|
||||
$directory = [System.IO.Path]::GetDirectoryName($JsonPath)
|
||||
$baseName = [System.IO.Path]::GetFileNameWithoutExtension($JsonPath)
|
||||
return [System.IO.Path]::Combine($directory, "${baseName}_logs.txt")
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
function Get-WinUtilRecentLogs {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Concatenates WinUtil session logs from the last N days into a single text blob.
|
||||
|
||||
.PARAMETER Days
|
||||
How many days back to include. Defaults to 7, matching what the support forum/server
|
||||
typically asks users for.
|
||||
|
||||
.PARAMETER LogDirectory
|
||||
Overrides the log directory (normally $sync.winutildir\logs). Mainly for testing.
|
||||
#>
|
||||
param(
|
||||
[int]$Days = 7,
|
||||
[string]$LogDirectory
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($LogDirectory)) {
|
||||
if ($null -eq $sync -or -not $sync.ContainsKey("winutildir") -or [string]::IsNullOrWhiteSpace($sync.winutildir)) {
|
||||
return ""
|
||||
}
|
||||
$LogDirectory = Join-Path $sync.winutildir "logs"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $LogDirectory)) {
|
||||
return ""
|
||||
}
|
||||
|
||||
$cutoff = (Get-Date).AddDays(-$Days)
|
||||
$logFiles = Get-ChildItem -Path $LogDirectory -Filter "winutil_*.log" -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.LastWriteTime -ge $cutoff } |
|
||||
Sort-Object LastWriteTime
|
||||
|
||||
$sections = foreach ($logFile in $logFiles) {
|
||||
"=== $($logFile.Name) ===`n$(Get-Content -Path $logFile.FullName -Raw)"
|
||||
}
|
||||
|
||||
return ($sections -join "`n`n")
|
||||
}
|
||||
@@ -1,23 +1,32 @@
|
||||
Function Get-WinUtilToggleStatus ($ToggleSwitch) {
|
||||
Function Get-WinUtilToggleStatus {
|
||||
param(
|
||||
$ToggleSwitch,
|
||||
[switch]$BypassCache,
|
||||
[switch]$StopOnReadError
|
||||
)
|
||||
|
||||
$ToggleSwitchReg = $sync.configs.tweaks.$ToggleSwitch.registry
|
||||
|
||||
if ($null -eq $sync.ToggleStatusCache) {
|
||||
$sync.ToggleStatusCache = @{}
|
||||
if (-not $BypassCache) {
|
||||
if ($null -eq $sync.ToggleStatusCache) {
|
||||
$sync.ToggleStatusCache = @{}
|
||||
}
|
||||
|
||||
if ($sync.ToggleStatusCache.ContainsKey($ToggleSwitch)) {
|
||||
return [bool]$sync.ToggleStatusCache[$ToggleSwitch]
|
||||
}
|
||||
}
|
||||
|
||||
if ($sync.ToggleStatusCache.ContainsKey($ToggleSwitch)) {
|
||||
return [bool]$sync.ToggleStatusCache[$ToggleSwitch]
|
||||
}
|
||||
$readErrorAction = if ($StopOnReadError) { "Stop" } else { "Continue" }
|
||||
|
||||
if (-not (Get-PSDrive -Name HKU -ErrorAction SilentlyContinue)) {
|
||||
New-PSDrive -PSProvider Registry -Name HKU -Root HKEY_USERS | Out-Null
|
||||
New-PSDrive -PSProvider Registry -Name HKU -Root HKEY_USERS -ErrorAction $readErrorAction | Out-Null
|
||||
}
|
||||
|
||||
foreach ($regentry in $ToggleSwitchReg) {
|
||||
|
||||
if (Test-Path $regentry.Path) {
|
||||
$regstate = (Get-ItemProperty -Path $regentry.Path).$($regentry.Name)
|
||||
if (Test-Path $regentry.Path -ErrorAction $readErrorAction) {
|
||||
$regstate = (Get-ItemProperty -Path $regentry.Path -ErrorAction $readErrorAction).$($regentry.Name)
|
||||
} else {
|
||||
$regstate = $null
|
||||
}
|
||||
@@ -30,11 +39,15 @@ Function Get-WinUtilToggleStatus ($ToggleSwitch) {
|
||||
}
|
||||
|
||||
if ($regstate -ne $regentry.Value) {
|
||||
$sync.ToggleStatusCache[$ToggleSwitch] = $false
|
||||
if (-not $BypassCache) {
|
||||
$sync.ToggleStatusCache[$ToggleSwitch] = $false
|
||||
}
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
$sync.ToggleStatusCache[$ToggleSwitch] = $true
|
||||
if (-not $BypassCache) {
|
||||
$sync.ToggleStatusCache[$ToggleSwitch] = $true
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
function Get-WinUtilTweaksStateReport {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Groups every config/tweaks.json entry's live applied state by category, reusing the same
|
||||
detection Invoke-WPFGetInstalled uses to check the "Get Installed Tweaks" checkboxes.
|
||||
#>
|
||||
|
||||
$categoryFieldNames = [ordered]@{
|
||||
"Essential Tweaks" = "essentialTweaks"
|
||||
"Customize Preferences" = "customizePreferences"
|
||||
"z__Advanced Tweaks - CAUTION" = "advancedTweaks"
|
||||
"Performance Plans - NOT FOR LAPTOPS" = "performancePlans"
|
||||
}
|
||||
|
||||
$grouped = [ordered]@{}
|
||||
foreach ($fieldName in $categoryFieldNames.Values) {
|
||||
$grouped[$fieldName] = [ordered]@{}
|
||||
}
|
||||
$notEvaluable = [System.Collections.Generic.List[string]]::new()
|
||||
$collectionStatus = "collected"
|
||||
|
||||
try {
|
||||
$appliedTweaks = [System.Collections.Generic.HashSet[string]]::new(
|
||||
[string[]]@(Invoke-WinUtilCurrentSystem -CheckBox "tweaks" `
|
||||
-BypassToggleStatusCache -StopOnReadError)
|
||||
)
|
||||
|
||||
foreach ($property in $sync.configs.tweaks.PSObject.Properties) {
|
||||
$tweakKey = $property.Name
|
||||
$entry = $property.Value
|
||||
$fieldName = $categoryFieldNames[[string]$entry.category]
|
||||
|
||||
# Buttons embedded in the tweaks panel (e.g. the OOSU/Ultimate Performance launchers)
|
||||
# are actions, not stateful tweaks, so they're outside this report's scope entirely.
|
||||
if (-not $fieldName -or $entry.Type -eq "Button") {
|
||||
continue
|
||||
}
|
||||
|
||||
# Combobox tweaks and script-only tweaks with no registry/service schema have no
|
||||
# detectable current state. List them so the report doesn't silently drop them.
|
||||
if ($entry.Type -eq "Combobox" -or (-not $entry.registry -and -not $entry.service)) {
|
||||
$notEvaluable.Add($tweakKey)
|
||||
continue
|
||||
}
|
||||
|
||||
$grouped[$fieldName][$tweakKey] = $appliedTweaks.Contains($tweakKey)
|
||||
}
|
||||
} catch {
|
||||
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message "Failed to collect tweaks/toggle state: $($_.Exception.Message)"
|
||||
$collectionStatus = "unavailable"
|
||||
}
|
||||
|
||||
# Empty groups from a failed collection would otherwise be indistinguishable in the JSON from a
|
||||
# successful scan that found nothing notable, so record whether collection actually ran.
|
||||
$result = [ordered]@{ collectionStatus = $collectionStatus }
|
||||
foreach ($fieldName in $categoryFieldNames.Values) {
|
||||
$result[$fieldName] = [pscustomobject]$grouped[$fieldName]
|
||||
}
|
||||
$result.notEvaluable = @($notEvaluable)
|
||||
|
||||
return [pscustomobject]$result
|
||||
}
|
||||
@@ -11,7 +11,9 @@ Function Invoke-WinUtilCurrentSystem {
|
||||
#>
|
||||
|
||||
param(
|
||||
$CheckBox
|
||||
$CheckBox,
|
||||
[switch]$BypassToggleStatusCache,
|
||||
[switch]$StopOnReadError
|
||||
)
|
||||
if ($CheckBox -eq "choco") {
|
||||
$apps = (choco list | Select-String -Pattern "^\S+").Matches.Value
|
||||
@@ -52,6 +54,7 @@ Function Invoke-WinUtilCurrentSystem {
|
||||
if ($CheckBox -eq "tweaks") {
|
||||
|
||||
if (!(Test-Path 'HKU:\')) {$null = (New-PSDrive -PSProvider Registry -Name HKU -Root HKEY_USERS)}
|
||||
$readErrorAction = if ($StopOnReadError) { "Stop" } else { "SilentlyContinue" }
|
||||
|
||||
$sync.configs.tweaks | Get-Member -MemberType NoteProperty | ForEach-Object {
|
||||
|
||||
@@ -65,7 +68,9 @@ Function Invoke-WinUtilCurrentSystem {
|
||||
$Values = @()
|
||||
|
||||
if ($entryType -eq "Toggle") {
|
||||
if (-not (Get-WinUtilToggleStatus $Config)) {
|
||||
if (-not (Get-WinUtilToggleStatus $Config `
|
||||
-BypassCache:$BypassToggleStatusCache `
|
||||
-StopOnReadError:$StopOnReadError)) {
|
||||
$values += $False
|
||||
}
|
||||
} else {
|
||||
@@ -77,8 +82,12 @@ Function Invoke-WinUtilCurrentSystem {
|
||||
$registryTotal++
|
||||
$regstate = $null
|
||||
|
||||
if (Test-Path $tweak.Path) {
|
||||
$regstate = Get-ItemProperty -Name $tweak.Name -Path $tweak.Path -ErrorAction SilentlyContinue | Select-Object -ExpandProperty $($tweak.Name)
|
||||
if (Test-Path $tweak.Path -ErrorAction $readErrorAction) {
|
||||
if ($StopOnReadError) {
|
||||
$regstate = (Get-ItemProperty -Path $tweak.Path -ErrorAction Stop).$($tweak.Name)
|
||||
} else {
|
||||
$regstate = Get-ItemProperty -Name $tweak.Name -Path $tweak.Path -ErrorAction SilentlyContinue | Select-Object -ExpandProperty $($tweak.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if ($null -eq $regstate) {
|
||||
|
||||
Reference in New Issue
Block a user