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 ' | ')
}