WindowsUtils.psm1

<#
    .SYNOPSIS
 
        Returns installed .NET version information.
 
    .DESCRIPTION
 
        This function returns .NET version information for the current, or remote computer.
        Uses the registry to retrieve .NET Framework information, and the runtime to return .NET information.
        It also returns the CLR version, and installed patches.
 
    .PARAMETER ComputerName
 
        A list of computer names. The function first tries Remote Registry, and falls back to PS Remote, in case the Remote Registry service is not running.
 
    .PARAMETER Edition
 
        The edition(s) to get version information. Default is 'All'.
 
    .PARAMETER Credential
 
        Optional credential.
 
    .PARAMETER IncludeUpdate
 
        Includes .NET patching information. This parameter is only allowed with .NET Framework.
 
    .EXAMPLE
 
        Get-InstalledDotNetInformation -ComputerName MYCOMPUTER1.contoso.com, MYCOMPUTER2.contoso.com -Edition 'DotnetFramework' -Credential (Get-Credential) -IncludeUpdate
 
    .EXAMPLE
 
        Get-InstalledDotNetInformation -Edition 'Dotnet'
 
    .NOTES
 
        This section will keep a brief history, as new versions gets released.
 
        # 2023-JUN-24 - v1.0:
 
            .NET 8.0.0-preview.5
            .NET Framework 4.8.1
 
        Scripted by: Francisco Nabas
        Version: 1.0
 
        This software is part of the WindowsUtils module, distributed under the MIT license.
        This software is, and will always be free.
 
    .LINK
 
        https://learn.microsoft.com/en-us/dotnet/framework/migration-guide/how-to-determine-which-versions-are-installed
        https://learn.microsoft.com/en-us/dotnet/framework/migration-guide/how-to-determine-which-net-framework-updates-are-installed
        https://learn.microsoft.com/en-us/dotnet/core/install/how-to-detect-installed-versions?pivots=os-windows
#>

function Get-InstalledDotnet {

    [CmdletBinding()]
    param (
        [Parameter(HelpMessage = 'The computer name(s) to retrieve the information from. Default is the current computer.')]
        [ValidateNotNullOrEmpty()]
        [string[]]$ComputerName,

        [Parameter(HelpMessage = "The .NET edition. Accepted values are 'All', 'Dotnet', and 'DotnetFramework'. Default is 'All'.")]
        [ValidateSet('All', 'Dotnet', 'DotnetFramework')]
        [string]$Edition = 'All',

        [Parameter(HelpMessage = 'The credentials to query information with.')]
        [pscredential]$Credential,

        [Parameter(HelpMessage = 'Includes .NET installed updates.')]
        [ValidateScript({
            if ($Edition -eq 'Dotnet') {
                throw [ArgumentException]::new("'IncludeUpdate' is only allowed with .NET Framework.")
            }

            return $true
        })]
        [switch]$InlcudeUpdate
    )

    Begin {
        $ErrorActionPreference = 'Stop'
                
        #region Initial setup
        [System.Collections.Generic.List[WindowsUtils.DotNetVersionInfo]]$remoteVersionInfo = @()
        [System.Management.Automation.Runspaces.PSSession]$psSession = $null
        [System.Collections.Generic.List[WindowsUtils.Registry.RegistryManager]]$regHandleList = @()
        #endregion
    }

    Process {
        if ($ComputerName) {
            foreach ($computer in $ComputerName) {
                if ($Edition -eq 'DotnetFramework' -or $Edition -eq 'All') {
                    if ($Credential) { $regManager = [WindowsUtils.Registry.RegistryManager]::new($computer, $Credential, [Microsoft.Win32.RegistryHive]::LocalMachine) }
                    else { $regManager = [WindowsUtils.Registry.RegistryManager]::new($computer, [Microsoft.Win32.RegistryHive]::LocalMachine) }
                    [void]$regHandleList.Add($regManager)
                }
                
                $mainSplat = @{
                    Session = ([ref]$psSession)
                    ComputerName = $computer
                }
                if ($Credential) { $mainSplat.Credential = $Credential }

                switch ($Edition) {
                    'Dotnet' {
                        $result = Get-RemoteDotNetCoreVersionInfo @mainSplat
                        if ($result.Error) {
                            if ($result.Result.Exception.Message -notlike 'The term*is not recognized*') {
                                Write-Error -ErrorRecord $result.Result
                            }
                        }
                        else {
                            [version]$version = $null
                            if ([version]::TryParse($result.Result, [ref]$version)) {
                                [void]$remoteVersionInfo.Add([WindowsUtils.DotNetVersionInfo]::new(0, $version, 'Core'))
                            }
                        }
                    }
                    'DotnetFramework' {
                        $result = Get-RemoteDotNetFFVersionInfo @mainSplat -RegistryManager ([ref]$regManager)
                        if (![string]::IsNullOrEmpty($result.Version) -and $result.Release -ne 0) {
                            [void]$remoteVersionInfo.Add([WindowsUtils.DotNetVersionInfo]::new($result.Release, [version]$result.Version, 'FullFramework', $computer))
                        }
                        foreach ($legacyVersion in $result.Legacy) {
                            [void]$remoteVersionInfo.Add([WindowsUtils.DotNetVersionInfo]::new(0, [version]$legacyVersion, 'FullFramework', $computer))
                        }
                    }
                    Default {
                        $result = Get-RemoteDotNetCoreVersionInfo @mainSplat
                        if ($result.Error) {
                            if ($result.Result.Exception.Message -notlike 'The term*is not recognized*') {
                                Write-Error -ErrorRecord $result.Result
                            }
                        }
                        else {
                            [version]$version = $null
                            if ([version]::TryParse($result.Result, [ref]$version)) {
                                [void]$remoteVersionInfo.Add([WindowsUtils.DotNetVersionInfo]::new(0, $version, 'Core'))
                            }
                        }

                        $resultFf = Get-RemoteDotNetFFVersionInfo @mainSplat -RegistryManager ([ref]$regManager)
                        if (![string]::IsNullOrEmpty($resultFf.Version) -and $resultFf.Release -ne 0) {
                            [void]$remoteVersionInfo.Add([WindowsUtils.DotNetVersionInfo]::new($resultFf.Release, [version]$resultFf.Version, 'FullFramework', $computer))
                        }
                        foreach ($legacyVersion in $resultFf.Legacy) {
                            [void]$remoteVersionInfo.Add([WindowsUtils.DotNetVersionInfo]::new(0, [version]$legacyVersion, 'FullFramework', $computer))
                        }
                    }
                }
            }
        }
        else {
            if ($Edition -eq 'DotnetFramework' -or $Edition -eq 'All') {
                if ($Credential) { $regManager = [WindowsUtils.Registry.RegistryManager]::new($Credential, [Microsoft.Win32.RegistryHive]::LocalMachine) }
                else { $regManager = [WindowsUtils.Registry.RegistryManager]::new([Microsoft.Win32.RegistryHive]::LocalMachine) }
                [void]$regHandleList.Add($regManager)
            }

            $mainSplat = @{ Session = ([ref]$psSession) }
            if ($Credential) { $mainSplat.Credential = $Credential }

            switch ($Edition) {
                'Dotnet' {
                    $result = Get-RemoteDotNetCoreVersionInfo @mainSplat
                    if ($result.Error) {
                        if ($result.Result.Exception.Message -notlike 'The term*is not recognized*') {
                            Write-Error -ErrorRecord $result.Result
                        }
                    }
                    else {
                        [version]$version = $null
                        if ([version]::TryParse($result.Result, [ref]$version)) {
                            [void]$remoteVersionInfo.Add([WindowsUtils.DotNetVersionInfo]::new(0, $version, 'Core'))
                        }
                    }
                }
                'DotnetFramework' {
                    $result = Get-RemoteDotNetFFVersionInfo @mainSplat -RegistryManager ([ref]$regManager)
                    if (![string]::IsNullOrEmpty($result.Version) -and $result.Release -ne 0) {
                        [void]$remoteVersionInfo.Add([WindowsUtils.DotNetVersionInfo]::new($result.Release, [version]$result.Version, 'FullFramework'))
                    }
                    foreach ($legacyVersion in $result.Legacy) {
                        [void]$remoteVersionInfo.Add([WindowsUtils.DotNetVersionInfo]::new(0, [version]$legacyVersion, 'FullFramework'))
                    }
                }
                Default {
                    $result = Get-RemoteDotNetCoreVersionInfo @mainSplat
                    if ($result.Error) {
                        if ($result.Result.Exception.Message -notlike 'The term*is not recognized*') {
                            Write-Error -ErrorRecord $result.Result
                        }
                    }
                    else {
                        [version]$version = $null
                        if ([version]::TryParse($result.Result, [ref]$version)) {
                            [void]$remoteVersionInfo.Add([WindowsUtils.DotNetVersionInfo]::new(0, $version, 'Core'))
                        }
                    }

                    $resultFf = Get-RemoteDotNetFFVersionInfo @mainSplat -RegistryManager ([ref]$regManager)
                    if (![string]::IsNullOrEmpty($resultFf.Version) -and $resultFf.Release -ne 0) {
                        [void]$remoteVersionInfo.Add([WindowsUtils.DotNetVersionInfo]::new($resultFf.Release, [version]$resultFf.Version, 'FullFramework'))
                    }
                    foreach ($legacyVersion in $resultFf.Legacy) {
                        [void]$remoteVersionInfo.Add([WindowsUtils.DotNetVersionInfo]::new(0, [version]$legacyVersion, 'FullFramework'))
                    }
                }
            }
        }

        if ($InlcudeUpdate) {
            [System.Collections.Generic.List[WindowsUtils.DotNetInstalledUpdateInfo]]$patchInfo = @()
            if ($ComputerName) {
                foreach ($computer in $ComputerName) {
                    $mainSplat = @{
                        ComputerName = $computer
                        RegistryManager = ([ref]$regHandleList.Where({ $_.ComputerName -eq $computer }))
                        Session = ([ref]$psSession)
                    }
                    if ($Credential) { $mainSplat.Credential = $Credential }
                    
                    foreach ($info in (Get-DotNetInstalledPatches @mainSplat)) {
                        [void]$patchInfo.Add($info)
                    }
                }
            }
            else {
                $mainSplat = @{
                    RegistryManager = ([ref]$regManager)
                    Session = ([ref]$psSession)
                }
                if ($Credential) { $mainSplat.Credential = $Credential }
                
                foreach ($info in (Get-DotNetInstalledPatches @mainSplat)) {
                    [void]$patchInfo.Add($info)
                }
            }

            Write-Output ([PSCustomObject]@{
                VersionInfo = $remoteVersionInfo
                InstalledUpdates = $patchInfo
            })
        }
        else {
            Write-Output $remoteVersionInfo
        }
    }

    End {
        foreach ($handle in $regHandleList) {
            $handle.Dispose()
        }
    }
}

function Get-DotNetInstalledPatches {

    param(
        [ref]$RegistryManager,
        [string]$ComputerName,
        [pscredential]$Credential,
        [ref]$Session
    )

    $fallback = $false
    [System.Collections.Generic.List[WindowsUtils.DotNetInstalledUpdateInfo]]$patchInfo = @()
    try {
        foreach ($mainVersion in $RegistryManager.Value.GetRegistrySubKeyNames('SOFTWARE\WOW6432Node\Microsoft\Updates').Where({ $_ -like  '*.NET Framework*'})) {
            $result = $RegistryManager.Value.GetRegistrySubKeyNames("SOFTWARE\WOW6432Node\Microsoft\Updates\$mainVersion")
            [void]$patchInfo.Add([WindowsUtils.DotNetInstalledUpdateInfo]::new($mainVersion, $result))
        }
    }
    catch {
        if ($_.Exception.InnerException.NativeErrorCode -eq 53) {
            $fallback = $true
        }
        else {
            throw $_
        }
    }

    if ($fallback) {
        if ([string]::IsNullOrEmpty($ComputerName)) {
            $isNewPsDrive = $false
            if ($Credential) {
                $psDrive = New-PSDrive -Name 'HKLMImp' -PSProvider 'Registry' -Root 'HKEY_LOCAL_MACHINE' -Credential $Credential
                $isNewPsDrive = $true
            }
            else {
                $psDrive = Get-PSDrive -Name 'HKLM'
            }
            
            try {
                foreach ($subkey in (Get-ChildItem -Path 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Updates\' | Where-Object { $_.Name -like  '*.NET Framework*'})) {
                    [void]$patchInfo.Add([WindowsUtils.DotNetInstalledUpdateInfo]::new(
                        $subkey,
                        (Get-ChildItem -Path "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Updates\$($subkey.PSChildName)").PSChildName
                    ))
                }
            }
            catch {
                throw $_
            }
            finally {
                if ($isNewPsDrive) {
                    Remove-PSDrive -Name $psDrive.Name -Force
                }
            }
        }
        else {
            if (!$Session.Value.Runspace -or $Session.Value.Runspace.RunspaceStateInfo.State -ne 'Opened') {
                if ($Session.Value.Runspace) { $Session.Value.Runspace.CloseAsync() }
                
                $remoteSessSplat = @{ ComputerName = $ComputerName }
                if ($Credential) { $remoteSessSplat.Credential = $Credential }
        
                $Session.Value = New-PSSession @remoteSessSplat
            }

            try {
                $result = Invoke-Command -Session $Session.Value -ScriptBlock {
                    $ErrorActionPreference = 'SilentlyContinue'
                
                    foreach ($subkey in (Get-ChildItem -Path 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Updates\' | Where-Object { $_.Name -like  '*.NET Framework*'})) {
                        [pscustomobject]@{
                            Version = $subkey
                            Patches = (Get-ChildItem -Path "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Updates\$($subkey.PSChildName)").PSChildName
                        }
                    }
                }

                foreach ($info in $result) {
                    [void]$patchInfo.Add([WindowsUtils.DotNetInstalledUpdateInfo]::new($ComputerName, $info.Version, $info.Patches))
                }
            }
            catch {
                throw $_
            }
        }
    }

    return $patchInfo
}

function Get-RemoteDotNetCoreVersionInfo {

    param(
        [string]$ComputerName,
        [pscredential]$Credential,
        [ref]$Session
    )

    $isError = $false
    $invCmdSplat = @{
        ScriptBlock = {
            $result = ''
            try {
                if (Test-Path -Path "$env:ProgramFiles\dotnet\dotnet.exe" -PathType 'Leaf') {
                    $result = & "$env:ProgramFiles\dotnet\dotnet.exe" --version
                }
                else {
                    if (Test-Path -Path "${env:ProgramFiles(x86)}\dotnet\dotnet.exe" -PathType 'Leaf') {
                        $result = & "$env:ProgramFiles\dotnet\dotnet.exe" --version
                    }
                    else {
                        # Trying from the PATH.
                        $result = & 'dotnet.exe' --version
                    }
                }
            }
            catch {
                $result = $_
                $isError = $true
            }

            return [pscustomobject]@{ Result = $result; Error = $isError }
        }
    }
    
    if (![string]::IsNullOrEmpty($ComputerName)) {
        if (!$Session.Value.Runspace -or $Session.Value.Runspace.RunspaceStateInfo.State -ne 'Opened') {
            if ($Session.Value.Runspace) { $Session.Value.Runspace.CloseAsync() }
            
            $remoteSessSplat = @{ ComputerName = $ComputerName }
            if ($Credential) { $remoteSessSplat.Credential = $Credential }
    
            $Session.Value = New-PSSession @remoteSessSplat
        }
        $invCmdSplat.Session = $Session.Value
    }

    return Invoke-Command @invCmdSplat
}

function Get-RemoteDotNetFFVersionInfo {
    
    param(
        [ref]$RegistryManager,
        [string]$ComputerName,
        [pscredential]$Credential,
        [ref]$Session
    )

    $output = [PSCustomObject]@{
        ComputerName = $ComputerName
        Version = $null
        Release = 0
        Legacy = [System.Collections.Generic.List[string]]::new()
    }

    # Attempting to get installed versions greater than 4.5.
    try {
        $getVerSplat = @{
            RegistryManager = $RegistryManager
            SubKey = 'SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full'
            ValueName = @('Release', 'Version')
            ComputerName = $ComputerName
            Credential = $Credential
            Session = $Session
        }

        try {
            $mainVersionInfo = Get-RegistryValueWithWinrmFallback @getVerSplat
            $release = $mainVersionInfo[0]
            $versionText = $mainVersionInfo[1]
        }
        catch {
            $getVerSplat.ValueName = @('Release')
            $mainVersionInfo = Get-RegistryValueWithWinrmFallback @getVerSplat
            $release = $mainVersionInfo[0]
        }

        if ([string]::IsNullOrEmpty($versionText)) {
            $ffVersionInfo = [WindowsUtils.DotNetVersionInfo]::GetInfoFromRelease($release)
            $output.Release = $release
            $output.Version = $ffVersionInfo.Version
        }
        else {
            $output.Release = $release
            $output.Version = $versionText
        }
    }
    catch {
        if (!($_.Exception.InnerException.NativeErrorCode -eq 2)) {
            Write-Error -Exception $_.Exception
        }
    }

    #region Legacy versions
    foreach ($subKey in $RegistryManager.Value.GetRegistrySubKeyNames('SOFTWARE\Microsoft\NET Framework Setup\NDP\').Where({ $_ -ne 'CDF' -and $_ -ne 'v4.0' })) {
        $getVerSplat = @{
            RegistryManager = $RegistryManager
            Session = $Session
        }
        
        switch ($subKey) {
            'v3.0' {
                $getVerSplat.SubKey = 'SOFTWARE\Microsoft\NET Framework Setup\NDP\v3.0\Setup'
                $getVerSplat.ValueName = @('InstallSuccess', 'Version')
                $result = Get-RegistryValueWithWinrmFallback @getVerSplat
                
                if ($result[0] -eq 1) {
                    if ([string]::IsNullOrEmpty($result[1])) {
                        $output.Legacy.Add('v3.0')
                    }
                    else {
                        $output.Legacy.Add($result[1])
                    }
                }
            }
            'v4' {
                foreach ($versionProfile in @('Client', 'Full')) {
                    $getVerSplat.SubKey = "SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\$versionProfile"
                    $getVerSplat.ValueName = @('Install', 'Version')
                    $result = Get-RegistryValueWithWinrmFallback @getVerSplat
                
                    if ($result[0] -eq 1) {
                        if ([string]::IsNullOrEmpty($result[1])) {
                            $output.Legacy.Add("v4-$versionProfile")
                        }
                        else {
                            if ([version]$result[1] -lt [version]'4.5') {
                                $output.Legacy.Add("$($result[1])-$versionProfile")
                            }
                        }
                    }
                }
            }
            Default {
                $getVerSplat.SubKey = "SOFTWARE\Microsoft\NET Framework Setup\NDP\$subKey"
                $getVerSplat.ValueName = @('Install', 'Version')
                $result = Get-RegistryValueWithWinrmFallback @getVerSplat
                
                if ($result[0] -eq 1) {
                    if ([string]::IsNullOrEmpty($result[1])) {
                        $output.Legacy.Add($subKey)
                    }
                    else {
                        $output.Legacy.Add($result[1])
                    }
                }
            }
        }
    }
    #endregion

    return $output
}

function Get-RegistryValueWithWinrmFallback {

    [CmdletBinding()]
    param(
        [ref]$RegistryManager,
        [string]$SubKey,
        [string[]]$ValueName,
        [string]$ComputerName,
        [pscredential]$Credential,
        [ref]$Session
    )

    try {
        $result = $RegistryManager.Value.GetRegistryValueList($SubKey, $ValueName)
        return $result
    }
    catch {
        if ($_.Exception.InnerException.NativeErrorCode -eq 53) {
            Write-Verbose 'Failed with native error code 53.'
            $fallback = $true
        }
        else {
            throw $_
        }
    }

    if ($fallback) {
        
        if ([string]::IsNullOrEmpty($ComputerName)) {
            $isNewDrive = $false
            try {
                Write-Verbose 'Attempting using WinRM on local computer.'
                switch ($Hive) {
                    'ClassesRoot' { $hiveData = [pscustomobject]@{ HiveName = 'HKEY_CLASSES_ROOT'; DriveName = 'HKCR' } }
                    'CurrentUser' { $hiveData = [pscustomobject]@{ HiveName = 'HKEY_CURRENT_USER'; DriveName = 'HKCU' } }
                    'LocalMachine' { $hiveData = [pscustomobject]@{ HiveName = 'HKEY_LOCAL_MACHINE'; DriveName = 'HKLM' } }
                    'Users' { $hiveData = [pscustomobject]@{ HiveName = 'HKEY_USERS'; DriveName = 'HKU' } }
                    'PerformanceData' { $hiveData = [pscustomobject]@{ HiveName = 'HKEY_PERFORMANCE_DATA'; DriveName = 'HKPD' } }
                    'CurrentConfig' { $hiveData = [pscustomobject]@{ HiveName = 'HKEY_CURRENT_CONFIG'; DriveName = 'HKCC' } }
                }
                
                if ($Credential) {
                    $psDrive = New-PSDrive -Name "$($hiveData.DriveName)Imp" -PSProvider 'Registry' -Root $hiveData.HiveName -Credential $Credential
                    $isNewDrive = $true
                }
                else {
                    $psDrive = Get-PSDrive -PSProvider 'Registry' | Where-Object { $_.Root -eq $hiveData.HiveName }
                    if (!$psDrive) {
                        $psDrive = New-PSDrive -Name $hiveData.DriveName -PSProvider 'Registry' -Root $hiveData.HiveName
                        $isNewDrive = $true
                    }
                }
        
                return Get-ItemPropertyValue -LiteralPath "$($psDrive.Name):\$SubKey" -Name $ValueName
            }
            catch {
                throw $_
            }
            finally {
                if ($isNewDrive) {
                    Remove-PSDrive -Name $hiveData.DriveName -Force
                }
            }
        }
        else {
            try {
                if (!$Session.Value.Runspace -or $Session.Value.Runspace.RunspaceStateInfo.State -ne 'Opened') {
                    if ($Session.Value.Runspace) { $Session.Value.Runspace.CloseAsync() }
                    
                    $remoteSessSplat = @{ ComputerName = $ComputerName }
                    if ($Credential) { $remoteSessSplat.Credential = $Credential }

                    $Session.Value = New-PSSession @remoteSessSplat
                }

                Write-Verbose "Attempting using WinRM on '$ComputerName'."
                $result = Invoke-Command -Session $Session.Value -ScriptBlock {
                    $isNewDrive = $false
                    try {
                        switch ($Using:Hive) {
                            'ClassesRoot' { $hiveData = [pscustomobject]@{ HiveName = 'HKEY_CLASSES_ROOT'; DriveName = 'HKCR' } }
                            'CurrentUser' { $hiveData = [pscustomobject]@{ HiveName = 'HKEY_CURRENT_USER'; DriveName = 'HKCU' } }
                            'LocalMachine' { $hiveData = [pscustomobject]@{ HiveName = 'HKEY_LOCAL_MACHINE'; DriveName = 'HKLM' } }
                            'Users' { $hiveData = [pscustomobject]@{ HiveName = 'HKEY_USERS'; DriveName = 'HKU' } }
                            'PerformanceData' { $hiveData = [pscustomobject]@{ HiveName = 'HKEY_PERFORMANCE_DATA'; DriveName = 'HKPD' } }
                            'CurrentConfig' { $hiveData = [pscustomobject]@{ HiveName = 'HKEY_CURRENT_CONFIG'; DriveName = 'HKCC' } }
                        }
                        
                        $psDrive = Get-PSDrive -PSProvider 'Registry' | Where-Object { $_.Root -eq $hiveData.HiveName }
                        if (!$psDrive) {
                            $psDrive = New-PSDrive -Name $hiveData.DriveName -PSProvider 'Registry' -Root $hiveData.HiveName
                            $isNewDrive = $true
                        }
        
                        Get-ItemPropertyValue -LiteralPath "$($hiveData.DriveName):\$Using:SubKey" -Name $Using:ValueName
                    }
                    catch {
                        throw $_
                    }
                    finally {
                        if ($isNewDrive) {
                            Remove-PSDrive -Name $hiveData.DriveName -Force
                        }
                    }
                }
    
                if ($result.GetType() -eq [System.Management.Automation.ErrorRecord]) {
                    throw $result
                }
    
                return $result
            }
            catch {
                throw $_
            }
        }
    }
}