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:
@@ -235,6 +235,21 @@ Now that you're set up, explore these guides:
|
|||||||
- [Tweaks Guide](/guides/tweaks/) — Understand system optimizations
|
- [Tweaks Guide](/guides/tweaks/) — Understand system optimizations
|
||||||
- [FAQ](/faq/) — Common questions and answers
|
- [FAQ](/faq/) — Common questions and answers
|
||||||
|
|
||||||
|
## Exporting a diagnostics report
|
||||||
|
|
||||||
|
If you're reporting a problem, click the gear icon in the top-right corner and choose **Export Environment Report**. It saves a read-only JSON file with:
|
||||||
|
|
||||||
|
- Windows edition, version, build, and architecture
|
||||||
|
- CPU model, logical processor count, and total memory
|
||||||
|
- PowerShell edition, version, and execution policy
|
||||||
|
- Whether WinGet and Chocolatey are installed, and their versions
|
||||||
|
- Whether a reboot is pending
|
||||||
|
- The current applied/not-applied state of every tweak and toggle
|
||||||
|
|
||||||
|
It does not include computer or user names, paths, IP or MAC addresses, serial numbers, installed-app inventories, services, raw registry paths or values, secrets, or logs. The tweak/toggle state is derived from a registry comparison, but only the resulting true/false per tweak is included, not any registry content itself. WinUtil never uploads the report — you choose where to save it and who to send it to.
|
||||||
|
|
||||||
|
You'll be asked whether to also bundle the last 7 days of WinUtil logs into a companion `.txt` file, which maintainers often need alongside the report to diagnose an issue.
|
||||||
|
|
||||||
## Getting help
|
## Getting help
|
||||||
|
|
||||||
If you need assistance:
|
If you need assistance:
|
||||||
|
|||||||
@@ -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
|
$ToggleSwitchReg = $sync.configs.tweaks.$ToggleSwitch.registry
|
||||||
|
|
||||||
if ($null -eq $sync.ToggleStatusCache) {
|
if (-not $BypassCache) {
|
||||||
$sync.ToggleStatusCache = @{}
|
if ($null -eq $sync.ToggleStatusCache) {
|
||||||
|
$sync.ToggleStatusCache = @{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sync.ToggleStatusCache.ContainsKey($ToggleSwitch)) {
|
||||||
|
return [bool]$sync.ToggleStatusCache[$ToggleSwitch]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($sync.ToggleStatusCache.ContainsKey($ToggleSwitch)) {
|
$readErrorAction = if ($StopOnReadError) { "Stop" } else { "Continue" }
|
||||||
return [bool]$sync.ToggleStatusCache[$ToggleSwitch]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Get-PSDrive -Name HKU -ErrorAction SilentlyContinue)) {
|
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) {
|
foreach ($regentry in $ToggleSwitchReg) {
|
||||||
|
|
||||||
if (Test-Path $regentry.Path) {
|
if (Test-Path $regentry.Path -ErrorAction $readErrorAction) {
|
||||||
$regstate = (Get-ItemProperty -Path $regentry.Path).$($regentry.Name)
|
$regstate = (Get-ItemProperty -Path $regentry.Path -ErrorAction $readErrorAction).$($regentry.Name)
|
||||||
} else {
|
} else {
|
||||||
$regstate = $null
|
$regstate = $null
|
||||||
}
|
}
|
||||||
@@ -30,11 +39,15 @@ Function Get-WinUtilToggleStatus ($ToggleSwitch) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($regstate -ne $regentry.Value) {
|
if ($regstate -ne $regentry.Value) {
|
||||||
$sync.ToggleStatusCache[$ToggleSwitch] = $false
|
if (-not $BypassCache) {
|
||||||
|
$sync.ToggleStatusCache[$ToggleSwitch] = $false
|
||||||
|
}
|
||||||
return $false
|
return $false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$sync.ToggleStatusCache[$ToggleSwitch] = $true
|
if (-not $BypassCache) {
|
||||||
|
$sync.ToggleStatusCache[$ToggleSwitch] = $true
|
||||||
|
}
|
||||||
return $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(
|
param(
|
||||||
$CheckBox
|
$CheckBox,
|
||||||
|
[switch]$BypassToggleStatusCache,
|
||||||
|
[switch]$StopOnReadError
|
||||||
)
|
)
|
||||||
if ($CheckBox -eq "choco") {
|
if ($CheckBox -eq "choco") {
|
||||||
$apps = (choco list | Select-String -Pattern "^\S+").Matches.Value
|
$apps = (choco list | Select-String -Pattern "^\S+").Matches.Value
|
||||||
@@ -52,6 +54,7 @@ Function Invoke-WinUtilCurrentSystem {
|
|||||||
if ($CheckBox -eq "tweaks") {
|
if ($CheckBox -eq "tweaks") {
|
||||||
|
|
||||||
if (!(Test-Path 'HKU:\')) {$null = (New-PSDrive -PSProvider Registry -Name HKU -Root HKEY_USERS)}
|
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 {
|
$sync.configs.tweaks | Get-Member -MemberType NoteProperty | ForEach-Object {
|
||||||
|
|
||||||
@@ -65,7 +68,9 @@ Function Invoke-WinUtilCurrentSystem {
|
|||||||
$Values = @()
|
$Values = @()
|
||||||
|
|
||||||
if ($entryType -eq "Toggle") {
|
if ($entryType -eq "Toggle") {
|
||||||
if (-not (Get-WinUtilToggleStatus $Config)) {
|
if (-not (Get-WinUtilToggleStatus $Config `
|
||||||
|
-BypassCache:$BypassToggleStatusCache `
|
||||||
|
-StopOnReadError:$StopOnReadError)) {
|
||||||
$values += $False
|
$values += $False
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -77,8 +82,12 @@ Function Invoke-WinUtilCurrentSystem {
|
|||||||
$registryTotal++
|
$registryTotal++
|
||||||
$regstate = $null
|
$regstate = $null
|
||||||
|
|
||||||
if (Test-Path $tweak.Path) {
|
if (Test-Path $tweak.Path -ErrorAction $readErrorAction) {
|
||||||
$regstate = Get-ItemProperty -Name $tweak.Name -Path $tweak.Path -ErrorAction SilentlyContinue | Select-Object -ExpandProperty $($tweak.Name)
|
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) {
|
if ($null -eq $regstate) {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
function Invoke-WPFExportEnvironmentReport {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Exports an allowlisted, read-only environment report as JSON, with an optional bundle of
|
||||||
|
recent WinUtil logs.
|
||||||
|
#>
|
||||||
|
|
||||||
|
try {
|
||||||
|
$includeLogs = [System.Windows.MessageBox]::Show(
|
||||||
|
$sync.Form,
|
||||||
|
"Also include the last 7 days of WinUtil logs? This can help maintainers troubleshoot an issue.",
|
||||||
|
"Environment Report", "YesNo", "Question") -eq "Yes"
|
||||||
|
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
$dialog = [System.Windows.Forms.SaveFileDialog]::new()
|
||||||
|
$dialog.Title = "Export Environment Report"
|
||||||
|
$dialog.Filter = "JSON files (*.json)|*.json"
|
||||||
|
$dialog.FileName = "WinUtilEnvironmentReport_$(Get-Date -Format 'yyyyMMdd').json"
|
||||||
|
$dialog.InitialDirectory = [Environment]::GetFolderPath("Desktop")
|
||||||
|
|
||||||
|
if ($dialog.ShowDialog() -ne [System.Windows.Forms.DialogResult]::OK) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$jsonPath = $dialog.FileName
|
||||||
|
$logsPath = Get-WinUtilEnvironmentReportLogsPath -JsonPath $jsonPath
|
||||||
|
|
||||||
|
# SaveFileDialog's own overwrite prompt only covers $jsonPath. $logsPath is derived and never
|
||||||
|
# shown to the user, so a same-day re-export would otherwise silently replace it.
|
||||||
|
if ($includeLogs -and (Test-Path $logsPath)) {
|
||||||
|
$includeLogs = [System.Windows.MessageBox]::Show(
|
||||||
|
$sync.Form,
|
||||||
|
"A logs file already exists at:`n$logsPath`n`nReplace it?",
|
||||||
|
"Environment Report", "YesNo", "Warning") -eq "Yes"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-WinUtilLog -Component "EnvironmentReport" -Message "Environment report export started."
|
||||||
|
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Exporting environment report..." -Percent 0
|
||||||
|
|
||||||
|
# Registry reads across every tweak/toggle and the log bundle's file read add up to a
|
||||||
|
# couple of seconds. This handler runs directly on the WPF dispatcher thread (wired from
|
||||||
|
# the Settings menu), so the collection and write happen in a background runspace to avoid
|
||||||
|
# freezing the window.
|
||||||
|
Invoke-WPFRunspace -ParameterList @(("JsonPath", $jsonPath), ("LogsPath", $logsPath), ("IncludeLogs", $includeLogs)) -ScriptBlock {
|
||||||
|
param($JsonPath, $LogsPath, $IncludeLogs)
|
||||||
|
|
||||||
|
try {
|
||||||
|
$report = Get-WinUtilEnvironmentReport
|
||||||
|
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Writing environment report..." -Percent 60
|
||||||
|
$json = $report | ConvertTo-Json -Depth 6
|
||||||
|
[System.IO.File]::WriteAllText($JsonPath, $json, [System.Text.UTF8Encoding]::new($false))
|
||||||
|
|
||||||
|
if ($IncludeLogs) {
|
||||||
|
$logs = Get-WinUtilRecentLogs
|
||||||
|
[System.IO.File]::WriteAllText($LogsPath, $logs, [System.Text.UTF8Encoding]::new($false))
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-WinUtilLog -Component "EnvironmentReport" -Message "Environment report exported to $JsonPath."
|
||||||
|
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Environment export completed" -Percent 100
|
||||||
|
Invoke-WPFUIThread { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" }
|
||||||
|
} catch {
|
||||||
|
# No MessageBox here: it would hop through Invoke-WPFUIThread/Dispatcher.Invoke from
|
||||||
|
# this background thread, the combination that can stall/freeze the UI.
|
||||||
|
# The progress label, taskbar overlay, and log line carry the failure instead.
|
||||||
|
Write-WinUtilLog -Component "EnvironmentReport" -Level "ERROR" -Message "Environment report export failed: $($_.Exception.Message)"
|
||||||
|
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Environment export failed: $($_.Exception.Message)" -Percent 100
|
||||||
|
Invoke-WPFUIThread { Set-WinUtilTaskbaritem -state "Error" -overlay "warning" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# This is wired from a Settings-menu item, not a feature.json Button, so it never
|
||||||
|
# benefits from Invoke-WPFButton's implicit "clear the progress indicator on the next
|
||||||
|
# click" reset. Hide it explicitly instead, after a brief pause so the completed/failed
|
||||||
|
# label is actually visible.
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
Set-WinUtilTweaksProgressIndicator -Visible $false
|
||||||
|
} | Out-Null
|
||||||
|
} catch {
|
||||||
|
Write-WinUtilLog -Component "EnvironmentReport" -Level "ERROR" -Message "Environment report export failed: $($_.Exception.Message)"
|
||||||
|
[System.Windows.MessageBox]::Show(
|
||||||
|
$sync.Form,
|
||||||
|
"The environment report could not be exported. $($_.Exception.Message)",
|
||||||
|
"Environment Report",
|
||||||
|
"OK",
|
||||||
|
"None"
|
||||||
|
) | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#===========================================================================
|
||||||
|
# Tests - Environment report log bundling
|
||||||
|
#===========================================================================
|
||||||
|
|
||||||
|
BeforeAll {
|
||||||
|
. (Join-Path (Resolve-Path (Join-Path $PSScriptRoot "..")).Path "functions\private\Get-WinUtilRecentLogs.ps1")
|
||||||
|
}
|
||||||
|
|
||||||
|
Describe "Get-WinUtilRecentLogs" {
|
||||||
|
BeforeEach {
|
||||||
|
$script:logDir = Join-Path $TestDrive "logs"
|
||||||
|
New-Item -Path $script:logDir -ItemType Directory -Force | Out-Null
|
||||||
|
|
||||||
|
$recentPath = Join-Path $script:logDir "winutil_2026-08-20_10-00-00.log"
|
||||||
|
"recent session log" | Out-File -FilePath $recentPath -Encoding utf8
|
||||||
|
(Get-Item $recentPath).LastWriteTime = (Get-Date).AddDays(-1)
|
||||||
|
|
||||||
|
$oldPath = Join-Path $script:logDir "winutil_2026-07-01_10-00-00.log"
|
||||||
|
"old session log" | Out-File -FilePath $oldPath -Encoding utf8
|
||||||
|
(Get-Item $oldPath).LastWriteTime = (Get-Date).AddDays(-30)
|
||||||
|
|
||||||
|
$unrelatedPath = Join-Path $script:logDir "notes.txt"
|
||||||
|
"unrelated file" | Out-File -FilePath $unrelatedPath -Encoding utf8
|
||||||
|
(Get-Item $unrelatedPath).LastWriteTime = (Get-Date).AddDays(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
It "includes only winutil_*.log files within the day window" {
|
||||||
|
$result = Get-WinUtilRecentLogs -Days 7 -LogDirectory $script:logDir
|
||||||
|
|
||||||
|
$result | Should -Match "recent session log"
|
||||||
|
$result | Should -Not -Match "old session log"
|
||||||
|
$result | Should -Not -Match "unrelated file"
|
||||||
|
}
|
||||||
|
|
||||||
|
It "prefixes each included file with a header naming it" {
|
||||||
|
$result = Get-WinUtilRecentLogs -Days 7 -LogDirectory $script:logDir
|
||||||
|
|
||||||
|
$result | Should -Match "=== winutil_2026-08-20_10-00-00\.log ==="
|
||||||
|
}
|
||||||
|
|
||||||
|
It "returns an empty string when the log directory does not exist" {
|
||||||
|
$result = Get-WinUtilRecentLogs -Days 7 -LogDirectory (Join-Path $TestDrive "missing")
|
||||||
|
|
||||||
|
$result | Should -BeNullOrEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
#===========================================================================
|
||||||
|
# Tests - Environment Report
|
||||||
|
#===========================================================================
|
||||||
|
|
||||||
|
BeforeAll {
|
||||||
|
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||||
|
. (Join-Path $script:repoRoot "functions\private\Write-WinUtilLog.ps1")
|
||||||
|
. (Join-Path $script:repoRoot "functions\private\Get-WinUtilToggleStatus.ps1")
|
||||||
|
. (Join-Path $script:repoRoot "functions\private\Invoke-WinUtilCurrentSystem.ps1")
|
||||||
|
. (Join-Path $script:repoRoot "functions\private\Get-WinUtilTweaksStateReport.ps1")
|
||||||
|
. (Join-Path $script:repoRoot "functions\private\Test-WinUtilPackageManager.ps1")
|
||||||
|
. (Join-Path $script:repoRoot "functions\private\Get-WinUtilEnvironmentReport.ps1")
|
||||||
|
. (Join-Path $script:repoRoot "functions\private\Get-WinUtilEnvironmentReportLogsPath.ps1")
|
||||||
|
}
|
||||||
|
|
||||||
|
Describe "Get-WinUtilEnvironmentReport" {
|
||||||
|
BeforeEach {
|
||||||
|
# Keep this Describe focused on the top-level schema; tweaksState grouping/detection has
|
||||||
|
# its own dedicated Describe below with its own registry fixture.
|
||||||
|
Mock Get-WinUtilTweaksStateReport {
|
||||||
|
[pscustomobject]@{
|
||||||
|
collectionStatus = "collected"
|
||||||
|
essentialTweaks = [pscustomobject]@{}
|
||||||
|
customizePreferences = [pscustomobject]@{}
|
||||||
|
advancedTweaks = [pscustomobject]@{}
|
||||||
|
performancePlans = [pscustomobject]@{}
|
||||||
|
notEvaluable = @()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Mock Get-CimInstance {
|
||||||
|
switch ($ClassName) {
|
||||||
|
"Win32_OperatingSystem" {
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Caption = "Windows 11 Pro"
|
||||||
|
Version = "10.0.26100"
|
||||||
|
BuildNumber = "26100"
|
||||||
|
OSArchitecture = "64-bit"
|
||||||
|
TotalVisibleMemorySize = 16777216
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"Win32_Processor" {
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Name = "Example CPU"
|
||||||
|
NumberOfLogicalProcessors = 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Mock Get-ExecutionPolicy { "RemoteSigned" }
|
||||||
|
Mock Test-WinUtilPackageManager { "not-installed" }
|
||||||
|
Mock Test-Path { $false }
|
||||||
|
Mock Get-ItemProperty { $null }
|
||||||
|
Mock Write-WinUtilLog { }
|
||||||
|
}
|
||||||
|
|
||||||
|
It "returns the versioned allowlisted report schema" {
|
||||||
|
$report = Get-WinUtilEnvironmentReport
|
||||||
|
|
||||||
|
$report.schemaVersion | Should -Be "1.0"
|
||||||
|
$report.generatedAtUtc | Should -Match '^\d{4}-\d{2}-\d{2}T'
|
||||||
|
$report.windows.edition | Should -Be "Windows 11 Pro"
|
||||||
|
$report.hardware.cpuModel | Should -Be "Example CPU"
|
||||||
|
$report.hardware.logicalProcessorCount | Should -Be 8
|
||||||
|
$report.hardware.totalMemoryGB | Should -Be 16
|
||||||
|
$report.powershell.PSObject.Properties.Name | Should -Be @("edition", "version", "executionPolicy")
|
||||||
|
$report.powershell.executionPolicy | Should -Be "RemoteSigned"
|
||||||
|
$report.packageManagers.PSObject.Properties.Name | Should -Be @("winget", "chocolatey")
|
||||||
|
$report.system.PSObject.Properties.Name | Should -Be @("pendingRebootRequired")
|
||||||
|
$report.PSObject.Properties.Name | Should -Not -Contain "windowsFeatures"
|
||||||
|
$report.PSObject.Properties.Name | Should -Contain "tweaksState"
|
||||||
|
}
|
||||||
|
|
||||||
|
It "contains no fields outside the approved report schema" {
|
||||||
|
# Exact property-name assertions, not a blocklist of specific bad names - a blocklist only
|
||||||
|
# catches fields someone thought to list, and would miss e.g. a future "biosUuid" or "userSid".
|
||||||
|
# Dynamic tweak keys inside tweaksState's groups are intentionally not asserted here.
|
||||||
|
$report = Get-WinUtilEnvironmentReport
|
||||||
|
|
||||||
|
$report.PSObject.Properties.Name | Should -Be @(
|
||||||
|
"schemaVersion", "generatedAtUtc", "windows", "hardware", "powershell",
|
||||||
|
"packageManagers", "system", "tweaksState"
|
||||||
|
)
|
||||||
|
$report.windows.PSObject.Properties.Name | Should -Be @("edition", "version", "buildNumber", "architecture")
|
||||||
|
$report.hardware.PSObject.Properties.Name | Should -Be @("cpuModel", "logicalProcessorCount", "totalMemoryGB")
|
||||||
|
$report.packageManagers.winget.PSObject.Properties.Name | Should -Be @("installed", "version")
|
||||||
|
$report.packageManagers.chocolatey.PSObject.Properties.Name | Should -Be @("installed", "version")
|
||||||
|
$report.tweaksState.PSObject.Properties.Name | Should -Be @(
|
||||||
|
"collectionStatus", "essentialTweaks", "customizePreferences", "advancedTweaks",
|
||||||
|
"performancePlans", "notEvaluable"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
It "does not report a package manager as installed unless Test-WinUtilPackageManager confirms it" {
|
||||||
|
$report = Get-WinUtilEnvironmentReport
|
||||||
|
|
||||||
|
$report.packageManagers.winget.installed | Should -BeFalse
|
||||||
|
$report.packageManagers.winget.version | Should -BeNullOrEmpty
|
||||||
|
$report.packageManagers.chocolatey.installed | Should -BeFalse
|
||||||
|
$report.packageManagers.chocolatey.version | Should -BeNullOrEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
It "reads a package manager's version via its own -v flag once installed is confirmed" {
|
||||||
|
Mock Test-WinUtilPackageManager { "installed" } -ParameterFilter { $winget }
|
||||||
|
Mock winget { "v1.29.290" }
|
||||||
|
|
||||||
|
$report = Get-WinUtilEnvironmentReport
|
||||||
|
|
||||||
|
$report.packageManagers.winget.installed | Should -BeTrue
|
||||||
|
$report.packageManagers.winget.version | Should -Be "v1.29.290"
|
||||||
|
}
|
||||||
|
|
||||||
|
It "flags a pending reboot from PendingFileRenameOperations" {
|
||||||
|
Mock Get-ItemProperty {
|
||||||
|
[pscustomobject]@{ PendingFileRenameOperations = @("a", "b") }
|
||||||
|
} -ParameterFilter { $Name -eq "PendingFileRenameOperations" }
|
||||||
|
|
||||||
|
$report = Get-WinUtilEnvironmentReport
|
||||||
|
|
||||||
|
$report.system.pendingRebootRequired | Should -BeTrue
|
||||||
|
}
|
||||||
|
|
||||||
|
It "does not flag a pending reboot for a present but empty PendingFileRenameOperations value" {
|
||||||
|
Mock Get-ItemProperty {
|
||||||
|
[pscustomobject]@{ PendingFileRenameOperations = @() }
|
||||||
|
} -ParameterFilter { $Name -eq "PendingFileRenameOperations" }
|
||||||
|
|
||||||
|
$report = Get-WinUtilEnvironmentReport
|
||||||
|
|
||||||
|
$report.system.pendingRebootRequired | Should -BeFalse
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
Describe "Get-WinUtilEnvironmentReportLogsPath" {
|
||||||
|
It "swaps the JSON extension for a _logs.txt suffix" {
|
||||||
|
Get-WinUtilEnvironmentReportLogsPath -JsonPath "C:\Reports\WinUtilEnvironmentReport_20260101.json" |
|
||||||
|
Should -Be "C:\Reports\WinUtilEnvironmentReport_20260101_logs.txt"
|
||||||
|
}
|
||||||
|
|
||||||
|
It "handles a path with no extension" {
|
||||||
|
Get-WinUtilEnvironmentReportLogsPath -JsonPath "C:\Reports\Report" |
|
||||||
|
Should -Be "C:\Reports\Report_logs.txt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Describe "Get-WinUtilTweaksStateReport" {
|
||||||
|
BeforeEach {
|
||||||
|
$script:sync = [Hashtable]::Synchronized(@{
|
||||||
|
configs = @{
|
||||||
|
tweaks = [pscustomobject]@{
|
||||||
|
WPFTweaksApplied = [pscustomobject]@{
|
||||||
|
category = "Essential Tweaks"
|
||||||
|
registry = @([pscustomobject]@{ Path = "HKCU:\Fake1"; Name = "Enabled"; Value = "1"; OriginalValue = "0"; DefaultState = "true" })
|
||||||
|
}
|
||||||
|
WPFTweaksNotApplied = [pscustomobject]@{
|
||||||
|
category = "Essential Tweaks"
|
||||||
|
registry = @([pscustomobject]@{ Path = "HKCU:\Fake2"; Name = "Enabled"; Value = "1"; OriginalValue = "0"; DefaultState = "false" })
|
||||||
|
}
|
||||||
|
WPFToggleCustomize = [pscustomobject]@{
|
||||||
|
category = "Customize Preferences"
|
||||||
|
Type = "Toggle"
|
||||||
|
registry = @([pscustomobject]@{ Path = "HKCU:\Fake3"; Name = "Enabled"; Value = "1"; OriginalValue = "0"; DefaultState = "true" })
|
||||||
|
}
|
||||||
|
WPFTweaksAdvanced = [pscustomobject]@{
|
||||||
|
category = "z__Advanced Tweaks - CAUTION"
|
||||||
|
registry = @([pscustomobject]@{ Path = "HKCU:\Fake4"; Name = "Enabled"; Value = "1"; OriginalValue = "0"; DefaultState = "false" })
|
||||||
|
}
|
||||||
|
WPFComboExample = [pscustomobject]@{
|
||||||
|
category = "Customize Preferences"
|
||||||
|
Type = "Combobox"
|
||||||
|
}
|
||||||
|
WPFButtonExample = [pscustomobject]@{
|
||||||
|
category = "z__Advanced Tweaks - CAUTION"
|
||||||
|
Type = "Button"
|
||||||
|
}
|
||||||
|
WPFScriptOnly = [pscustomobject]@{
|
||||||
|
category = "Essential Tweaks"
|
||||||
|
}
|
||||||
|
WPFUnknownCategory = [pscustomobject]@{
|
||||||
|
category = "Some Unmapped Category"
|
||||||
|
registry = @([pscustomobject]@{ Path = "HKCU:\Fake5"; Name = "Enabled"; Value = "1"; OriginalValue = "0"; DefaultState = "true" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Mock Get-PSDrive { [pscustomobject]@{ Name = "HKU" } } -ParameterFilter { $Name -eq "HKU" }
|
||||||
|
Mock New-PSDrive { }
|
||||||
|
Mock Test-Path { $false }
|
||||||
|
Mock Get-ItemProperty { $null }
|
||||||
|
Mock Write-WinUtilLog { }
|
||||||
|
}
|
||||||
|
|
||||||
|
It "groups applied/not-applied tweaks and toggles by category" {
|
||||||
|
$result = Get-WinUtilTweaksStateReport
|
||||||
|
|
||||||
|
$result.collectionStatus | Should -Be "collected"
|
||||||
|
$result.essentialTweaks.WPFTweaksApplied | Should -BeTrue
|
||||||
|
$result.essentialTweaks.WPFTweaksNotApplied | Should -BeFalse
|
||||||
|
$result.customizePreferences.WPFToggleCustomize | Should -BeTrue
|
||||||
|
$result.advancedTweaks.WPFTweaksAdvanced | Should -BeFalse
|
||||||
|
@($result.performancePlans.PSObject.Properties).Count | Should -Be 0
|
||||||
|
}
|
||||||
|
|
||||||
|
It "reads live toggle state instead of the cached UI state" {
|
||||||
|
$script:sync.ToggleStatusCache = @{ WPFToggleCustomize = $true }
|
||||||
|
Mock Test-Path { $Path -eq "HKCU:\Fake3" }
|
||||||
|
Mock Get-ItemProperty { [pscustomobject]@{ Enabled = "0" } } -ParameterFilter {
|
||||||
|
$Path -eq "HKCU:\Fake3"
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = Get-WinUtilTweaksStateReport
|
||||||
|
|
||||||
|
$result.customizePreferences.WPFToggleCustomize | Should -BeFalse
|
||||||
|
$script:sync.ToggleStatusCache.WPFToggleCustomize | Should -BeTrue
|
||||||
|
Should -Invoke -CommandName Get-ItemProperty -Times 1 -Exactly -ParameterFilter {
|
||||||
|
$Path -eq "HKCU:\Fake3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
It "reports collection as unavailable when a live registry read fails" {
|
||||||
|
Mock Test-Path { $Path -eq "HKCU:\Fake3" }
|
||||||
|
Mock Get-ItemProperty { Write-Error -Message "Registry access denied" -ErrorAction Stop } -ParameterFilter {
|
||||||
|
$Path -eq "HKCU:\Fake3"
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = Get-WinUtilTweaksStateReport
|
||||||
|
|
||||||
|
$result.collectionStatus | Should -Be "unavailable"
|
||||||
|
@($result.customizePreferences.PSObject.Properties).Count | Should -Be 0
|
||||||
|
}
|
||||||
|
|
||||||
|
It "lists combobox and script-only tweaks as not evaluable instead of silently dropping them" {
|
||||||
|
$result = Get-WinUtilTweaksStateReport
|
||||||
|
|
||||||
|
$result.notEvaluable | Should -Contain "WPFComboExample"
|
||||||
|
$result.notEvaluable | Should -Contain "WPFScriptOnly"
|
||||||
|
}
|
||||||
|
|
||||||
|
It "excludes action buttons and unmapped categories entirely" {
|
||||||
|
$result = Get-WinUtilTweaksStateReport
|
||||||
|
|
||||||
|
$result.advancedTweaks.PSObject.Properties.Name | Should -Not -Contain "WPFButtonExample"
|
||||||
|
$result.notEvaluable | Should -Not -Contain "WPFButtonExample"
|
||||||
|
$result.notEvaluable | Should -Not -Contain "WPFUnknownCategory"
|
||||||
|
($result.essentialTweaks.PSObject.Properties.Name +
|
||||||
|
$result.customizePreferences.PSObject.Properties.Name +
|
||||||
|
$result.advancedTweaks.PSObject.Properties.Name +
|
||||||
|
$result.performancePlans.PSObject.Properties.Name) | Should -Not -Contain "WPFUnknownCategory"
|
||||||
|
}
|
||||||
|
|
||||||
|
It "returns empty groups instead of throwing when Invoke-WinUtilCurrentSystem fails" {
|
||||||
|
Mock Invoke-WinUtilCurrentSystem { throw "Registry access denied" }
|
||||||
|
|
||||||
|
# Calling this directly (rather than via a wrapped scriptblock piped to Should -Not -Throw)
|
||||||
|
# avoids a PowerShell scoping pitfall where an assignment inside such a scriptblock doesn't
|
||||||
|
# reliably propagate to this scope - an uncaught exception here still fails the test anyway.
|
||||||
|
$result = Get-WinUtilTweaksStateReport
|
||||||
|
|
||||||
|
# Empty groups alone would be indistinguishable from a successful scan that found nothing
|
||||||
|
# notable, so collectionStatus is what actually records that this run failed.
|
||||||
|
$result.collectionStatus | Should -Be "unavailable"
|
||||||
|
$result.essentialTweaks.PSObject.Properties.Name | Should -Not -Contain "WPFTweaksApplied"
|
||||||
|
@($result.essentialTweaks.PSObject.Properties).Count | Should -Be 0
|
||||||
|
@($result.customizePreferences.PSObject.Properties).Count | Should -Be 0
|
||||||
|
@($result.advancedTweaks.PSObject.Properties).Count | Should -Be 0
|
||||||
|
@($result.performancePlans.PSObject.Properties).Count | Should -Be 0
|
||||||
|
$result.notEvaluable | Should -BeNullOrEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -431,6 +431,10 @@ $sync["ExportMenuItem"].Add_Click({
|
|||||||
Invoke-WPFPopup -Action "Hide" -Popups @("Settings")
|
Invoke-WPFPopup -Action "Hide" -Popups @("Settings")
|
||||||
Invoke-WPFImpex -type "export"
|
Invoke-WPFImpex -type "export"
|
||||||
})
|
})
|
||||||
|
$sync["ExportEnvironmentReportMenuItem"].Add_Click({
|
||||||
|
Invoke-WPFPopup -Action "Hide" -Popups @("Settings")
|
||||||
|
Invoke-WPFExportEnvironmentReport
|
||||||
|
})
|
||||||
$sync["AboutMenuItem"].Add_Click({
|
$sync["AboutMenuItem"].Add_Click({
|
||||||
Invoke-WPFPopup -Action "Hide" -Popups @("Settings")
|
Invoke-WPFPopup -Action "Hide" -Popups @("Settings")
|
||||||
|
|
||||||
|
|||||||
@@ -1282,6 +1282,12 @@
|
|||||||
</MenuItem.ToolTip>
|
</MenuItem.ToolTip>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
|
<MenuItem FontSize="{DynamicResource ButtonFontSize}" Header="Export Environment Report" Name="ExportEnvironmentReportMenuItem" Foreground="{DynamicResource MainForegroundColor}">
|
||||||
|
<MenuItem.ToolTip>
|
||||||
|
<ToolTip Content="Export a read-only diagnostics report for troubleshooting."/>
|
||||||
|
</MenuItem.ToolTip>
|
||||||
|
</MenuItem>
|
||||||
|
<Separator/>
|
||||||
<MenuItem FontSize="{DynamicResource ButtonFontSize}" Header="About" Name="AboutMenuItem" Foreground="{DynamicResource MainForegroundColor}"/>
|
<MenuItem FontSize="{DynamicResource ButtonFontSize}" Header="About" Name="AboutMenuItem" Foreground="{DynamicResource MainForegroundColor}"/>
|
||||||
<MenuItem FontSize="{DynamicResource ButtonFontSize}" Header="Documentation" Name="DocumentationMenuItem" Foreground="{DynamicResource MainForegroundColor}"/>
|
<MenuItem FontSize="{DynamicResource ButtonFontSize}" Header="Documentation" Name="DocumentationMenuItem" Foreground="{DynamicResource MainForegroundColor}"/>
|
||||||
<MenuItem FontSize="{DynamicResource ButtonFontSize}" Header="Sponsors" Name="SponsorMenuItem" Foreground="{DynamicResource MainForegroundColor}"/>
|
<MenuItem FontSize="{DynamicResource ButtonFontSize}" Header="Sponsors" Name="SponsorMenuItem" Foreground="{DynamicResource MainForegroundColor}"/>
|
||||||
|
|||||||
Reference in New Issue
Block a user