Private/Kinds/AppRuntime.ps1
|
# The AppRuntime Kind: which .NET an application runs on, read from the application. # # The DotNet Kind lists the .NET that is installed on the machine. That says nothing about # an application that brings its own: a self-contained .NET application ships the runtime # in its program folder and never touches the installed one, so a Technician who updates # .NET on the machine has changed nothing for it - only the vendor's update does. Which of # the two an application is stands in the file beside its executable. # # Every .NET (Core) application has a <name>.runtimeconfig.json # (https://learn.microsoft.com/en-us/dotnet/core/runtime-config/, read 2026-10-09): # # - "includedFrameworks" lists the runtime it ships: self-contained. # - "framework" or "frameworks" lists the runtime it needs installed, by the lowest # version it accepts: framework-dependent. It rolls forward within the major version, # so what matters is whether that major version is installed at all. # # Observed on 2026-10-09: a self-contained WPF application, tfm net10.0, includedFrameworks # Microsoft.NETCore.App, Microsoft.WindowsDesktop.App and Microsoft.AspNetCore.App, all # 10.0.9, and coreclr.dll loaded from the program folder. # # Support. Microsoft supports a .NET version for a fixed time and publishes the day it # ends (https://dotnet.microsoft.com/platform/support/policy/dotnet-core). The days are a # Check Definition parameter, EndOfSupport, because a new version appears every year and # the module on the Gallery cannot know of it. # The day support ended or ends, by major version. Defaults for what was published when # this was written; a Check Definition replaces the table as a whole. $script:AppRuntimeEndOfSupport = @{ '5' = '2022-05-10' '6' = '2024-11-12' '7' = '2024-05-14' '8' = '2026-11-10' '9' = '2026-11-10' '10' = '2028-11-14' } function ConvertFrom-AppRuntimeConfig { <# .SYNOPSIS What a runtimeconfig.json says: the target framework, whether the application brings its runtime, and which frameworks in which versions. Pure. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$Text) $json = $null try { $json = "$Text" | ConvertFrom-Json -ErrorAction Stop } catch { return } $options = Get-DataProperty $json 'runtimeOptions' if ($null -eq $options) { return } $included = @((Get-DataCollection $options 'includedFrameworks') | Where-Object { $_ }) $needed = @((Get-DataCollection $options 'frameworks') | Where-Object { $_ }) $single = Get-DataProperty $options 'framework' if ($single) { $needed += $single } $selfContained = [bool]$included.Count $list = $needed if ($selfContained) { $list = $included } [pscustomobject]@{ Tfm = "$(Get-DataProperty $options 'tfm')" SelfContained = $selfContained Frameworks = @($list | ForEach-Object { [pscustomobject]@{ Name = "$(Get-DataProperty $_ 'name')"; Version = "$(Get-DataProperty $_ 'version')" } } | Where-Object { $_.Name }) } } function Get-AppRuntimeData { <# .PARAMETER Observed What earlier Checks gathered. The DotNet Check's list of installed shared frameworks is what a framework-dependent application is held against; when that Check did not run, InstalledFrameworks is $null, which is not an empty list. #> [CmdletBinding()] [OutputType([psobject])] param( [hashtable]$Parameters = @{}, [AllowNull()][hashtable]$Observed = @{} ) $configured = "$(Get-Parameter $Parameters 'Path' '')".Trim() $path = '' $file = $null $failure = $null $config = $null if ($configured) { # The file is named for the executable; the last part of the path may be *.runtimeconfig.json. $path = [Environment]::ExpandEnvironmentVariables($configured) $folder = Split-Path -Path $path -Parent $leaf = Split-Path -Path $path -Leaf if ($folder -and (Test-Path -LiteralPath $folder -PathType Container)) { $file = @(Get-ChildItem -LiteralPath $folder -Filter $leaf -File -Force -ErrorAction SilentlyContinue)[0] } if ($file) { try { $config = ConvertFrom-AppRuntimeConfig -Text ([IO.File]::ReadAllText($file.FullName)) } catch { $failure = $_.Exception.GetBaseException().Message } if (-not $config -and -not $failure) { $failure = 'not a runtimeconfig.json' } } } $installed = $null if ($Observed -and $Observed.ContainsKey('DotNet') -and $null -ne $Observed['DotNet']) { $installed = @((Get-DataCollection $Observed['DotNet'] 'SharedFrameworks') | ForEach-Object { [pscustomobject]@{ Name = "$(Get-DataProperty $_ 'Name')"; Version = "$(Get-DataProperty $_ 'Version')" Architecture = "$(Get-DataProperty $_ 'Architecture')" } }) } [pscustomobject]@{ PSTypeName = 'Gutcheck.Data.AppRuntime' App = "$(Get-Parameter $Parameters 'App' '')" ConfiguredPath = $configured Path = $(if ($file) { $file.FullName } else { $path }) Exists = [bool]$file Error = $failure Tfm = Get-DataProperty $config 'Tfm' SelfContained = Get-DataProperty $config 'SelfContained' Frameworks = (Get-DataCollection $config 'Frameworks') InstalledFrameworks = $installed # What "still supported" is counted from, so the Judge never asks the clock. GatheredAt = Get-Date } } function ConvertTo-AppRuntimeFinding { [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()]$Data, [hashtable]$Parameters = @{} ) $check = Get-Text 'Check.AppRuntime.Runtime' if (-not "$(Get-DataProperty $Data 'ConfiguredPath')") { return New-Finding -Category Apps -Check $check -Severity INFO ` -Value (Get-Text 'Value.AppRuntime.NotConfigured') -Hint (Get-Text 'Hint.AppRuntime.NotConfigured') } $path = "$(Get-DataProperty $Data 'Path')" if (-not (Get-DataProperty $Data 'Exists')) { return New-Finding -Category Apps -Check $check -Severity INFO ` -Value ((Get-Text 'Value.AppRuntime.NoFile') -f $path) -Hint (Get-Text 'Hint.AppRuntime.NoFile') } $frameworks = Get-DataCollection $Data 'Frameworks' if ((Get-DataProperty $Data 'Error') -or -not $frameworks.Count) { return New-Finding -Category Apps -Check $check -Severity INFO ` -Value ((Get-Text 'Value.AppRuntime.Unreadable') -f $path) -Hint (Get-Text 'Hint.AppRuntime.NoFile') } # The runtime itself decides the version; the desktop and web parts follow it. $core = @($frameworks | Where-Object { "$(Get-DataProperty $_ 'Name')" -eq 'Microsoft.NETCore.App' })[0] if (-not $core) { $core = $frameworks[0] } $version = "$(Get-DataProperty $core 'Version')" $major = @($version -split '\.')[0] $parts = @($frameworks | ForEach-Object { ("$(Get-DataProperty $_ 'Name')" -replace '^Microsoft\.', '') -replace '\.App$', '' }) -join ', ' $selfContained = [bool](Get-DataProperty $Data 'SelfContained') $lines = @() $hints = @() $severity = 'OK' if ($selfContained) { $lines += (Get-Text 'Value.AppRuntime.SelfContained') -f $version, $parts } else { $lines += (Get-Text 'Value.AppRuntime.FrameworkDependent') -f $version, $parts $installed = Get-DataProperty $Data 'InstalledFrameworks' if ($null -ne $installed) { # Every framework it names, in its major version, in some architecture. $missing = @($frameworks | Where-Object { $name = "$(Get-DataProperty $_ 'Name')" $need = @("$(Get-DataProperty $_ 'Version')" -split '\.')[0] -not @(@($installed) | Where-Object { "$(Get-DataProperty $_ 'Name')" -eq $name -and @("$(Get-DataProperty $_ 'Version')" -split '\.')[0] -eq $need }).Count } | ForEach-Object { '{0} {1}' -f (Get-DataProperty $_ 'Name'), @("$(Get-DataProperty $_ 'Version')" -split '\.')[0] }) if ($missing.Count) { $severity = 'FAIL' $lines += (Get-Text 'Value.AppRuntime.Missing') -f ($missing -join ', ') $hints += Get-Text 'Hint.AppRuntime.Missing' } else { $have = @(@($installed) | Where-Object { "$(Get-DataProperty $_ 'Name')" -eq "$(Get-DataProperty $core 'Name')" -and @("$(Get-DataProperty $_ 'Version')" -split '\.')[0] -eq $major } | ForEach-Object { "$(Get-DataProperty $_ 'Version')" } | Select-Object -Unique) if ($have.Count) { $lines += (Get-Text 'Value.AppRuntime.Installed') -f ($have -join ', ') } } } } # Out of support, as of the Run. A version the table does not know is not judged. $table = Get-Parameter $Parameters 'EndOfSupport' $script:AppRuntimeEndOfSupport $ends = $null if ($major) { $ends = Get-DataProperty $table $major } $day = [datetime]::MinValue $asOf = $null try { $asOf = [datetime](Get-DataProperty $Data 'GatheredAt') } catch { } if ($ends -and $null -ne $asOf -and [datetime]::TryParse("$ends", [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::None, [ref]$day)) { if ($day -lt $asOf.Date) { $severity = Get-WorstSeverity $severity 'WARN' $lines += (Get-Text 'Value.AppRuntime.SupportEnded') -f $major, $day if ($selfContained) { $hints += Get-Text 'Hint.AppRuntime.SupportEndedSelfContained' } else { $hints += Get-Text 'Hint.AppRuntime.SupportEnded' } } else { $lines += (Get-Text 'Value.AppRuntime.SupportedUntil') -f $major, $day } } if ($selfContained -and $severity -eq 'OK') { # Not a fault, and still worth a line: it is what a Technician gets wrong. return New-Finding -Category Apps -Check $check -Severity INFO -Value ($lines -join ' | ') ` -Hint (Get-Text 'Hint.AppRuntime.SelfContained') } New-Finding -Category Apps -Check $check -Severity $severity -Value ($lines -join ' | ') -Hint ($hints -join ' | ') } |