PSRedstone.psm1


class Redstone {
    hidden  [string]                $_Action                = $null
    hidden  [hashtable]             $_CimInstance           = $null
    hidden  [hashtable]             $_Env                   = $null
    hidden  [hashtable]             $_OS                    = $null
    hidden  [hashtable]             $_Vars                  = $null
    hidden  [string]                $_Product               = $null
    hidden  [hashtable]             $_ProfileList           = $null
    hidden  [string]                $_Publisher             = $null
    hidden  [string]                $_Version               = 'None'
    [int]                           $ExitCode               = 0
    [System.Collections.ArrayList]  $Exiting                = @()
    [bool]                          $IsElevated             = $null
    [hashtable]                     $Settings               = @{}

    # Use the default settings, don't read any of the settings in from the registry. In production this is never set.
    [bool]                          $OnlyUseDefaultSettings = $false
    [hashtable]                     $Debug                  = @{}

    #region Instantiation
    static Redstone() {
        # Creating some custom setters that update other properties, like Log Paths, when related properties are changed.
        Update-TypeData -TypeName 'Redstone' -MemberName 'Action' -MemberType 'ScriptProperty' -Value {
            # Getter
            return $this._Action
        } -SecondValue {
            param($value)
            # Setter
            $this._Action = $value
            $this.SetUpLog()
        } -Force
        Update-TypeData -TypeName 'Redstone' -MemberName 'CimInstance' -MemberType 'ScriptProperty' -Value {
            # Getter
            $className = $MyInvocation.Line.Split('.')[2]
            return $this.GetCimInstance($className, $true)
        } -Force
        Update-TypeData -TypeName 'Redstone' -MemberName 'Env' -MemberType 'ScriptProperty' -Value {
            # Getter
            if (-not $this._Env) {
                # This is the Lazy Loading logic.
                $this.SetUpEnv()
            }
            return $this._Env
        } -Force
        Update-TypeData -TypeName 'Redstone' -MemberName 'OS' -MemberType 'ScriptProperty' -Value {
            # Getter
            if (-not $this._OS) {
                # This is the Lazy Loading logic.
                $this.SetUpOS()
            }
            return $this._OS
        } -Force
        Update-TypeData -TypeName 'Redstone' -MemberName 'Vars' -MemberType 'ScriptProperty' -Value {
            # Getter
            if (-not $this._Vars) {
                # This is the Lazy Loading logic.
                $this.SetUpVars()
            }
            return $this._Vars
        } -SecondValue {
            param($value)
            # Setter
            $this._Vars = $value
        } -Force
        Update-TypeData -TypeName 'Redstone' -MemberName 'Product' -MemberType 'ScriptProperty' -Value {
            # Getter
            return $this._Product
        } -SecondValue {
            param($value)
            # Setter
            $this._Product = $value
            $this.SetUpLog()
        } -Force
        Update-TypeData -TypeName 'Redstone' -MemberName 'ProfileList' -MemberType 'ScriptProperty' -Value {
            # Getter
            if (-not $this._ProfileList) {
                # This is the Lazy Loading logic.
                $this.SetUpProfileList()
            }
            return $this._ProfileList
        } -Force
        Update-TypeData -TypeName 'Redstone' -MemberName 'Publisher' -MemberType 'ScriptProperty' -Value {
            # Getter
            return $this._Publisher
        } -SecondValue {
            param($value)
            # Setter
            $this._Publisher = $value
            $this.SetUpLog()
        } -Force
        Update-TypeData -TypeName 'Redstone' -MemberName 'Version' -MemberType 'ScriptProperty' -Value {
            # Getter
            return $this._Version
        } -SecondValue {
            param($value)
            # Setter
            $this._Version = $value
            $this.SetUpLog()
        } -Force
    }

    Redstone() {
        $this.SetUpSettings()
        $this.Settings.JSON = @{}

        $settingsFiles = @(
            [IO.FileInfo] ([IO.Path]::Combine($PWD.ProviderPath, 'settings.json'))
            [IO.FileInfo] ([IO.Path]::Combine(([IO.FileInfo] $this.Debug.PSCallStack[2].ScriptName).Directory.FullName, 'settings.json'))
            [IO.FileInfo] ([IO.Path]::Combine(([IO.DirectoryInfo] $PWD.ProviderPath).Parent, 'settings.json'))
            [IO.FileInfo] ([IO.Path]::Combine(([IO.FileInfo] $this.Debug.PSCallStack[2].ScriptName).Directory.Parent.FullName, 'settings.json'))
        )

        foreach ($location in $settingsFiles) {
            if ($location.Exists) {
                $this.Settings.JSON.File = $location
                $this.Settings.JSON.Data = Get-Content $this.Settings.JSON.File.FullName | ConvertFrom-Json
                break
            }
        }

        if (-not $this.Settings.JSON.File.Exists) {
            if (Get-Variable 'settings' -Scope 'script' -ErrorAction 'Ignore') {
                if ($script:settings.Keys -notcontains 'Publisher') {
                    Throw [System.IO.FileNotFoundException] ('Settings must contain Publisher: {0}' -f ($script:settings | ConvertTo-Json))
                }
                if ($script:settings.Keys -notcontains 'Product') {
                    Throw [System.IO.FileNotFoundException] ('Settings must contain Product: {0}' -f ($script:settings | ConvertTo-Json))
                }
                if ($script:settings.Keys -notcontains 'Version') {
                    Throw [System.IO.FileNotFoundException] ('Settings must contain Version: {0}' -f ($script:settings | ConvertTo-Json))
                }
            } else {
                Throw [System.IO.FileNotFoundException] ('Could NEITHER find the settings variable nor a file at any of these locations: {0}' -f ($settingsFiles.FullName -join ', '))
            }
        }

        $this.SetDefaultSettingsFromRegistry($this.Settings.Registry.KeyRoot)
        $this.SetPSDefaultParameterValues($this.Settings.Functions)

        $this.set__Publisher($this.Settings.JSON.Data.Publisher)
        $this.set__Product($this.Settings.JSON.Data.Product)
        $this.set__Version($this.Settings.JSON.Data.Version)
        $this.set__Action($(
            if ($this.Settings.JSON.Data.Action) {
                $this.Settings.JSON.Data.Action
            } else {
                $scriptName = ($this.Debug.PSCallStack | Where-Object {
                    ([IO.FileInfo] $_.ScriptName).Name -ne ([IO.FileInfo] $this.Debug.PSCallStack[0].ScriptName).Name
                } | Select-Object -First 1).ScriptName
                ([IO.FileInfo] $scriptName).BaseName
            }
        ))

        $this.SetUpLog()
    }

    Redstone([IO.FileInfo] $Settings) {
        $this.SetUpSettings()

        $this.Settings.JSON = @{}
        $this.Settings.JSON.File = [IO.FileInfo] $Settings
        if ($this.Settings.JSON.File.Exists) {
            $this.Settings.JSON.Data = Get-Content $this.Settings.JSON.File.FullName | ConvertFrom-Json
        } else {
            Throw [System.IO.FileNotFoundException] $this.Settings.JSON.File.FullName
        }

        $this.SetDefaultSettingsFromRegistry($this.Settings.Registry.KeyRoot)
        $this.SetPSDefaultParameterValues($this.Settings.Functions)

        $this.set__Publisher($this.Settings.JSON.Data.Publisher)
        $this.set__Product($this.Settings.JSON.Data.Product)
        $this.set__Version($this.Settings.JSON.Data.Version)
        $this.set__Action($(
            if ($this.Settings.JSON.Data.Action) {
                $this.Settings.JSON.Data.Action
            } else {
                $scriptName = ($this.Debug.PSCallStack | Where-Object {
                    ([IO.FileInfo] $_.ScriptName).Name -ne ([IO.FileInfo] $this.Debug.PSCallStack[0].ScriptName).Name
                } | Select-Object -First 1).ScriptName
                ([IO.FileInfo] $scriptName).BaseName
            }
        ))

        $this.SetUpLog()
    }
    
    Redstone([PSObject] $Settings) {
        $this.SetUpSettings()

        $this.Settings.JSON = @{}
        $this.Settings.JSON.Data = $Settings

        $this.SetDefaultSettingsFromRegistry($this.Settings.Registry.KeyRoot)
        $this.SetPSDefaultParameterValues($this.Settings.Functions)

        $this.set__Publisher($this.Settings.JSON.Data.Publisher)
        $this.set__Product($this.Settings.JSON.Data.Product)
        $this.set__Version($this.Settings.JSON.Data.Version)
        $this.set__Action($(
            if ($this.Settings.JSON.Data.Action) {
                $this.Settings.JSON.Data.Action
            } else {
                $scriptName = ($this.Debug.PSCallStack | Where-Object {
                    ([IO.FileInfo] $_.ScriptName).Name -ne ([IO.FileInfo] $this.Debug.PSCallStack[0].ScriptName).Name
                } | Select-Object -First 1).ScriptName
                ([IO.FileInfo] $scriptName).BaseName
            }
        ))

        $this.SetUpLog()
    }

    Redstone([string] $Publisher, [string] $Product, [string] $Version, [string] $Action) {
        $this.SetUpSettings()

        $this.SetDefaultSettingsFromRegistry($this.Settings.Registry.KeyRoot)
        $this.SetPSDefaultParameterValues($this.Settings.Functions)

        $this.set__Publisher($Publisher)
        $this.set__Product($Product)
        $this.set__Version($Version)
        $this.set__Action($Action)

        $this.SetUpLog()
    }
    #endregion Instantiation

    #region Settings
    hidden [void] SetUpSettings() {
        <#
        This is the original Settings
        #>

        $this.Debug = @{
            MyInvocation = $MyInvocation
            PSCallStack = (Get-PSCallStack)
        }

        $this.IsElevated = (New-Object System.Security.Principal.WindowsPrincipal([System.Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
        $this.Settings = @{}

        $regKeyPSRedstone = 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\com.github.VertigoRay\PSRedstone'
        $this.Settings.Registry = @{
            KeyRoot = $this.GetSetting($regKeyPSRedstone, 'RegistryKeyRoot', $regKeyPSRedstone)
        }
    }

    hidden [string] GetSetting([string] $Key, [string] $Name, [string] $Default) {
        $item = Get-Item ('env:{0}' -f $Name) -ErrorAction 'Ignore'
        if ($item.Value) {
            return ($item.Value -as [string])
        } else {
            return ((Get-RegistryValueOrDefault $Key $Name $Default) -as [string])
        }
    }
    #endregion Settings

    #region CimInstance
    hidden [object] GetCimInstance($ClassName) {
        return $this.GetCimInstance($ClassName, $false, $false)
    }

    hidden [object] GetCimInstance($ClassName, $ReturnCimInstanceNotClass) {
        return $this.GetCimInstance($ClassName, $ReturnCimInstanceNotClass, $false)
    }

    hidden [object] GetCimInstance($ClassName, $ReturnCimInstanceNotClass, $Refresh) {
        # This is the Lazy Loading logic.
        if (-not $this._CimInstance) {
            $this._CimInstance = @{}
        }
        if ($Refresh -or ($ClassName -and -not $this._CimInstance.$ClassName)) {
            $this._CimInstance.Set_Item($ClassName, (Get-CimInstance -ClassName $ClassName -ErrorAction 'Ignore'))
        }
        if ($ReturnCimInstanceNotClass) {
            return $this._CimInstance
        } else {
            return $this._CimInstance.$ClassName
        }
    }

    [object] CimInstanceRefreshed($ClassName) {
        return $this.GetCimInstance($ClassName, $false, $true)
    }
    #endregion CimInstance

    #region Debug Overrides
    hidden [bool] Is64BitOperatingSystem() {
        if ('Is64BitOperatingSystem' -in $this.Debug.Keys) {
            return $this.Debug.Is64BitOperatingSystem
        } else {
            return ([System.Environment]::Is64BitOperatingSystem)
        }
    }

    hidden [System.Collections.DictionaryEntry] Is64BitOperatingSystem([bool] $Override) {
        # Used for Pester Testing
        $this.Debug.Is64BitOperatingSystem = $Override
        return ($this.Debug.GetEnumerator() | Where-Object{ $_.Name -eq 'Is64BitOperatingSystem' })
    }

    hidden [bool] Is64BitProcess() {
        if ('Is64BitProcess' -in $this.Debug.Keys) {
            return $this.Debug.Is64BitProcess
        } else {
            return ([System.Environment]::Is64BitProcess)
        }
    }

    hidden [System.Collections.DictionaryEntry] Is64BitProcess([bool] $Override) {
        # Used for Pester Testing
        $this.Debug.Is64BitProcess = $Override
        return ($this.Debug.GetEnumerator() | Where-Object{ $_.Name -eq 'Is64BitProcess' })
    }
    #endregion Debug Overrides

    #region Env
    hidden [void] SetUpEnv() {
        # This section

        $this._Env = @{}
        if ($this.Is64BitOperatingSystem()) {
            # x64 OS
            if ($this.Is64BitProcess()) {
                # x64 Process
                $this._Env.CommonProgramFiles = $env:CommonProgramFiles
                $this._Env.'CommonProgramFiles(x86)' = ${env:CommonProgramFiles(x86)}
                $this._Env.PROCESSOR_ARCHITECTURE = $env:PROCESSOR_ARCHITECTURE
                $this._Env.ProgramFiles = $env:ProgramFiles
                $this._Env.'ProgramFiles(x86)' = ${env:ProgramFiles(x86)}
                $this._Env.System32 = "${env:SystemRoot}\System32"
                $this._Env.SysWOW64 = "${env:SystemRoot}\SysWOW64"
            } else {
                # Running as x86 on x64 OS
                $this._Env.CommonProgramFiles = $env:CommonProgramW6432
                $this._Env.'CommonProgramFiles(x86)' = ${env:CommonProgramFiles(x86)}
                $this._Env.PROCESSOR_ARCHITECTURE = $env:PROCESSOR_ARCHITEW6432
                $this._Env.ProgramFiles = $env:ProgramW6432
                $this._Env.'ProgramFiles(x86)' = ${env:ProgramFiles(x86)}
                $this._Env.System32 = "${env:SystemRoot}\SysNative"
                $this._Env.SysWOW64 = "${env:SystemRoot}\SysWOW64"
            }
        } else {
            # x86 OS
            $this._Env.CommonProgramFiles = $env:CommonProgramFiles
            $this._Env.'CommonProgramFiles(x86)' = $env:CommonProgramFiles
            $this._Env.PROCESSOR_ARCHITECTURE = $env:PROCESSOR_ARCHITECTURE
            $this._Env.ProgramFiles = $env:ProgramFiles
            $this._Env.'ProgramFiles(x86)' = $env:ProgramFiles
            $this._Env.System32 = "${env:SystemRoot}\System32"
            $this._Env.SysWOW64 = "${env:SystemRoot}\System32"
        }
    }
    #endregion Env

    #region Log
    hidden [void] SetUpLog() {
        $this.Settings.Log = @{}

        if ($this.IsElevated) {
            $private:Directory = [IO.DirectoryInfo] "${env:SystemRoot}\Logs\Redstone"
        } else {
            $private:Directory = [IO.DirectoryInfo] "${env:Temp}\Logs\Redstone"
        }

        if (-not $private:Directory.Exists) {
            New-Item -ItemType 'Directory' -Path $private:Directory.FullName -Force | Out-Null
            $private:Directory.Refresh()
        }

        $this.Settings.Log.File = [IO.FileInfo] (Join-Path $private:Directory.FullName ('{0} {1} {2} {3}.log' -f $this.Publisher, $this.Product, $this.Version, $this.Action))
        $this.Settings.Log.FileF = (Join-Path $private:Directory.FullName ('{0} {1} {2} {3}.{{0}}.log' -f $this.Publisher, $this.Product, $this.Version, $this.Action)) -as [string]
        $this.PSDefaultParameterValuesSetUp()
    }
    #endregion Log

    #region OS
    hidden [void] SetUpOS() {
        $this._OS = @{}
        [bool]   $this._OS.Is64BitOperatingSystem = [System.Environment]::Is64BitOperatingSystem
        [bool]   $this._OS.Is64BitProcess = [System.Environment]::Is64BitProcess

        [bool] $this._OS.Is64BitProcessor = ($this.GetCimInstance('Win32_Processor')| Where-Object { $_.DeviceID -eq 'CPU0' }).AddressWidth -eq '64'
        [bool]      $this._OS.IsMachinePartOfDomain = $this.GetCimInstance('Win32_ComputerSystem').PartOfDomain

        [string]    $this._OS.MachineWorkgroup = $null
        [string]    $this._OS.MachineADDomain = $null
        [string]    $this._OS.LogonServer = $null
        [string]    $this._OS.MachineDomainController = $null
        if ($this._OS.IsMachinePartOfDomain) {
            [string] $this._OS.MachineADDomain = $this.GetCimInstance('Win32_ComputerSystem').Domain | Where-Object { $_ } | ForEach-Object { $_.ToLower() }
            try {
                [string] $this._OS.LogonServer = $env:LOGONSERVER | Where-Object { (($_) -and (-not $_.Contains('\\MicrosoftAccount'))) } | ForEach-Object { $_.TrimStart('\') } | ForEach-Object { ([System.Net.Dns]::GetHostEntry($_)).HostName }
                [string] $this._OS.MachineDomainController = [System.DirectoryServices.ActiveDirectory.Domain]::GetCurrentDomain().FindDomainController().Name
            } catch {
                Write-Verbose 'Not in AD'
            }
        } else {
            [string] $this._OS.MachineWorkgroup = $this.GetCimInstance('Win32_ComputerSystem').Domain | Where-Object { $_ } | ForEach-Object { $_.ToUpper() }
        }
        [string]    $this._OS.MachineDNSDomain = [System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties().DomainName | Where-Object { $_ } | ForEach-Object { $_.ToLower() }
        [string]    $this._OS.MachineSid = ((Get-LocalUser | Select-Object -First 1).SID).AccountDomainSID.ToString()
        [string]    $this._OS.UserDNSDomain = $env:USERDNSDOMAIN | Where-Object { $_ } | ForEach-Object { $_.ToLower() }
        [string]    $this._OS.UserDomain = $env:USERDOMAIN | Where-Object { $_ } | ForEach-Object { $_.ToUpper() }
        [string]    $this._OS.Name = $this.GetCimInstance('Win32_OperatingSystem').Name.Trim()
        [string]    $this._OS.ShortName = (($this._OS.Name).Split('|')[0] -replace '\w+\s+(Windows [\d\.]+\s+\w+)', '$1').Trim()
        [string]    $this._OS.ShorterName = (($this._OS.Name).Split('|')[0] -replace '\w+\s+(Windows [\d\.]+)\s+\w+', '$1').Trim()
        [string]    $this._OS.ServicePack = $this.GetCimInstance('Win32_OperatingSystem').CSDVersion
        [version]   $this._OS.Version = [System.Environment]::OSVersion.Version
        # Get the operating system type
        [int32]     $this._OS.ProductType = $this.GetCimInstance('Win32_OperatingSystem').ProductType
        [bool]      $this._OS.IsServerOS = [bool]($this._OS.ProductType -eq 3)
        [bool]      $this._OS.IsDomainControllerOS = [bool]($this._OS.ProductType -eq 2)
        [bool]      $this._OS.IsWorkStationOS = [bool]($this._OS.ProductType -eq 1)
        switch ($this._OS.ProductType) {
            1       { [string] $this._OS.ProductTypeName = 'Workstation' }
            2       { [string] $this._OS.ProductTypeName = 'Domain Controller' }
            3       { [string] $this._OS.ProductTypeName = 'Server' }
            default { [string] $this._OS.ProductTypeName = 'Unknown' }
        }
    }
    #endregion OS

    #region Profile List
    hidden [void] SetUpProfileList() {
        Write-Debug 'GETTER: ProfileList'
        if (-not $this._ProfileList) {
            Write-Debug 'GETTER: Setting up ProfileList'
            $this._ProfileList = @{}
            $regProfileListPath = 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList'
            $regProfileList = Get-Item $regProfileListPath
            foreach ($property in $regProfileList.Property) {
                $value = if ($dirInfo = (Get-ItemProperty -Path $regProfileListPath).$property -as [IO.DirectoryInfo]) {
                    $dirInfo
                } else {
                    (Get-ItemProperty -Path $regProfileListPath).$property
                }
                $this._ProfileList.Add($property, $value)
            }

            [System.Collections.ArrayList] $this._ProfileList.Profiles = @()
            foreach ($userProfile in (Get-ChildItem $regProfileListPath)) {
                [hashtable] $user = @{}
                $user.Add('SID', $userProfile.PSChildName)
                $user.Add('Path', ((Get-ItemProperty "${regProfileListPath}\$($userProfile.PSChildName)").ProfileImagePath -as [IO.DirectoryInfo]))
                $objSID = New-Object System.Security.Principal.SecurityIdentifier($user.SID)
                try {
                    $objUser = $objSID.Translate([System.Security.Principal.NTAccount])
                    $domainUsername = $objUser.Value
                } catch [System.Management.Automation.MethodInvocationException] {
                    Write-Warning "Unable to translate the SID ($($user.SID)) to a Username."
                    $domainUsername = $null
                }

                $domain, $username = $domainUsername.Split('\')
                try {
                    $user.Add('Domain', $domain.Trim())
                } catch {
                    $user.Add('Domain', $null)
                }
                try {
                    $user.Add('Username', $username.Trim())
                } catch {
                    $user.Add('Username', $domainUsername)
                }
                ($this._ProfileList.Profiles).Add($user) | Out-Null
            }
        }
    }
    #endregion Profile List

    #region Vars
    hidden [void] SetUpVars() {
        $this.Settings.Registry.KeyOrg = $this.GetSetting($this.Settings.Registry.KeyRoot, 'RegistryKeyOrg', [IO.Path]::Combine($this.Settings.Registry.KeyRoot, 'Org'))
        $this.Settings.Registry.KeyOrgRecurse = $this.GetSetting($this.Settings.Registry.KeyRoot, 'RegistryKeyOrgRecurse', $false)
        $this.Settings.Registry.KeyPublisherParent = $this.GetSetting($this.Settings.Registry.KeyRoot, 'RegistryKeyPublisherParent', [IO.Path]::Combine($this.Settings.Registry.KeyRoot, 'Software'))
        $this.Settings.Registry.KeyPublisherRecurse = $this.GetSetting($this.Settings.Registry.KeyRoot, 'RegistryKeyPublisherRecurse', $false)
        $this.Settings.Registry.KeyProductParent = $this.GetSetting($this.Settings.Registry.KeyRoot, 'RegistryKeyProductParent', [IO.Path]::Combine($this.Settings.Registry.KeyPublisherParent, $this._Publisher))
        $this.Settings.Registry.KeyProductRecurse = $this.GetSetting($this.Settings.Registry.KeyRoot, 'RegistryKeyProductRecurse', $true)

        $this.Settings.Org = @{}
        $query = @{
            Key = $this.Settings.Registry.KeyOrg
            Recurse = $this.Settings.Registry.KeyOrgRecurse
        }
        $this.Settings.Org = Get-RegistryKeyAsHashTable @query
        $this.Vars = Get-RegistryKeyAsHashTable @query

        $this.Settings.Publisher = @{}
        $query = @{
            Key = [IO.Path]::Combine($this.Settings.Registry.KeyPublisherParent, $this._Publisher)
            Recurse = $this.Settings.Registry.KeyPublisherRecurse
        }
        $this.Settings.Publisher = Get-RegistryKeyAsHashTable @query
        $this.Vars = Get-RegistryKeyAsHashTable @query

        $this.Settings.Product = @{}
        $query = @{
            Key = [IO.Path]::Combine($this.Settings.Registry.KeyProductParent, $this._Product)
            Recurse = $this.Settings.Registry.KeyProductRecurse
        }
        $this.Settings.Product = Get-RegistryKeyAsHashTable @query
        $this.Vars = Get-RegistryKeyAsHashTable @query
    }

    [PSObject] GetVar([string] $Path) {
        return (Get-HashtableValue -Hashtable $this._Vars -Path $Path)
    }

    [PSObject] GetVar([string] $Path, [PSObject] $Default) {
        return (Get-HashtableValue -Hashtable $this._Vars -Path $Path -Default $Default)
    }
    #endregion Vars

    #region PSDefaultParameterValues
    hidden [void] PSDefaultParameterValuesSetUp() {
        $_prefix = (Get-Module 'PSRedstone').Prefix

        # $global:PSDefaultParameterValues.Set_Item(('*-{0}*:LogFileF' -f $_prefix), $this.Settings.Log.FileF)
        # $global:PSDefaultParameterValues.Set_Item(('*-{0}*:LogFileF' -f $_prefix), $this.Settings.Log.FileF)
        foreach ($_exportedCommand in (Get-Module 'PSRedstone').ExportedCommands.Keys) {
            if ((Get-Command $_exportedCommand).Parameters.Keys -contains 'LogFile') {
                $global:PSDefaultParameterValues.Set_Item(('{0}:LogFile' -f $_exportedCommand), $this.Settings.Log.File.FullName)
            }
            if ((Get-Command $_exportedCommand).Parameters.Keys -contains 'LogFileF') {
                $global:PSDefaultParameterValues.Set_Item(('{0}:LogFileF' -f $_exportedCommand), $this.Settings.Log.FileF)
            }
        }

        $_onlyUseDefaultSettings = $this.GetRegOrDefault('Settings\Functions\Get-RegistryValueOrDefault', 'OnlyUseDefaultSettings', $false)
        $global:PSDefaultParameterValues.Set_Item(('Get-{0}RegistryValueOrDefault:OnlyUseDefaultSettings' -f $_prefix), $_onlyUseDefaultSettings)

        # https://github.com/VertigoRay/PSWriteLog/wiki
        $global:PSDefaultParameterValues.Set_Item('Write-Log:FilePath', $this.Settings.Log.File.FullName)
    }

    hidden [void] SetPSDefaultParameterValues([hashtable] $FunctionParameters) {
        if ($FunctionParameters) {
            foreach ($function in $FunctionParameters.GetEnumerator()) {
                Write-Debug ('[Redstone::SetPSDefaultParameterValues] Function Type: [{0}]' -f $function.GetType().FullName)
                Write-Debug ('[Redstone::SetPSDefaultParameterValues] Function: {0}: {1}' -f $function.Name, ($function.Value | ConvertTo-Json))
                foreach ($parameter in $function.Value.GetEnumerator()) {
                    Write-Debug ('[Redstone::SetPSDefaultParameterValues] Parameter: {0}: {1}' -f $parameter.Name, ($parameter.Value | ConvertTo-Json))
                    Write-Debug ('[Redstone::SetPSDefaultParameterValues] PSDefaultParameterValues: {0}:{1} :: {2}' -f $function.Name, $parameter.Name, $parameter.Value)
                    $global:PSDefaultParameterValues.Set_Item(('{0}:{1}' -f $function.Name, $parameter.Name), $parameter.Value)
                }
            }
        }
    }
    #endregion PSDefaultParameterValues

    #region Registry
    hidden [psobject] GetRegOrDefault($RegistryKey, $RegistryValue, $DefaultValue) {
        Write-Verbose "[Redstone GetRegOrDefault] > $($MyInvocation.BoundParameters | ConvertTo-Json -Compress)"
        Write-Debug "[Redstone GetRegOrDefault] Function Invocation: $($MyInvocation | Out-String)"

        if ($this.OnlyUseDefaultSettings) {
            Write-Verbose "[Redstone GetRegOrDefault] OnlyUseDefaultSettings Set; Returning: ${DefaultValue}"
            return $DefaultValue
        }

        try {
            $ret = Get-ItemPropertyValue -Path ('Registry::{0}\{1}' -f $this.Settings.Registry.KeyRoot, $RegistryKey) -Name $RegistryValue -ErrorAction 'Stop'
            Write-Verbose "[Redstone GetRegOrDefault] Registry Set; Returning: ${ret}"
            return $ret
        } catch [System.Management.Automation.PSArgumentException] {
            Write-Verbose "[Redstone GetRegOrDefault] Registry Not Set; Returning Default: ${DefaultValue}"
            # This isn't a real error, so I don't want it in the error record.
            # This is a weird way to remove the record, but I've seen in testing where $Error length is 0, and
            # I don't understand it. However, this catches that error and ensure it doesn't end up on the $Error.
            # Ref: https://ci.appveyor.com/project/VertigoRay/psredstone/builds/46036142
            if ($Error.Count -gt 0) {
                $Error.RemoveAt(0)
            }
            return $DefaultValue
        } catch [System.Management.Automation.ItemNotFoundException] {
            Write-Verbose "[Redstone GetRegOrDefault] Registry Not Set; Returning Default: ${DefaultValue}"
            # This isn't a real error, so I don't want it in the error record.
            # This is a weird way to remove the record, but I've seen in testing where $Error length is 0, and
            # I don't understand it. However, this catches that error and ensure it doesn't end up on the $Error.
            # Ref: https://ci.appveyor.com/project/VertigoRay/psredstone/builds/46036142
            if ($Error.Count -gt 0) {
                $Error.RemoveAt(0)
            }
            return $DefaultValue
        }
    }

    hidden [void] SetDefaultSettingsFromRegistrySubKey([hashtable] $Hash, [string] $Key) {
        foreach ($regValue in (Get-Item $Key -ErrorAction 'Ignore').Property) {
            $Hash.Set_Item($regValue, (Get-ItemProperty -Path $Key -Name $regValue).$regValue)
        }
    }

    [string] GetRegValueDoNotExpandEnvironmentNames($Key, $Value) {
        $item = Get-Item $Key
        if ($item) {
            return $item.GetValue($Value, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
        } else {
            return $null
        }
    }

    hidden [void] SetDefaultSettingsFromRegistry([string] $Key) {
        <#
        Dig through the Registry Key and import all the Keys and Values into the $global:Redstone objet.
 
        There's a fundamental flaw that I haven't addressed yet.
        - if there's a value and sub-key with the same name at the same key level, the sub-key won't be processed.
        #>

        if (Test-Path $Key) {
            $this.SetDefaultSettingsFromRegistrySubKey($this.Settings, $Key)

            foreach ($item in (Get-ChildItem $Key -Recurse -ErrorAction 'Ignore')) {
                $private:psPath = $item.PSPath.Split(':')[-1].Replace($Key.Split(':')[-1], $null)
                $private:node = $this.Settings
                foreach ($child in ($private:psPath.Trim('\').Split('\'))) {
                    if (-not $node.$child) {
                        [hashtable] $node.$child = @{}
                    }
                    $node = $node.$child
                }

                $this.SetDefaultSettingsFromRegistrySubKey($node, $item.PSPath)
            }
        }
    }
    #endregion Registry

    #region Special Folders
    [psobject] GetSpecialFolders() {
        $specialFolders = [ordered] @{}
        foreach ($folder in ([Environment+SpecialFolder]::GetNames([Environment+SpecialFolder]) | Sort-Object)) {
            $specialFolders.Add($folder, $this.GetSpecialFolder($folder))
        }
        return ([psobject] $specialFolders)
    }

    [IO.DirectoryInfo] GetSpecialFolder([string] $Name) {
        return ([Environment]::GetFolderPath($Name) -as [IO.DirectoryInfo])
    }
    #endregion Special Folders

    #region Quit
    [void] Quit() {
        Write-Debug ('[Redstone.Quit 0] > {0}' -f ($MyInvocation | Out-String))
        [void] $this.Quit(0, $true , 0)
    }

    [void] Quit($ExitCode = 0) {
        Write-Verbose ('[Redstone.Quit 1] > {0}' -f ($MyInvocation | Out-String))
        $this.ExitCode = if ($ExitCode -eq 'line_number') {
            (Get-PSCallStack)[1].Location.Split(':')[1].Replace('line', '') -as [int]
        } else {
            $ExitCode
        }
        [void] $this.Quit($this.ExitCode, $false , 55550000)
    }

    [void] Quit($ExitCode = 0, [boolean] $ExitCodeAdd = $false) {
        Write-Verbose ('[Redstone.Quit 1] > {0}' -f ($MyInvocation | Out-String))
        $this.ExitCode = if ($ExitCode -eq 'line_number') {
            (Get-PSCallStack)[1].Location.Split(':')[1].Replace('line', '') -as [int]
        } else {
            $ExitCode
        }
        [void] $this.Quit($this.ExitCode, $ExitCodeAdd , 55550000)
    }

    [void] Quit($ExitCode = 0, [boolean] $ExitCodeAdd = $false, [int] $ExitCodeErrorBase = 55550000) {
        Write-Debug ('[Redstone.Quit 3] > {0}' -f ($MyInvocation | Out-String))

        Write-Verbose ('[Redstone.Quit] ExitCode: {0}' -f $ExitCode)
        $this.ExitCode = if ($ExitCode -eq 'line_number') {
            (Get-PSCallStack)[1].Location.Split(':')[1].Replace('line', '') -as [int]
        } else {
            $ExitCode -as [int]
        }

        if ($ExitCodeAdd) {
            Write-Information ('[Redstone.Quit] ExitCodeErrorBase: {0}' -f $ExitCodeErrorBase)
            if (($this.ExitCode -lt 0) -and ($ExitCodeErrorBase -gt 0)) {
                # Always Exit positive
                Write-Verbose ('[Redstone.Quit] ExitCodeErrorBase: {0}' -f $ExitCodeErrorBase)
                $ExitCodeErrorBase = $ExitCodeErrorBase * -1
                Write-Verbose ('[Redstone.Quit] ExitCodeErrorBase: {0}' -f $ExitCodeErrorBase)
            }

            if (([string] $this.ExitCode).Length -gt 4) {
                Write-Warning "[Redstone.Quit] ExitCode should not be added to Base when more than 4 digits. Doing it anyway ..."
            }

            if ($this.ExitCode -eq 0) {
                Write-Warning "[Redstone.Quit] ExitCode 0 being added may cause failure; not sure if this is expected. Doing it anyway ..."
            }

            $this.ExitCode = $this.ExitCode + $ExitCodeErrorBase
        }

        Write-Information ('[Redstone.Quit] ExitCode: {0}' -f $this.ExitCode)

        # Debug.Quit.DoNotExit is used in Pester testing.
        if (-not $this.Debug.Quit.DoNotExit) {
            $global:Host.SetShouldExit($ExitCode)
            Exit $ExitCode
        }
    }
    #endregion Quit
}
<#
.SYNOPSIS
Is the current process elevated (running as administrator)?
.OUTPUTS
[bool]
.EXAMPLE
Assert-IsElevated
Returns `$true` if you're running as an administrator.
.LINK
https://github.com/VertigoRay/PSRedstone/wiki/Functions#assert-iselevated
#>

function Assert-IsElevated {
    [CmdletBinding()]
    [OutputType([bool])]
    Param()

    Write-Verbose ('[Assert-IsElevated] >')
    Write-Debug ('[Assert-IsElevated] > {0}' -f ($MyInvocation | Out-String))

    $isElevated = (New-Object System.Security.Principal.WindowsPrincipal([System.Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
    Write-Verbose ('[Assert-IsElevated] IsElevated: {0}' -f $isElevated)

    return $isElevated
}
<#
.SYNOPSIS
Wait, up to a timeout value, to check if current thread is able to acquire an exclusive lock on a system mutex.
.DESCRIPTION
A mutex can be used to serialize applications and prevent multiple instances from being opened at the same time.
Wait, up to a timeout (default is 1 millisecond), for the mutex to become available for an exclusive lock.
This is an internal script function and should typically not be called directly.
.PARAMETER MutexName
The name of the system mutex.
.PARAMETER MutexWaitTimeInMilliseconds
The number of milliseconds the current thread should wait to acquire an exclusive lock of a named mutex. Default is: $Redstone.Settings.'Test-IsMutexAvailable'.MutexWaitTimeInMilliseconds
A wait time of -1 milliseconds means to wait indefinitely. A wait time of zero does not acquire an exclusive lock but instead tests the state of the wait handle and returns immediately.
.EXAMPLE
Assert-IsMutexAvailable -MutexName 'Global\_MSIExecute' -MutexWaitTimeInMilliseconds 500
.EXAMPLE
Assert-IsMutexAvailable -MutexName 'Global\_MSIExecute' -MutexWaitTimeInMilliseconds (New-TimeSpan -Minutes 5).TotalMilliseconds
.EXAMPLE
Assert-IsMutexAvailable -MutexName 'Global\_MSIExecute' -MutexWaitTimeInMilliseconds (New-TimeSpan -Seconds 60).TotalMilliseconds
.NOTES
- [_MSIExecute Mutex](https://learn.microsoft.com/en-us/windows/win32/msi/-msiexecute-mutex)
 
> Copyright â’¸ 2015 - PowerShell App Deployment Toolkit Team
>
> Copyright â’¸ 2023 - Raymond Piller (VertigoRay)
.LINK
https://github.com/VertigoRay/PSRedstone/wiki/Functions/#assert-ismutexavailable
#>

function Assert-IsMutexAvailable {
    [CmdletBinding()]
    [OutputType([bool])]
    Param (
        [Parameter(Mandatory = $true)]
        [ValidateLength(1,260)]
        [string]
        $MutexName,

        [Parameter(Mandatory = $false)]
        [ValidateRange(-1, [int32]::MaxValue)]
        [int32]
        $MutexWaitTimeInMilliseconds = 300000 #5min
    )

    Write-Information "> $($MyInvocation.BoundParameters | ConvertTo-Json -Compress)"
    Write-Debug "Function Invocation: $($MyInvocation | Out-String)"


    ## Initialize Variables
    [timespan] $MutexWaitTime = [timespan]::FromMilliseconds($MutexWaitTimeInMilliseconds)
    if ($MutexWaitTime.TotalMinutes -ge 1) {
        [string] $WaitLogMsg = "$($MutexWaitTime.TotalMinutes) minute(s)"
    } elseif ($MutexWaitTime.TotalSeconds -ge 1) {
        [string] $WaitLogMsg = "$($MutexWaitTime.TotalSeconds) second(s)"
    } else {
        [string] $WaitLogMsg = "$($MutexWaitTime.Milliseconds) millisecond(s)"
    }
    [boolean] $IsUnhandledException = $false
    [boolean] $IsMutexFree = $false
    [Threading.Mutex] $OpenExistingMutex = $null

    Write-Information "Check to see if mutex [$MutexName] is available. Wait up to [$WaitLogMsg] for the mutex to become available."
    try {
        ## Using this variable allows capture of exceptions from .NET methods. Private scope only changes value for current function.
        $private:previousErrorActionPreference = $ErrorActionPreference
        $ErrorActionPreference = 'Stop'

        ## Open the specified named mutex, if it already exists, without acquiring an exclusive lock on it. If the system mutex does not exist, this method throws an exception instead of creating the system object.
        [Threading.Mutex] $OpenExistingMutex = [Threading.Mutex]::OpenExisting($MutexName)
        ## Attempt to acquire an exclusive lock on the mutex. Use a Timespan to specify a timeout value after which no further attempt is made to acquire a lock on the mutex.
        $IsMutexFree = $OpenExistingMutex.WaitOne($MutexWaitTime, $false)
    } catch [Threading.WaitHandleCannotBeOpenedException] {
        ## The named mutex does not exist
        $IsMutexFree = $true
    } catch [ObjectDisposedException] {
        ## Mutex was disposed between opening it and attempting to wait on it
        $IsMutexFree = $true
    } catch [UnauthorizedAccessException] {
        ## The named mutex exists, but the user does not have the security access required to use it
        $IsMutexFree = $false
    } catch [Threading.AbandonedMutexException] {
        ## The wait completed because a thread exited without releasing a mutex. This exception is thrown when one thread acquires a mutex object that another thread has abandoned by exiting without releasing it.
        $IsMutexFree = $true
    } catch {
        $IsUnhandledException = $true
        ## Return $true, to signify that mutex is available, because function was unable to successfully complete a check due to an unhandled exception. Default is to err on the side of the mutex being available on a hard failure.
        Write-Error "Unable to check if mutex [$MutexName] is available due to an unhandled exception. Will default to return value of [$true]. `n$(Resolve-Error)"
        $IsMutexFree = $true
    } finally {
        if ($IsMutexFree) {
            if (-not $IsUnhandledException) {
                Write-Information "Mutex [$MutexName] is available for an exclusive lock."
            }
        } else {
            if ($MutexName -eq 'Global\_MSIExecute') {
                ## Get the command line for the MSI installation in progress
                try {
                    [string] $msiInProgressCmdLine = Get-CimInstance -Class 'Win32_Process' -Filter "name = 'msiexec.exe'" -ErrorAction 'Stop' | Where-Object { $_.CommandLine } | Select-Object -ExpandProperty 'CommandLine' | Where-Object { $_ -match '\.msi' } | ForEach-Object { $_.Trim() }
                } catch {
                    Write-Warning ('Unexpected/Unhandled Error caught: {0}' -f $_)
                }
                Write-Warning "Mutex [$MutexName] is not available for an exclusive lock because the following MSI installation is in progress [$msiInProgressCmdLine]."
            } else {
                Write-Information "Mutex [$MutexName] is not available because another thread already has an exclusive lock on it."
            }
        }

        if (($null -ne $OpenExistingMutex) -and ($IsMutexFree)) {
            ## Release exclusive lock on the mutex
            $null = $OpenExistingMutex.ReleaseMutex()
            $OpenExistingMutex.Close()
        }
        if ($private:previousErrorActionPreference) {
            $ErrorActionPreference = $private:previousErrorActionPreference
        }
    }

    return $IsMutexFree
}
<#
.SYNOPSIS
Is the current process running in a non-interactive shell?
.DESCRIPTION
There are two ways to determine if the current process is in a non-interactive shell:
 
- See if the user environment is marked as interactive.
- See if PowerShell was launched with the -NonInteractive
.EXAMPLE
Assert-IsNonInteractiveShell
If you're typing this into PowerShell, you should see `$false`.
.NOTES
- [Powershell test for noninteractive mode](https://stackoverflow.com/a/34098997/615422)
- [Environment.UserInteractive Property](https://learn.microsoft.com/en-us/dotnet/api/system.environment.userinteractive)
- [About PowerShell.exe: NonInteractive](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_powershell_exe?view=powershell-5.1#-noninteractive)
.LINK
https://github.com/VertigoRay/PSRedstone/wiki/Functions#assert-isnoninteractiveshell
#>

function Assert-IsNonInteractiveShell {
    [CmdletBinding()]
    [OutputType([bool])]
    param()

    # Test each Arg for match of abbreviated '-NonInteractive' command.
    $NonInteractive = [Environment]::GetCommandLineArgs() | Where-Object{ $_ -like '-NonI*' }

    if ([Environment]::UserInteractive -and -not $NonInteractive) {
        # We are in an interactive shell.
        return $false
    }

    return $true
}
<#
.SYNOPSIS
Close the supplied process.
.DESCRIPTION
The supplied process is expected to be a program and have a visible window.
This function will attempt to safely close the window before force killing the process.
It's a little safer than just doing a `Stop-Process -Force`.
.EXAMPLE
Get-Process code | Close-Program
.EXAMPLE
$codes = Get-Process code; $codes | Close-Program -SleepSeconds [math]::Ceiling($codes.Count / 2)
#>

function Close-Program {
    [CmdletBinding()]
    param (
        # Process to close.
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [System.Diagnostics.Process]
        $Process,

        # The number of seconds to wait after closing the main window before we force kill.
        # If passing this in a pipeline, this is per pipeline item; otherwise, it is the wait time for all processes.
        [Parameter(Mandatory = $false)]
        [int32]
        $SleepSeconds = 1
    )
    process {
        foreach ($proc in $Process) {
            $Process | ForEach-Object { $_.CloseMainWindow() | Out-Null }

            # Wait for windows to close before attempting a force kill.
            $sw = [System.Diagnostics.Stopwatch]::new()
            $sw.Start()

            while ($Process.HasExited -contains $false) {
                Start-Sleep -Milliseconds 250
                if ($sw.Elapsed.TotalSeconds -gt $SleepSeconds) {
                    break
                }
            }

            # In case gracefull shutdown did not succeed, try hard kill
            $Process | Where-Object { -not $_.HasExited } | Stop-Process -Force
        }
    }
}
<#
.SYNOPSIS
Dismount a registry hive.
.DESCRIPTION
Dismount a hive to the registry.
.OUTPUTS
[void]
.PARAMETER Hive
The key object returned from `Mount-RegistryHive`.
.EXAMPLE
Dismount-RegistryHive -Hive $hive
 
Where `$hive` was created with:
 
```powershell
$hive = Mount-RegistryHive -DefaultUser
```
#>

function Dismount-RegistryHive ([Microsoft.Win32.RegistryKey] $Hive) {
    # Garbage Collection
    [gc]::Collect()

    $regLoad = @{
        FilePath = (Get-Command 'reg.exe').Source
        ArgumentList = @(
            'UNLOAD'
            $Hive
        )
    }
    $result = Invoke-Run $regLoad

    if ($result.Process.ExitCode) {
        # Non-Zero Exit Code
        Throw ($result.StdErr | Out-String)
    } else {
        return (Get-Item ('Registry::{0}' -f $defaultHive))
    }
}
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Dismount a WIM.
.DESCRIPTION
Dismount a WIM from the provided mount path.
.EXAMPLE
Dismount-Wim -MountPath $mountPath
 
Where `$mountPath` is the path returned by `Mount-Wim`.
.LINK
https://github.com/VertigoRay/PSRedstone/wiki/Functions#dismount-wim
#>

function Dismount-Wim {
    [CmdletBinding()]
    [OutputType([void])]
    param (
        # Specifies a path to one or more locations.
        [Parameter(Mandatory = $true, Position = 0, HelpMessage = 'Path the WIM was mounted.')]
        [ValidateNotNullOrEmpty()]
        [IO.DirectoryInfo]
        $MountPath,

        [Parameter(Mandatory = $false, HelpMessage = 'Full path for the DISM log with {0} formatter to inject "DISM".')]
        [IO.FileInfo]
        $LogFileF
    )

    begin {
        Write-Verbose "[Dismount-Wim] > $($MyInvocation.BoundParameters | ConvertTo-Json -Compress)"
        Write-Debug "[Dismount-Wim] Function Invocation: $($MyInvocation | Out-String)"

        $windowsImage = @{
            Path = $MountPath.FullName
            Discard = $true
            ErrorAction = 'Stop'
        }

        if ($LogFileF) {
            $windowsImage.Add('LogPath', ($LogFileF -f 'DISM'))
        }

        <#
            Script used inside of the Scheduled Task that's created, if needed.
        #>

        $mounted = {
            $mountedInvalid = Get-WindowsImage -Mounted | Where-Object { $_.MountStatus -eq 'Invalid' }
            $errorOccured = $false
            foreach ($mountedWim in $mountedInvalid) {
                $windowsImage = @{
                    Path = $mountedWim.Path
                    Discard = $true
                    ErrorAction = 'Stop'
                }

                try {
                    Dismount-WindowsImage @windowsImage
                } catch {
                    $errorOccured = $true
                }
            }

            if (-not $errorOccured) {
                Clear-WindowsCorruptMountPoint
                Unregister-ScheduledTask -TaskName 'Redstone Cleanup WIM' -Confirm:$false
            }
        }
        $encodedCommand = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($mounted.ToString()))
        $cleanupTaskAction = @{
            Execute = 'powershell.exe'
            Argument = '-Exe Bypass -Win Hidden -NoProfile -NonInteractive -EncodedCommand {0}' -f $encodedCommand.tostring()
        }
    }

    process {
        ## dismount the WIM whether we succeeded or failed
        try {
            Write-Verbose "[Dismount-Wim] Dismount-WindowImage: $($windowsImage | ConvertTo-Json)"
            Dismount-WindowsImage @windowsImage
        } catch [System.Runtime.InteropServices.COMException] {
            Write-Warning ('[Dismount-Wim] [{0}] {1}' -f $_.Exception.GetType().FullName, $_.Exception.Message)
            if ($_.Exception.Message -eq 'The system cannot find the file specified.') {
                Throw $_
            } else {
                # $_.Exception.Message -eq 'The system cannot find the file specified.'
                ## failed to cleanly dismount, so set a task to cleanup after reboot

                Write-Verbose ('[Dismount-Wim] Scheduled Task Action: {0}' -f ($cleanupTaskAction | ConvertTo-Json))

                $scheduledTaskAction = New-ScheduledTaskAction @cleanupTaskAction
                $scheduledTaskTrigger = New-ScheduledTaskTrigger -AtStartup

                $scheduledTask = @{
                    Action = $scheduledTaskAction
                    Trigger = $scheduledTaskTrigger
                    TaskName = 'Redstone Cleanup WIM'
                    Description = 'Clean up WIM Mount points that failed to dismount properly.'
                    User = 'NT AUTHORITY\SYSTEM'
                    RunLevel = 'Highest'
                    Force = $true
                }
                Write-Verbose ('[Dismount-Wim] Scheduled Task: {0}' -f ($scheduledTask | ConvertTo-Json))
                Register-ScheduledTask @scheduledTask
            }
        }

        $clearWindowsCorruptMountPoint = @{}
        if ($LogFileF) {
            $windowsImage.Add('LogPath', ($LogFileF -f ('DISM')))
        }

        Clear-WindowsCorruptMountPoint @clearWindowsCorruptMountPoint
    }

    end {}
}
<#
.SYNOPSIS
Attempt to find the EXE in the provided Path.
.DESCRIPTION
This functions will go through three steps to find the provided EXE:
 
- Determine if you provided the full path to the EXE or if it's in the current directory.
- Determine if it can be found under any path in $env:PATH.
- Determine if the locations was registered in the registry.
 
If one of these is true, it'll stop looking and return the `IO.FileInfo` of the EXE.
.OUTPUTS
[IO.FileInfo]
.EXAMPLE
Get-ExeFileInfo 'notepad.exe'
.EXAMPLE
Get-ExeFileInfo 'chrome.exe'
.LINK
https://github.com/VertigoRay/PSRedstone/wiki/Functions#get-exefileinfo
#>

function Get-ExeFileInfo {
    [CmdletBinding()]
    [OutputType([IO.FileInfo])]
    param(
        [Parameter(Mandatory = $true, Position = 0, HelpMessage = 'Name of the EXE to search for.')]
        [ValidateNotNullOrEmpty()]
        [ValidateScript({
            if (([IO.FileInfo] $_).Extension -eq '.exe') {
                Write-Output $true
            } else {
                Throw ('The Path "{0}" has an unexpected extension "{1}"; expecting ".exe".' -f @(
                    $_
                    ([IO.FileInfo] $_).Extension
                ))
            }
        })]
        [string]
        $Path
    )

    Write-Information "[Get-ExeFileInfo] > $($MyInvocation.BoundParameters | ConvertTo-Json -Compress)"
    Write-Debug "[Get-ExeFileInfo] Function Invocation: $($MyInvocation | Out-String)"

    if (([IO.FileInfo] $Path).Exists) {
        $result = $Path
    } elseif ($command = Get-Command $Path -ErrorAction 'Ignore') {
        $result = $command.Source
    } else {
        $appPath = ('Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\{0}' -f $Path)
        if ($defaultPath = (Get-ItemProperty $appPath -ErrorAction 'Ignore').'(default)') {
            $result = $defaultPath
        } else {
            Write-Warning ('EXE file location not discoverable: {0}' -f $Path)
            $result = $Path
        }
    }
    return ([IO.FileInfo] $result.Trim('"'))
}
<#
.SYNOPSIS
This function is purely designed to make things easier when getting a value from a hashtable using a path in string form.
.DESCRIPTION
This function is purely designed to make things easier when getting a value from a hashtable using a path in string form.
It has the added benefit of returning a provided default value if the path doesn't exist.
.EXAMPLE
Get-HashtableValue -Hashtable $vars -Path 'Thing2.This2.That1' -Default 'nope'
 
Returns `221` from the following `$vars` hashtable:
 
```powershell
$vars = @{
    Thing1 = 1
    Thing2 = @{
        This1 = 21
        This2 = @{
            That1 = 221
            That2 = 222
            That3 = 223
            That4 = $null
        }
        This3 = 23
    }
    Thing3 = 3
}
```
.EXAMPLE
Get-HashtableValue -Hashtable $vars -Path 'Thing2.This2.That4' -Default 'nope'
 
Returns `$null` from the following `$vars` hashtable:
 
```powershell
$vars = @{
    Thing1 = 1
    Thing2 = @{
        This1 = 21
        This2 = @{
            That1 = 221
            That2 = 222
            That3 = 223
            That4 = $null
        }
        This3 = 23
    }
    Thing3 = 3
}
```
.EXAMPLE
Get-HashtableValue -Hashtable $vars -Path 'Thing2.This4' -Default 'nope'
 
Returns `"nope"` from the following `$vars` hashtable:
 
```powershell
$vars = @{
    Thing1 = 1
    Thing2 = @{
        This1 = 21
        This2 = @{
            That1 = 221
            That2 = 222
            That3 = 223
            That4 = $null
        }
        This3 = 23
    }
    Thing3 = 3
}
```
.EXAMPLE
$redstone.GetVar('Thing2.This2.That4', 'nope')
 
When being used to access `$redstone.Vars` there's a built-in method that calls this function a bit easier.
Returns `$null` from the following `$redstone.Vars` hashtable:
 
```powershell
$redstone.Vars = @{
    Thing1 = 1
    Thing2 = @{
        This1 = 21
        This2 = @{
            That1 = 221
            That2 = 222
            That3 = 223
            That4 = $null
        }
        This3 = 23
    }
    Thing3 = 3
}
```
.LINK
https://github.com/VertigoRay/PSRedstone/wiki/Functions#get-hashtablevalue
#>

function Get-HashtableValue([hashtable] $Hashtable, [string] $Path, $Default = $null) {
    $parent, $leaf = $Path.Split('.', 2)

    if ($leaf) {
        return (Get-HashtableValue $Hashtable.$parent $leaf $Default)
    } elseif ($Hashtable.Keys -contains $parent) {
        return $Hashtable.$parent
    } else {
        return $Default
    }
}
<#
.SYNOPSIS
Retrieves information about installed applications.
.DESCRIPTION
Retrieves information about installed applications by querying the registry. You can specify an application name, a product code, or both.
Returns information about application publisher, name & version, product code, uninstall string, quiet uninstall string, install source, location, date, and application architecture.
.PARAMETER Name
The name of the application to retrieve information for. Performs a regex match on the application display name by default.
.PARAMETER Exact
Specifies that the named application must be matched using the exact name.
.PARAMETER WildCard
Specifies that the named application must be matched using a wildcard search.
.PARAMETER ProductCode
The product code of the application to retrieve information for.
.PARAMETER IncludeUpdatesAndHotfixes
Include matches against updates and hotfixes in results.
.PARAMETER UninstallRegKeys
Private Parameter; used for debug overrides.
.OUTPUTS
[hashtable[]]
.EXAMPLE
Get-InstalledApplication -Name 'Adobe Flash'
.EXAMPLE
Get-InstalledApplication -ProductCode '{1AD147D0-BE0E-3D6C-AC11-64F6DC4163F1}'
.NOTES
.LINK
https://github.com/VertigoRay/PSRedstone/wiki/Functions#get-installedapplication
#>

function Get-InstalledApplication {
    [CmdletBinding(DefaultParameterSetName = 'Like')]
    [OutputType([hashtable[]])]
    Param (
        [Parameter(Mandatory = $false, Position = 0, ParameterSetName = 'Eq')]
        [Parameter(Mandatory = $false, Position = 0, ParameterSetName = 'Exact')]
        [Parameter(Mandatory = $false, Position = 0, ParameterSetName = 'Like')]
        [Parameter(Mandatory = $false, Position = 0, ParameterSetName = 'Regex')]
        [ValidateNotNullorEmpty()]
        [string[]]
        $Name = '*',

        [Parameter(Mandatory = $false, ParameterSetName = 'Eq')]
        [Parameter(Mandatory = $false, ParameterSetName = 'Exact')]
        [Parameter(Mandatory = $false, ParameterSetName = 'Like')]
        [Parameter(Mandatory = $false, ParameterSetName = 'Regex')]
        [switch]
        $CaseSensitive,

        [Parameter(Mandatory = $false, ParameterSetName = 'Exact')]
        [switch]
        $Exact,

        [Parameter(Mandatory = $false, ParameterSetName = 'Like')]
        [switch]
        $WildCard,

        [Parameter(Mandatory = $false, ParameterSetName = 'Regex')]
        [switch]
        $RegEx,

        [Parameter(Mandatory = $false, ParameterSetName = 'Productcode')]
        [ValidateNotNullorEmpty()]
        [string]
        $ProductCode,

        [Parameter(Mandatory = $false, ParameterSetName = 'Eq')]
        [Parameter(Mandatory = $false, ParameterSetName = 'Exact')]
        [Parameter(Mandatory = $false, ParameterSetName = 'Like')]
        [Parameter(Mandatory = $false, ParameterSetName = 'Regex')]
        [Parameter(Mandatory = $false, ParameterSetName = 'Productcode')]
        [switch]
        $IncludeUpdatesAndHotfixes,

        [ValidateNotNullorEmpty()]
        [string[]]
        $UninstallRegKeys = @(
            'HKLM:SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',
            'HKLM:SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall'
        )
    )

    Write-Information "[Get-InstalledApplication] > $($MyInvocation.BoundParameters | ConvertTo-Json -Compress)"
    Write-Information "[Get-InstalledApplication] ParameterSetName> $($PSCmdlet.ParameterSetName | ConvertTo-Json -Compress)"
    Write-Debug "[Get-InstalledApplication] Function Invocation: $($MyInvocation | Out-String)"


    if ($Name) {
        Write-Information "[Get-InstalledApplication] Get information for installed Application Name(s) [$($name -join ', ')]..."
    }
    if ($ProductCode) {
        Write-Information "[Get-InstalledApplication] Get information for installed Product Code [$ProductCode]..."
    }

    ## Enumerate the installed applications from the registry for applications that have the "DisplayName" property
    [psobject[]] $regKeyApplication = @()
    foreach ($regKey in $UninstallRegKeys) {
        Write-Verbose "[Get-InstalledApplication] Checking Key: ${regKey}"
        if (Test-Path -LiteralPath $regKey -ErrorAction 'SilentlyContinue' -ErrorVariable '+ErrorUninstallKeyPath') {
            [psobject[]] $UninstallKeyApps = Get-ChildItem -LiteralPath $regKey -ErrorAction 'SilentlyContinue' -ErrorVariable '+ErrorUninstallKeyPath'
            foreach ($UninstallKeyApp in $UninstallKeyApps) {
                Write-Verbose "[Get-InstalledApplication] Checking Key: $($UninstallKeyApp.PSChildName)"
                try {
                    [psobject] $regKeyApplicationProps = Get-ItemProperty -LiteralPath $UninstallKeyApp.PSPath -ErrorAction 'Stop'
                    if ($regKeyApplicationProps.DisplayName) { [psobject[]] $regKeyApplication += $regKeyApplicationProps }
                } catch {
                    Write-Warning "[Get-InstalledApplication] Unable to enumerate properties from registry key path [$($UninstallKeyApp.PSPath)].$(if (Get-Command 'Resolve-Error' -ErrorAction 'Ignore') { "`n{0}" -f (Resolve-Error) })"
                    continue
                }
            }
        }
    }
    if ($ErrorUninstallKeyPath) {
        Write-Warning "[Get-InstalledApplication] The following error(s) took place while enumerating installed applications from the registry.$(if (Get-Command 'Resolve-Error' -ErrorAction 'Ignore') { "`n{0}" -f (Resolve-Error -ErrorRecord $ErrorUninstallKeyPath) })"
    }

    ## Create a custom object with the desired properties for the installed applications and sanitize property details
    [Collections.ArrayList] $installedApplication = @()
    foreach ($regKeyApp in $regKeyApplication) {
        try {
            [string] $appDisplayName = ''
            [string] $appDisplayVersion = ''
            [string] $appPublisher = ''

            ## Bypass any updates or hotfixes
            if (-not $IncludeUpdatesAndHotfixes.IsPresent) {
                if ($regKeyApp.DisplayName -match '(?i)kb\d+') { continue }
                if ($regKeyApp.DisplayName -match 'Cumulative Update') { continue }
                if ($regKeyApp.DisplayName -match 'Security Update') { continue }
                if ($regKeyApp.DisplayName -match 'Hotfix') { continue }
            }

            ## Remove any control characters which may interfere with logging and creating file path names from these variables
            $appDisplayName = $regKeyApp.DisplayName -replace '[^\u001F-\u007F]',''
            $appDisplayVersion = $regKeyApp.DisplayVersion -replace '[^\u001F-\u007F]',''
            $appPublisher = $regKeyApp.Publisher -replace '[^\u001F-\u007F]',''

            ## Determine if application is a 64-bit application
            [boolean] $Is64BitApp = if (([System.Environment]::Is64BitOperatingSystem) -and ($regKeyApp.PSPath -notmatch '^Microsoft\.PowerShell\.Core\\Registry::HKEY_LOCAL_MACHINE\\SOFTWARE\\Wow6432Node')) { $true } else { $false }

            if ($PSCmdlet.ParameterSetName -eq 'ProductCode') {
                ## Verify if there is a match with the product code passed to the script
                if (($regKeyApp.PSChildName -as [guid]).Guid -eq ($ProductCode -as [guid]).Guid) {
                    Write-Information "[Get-InstalledApplication] Found installed application [$appDisplayName] version [$appDisplayVersion] matching product code [$productCode]."
                    $installedApplication.Add(@{
                        UninstallSubkey = $regKeyApp.PSChildName
                        ProductCode = $regKeyApp.PSChildName -as [guid]
                        DisplayName = $appDisplayName
                        DisplayVersion = $appDisplayVersion
                        UninstallString = $regKeyApp.UninstallString
                        QuietUninstallString = $regKeyApp.QuietUninstallString
                        InstallSource = $regKeyApp.InstallSource
                        InstallLocation = $regKeyApp.InstallLocation
                        InstallDate = $regKeyApp.InstallDate
                        Publisher = $appPublisher
                        Is64BitApplication = $Is64BitApp
                        PSPath = $regKeyApp.PSPath
                    }) | Out-Null
                }
            } else {
                ## Verify if there is a match with the application name(s) passed to the script
                foreach ($application in $Name) {
                    $applicationMatched = $false
                    if ($Exact.IsPresent) {
                        Write-Debug ('[Get-InstalledApplication] $Exact.IsPresent')
                        # Check for exact application name match
                        if ($CaseSensitive.IsPresent) {
                            # Check for a CaseSensitive application name match
                            if ($regKeyApp.DisplayName -ceq $application) {
                                $applicationMatched = $true
                                Write-Information "[Get-InstalledApplication] Found installed application [$appDisplayName] version [$appDisplayVersion] using casesensitive exact name matching for search term [$application]."
                            }
                        } elseif ($regKeyApp.DisplayName -eq $application) {
                            $applicationMatched = $true
                            Write-Information "[Get-InstalledApplication] Found installed application [$appDisplayName] version [$appDisplayVersion] using exact name matching for search term [$application]."
                        }
                    } elseif ($RegEx.IsPresent) {
                        Write-Debug ('[Get-InstalledApplication] $RegEx.IsPresent')
                        # Check for a regex application name match
                        if ($CaseSensitive.IsPresent) {
                            # Check for a CaseSensitive application name match
                            if ($regKeyApp.DisplayName -cmatch $application) {
                                $applicationMatched = $true
                                Write-Information "[Get-InstalledApplication] Found installed application [$appDisplayName] version [$appDisplayVersion] using casesensitive regex name matching for search term [$application]."
                            }
                        } elseif ($regKeyApp.DisplayName -match $application) {
                            $applicationMatched = $true
                            Write-Information "[Get-InstalledApplication] Found installed application [$appDisplayName] version [$appDisplayVersion] using regex name matching for search term [$application]."
                        }
                    } else {
                        # Check for a like application name match
                        if ($CaseSensitive.IsPresent) {
                            # Check for a CaseSensitive application name match
                            if ($regKeyApp.DisplayName -clike $application) {
                                $applicationMatched = $true
                                Write-Information "[Get-InstalledApplication] Found installed application [$appDisplayName] version [$appDisplayVersion] using casesensitive like name matching for search term [$application]."
                            } else {
                                Write-Information "[Get-InstalledApplication] No found installed application using casesensitive like name matching for search term [$application]."
                            }
                        } elseif ($regKeyApp.DisplayName -like $application) {
                            $applicationMatched = $true
                            Write-Information "[Get-InstalledApplication] Found installed application [$appDisplayName] version [$appDisplayVersion] using like name matching for search term [$application]."
                        }
                    }

                    if ($applicationMatched) {
                        $installedApplication.Add(@{
                            UninstallSubkey = $regKeyApp.PSChildName
                            ProductCode = $regKeyApp.PSChildName -as [guid]
                            DisplayName = $appDisplayName
                            DisplayVersion = $appDisplayVersion
                            UninstallString = $regKeyApp.UninstallString
                            QuietUninstallString = $regKeyApp.QuietUninstallString
                            InstallSource = $regKeyApp.InstallSource
                            InstallLocation = $regKeyApp.InstallLocation
                            InstallDate = $regKeyApp.InstallDate
                            Publisher = $appPublisher
                            Is64BitApplication = $Is64BitApp
                            PSPath = $regKeyApp.PSPath
                        }) | Out-Null
                    }
                }
            }
        } catch {
            Write-Error "[Get-InstalledApplication] Failed to resolve application details from registry for [$appDisplayName].$(if (Get-Command 'Resolve-Error' -ErrorAction 'Ignore') { "`n{0}" -f (Resolve-Error) })"
            continue
        }
    }

    Write-Information ('[Get-InstalledApplication] Application Searched: {0}' -f $application)
    return $installedApplication
}
<#
.SYNOPSIS
Get message for MSI error code
.DESCRIPTION
Get message for MSI error code by reading it from msimsg.dll
.PARAMETER MsiErrorCode
MSI error code
.PARAMETER MsiLog
MSI Log File. Parsed if ErrorCode is 1603.
.EXAMPLE
Get-MsiExitCodeMessage -MsiExitCode 1618
.NOTES
This is an internal script function and should typically not be called directly.
- https://learn.microsoft.com/en-us/previous-versions//aa368542(v=vs.85)
 
> Copyright â’¸ 2015 - PowerShell App Deployment Toolkit Team
>
> Copyright â’¸ 2023 - Raymond Piller (VertigoRay)
.LINK
https://github.com/VertigoRay/PSRedstone/wiki/Functions#get-msiexitcodemessage
#>

function Get-MsiExitCodeMessage {
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory=$true)]
        [ValidateNotNullorEmpty()]
        [int32]
        $MsiExitCode
        ,
        [Parameter(Mandatory=$false)]
        [ValidateNotNullorEmpty()]
        [string]
        $MsiLog
    )

    Write-Information "> $($MyInvocation.BoundParameters | ConvertTo-Json -Compress)"
    Write-Debug "Function Invocation: $($MyInvocation | Out-String)"

    switch ($MsiExitCode) {
        # MsiExec.exe and InstMsi.exe Error Messages
        # https://msdn.microsoft.com/en-us/library/aa368542(v=vs.85).aspx
        1603 {
            $return = 'ERROR_INSTALL_FAILURE: A fatal error occurred during installation.'
            $return += "`nLook for `"return value 3`" in the MSI log file. The real cause of this error will be just before this line."

            if ($MsiLog) {
                $return += "`nImporting `"return value 3`" info from the MSI log, but you might still want to look at the MSI log:"
                $log_contents = Get-Content $MsiLog

                [System.Collections.ArrayList] $return_value_3_lines = @()
                foreach ($line in $log_contents) {
                    if ($line -ilike '*return value 3*') {
                        $return_value_3_lines.Add($line) | Out-Null
                    }
                }

                foreach ($return_value_3 in $return_value_3_lines) {
                    $i = $log_contents.IndexOf($return_value_3)

                    $return += "`n`t$(Split-Path $MsiLog -Leaf):$($i-1) : $($log_contents[$i-1])"
                    $return += "`n`t$(Split-Path $MsiLog -Leaf):$($i) : $($log_contents[$i])"
                }
            }
        }
        3010 {
            Write-Information "Standard Message: Restart required. The installation or update for the product required a restart for all changes to take effect. The restart was deferred to a later time."
            $return = (Get-Content $MsiLog)[-10..-1] | Where-Object { $_.Trim() -ne '' } | Out-String
        }
        default {
            $code = @'
                enum LoadLibraryFlags : int {
                    DONT_RESOLVE_DLL_REFERENCES = 0x00000001,
                    LOAD_IGNORE_CODE_AUTHZ_LEVEL = 0x00000010,
                    LOAD_LIBRARY_AS_DATAFILE = 0x00000002,
                    LOAD_LIBRARY_AS_DATAFILE_EXCLUSIVE = 0x00000040,
                    LOAD_LIBRARY_AS_IMAGE_RESOURCE = 0x00000020,
                    LOAD_WITH_ALTERED_SEARCH_PATH = 0x00000008
                }
 
                [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = false)]
                static extern IntPtr LoadLibraryEx(string lpFileName, IntPtr hFile, LoadLibraryFlags dwFlags);
 
                [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = false)]
                static extern int LoadString(IntPtr hInstance, int uID, StringBuilder lpBuffer, int nBufferMax);
 
                // Get MSI exit code message from msimsg.dll resource dll
                public static string GetMessageFromMsiExitCode(int errCode) {
                    IntPtr hModuleInstance = LoadLibraryEx("msimsg.dll", IntPtr.Zero, LoadLibraryFlags.LOAD_LIBRARY_AS_DATAFILE);
 
                    StringBuilder sb = new StringBuilder(255);
                    LoadString(hModuleInstance, errCode, sb, sb.Capacity + 1);
 
                    return sb.ToString();
                }
'@


            [string[]] $ReferencedAssemblies = 'System', 'System.IO', 'System.Reflection'
            try {
                Add-Type -Name 'MsiMsg' -MemberDefinition $code -ReferencedAssemblies $ReferencedAssemblies -UsingNamespace 'System.Text' -IgnoreWarnings -ErrorAction 'Stop'
            } catch [System.Exception] {
                # Add-Type : Cannot add type. The type name 'Microsoft.PowerShell.Commands.AddType.AutoGeneratedTypes.MsiMsg' already exists.
                Write-Warning $_
            }

            $return = [Microsoft.PowerShell.Commands.AddType.AutoGeneratedTypes.MsiMsg]::GetMessageFromMsiExitCode($MsiExitCode)
        }
    }

        Write-Information "Return: ${return}"
        return $return
}
<#
.SYNOPSIS
Get all of the properties from a Windows Installer database table or the Summary Information stream and return as a custom object.
.DESCRIPTION
Use the Windows Installer object to read all of the properties from a Windows Installer database table or the Summary Information stream.
.PARAMETER Path
The fully qualified path to an database file. Supports .msi and .msp files.
.PARAMETER TransformPath
The fully qualified path to a list of MST file(s) which should be applied to the MSI file.
.PARAMETER Table
The name of the the MSI table from which all of the properties must be retrieved. Default is: 'Property'.
.PARAMETER TablePropertyNameColumnNum
Specify the table column number which contains the name of the properties. Default is: 1 for MSIs and 2 for MSPs.
.PARAMETER TablePropertyValueColumnNum
Specify the table column number which contains the value of the properties. Default is: 2 for MSIs and 3 for MSPs.
.PARAMETER GetSummaryInformation
Retrieves the Summary Information for the Windows Installer database.
Summary Information property descriptions: https://msdn.microsoft.com/en-us/library/aa372049(v=vs.85).aspx
.PARAMETER ContinueOnError
Continue if an error is encountered. Default is: $true.
.EXAMPLE
# Retrieve all of the properties from the default 'Property' table.
Get-MsiTableProperty -Path 'C:\Package\AppDeploy.msi' -TransformPath 'C:\Package\AppDeploy.mst'
Get-MsiTableProperty -Path 'C:\Package\AppDeploy.msi' -TransformPath 'C:\Package\AppDeploy.mst'
.EXAMPLE
# Retrieve all of the properties from the 'Property' table and then pipe to Select-Object to select the ProductCode property.
Get-MsiTableProperty -Path 'C:\Package\AppDeploy.msi' -TransformPath 'C:\Package\AppDeploy.mst' -Table 'Property' | Select-Object -ExpandProperty ProductCode
Get-MsiTableProperty -Path 'C:\Package\AppDeploy.msi' -TransformPath 'C:\Package\AppDeploy.mst' -Table 'Property' | Select-Object -ExpandProperty ProductCode
.EXAMPLE
# Retrieves the Summary Information for the Windows Installer database.
Get-MsiTableProperty -Path 'C:\Package\AppDeploy.msi' -GetSummaryInformation
Get-MsiTableProperty -Path 'C:\Package\AppDeploy.msi' -GetSummaryInformation
.NOTES
This is an internal script function and should typically not be called directly.
 
> Copyright â’¸ 2015 - PowerShell App Deployment Toolkit Team
>
> Copyright â’¸ 2023 - Raymond Piller (VertigoRay)
.LINK
https://github.com/VertigoRay/PSRedstone/wiki/Functions#get-msitableproperty
#>

function Get-MsiTableProperty {
    [CmdletBinding(DefaultParameterSetName='TableInfo')]
    Param (
        [Parameter(Mandatory=$true, Position=0)]
        [ValidateScript({ Test-Path -LiteralPath $_ -PathType 'Leaf' })]
        [string]
        $Path
        ,
        [Parameter(Mandatory=$false)]
        [ValidateScript({ Test-Path -LiteralPath $_ -PathType 'Leaf' })]
        [string[]]
        $TransformPath
        ,
        [Parameter(Mandatory=$false,ParameterSetName='TableInfo')]
        [ValidateNotNullOrEmpty()]
        [string]
        $Table = $(if ([IO.Path]::GetExtension($Path) -eq '.msi') { 'Property' } else { 'MsiPatchMetadata' })
        ,
        [Parameter(Mandatory=$false,ParameterSetName='TableInfo')]
        [ValidateNotNullorEmpty()]
        [int32]
        $TablePropertyNameColumnNum = $(if ([IO.Path]::GetExtension($Path) -eq '.msi') { 1 } else { 2 })
        ,
        [Parameter(Mandatory=$false,ParameterSetName='TableInfo')]
        [ValidateNotNullorEmpty()]
        [int32]
        $TablePropertyValueColumnNum = $(if ([IO.Path]::GetExtension($Path) -eq '.msi') { 2 } else { 3 })
        ,
        [Parameter(Mandatory=$false,ParameterSetName='SummaryInfo')]
        [ValidateNotNullorEmpty()]
        [switch]
        $GetSummaryInformation = $false
        ,
        [Parameter(Mandatory=$false)]
        [ValidateNotNullorEmpty()]
        [boolean]
        $ContinueOnError = $true
    )

    Begin {
        <#
        .SYNOPSIS
        Get a property from any object.
        .DESCRIPTION
        Get a property from any object.
        .PARAMETER InputObject
        Specifies an object which has properties that can be retrieved.
        .PARAMETER PropertyName
        Specifies the name of a property to retrieve.
        .PARAMETER ArgumentList
        Argument to pass to the property being retrieved.
        .EXAMPLE
        Get-ObjectProperty -InputObject $Record -PropertyName 'StringData' -ArgumentList @(1)
        .NOTES
        This is an internal script function and should typically not be called directly.
        .LINK
        https://psappdeploytoolkit.com
        #>

        function Private:Get-ObjectProperty {
            [CmdletBinding()]
            Param (
                [Parameter(Mandatory=$true,Position=0)]
                [ValidateNotNull()]
                [object]$InputObject,
                [Parameter(Mandatory=$true,Position=1)]
                [ValidateNotNullorEmpty()]
                [string]$PropertyName,
                [Parameter(Mandatory=$false,Position=2)]
                [object[]]$ArgumentList
            )

            Begin { }
            Process {
                ## Retrieve property
                Write-Output -InputObject $InputObject.GetType().InvokeMember($PropertyName, [Reflection.BindingFlags]::GetProperty, $null, $InputObject, $ArgumentList, $null, $null, $null)
            }
            End { }
        }
    }



    Process {
        try {
            if ($PSCmdlet.ParameterSetName -eq 'TableInfo') {
                Write-Information "Read data from Windows Installer database file [${Path}] in table [${Table}]."
            } else {
                Write-Information "Read the Summary Information from the Windows Installer database file [${Path}]."
            }

            ## Create a Windows Installer object
            [__comobject]$Installer = New-Object -ComObject 'WindowsInstaller.Installer' -ErrorAction 'Stop'
            ## Determine if the database file is a patch (.msp) or not
            if ([IO.Path]::GetExtension($Path) -eq '.msp') { [boolean]$IsMspFile = $true }
            ## Define properties for how the MSI database is opened
            [int32]$msiOpenDatabaseModeReadOnly = 0
            [int32]$msiSuppressApplyTransformErrors = 63
            [int32]$msiOpenDatabaseMode = $msiOpenDatabaseModeReadOnly
            [int32]$msiOpenDatabaseModePatchFile = 32
            if ($IsMspFile) { [int32]$msiOpenDatabaseMode = $msiOpenDatabaseModePatchFile }
            ## Open database in read only mode
            [__comobject]$Database = Invoke-ObjectMethod -InputObject $Installer -MethodName 'OpenDatabase' -ArgumentList @($Path, $msiOpenDatabaseMode)
            ## Apply a list of transform(s) to the database
            if (($TransformPath) -and (-not $IsMspFile)) {
                foreach ($Transform in $TransformPath) {
                    $null = Invoke-ObjectMethod -InputObject $Database -MethodName 'ApplyTransform' -ArgumentList @($Transform, $msiSuppressApplyTransformErrors)
                }
            }

            ## Get either the requested windows database table information or summary information
            if ($PSCmdlet.ParameterSetName -eq 'TableInfo') {
                ## Open the requested table view from the database
                [__comobject]$View = Invoke-ObjectMethod -InputObject $Database -MethodName 'OpenView' -ArgumentList @("SELECT * FROM ${Table}")
                $null = Invoke-ObjectMethod -InputObject $View -MethodName 'Execute'

                ## Create an empty object to store properties in
                [psobject]$TableProperties = New-Object -TypeName 'PSObject'

                ## Retrieve the first row from the requested table. if the first row was successfully retrieved, then save data and loop through the entire table.
                # https://msdn.microsoft.com/en-us/library/windows/desktop/aa371136(v=vs.85).aspx
                [__comobject]$Record = Invoke-ObjectMethod -InputObject $View -MethodName 'Fetch'
                while ($Record) {
                    # Read string data from record and add property/value pair to custom object
                    $TableProperties | Add-Member -MemberType 'NoteProperty' -Name (Get-ObjectProperty -InputObject $Record -PropertyName 'StringData' -ArgumentList @($TablePropertyNameColumnNum)) -Value (Get-ObjectProperty -InputObject $Record -PropertyName 'StringData' -ArgumentList @($TablePropertyValueColumnNum)) -Force
                    # Retrieve the next row in the table
                    [__comobject]$Record = Invoke-ObjectMethod -InputObject $View -MethodName 'Fetch'
                }
                Write-Output -InputObject $TableProperties
            } else {
                ## Get the SummaryInformation from the windows installer database
                [__comobject]$SummaryInformation = Get-ObjectProperty -InputObject $Database -PropertyName 'SummaryInformation'
                [hashtable]$SummaryInfoProperty = @{}
                ## Summary property descriptions: https://msdn.microsoft.com/en-us/library/aa372049(v=vs.85).aspx
                $SummaryInfoProperty.Add('CodePage', (Get-ObjectProperty -InputObject $SummaryInformation -PropertyName 'Property' -ArgumentList @(1)))
                $SummaryInfoProperty.Add('Title', (Get-ObjectProperty -InputObject $SummaryInformation -PropertyName 'Property' -ArgumentList @(2)))
                $SummaryInfoProperty.Add('Subject', (Get-ObjectProperty -InputObject $SummaryInformation -PropertyName 'Property' -ArgumentList @(3)))
                $SummaryInfoProperty.Add('Author', (Get-ObjectProperty -InputObject $SummaryInformation -PropertyName 'Property' -ArgumentList @(4)))
                $SummaryInfoProperty.Add('Keywords', (Get-ObjectProperty -InputObject $SummaryInformation -PropertyName 'Property' -ArgumentList @(5)))
                $SummaryInfoProperty.Add('Comments', (Get-ObjectProperty -InputObject $SummaryInformation -PropertyName 'Property' -ArgumentList @(6)))
                $SummaryInfoProperty.Add('Template', (Get-ObjectProperty -InputObject $SummaryInformation -PropertyName 'Property' -ArgumentList @(7)))
                $SummaryInfoProperty.Add('LastSavedBy', (Get-ObjectProperty -InputObject $SummaryInformation -PropertyName 'Property' -ArgumentList @(8)))
                $SummaryInfoProperty.Add('RevisionNumber', (Get-ObjectProperty -InputObject $SummaryInformation -PropertyName 'Property' -ArgumentList @(9)))
                $SummaryInfoProperty.Add('LastPrinted', (Get-ObjectProperty -InputObject $SummaryInformation -PropertyName 'Property' -ArgumentList @(11)))
                $SummaryInfoProperty.Add('CreateTimeDate', (Get-ObjectProperty -InputObject $SummaryInformation -PropertyName 'Property' -ArgumentList @(12)))
                $SummaryInfoProperty.Add('LastSaveTimeDate', (Get-ObjectProperty -InputObject $SummaryInformation -PropertyName 'Property' -ArgumentList @(13)))
                $SummaryInfoProperty.Add('PageCount', (Get-ObjectProperty -InputObject $SummaryInformation -PropertyName 'Property' -ArgumentList @(14)))
                $SummaryInfoProperty.Add('WordCount', (Get-ObjectProperty -InputObject $SummaryInformation -PropertyName 'Property' -ArgumentList @(15)))
                $SummaryInfoProperty.Add('CharacterCount', (Get-ObjectProperty -InputObject $SummaryInformation -PropertyName 'Property' -ArgumentList @(16)))
                $SummaryInfoProperty.Add('CreatingApplication', (Get-ObjectProperty -InputObject $SummaryInformation -PropertyName 'Property' -ArgumentList @(18)))
                $SummaryInfoProperty.Add('Security', (Get-ObjectProperty -InputObject $SummaryInformation -PropertyName 'Property' -ArgumentList @(19)))
                [psobject]$SummaryInfoProperties = New-Object -TypeName 'PSObject' -Property $SummaryInfoProperty
                Write-Output -InputObject $SummaryInfoProperties
            }
        } catch {
            $resolvedError = if (Get-Command 'Resolve-Error' -ErrorAction 'Ignore') { Resolve-Error } else { $null }
            Write-Error ('Failed to get the MSI table [{0}]. {1}' -f $Table, $resolvedError)
            if (-not $ContinueOnError) {
                throw ('Failed to get the MSI table [{0}]. {1}' -f $Table, $_.Exception.Message)
            }
        }
        finally {
            try {
                if ($View) {
                    $null = Invoke-ObjectMethod -InputObject $View -MethodName 'Close' -ArgumentList @()
                    try {
                        $null = [Runtime.Interopservices.Marshal]::ReleaseComObject($View)
                    } catch {
                        Write-Verbose ('[Get-MsiTableProperty] Unexpected Non-Fatal Error: {0}' -f $_)
                    }
                } elseif ($SummaryInformation) {
                    try {
                        $null = [Runtime.Interopservices.Marshal]::ReleaseComObject($SummaryInformation)
                    } catch {
                        Write-Verbose ('[Get-MsiTableProperty] Unexpected Non-Fatal Error: {0}' -f $_)
                    }
                }
            } catch {
                Write-Verbose ('[Get-MsiTableProperty] Unexpected Non-Fatal Error: {0}' -f $_)
            }
            try {
                $null = [Runtime.Interopservices.Marshal]::ReleaseComObject($DataBase)
            } catch {
                Write-Verbose ('[Get-MsiTableProperty] Unexpected Non-Fatal Error: {0}' -f $_)
            }
            try {
                $null = [Runtime.Interopservices.Marshal]::ReleaseComObject($Installer)
            } catch {
                Write-Verbose ('[Get-MsiTableProperty] Unexpected Non-Fatal Error: {0}' -f $_)
            }
        }
    }

    End {}
}
<#
.SYNOPSIS
Recursively probe registry key's sub-key's and values and output a sorted array.
.DESCRIPTION
Recursively probe registry key's sub-key's and values and output a sorted array.
.PARAMETER Key
This is the key path within the hive. Do not include the Hive itself.
.PARAMETER Hive
This is a top-level node in the registry as defined by [RegistryHive Enum](https://learn.microsoft.com/en-us/dotnet/api/microsoft.win32.registryhive).
.EXAMPLE
Get-RecursiveRegistryKey 'SOFTWARE\Palo Alto Networks\GlobalProtect'
.LINK
https://github.com/VertigoRay/PSRedstone/wiki/Functions#get-registrykeyasarray
#>

function Get-RegistryKeyAsArray([string] $Key, [string] $Hive = 'LocalMachine') {
    #region Parameter Validation
    $hives = @(
        'ClassesRoot'
        'CurrentConfig'
        'CurrentUser'
        'LocalMachine'
        'PerformanceData'
        'Users'
    )
    if ($hives -notcontains $Hive) {
        throw [System.Management.Automation.ItemNotFoundException] ('Provided hive ({0}) should be one of: {1}.' -f $hive, ($hives -join ', '))
    }
    #endregion Parameter Validation

    # Declare an arraylist to which the recursive function below can append values.
    [System.Collections.ArrayList] $RegKeysArray = 'KeyName', 'ValueName', 'Value'

    $Reg = [Microsoft.Win32.RegistryKey]::OpenRemoteBaseKey($Hive, $ComputerName)
    $RegKey= $Reg.OpenSubKey($RegPath)

    function DigThroughKeys([Microsoft.Win32.RegistryKey] $Key) {
        # If it has no subkeys, retrieve the values and append to them to the global array.
        if ($Key.SubKeyCount-eq 0) {
            foreach ($value in $Key.GetValueNames()) {
                if ($null -ne $Key.GetValue($value)) {
                    [void] $RegKeysArray.Add(([PSObject] @{
                        KeyName = $Key.Name
                        ValueName = $value.ToString()
                        Value = $Key.GetValue($value)
                    }))
                }
            }
        } else {
            if ($Key.ValueCount -gt 0) {
                foreach ($value in $Key.GetValueNames()) {
                    if ($null -ne $Key.GetValue($value)) {
                        [void] $RegKeysArray.Add(([PSObject] @{
                            KeyName = $Key.Name
                            ValueName = $value.ToString()
                            Value = $Key.GetValue($value)
                        }))
                    }
                }
            }
            #Recursive lookup happens here. If the key has subkeys, send the key(s) back to this same function.
            if ($Key.SubKeyCount -gt 0) {
                foreach ($subKey in $Key.GetSubKeyNames()) {
                    DigThroughKeys -Key $Key.OpenSubKey($subKey)
                }
            }
        }
    }

    DigThroughKeys -Key $RegKey

    #Write the output to the console.
    Write-Output ($RegKeysArray | Select-Object KeyName, ValueName, Value | Sort-Object ValueName | Format-Table)

    $Reg.Close()
}
<#
.SYNOPSIS
Get the values in a registry key and all sub-keys.
.DESCRIPTION
Get the values in a registry key and all sub-keys.
This shouldn't be used to pull a massive section of the registry expecting perfect results.
 
There's a fundamental flaw that I'm unsure how to address with a hashtable.
If there's a value and sub-key with the same name at the same key level, the sub-key won't be processed.
Because of this, use this function to only return key sections with known/expected structures.
Otherwise, consider using [Get-RedstoneRegistryKeyAsArray](https://github.com/VertigoRay/PSRedstone/wiki/Functions#get-registrykeyasarray).
.LINK
https://github.com/VertigoRay/PSRedstone/wiki/Functions#get-registrykeyashashtable
#>

function Get-RegistryKeyAsHashtable ([string] $Key, [switch] $Recurse) {
    $private:hash = @{}

    if (Test-Path $Key) {
        $values = (Get-Item $Key).Property
        foreach ($value in (Get-ItemProperty $Key).PSObject.Properties) {
            if ($value.Name -in $values) {
                $private:hash.Add($value.Name, $value.Value)
            }
        }

        if ($Recurse) {
            foreach ($item in (Get-ChildItem $Key -ErrorAction 'Ignore')) {
                if ($private:hash.Keys -notcontains $item.PSChildName) {
                    $private:hash.Add($item.PSChildName, (Get-RegistryKeyAsHashtable -Key $item.PSPath))
                }
            }
        }
    }

    return $private:hash
}
<#
.SYNOPSIS
Get a registry value without expanding environment variables.
.OUTPUTS
[bool]
.EXAMPLE
Get-RegistryValueDoNotExpandEnvironmentName 'HKCU:\Thing Foo'
.LINK
https://github.com/VertigoRay/PSRedstone/wiki/Functions#get-registryvaluedonotexpandenvironmentname
#>

function Get-RegistryValueDoNotExpandEnvironmentName {
    [OutputType([bool])]
    [CmdletBinding()]
    Param(
        [Parameter()]
        [string]
        $Key,

        [Parameter()]
        [string]
        $Value
    )

    Write-Verbose ('[Get-RegistryValueDoNotExpandEnvironmentName] >')
    Write-Debug ('[Get-RegistryValueDoNotExpandEnvironmentName] > {0}' -f ($MyInvocation | Out-String))

    $item = Get-Item $Key
    if ($item) {
        return $item.GetValue($Value, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
    } else {
        return $null
    }
}
<#
.LINK
https://github.com/VertigoRay/PSRedstone/wiki/Functions#get-registryvalueordefault
#>

function Get-RegistryValueOrDefault {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false, Position = 0)]
        [string]
        $RegistryKey,

        [Parameter(Mandatory = $true, Position = 1)]
        [string]
        $RegistryValue,

        [Parameter(Mandatory = $true, Position = 2)]
        $DefaultData,

        [Parameter(Mandatory = $false)]
        [string]
        $RegistryKeyRoot,

        [Parameter(HelpMessage = 'Do Not Expand Environment Variables.')]
        [switch]
        $DoNotExpand,

        [Parameter(HelpMessage = 'For development.')]
        [bool]
        $OnlyUseDefaultSettings
    )

    Write-Verbose "[Get-RegistryValueOrDefault] > $($MyInvocation.BoundParameters | ConvertTo-Json -Compress)"
    Write-Debug "[Get-RegistryValueOrDefault] Function Invocation: $($MyInvocation | Out-String)"

    if ($OnlyUseDefaultSettings) {
        Write-Verbose "[Get-RegistryValueOrDefault] OnlyUseDefaultSettings Set; Returning: ${DefaultValue}"
        return $DefaultData
    }

    if ($RegistryKeyRoot -as [bool]) {
        $RegistryDrives = (Get-PSDrive -PSProvider 'Registry').Name + 'Registry:' | ForEach-Object { '{0}:' -f $_ }
        if ($RegistryKey -notmatch ($RegistryDrives -join '|')) {
            $RegistryKey = Join-Path $RegistryKeyRoot $RegistryKey
            Write-Debug "[Get-RegistryValueOrDefault] RegistryKey adjusted to: ${RegistryKey}"
        }
    }

    try {
        if ($DoNotExpand.IsPresent) {
            $result = Get-RegistryValueDoNotExpandEnvironmentName -Key $RegistryKey -Value $RegistryValue
            Write-Verbose "[Get-RegistryValueOrDefault] Registry Set; Returning: ${result}"
        } else {
            $result = Get-ItemPropertyValue -Path $RegistryKey -Name $RegistryValue -ErrorAction 'Stop'
            Write-Verbose "[Get-RegistryValueOrDefault] Registry Set; Returning: ${result}"
        }
        return $result
    } catch [System.Management.Automation.PSArgumentException] {
        Write-Verbose "[Get-RegistryValueOrDefault] Registry Not Set; Returning Default: ${DefaultValue}"
        if ($Error) { $Error.RemoveAt(0) } # This isn't a real error, so I don't want it in the error record.
        return $DefaultData
    } catch [System.Management.Automation.ItemNotFoundException] {
        Write-Verbose "[Get-RegistryValueOrDefault] Registry Not Set; Returning Default: ${DefaultValue}"
        if ($Error) { $Error.RemoveAt(0) } # This isn't a real error, so I don't want it in the error record.
        return $DefaultData
    }
}
<#
.NOTES
https://learn.microsoft.com/en-us/dotnet/api/system.componentmodel.win32exception
.LINK
https://github.com/VertigoRay/PSRedstone/wiki/Functions#get-translatederrorcode
#>

function Get-TranslatedErrorCode {
    [CmdletBinding()]
    [OutputType([System.ComponentModel.Win32Exception])]
    param (
        [Parameter(Mandatory = $true, Position = 0)]
        [ComponentModel.Win32Exception]
        $ErrorCode,

        [Parameter(Mandatory = $false)]
        [switch]
        $MECM
    )

    Write-Verbose ('[Get-TranslatedErrorCode] >')
    Write-Debug ('[Get-TranslatedErrorCode] > {0}' -f ($MyInvocation | Out-String))

    # Write-Host ($ErrorCode | Select-Object '*' | Out-String) -ForegroundColor Cyan

    $srsResourcesGetErrorMessage = {
        param([ComponentModel.Win32Exception] $ErrorCode)

        $dllSrsResources = [IO.Path]::Combine(([IO.DirectoryInfo] $env:SMS_ADMIN_UI_PATH).Parent.FullName, 'SrsResources.dll')
        [void] [System.Reflection.Assembly]::LoadFrom($dllSrsResources)

        $result = @{
            ErrorCode = $ErrorCode.NativeErrorCode
            Message = [SrsResources.Localization]::GetErrorMessage($ErrorCode.NativeErrorCode, (Get-Culture).Name)
        }
        if ($result.Message.StartsWith('Unknown error (') -or $result.Message.StartsWith('Unspecified error')) {
            $result = @{
                ErrorCode = $ErrorCode.ErrorCode
                Message = [SrsResources.Localization]::GetErrorMessage($ErrorCode.ErrorCode, (Get-Culture).Name)
            }
        }

        if ($result.Message.StartsWith('Unknown error (') -or $result.Message.StartsWith('Unspecified error')) {
            # If nothing at all could be found, send back original error object.
            return $ErrorCode
        }
        # If we found something, send back what we found.
        return ([PSObject]  $result)
    }

    if ($MECM.IsPresent -and $env:SMS_ADMIN_UI_PATH) {
        $result = & $srsResourcesGetErrorMessage -ErrorCode $ErrorCode
    } elseif ($MECM.IsPresent) {
        Throw [System.Management.Automation.ItemNotFoundException] ('Environment Variable Expected: SMS_ADMIN_UI_PATH (https://learn.microsoft.com/en-us/powershell/sccm/overview?view=sccm-ps)')
    } else {
        $result = $ErrorCode
    }

    if ($result.Message.StartsWith('Unknown error (') -and $env:SMS_ADMIN_UI_PATH) {
        # Let's try looking it up as a MECM error
        $result = & $srsResourcesGetErrorMessage -ErrorCode $ErrorCode
    }

    if ($result.Message.StartsWith('Unknown error (')) {
        # Let's define some unknown errors the best we can ...
        switch ($result.ErrorCode) {
            -1073741728 {
                # https://errorco.de/win32/ntstatus-h/status_no_such_privilege/-1073741728/
                return ([PSObject] @{
                    ErrorCode = $result.ErrorCode
                    Message = 'A required privilege is not held by the client. (STATUS_PRIVILEGE_NOT_HELD 0x{0:x})' -f $result.ErrorCode
                })
            }
            default {
                return $result
            }
        }
    } else {
        return $ErrorCode
    }
}
<#
.SYNOPSIS
Simplify the looping through user profiles and user registries.
.DESCRIPTION
Simplify the looping through user profiles and user registries by calling this function that gets what you need quickly.
 
Each user profile that is returned will contain information in the following hashtable:
 
```powershell
@{
    Domain = 'CONTOSO'
    Username = 'jsmith'
    Path = [IO.DirectoryInfo] 'C:\Users\jsmith'
    SID = 'S-1-5-21-1111111111-2222222222-3333333333-123456'
    RegistryKey = [Microsoft.Win32.RegistryKey] 'HKEY_USERS\S-1-5-21-1111111111-2222222222-3333333333-123456'
}
```
.PARAMETER Redstone
Provide the redstone class variable so we don't have to create a new one for you.
.PARAMETER AllProfiles
Include all user profiles, including service accounts.
Otherwise just [S-1-5-21 User Accounts](https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/manage/understand-security-identifiers#security-identifier-architecture) would be included.
.PARAMETER AddDefaultUser
Include the default User.
Keep in mind, no Domain or SID information will be provided for the default user, and the username will be `DEFAULT`.
.PARAMETER IncludeUserRegistryKey
Include the path to each user hive (aka HKCU).
 
If `AddDefaultUser` was provided, the hive will be mounted and requires special considertion.
You should use [`Set-RedstoneRegistryHiveItemProperty`](https://github.com/VertigoRay/PSRedstone/wiki/Functions#set-redstoneregistryhiveitemproperty) to make changes to mounted hives.
The [`Dismount-RedstoneRegistryHive`](https://github.com/VertigoRay/PSRedstone/wiki/Functions#dismount-redstoneregistryhive) is registered to the `PowerShell.Exiting` event for you by the [`Mount-RedstoneRegistryHive`](https://github.com/VertigoRay/PSRedstone/wiki/Functions#mount-redstoneregistryhive) function.
.PARAMETER DomainSid
Filter for the provided Sid.
If an `*` is not included at the end, it will be added.
.PARAMETER NotDomainSid
Filter out the provided Sid.
If an `*` is not included at the end, it will be added.
.EXAMPLE
foreach ($profilePath in (Get-UserProfiles)) { $profilePath }
#>

function Get-UserProfiles ([Redstone] $Redstone, [switch] $AllProfiles, [string] $DomainSid = $null, [string] $NotDomainSid = $null, [switch] $AddDefaultUser, [switch] $IncludeUserRegKey) {
    if (-not $Redstone) {
        try {
            $Redstone, $null = New-Redstone
        } catch {
            Throw [System.Management.Automation.ItemNotFoundException] ('Unable to find or create a redstone class. If your Redstone class is not stored on the variable `$redstone` then you must provide it in the `-Redstone` parameter. Tried making you a redstone class, but got this instantiation error: {0}' -f $_)
        }
    }

    $profiles = $Redstone.ProfileList.Profiles

    if (-not $AllProfiles.IsPresent) {
        # filter down to only user accounts
        $profiles = $profiles | Where-Object { $_.SID.StartsWith('S-1-5-21-') }
    }

    if ($DomainSid.IsPresent) {
        $DomainSid = '{0}*' -f $DomainSid.TrimEnd('*')
        $profiles = $profiles | Where-Object { $_.SID -like $DomainSid }
    }

    if ($NotDomainSid.IsPresent) {
        $NotDomainSid = '{0}*' -f $NotDomainSid.TrimEnd('*')
        $profiles = $profiles | Where-Object { $_.SID -notlike $NotDomainSid }
    }

    if ($AddDefaultUser.IsPresent) {
        $profiles = $profiles + @(@{
            Domain = $null
            Username = 'DEFAULT'
            Path = $Redstone.ProfileList.Default
            SID = $null
        })
    }

    if ($IncludeUserRegistryKey.IsPresent) {
        $profiles = foreach ($profile in $profiles) {
            if ($profile.Username -eq 'DEFAULT') {
                $hive = Mount-RegistryHive -DefaultUser
                $profile.Add('RegistryKey', $hive)
            } elseif ($profile.SID) {
                $profile.Add('RegistryKey', (Get-Item ('Registry::HKEY_USERS\{0}' -f $profile.SID) -ErrorAction 'Ignore'))
            }

            Write-Output $profile
        }
    }

    return $profiles.GetEnumerator()
}
<#
.SYNOPSIS
This is an advanced function for scheduling the install and reboot Windows Updates.
It utilizes and augments functionality provided by [PSWindowsUpdate](https://www.powershellgallery.com/packages/PSWindowsUpdate).
.DESCRIPTION
This advanced function for installing Windows Updates will try to fix Windows Updates, if desired, and fail back to non-PowerShell mechanisms for forcing Windows Updates.
It utilizes and augments functionality provided by [PSWindowsUpdate](https://www.powershellgallery.com/packages/PSWindowsUpdate).
 
If you want PSWindowsUpdate to send a report, you can use [PSDefaultParameterValues](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_parameters_default_values?view=powershell-5.1) to make that happen:
 
```powershell
$PSDefaultParameterValues.Set_Item('Install-WindowsUpdate:SendReport', $true)
$PSDefaultParameterValues.Set_Item('Install-WindowsUpdate:SendHistory', $true)
$PSDefaultParameterValues.Set_Item('Install-WindowsUpdate:PSWUSettings', @{
    SmtpServer = 'smtp.sendgrid.net'
    Port = 465
    UseSsl = $true
    From = '{1} <{0}@mailinator.com>' -f (& HOSTNAME.EXE), $env:ComputerName
    To = 'PSRedstone@mailinator.com'
})
```
.PARAMETER LastDeploymentChangeThresholdDays
When using `PSWindowsUpdate`, this will check the `LastDeploymentChangeTime` and install updates past the threshold.
.PARAMETER ScheduleJob
Schedule with a valid `[datetime]` value.
I suggest using `Get-Date -Format O` to get a convertable string.
 
```powershell
$scheduleJob = (Get-Date -Format 'MM-dd-yyyy' | Get-Date).AddDays(1).AddHours(-7) | Get-Date -Format 'O' # 5pm today
```
.PARAMETER ScheduleReboot
Schedule with a valid `[datetime]` value.
I suggest using `Get-Date -Format O` to get a convertable string.
 
```powershell
$scheduleReboot = (Get-Date -Format 'MM-dd-yyyy' | Get-Date).AddDays(1).AddHours(-1) | Get-Date -Format 'O' # 11pm today
```
.PARAMETER NoPSWindowsUpdate
Do NOT install the PSWindowsUpdate module.
When this option is used, none of the advanced scheduling or reporting options are available.
.PARAMETER ToastNotification
If this parameter is not provided, not Toast Notification will be shown.
A hashtable used to [splat](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_splatting?view=powershell-5.1) into the PSRedstone Show-ToastNotification function.
 
The `ToastText` parameter will be [formatted](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_operators?view=powershell-5.1#format-operator--f) with:
 
0. `$updateCount`
1. `$ToastNotification.ToastTextFormatters[0][$updateCount -gt 1]`
2. `$ToastNotification.ToastTextFormatters[1][$updateCount -gt 1]`
3. `$ToastNotification.ToastTextFormatters[2][$ScheduleJob -as [bool]]`
4. `$ToastNotification.ToastTextFormatters[3][$ScheduleReboot -as [bool]]`
 
Here's an example:
 
```powershell
$lastDeploymentChangeThresholdDays = 30
$scheduleJob = (Get-Date -Format 'MM-dd-yyyy' | Get-Date).AddDays(1).AddHours(-7) | Get-Date -Format 'O' # 5pm today
$scheduleReboot = (Get-Date -Format 'MM-dd-yyyy' | Get-Date).AddDays(1).AddHours(-1) | Get-Date -Format 'O' # 11pm today
 
$toastNotification = @{
    ToastNotifier = 'Tech Solutions: Endpoint Solutions Engineering'
    ToastTitle = 'Windows Update'
    ToastText = 'This computer is at least 30 days overdue for {0} Windows Update{1}. {2} being forced on your system {3}. A reboot may occur {4}.'
    ToastTextFormatters = @(
        @($null, 's')
        @('The update is', 'Updates are')
        @(('on {0}' -f ($scheduleJob | Get-Date -Format (Get-Culture).DateTimeFormat.FullDateTimePattern)), 'now')
        @(('on {0}' -f ($scheduleReboot | Get-Date -Format (Get-Culture).DateTimeFormat.FullDateTimePattern)), 'immediately afterwards')
    )
}
```
 
When `$toastNotification` is passed to this function, and there are five Windows Updates past due, it will result in a Toast Notification like this:
 
> `Tech Solutions: Endpoint Solutions Engineering`
>
> # Windows Update
>
> This computer is at least 30 days overdue for 5 Windows Updates. Updates are being forced on your system on Saturday, February 11, 2023 5:00:00 PM. Reboot will occur on Saturday, February 11, 2023 11:00:00 PM.
.PARAMETER FixWUAU
Attempt to fix the WUAU service.
.EXAMPLE
Install-WindowsUpdateAdv
 
This will install all available updates now and restart now.
.EXAMPLE
Install-WindowsUpdateAdv -FixWUAU
 
This will attempt to fix the WUAU service, install all available updates now, and restart immediately afterwards.
.EXAMPLE
Install-WindowsUpdateAdv -LastDeploymentChangeThresholdDays 30 -FixWUAU
 
This will attempt to fix the WUAU service, install all available updates now that are more than 30 days old, and restart immediately afterwards.
.EXAMPLE
Install-WindowsUpdateAdv -LastDeploymentChangeThresholdDays 30 -FixWUAU -ScheduleJob ((Get-Date -Format 'MM-dd-yyyy' | Get-Date).AddDays(1).AddHours(-7) | Get-Date -Format 'O')
 
This will attempt to fix the WUAU service now, install all available updates today at 5 pm that are more than 30 days old, and restart immediately afterwards.
.EXAMPLE
Install-WindowsUpdateAdv -LastDeploymentChangeThresholdDays 30 -FixWUAU -ScheduleJob ((Get-Date -Format 'MM-dd-yyyy' | Get-Date).AddDays(1).AddHours(-7) | Get-Date -Format 'O') -ScheduleReboot ((Get-Date -Format 'MM-dd-yyyy' | Get-Date).AddDays(1).AddHours(-1) | Get-Date -Format 'O')
 
This will attempt to fix the WUAU service now, install all available updates today at 5 pm that are more than 30 days old, and restart today at 11 pm.
.EXAMPLE
Install-WindowsUpdateAdv -LastDeploymentChangeThresholdDays 30 -FixWUAU -ScheduleJob $scheduleJob -ScheduleReboot $scheduleReboot -ToastNotification $toastNotification
 
This will show a toast notification for any logged on users, attempt to fix the WUAU service now, install all available updates today at 5 pm that are more than 30 days old, and restart today at 11 pm. The variables were defined like this:
 
```powershell
$scheduleJob = (Get-Date -Format 'MM-dd-yyyy' | Get-Date).AddDays(1).AddHours(-7) | Get-Date -Format 'O' # 5pm today
$scheduleReboot = (Get-Date -Format 'MM-dd-yyyy' | Get-Date).AddDays(1).AddHours(-1) | Get-Date -Format 'O' # 11pm today
 
$toastNotification = @{
    ToastNotifier = 'Tech Solutions: Endpoint Solutions Engineering'
    ToastTitle = 'Windows Update'
    ToastText = 'This computer is at least 30 days overdue for {0} Windows Update{1}. {2} being forced on your system {3}. A reboot may occur {4}.'
    ToastTextFormatters = @(
        @($null, 's')
        @('The update is', 'Updates are')
        @(('on {0}' -f ($scheduleJob | Get-Date -Format (Get-Culture).DateTimeFormat.FullDateTimePattern)), 'now')
        @(('on {0}' -f ($scheduleReboot | Get-Date -Format (Get-Culture).DateTimeFormat.FullDateTimePattern)), 'immediately afterwards')
    )
}
```
.EXAMPLE
Install-WindowsUpdateAdv -FixWUAU -NoPSWindowsUpdate
 
This will attempt to fix the WUAU service, install all available updates now, and restart immediately afterwards.
.NOTES
#>

function Install-WindowsUpdateAdv {
    [CmdletBinding(DefaultParameterSetName = 'PSWindowsUpdate')]
    param(
        [Parameter(HelpMessage = 'When using PSWindowsUpdate, this will check the LastDeploymentChangeTime and install updates past the threshold.', ParameterSetName = 'PSWindowsUpdate')]
        [int]
        $LastDeploymentChangeThresholdDays,

        [Parameter(HelpMessage = 'Schedule with a valid datetime value. I suggest using `Get-Date -Format O` to get a convertable string.', ParameterSetName = 'PSWindowsUpdate')]
        [datetime]
        $ScheduleJob,

        [Parameter(HelpMessage = 'Schedule with a valid datetime value. I suggest using `Get-Date -Format O` to get a convertable string.', ParameterSetName = 'PSWindowsUpdate')]
        [datetime]
        $ScheduleReboot,

        [Parameter(HelpMessage = 'Do NOT install the PSWindowsUpdate module.', ParameterSetName = 'NoPSWindowsUpdate')]
        [switch]
        $NoPSWindowsUpdate,

        [Parameter(HelpMessage = 'Parameters for Show-ToastNotification, if a toast notification is desired.', ParameterSetName = 'PSWindowsUpdate')]
        [Parameter(HelpMessage = 'Parameters for Show-ToastNotification, if a toast notification is desired.', ParameterSetName = 'NoPSWindowsUpdate')]
        [hashtable]
        $ToastNotification,

        [Parameter(HelpMessage = 'Attempt to fix the WUAU service.', ParameterSetName = 'PSWindowsUpdate')]
        [Parameter(HelpMessage = 'Attempt to fix the WUAU service.', ParameterSetName = 'NoPSWindowsUpdate')]
        [switch]
        $FixWUAU
    )

    if (($PSVersionTable.PSVersion -ge '5.1')) {
        if (-not $NoPSWindowsUpdate.IsPresent) {
            [version] $nugetPPMinVersion = '2.8.5.201'
            if (-not (Get-PackageProvider -Name 'NuGet' -ErrorAction 'Ignore' | Where-Object { $_.Version -ge $nugetPPMinVersion })) {
                Install-PackageProvider -Name 'NuGet' -MinimumVersion $nugetPPMinVersion -Force | Out-Null
            }
            [version] $psWindowsUpdateMinVersion = '2.2.0.3'
            if (-not (Get-Module -Name 'PSWindowsUpdate' -ErrorAction 'Ignore' | Where-Object { $_.Version -ge $psWindowsUpdateMinVersion })) {
                Install-Module -Name 'PSWindowsUpdate' -Scope 'CurrentUser' -MinimumVersion $psWindowsUpdateMinVersion -Confirm:$false -Force -ErrorAction Ignore | Out-Null
            }
        }

        $updates = Get-WindowsUpdate
        if ($MyInvocation.BoundParameters.Keys -contains 'LastDeploymentChangeThresholdDays') {
            $updates | Where-Object { $_.LastDeploymentChangeTime -lt (Get-Date).AddDays(-$LastDeploymentChangeThresholdDays) }
        }
        $updateCount = ($updates | Measure-Object).Count
        if ($updateCount -eq 0) {
            Write-Verbose '[Install-WindowsUpdate] Update Count: 0'
            return $updates
        } else {
            Write-Output ('[Install-WindowsUpdate] Update Count: {0}' -f $updateCount)
        }

        if ($ToastNotification) {
            $toastNotification  = @{
                ToastNotifier = 'Tech Solutions: Endpoint Solutions Engineering'
                ToastTitle = 'Windows Update'
                ToastText =  'This computer is overdue for {0} Windows Update{1} and the time threshold has exceeded. {2} being forced on your system {3}.{4}' -f @(
                    $updateCount
                    $ToastNotification.ToastTextFormatters[0][$updateCount -gt 1]
                    $ToastNotification.ToastTextFormatters[1][$updateCount -gt 1]
                    $ToastNotification.ToastTextFormatters[2][$ScheduleJob -as [bool]]
                    $ToastNotification.ToastTextFormatters[3][$ScheduleReboot -as [bool]]
                )
            }
            $ToastNotification.Remove('ToastTextFormatters')

            Show-ToastNotification @toastNotification
        }
    }

    if ($FixWUAU.IsPresent) {
        Stop-Service -Name 'wuauserv'
        Remove-Item 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' -Recurse -Force
        Remove-Item ([IO.Path]::Combine($env:SystemRoot, 'SoftwareDistribution', '*')) -Recurse -Force

        & dism.exe /Online /Cleanup-Image /Restorehealth | Out-Null
        & sfc.exe /scannow | Out-Null

        Get-Service -Name 'wuauserv' | Set-Service -StartupType 'Automatic' | Out-Null
        Start-Service -Name 'wuauserv'
    }

    $altWindowsUpdate = {
        if (Get-Command -Name 'UsoClient.exe' -ErrorAction 'Ignore') {
            # wuauclt has been replaced by usoclient; if it exists, use it.
            & UsoClient.exe RefreshSettings StartScan StartDownload StartInstall
        } else {
            & wuauclt.exe /detectnow /updatenow
        }
    }

    if (-not $NoPSWindowsUpdate.IsPresent) {
        try {
            $installWindowsUpdate = @{
                MicrosoftUpdate = $true
                SendHistory = $true
                AcceptAll = $true
            }

            if ($ScheduleJob) {
                $installWindowsUpdate.Add('ScheduleJob', $ScheduleJob)
            }

            if ($ScheduleReboot) {
                $installWindowsUpdate.Add('ScheduleReboot', $ScheduleReboot)
            } else {
                $installWindowsUpdate.Add('AutoReboot', $true)
            }

            Install-WindowsUpdate @installWindowsUpdate -Verbose
        } catch {
            & $altWindowsUpdate
        }
    } else {
        & $altWindowsUpdate
    }
}
<#
.SYNOPSIS
Runs the given command in ComSpec (aka: Command Prompt).
.DESCRIPTION
This just runs a command in ComSpec by passing it to `Invoke-Run`. If you don't *need* ComSpec to run the command, it's normally best to just use `Invoke-Run`.
 
Returns the same object as `Invoke-Run`
 
```
@{
    'Process' = $proc; # The result from Start-Process; as returned from `Invoke-Run`.
    'StdOut' = $stdout;
    'StdErr' = $stderr;
}
```
.PARAMETER Cmd
Under normal usage, the string passed in here just gets appended to `cmd.exe /c `.
.PARAMETER KeepOpen
Applies /K instead of /C, but *why would you want to do this?*
 
/C Carries out the command specified by string and then terminates
/K Carries out the command specified by string but remains
.PARAMETER StringMod
Applies /S: Modifies the treatment of string after /C or /K (run cmd.exe below)
.PARAMETER Quiet
Applies /Q: Turns echo off
.PARAMETER DisableAutoRun
Applies /D: Disable execution of AutoRun commands from registry (see below)
.PARAMETER ANSI
Applies /A: Causes the output of internal commands to a pipe or file to be ANSI
.PARAMETER Unicode
Applies /U: Causes the output of internal commands to a pipe or file to be Unicode
.OUTPUTS
[Hashtable] As returned from `Invoke-Run`.
.EXAMPLE
Invoke-Cmd "MKLINK /D Temp C:\Temp"
.LINK
https://github.com/VertigoRay/PSRedstone/wiki/Functions#invoke-cmd
#>

function Invoke-Cmd {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true, Position=1)]
        [string]
        $Cmd,

        [Parameter(Mandatory=$false)]
        [switch]
        $KeepOpen,

        [Parameter(Mandatory=$false)]
        [switch]
        $StringMod,

        [Parameter(Mandatory=$false)]
        [switch]
        $Quiet,

        [Parameter(Mandatory=$false)]
        [switch]
        $DisableAutoRun,

        [Parameter(Mandatory=$false)]
        [switch]
        $ANSI,

        [Parameter(Mandatory=$false)]
        [switch]
        $Unicode
    )

    Write-Information "[Invoke-Cmd] > $($MyInvocation.BoundParameters | ConvertTo-Json -Compress)"
    Write-Debug "[Invoke-Cmd] Function Invocation: $($MyInvocation | Out-String)"

    [System.Collections.ArrayList] $ArgumentList = @()
    if ($KeepOpen) {
        $ArgumentList.Add('/K')
    } else {
        $ArgumentList.Add('/C')
    }
    if ($StringMod)      { $ArgumentList.Add('/S') }
    if ($Quiet)          { $ArgumentList.Add('/Q') }
    if ($DisableAutoRun) { $ArgumentList.Add('/D') }
    if ($ANSI)           { $ArgumentList.Add('/A') }
    if ($Unicode)        { $ArgumentList.Add('/U') }
    $ArgumentList.Add($Cmd)

    Write-Verbose "[Invoke-Cmd] Executing: cmd $($ArgumentList -join ' ')"

    Write-Verbose "[Invoke-Cmd] Invoke-Run ..."
    $proc = Invoke-Run -FilePath $env:ComSpec -ArgumentList $ArgumentList
    Write-Verbose "[Invoke-Cmd] ExitCode: $($proc.Process.ExitCode)"

    Write-Information "[Invoke-Cmd] Return: $($proc | Out-String)"
    return $proc
}
<#
.SYNOPSIS
Download a file and validate the checksum.
.DESCRIPTION
Download a file; use a few methods based on performance preference testing:
 
- `Start-BitsTransfer`
- `Net.WebClient`
- `Invoke-WebRequest`
 
If the first one fails, the next one will be tried. Target directory will be automatically created.
A checksum will be validated if it is supplied.
.PARAMETER Uri
Uri to the File to be downloaded.
.PARAMETER OutFile
The full path of the file to be downloaded.
.PARAMETER OutFolder
Folder where you want the file to go. If this is specified, the file name is derived from the last segment of the Uri parameter.
.PARAMETER Checksum
A string containing the Algorithm and the Hash separated by a colon.
For example: "SHA256:AA24A85644ECCCAD7098327899A3C827A6BE2AE1474C7958C1500DCD55EE66D8"
 
The algorithm should be a valid algorithm recognized by `Get-FileHash`.
.EXAMPLE
Invoke-Download 'https://download3.vmware.com/software/CART23FQ4_WIN_2212/VMware-Horizon-Client-2212-8.8.0-21079405.exe' -OutFile (Join-Path $env:Temp 'VMware-Horizon-Client-2212-8.8.0-21079405.exe')
.EXAMPLE
Invoke-Download 'https://download3.vmware.com/software/CART23FQ4_WIN_2212/VMware-Horizon-Client-2212-8.8.0-21079405.exe' -OutFolder $env:Temp
.EXAMPLE
Invoke-Download 'https://download3.vmware.com/software/CART23FQ4_WIN_2212/VMware-Horizon-Client-2212-8.8.0-21079405.exe' -OutFolder $env:Temp -Checksum 'sha256:a0bac35619328f5f9aa56508572f343f7a388286768b31ab95377c37b052e5ac'
.LINK
https://github.com/VertigoRay/PSRedstone/wiki/Functions#invoke-download
#>

function Invoke-Download {
    [CmdletBinding(DefaultParameterSetName = 'OutFile')]
    param (
        [Parameter(Mandatory = $true, Position = 0, ParameterSetName = 'OutFile')]
        [Parameter(Mandatory = $true, Position = 0, ParameterSetName = 'OutFolder')]
        [ValidateNotNullOrEmpty()]
        [uri]
        $Uri,

        [Parameter(Mandatory = $true, ParameterSetName = 'OutFile')]
        [ValidateNotNullOrEmpty()]
        [IO.FileInfo]
        $OutFile,

        [Parameter(Mandatory = $true, ParameterSetName = 'OutFolder')]
        [ValidateNotNullOrEmpty()]
        [IO.DirectoryInfo]
        $OutFolder,

        [Parameter(Mandatory = $false, ParameterSetName = 'OutFile', HelpMessage = 'A string containing the Algorithm and the Hash separated by a colon.')]
        [Parameter(Mandatory = $false, ParameterSetName = 'OutFolder', HelpMessage = 'A string containing the Algorithm and the Hash separated by a colon.')]
        [ValidateNotNullOrEmpty()]
        [ValidateScript({
            if ($_.Split(':', 2)[0] -in (Get-Command 'Microsoft.PowerShell.Utility\Get-FileHash').Parameters.Algorithm.Attributes.ValidValues) {
                Write-Output $true
            } else {
                Throw ('The first part ("{1}") of argument "{0}" does not belong to the set specified by Get-FileHash''s Algorithm parameter. Supply a first part "{1}" that is in the set "{2}" and then try the command again.' -f @(
                    $_
                    $_.Split(':', 2)[0]
                    ((Get-Command 'Microsoft.PowerShell.Utility\Get-FileHash').Parameters.Algorithm.Attributes.ValidValues -join ', ')
                ))
            }
        })]
        [string]
        $Checksum
    )

    Write-Information ('[Invoke-Download] > {0}' -f ($MyInvocation.BoundParameters | ConvertTo-Json -Compress))
    Write-Debug ('[Invoke-Download] Function Invocation: {0}' -f ($MyInvocation | Out-String))

    if ($PSCmdlet.ParameterSetName -eq 'OutFolder') {
        [IO.FileInfo] $OutFile = [IO.Path]::Combine($OutFolder.FullName, $Uri.Segments[-1])
    }

    if (-not $OutFile.Directory.Exists) {
        New-Item -ItemType 'Directory' -Path $OutFile.Directory.FullName | Out-Null
        Write-Verbose ('[Invoke-Download] Directory created: {0}' -f $OutFile.Directory.FullName)
    }

    $startBitsTransfer = @{
        Source      = $Uri.AbsoluteUri
        Destination = $OutFile.FullName
        ErrorAction = 'Stop'
    }
    Write-Verbose ('[Invoke-Download] startBitsTransfer: {0}' -f ($startBitsTransfer | ConvertTo-Json))

    try {
        Start-BitsTransfer @startBitsTransfer
    } catch {
        Write-Warning ('[Invoke-Download] BitsTransfer Failed: {0}' -f $_)
        try {
            (New-Object Net.WebClient).DownloadFile($startBitsTransfer.Source, $startBitsTransfer.Destination)
        } catch {
            Write-Warning ('[Invoke-Download] WebClient Failed: {0}' -f $_)
            Invoke-WebRequest -Uri $startBitsTransfer.Source -OutFile $startBitsTransfer.Destination
        }
    }

    if ($Checksum) {
        $checksumAlgorithm, $checksumHash = $Checksum.Split(':', 2)

        $hash = Get-FileHash -LiteralPath $startBitsTransfer.Destination -Algorithm $checksumAlgorithm
        Write-Verbose ('[Invoke-Download] Downloaded File Hash: {0}' -f ($hash | ConvertTo-Json))

        if ($checksumHash -ne $hash.Hash) {
            Remove-Item -LiteralPath $startBitsTransfer.Destination -Force
            Throw ('Unexpected Hash; Downloaded file deleted!')
        }
    }

    $OutFile.Refresh()
    return $OutFile
}
<#
.SYNOPSIS
Get SeTakeOwnership, SeBackup and SeRestore privileges before executes next lines, script needs Admin privilege
.NOTES
Ref: https://stackoverflow.com/a/35843420/17552750
.LINK
https://github.com/VertigoRay/PSRedstone/wiki/Functions#invoke-elevatecurrentprocess
#>

function Invoke-ElevateCurrentProcess {
    [CmdletBinding()]
    [OutputType([void])]
    param()

    Write-Information ('[Invoke-ElevateCurrentProcess] > {0}' -f ($MyInvocation.BoundParameters | ConvertTo-Json -Compress))
    Write-Debug ('[Invoke-ElevateCurrentProcess] Function Invocation: {0}' -f ($MyInvocation | Out-String))

    $import = '[DllImport("ntdll.dll")] public static extern int RtlAdjustPrivilege(ulong a, bool b, bool c, ref bool d);'
    $ntdll = Add-Type -Member $import -Name 'NtDll' -PassThru
    $privileges = @{
        SeTakeOwnership = 9
        SeBackup =  17
        SeRestore = 18
    }

    foreach ($privilege in $privileges.GetEnumerator()) {
        Write-Debug ('[Invoke-ElevateCurrentProcess] Adjusting Priv: {0}: {1}' -f $privilege.Name, $privilege.Value)
        $rtlAdjustPrivilege = $ntdll::RtlAdjustPrivilege($privilege.Value, 1, 0, [ref] 0)
        $returnedMessage = Get-TranslatedErrorCode $rtlAdjustPrivilege
        Write-Debug ('[Invoke-ElevateCurrentProcess] Adjusted Prif: {0}' -f ($returnedMessage | Select-Object '*' | Out-String))
    }
}
<#
.EXAMPLE
$MountPath.FullName | Invoke-ForceEmptyDirectory
.LINK
https://github.com/VertigoRay/PSRedstone/wiki/Functions#invoke-forceemptydirectory
#>

function Invoke-ForceEmptyDirectory {
    [CmdletBinding()]
    [OutputType([void])]
    param (
        [Parameter(
            Mandatory=$true,
            Position=0,
            ParameterSetName="ParameterSetName",
            ValueFromPipeline=$true,
            ValueFromPipelineByPropertyName=$true,
            HelpMessage="Path to one or more locations."
        )]
        [Alias("PSPath")]
        [ValidateNotNullOrEmpty()]
        [IO.DirectoryInfo]
        $Path
    )

    begin {}

    process {
        foreach ($p in $Path) {
            if (-not $p.Exists) {
                New-Item -ItemType 'Directory' -Path $p.FullName -Force | Out-Null
                $p.Refresh()
            } else { # Path Exists
                if ((Get-ChildItem $p.FullName | Measure-Object).Count) {
                    # Path (Directory) is NOT empty.
                    try {
                        $p.FullName | Remove-Item -Recurse -Force
                    } catch [System.ComponentModel.Win32Exception] {
                        if ($_.Exception.Message -eq 'Access to the cloud file is denied') {
                            Write-Warning ('[{0}] {1}' -f $_.Exception.GetType().FullName, $_.Exception.Message)
                            # It seems the problem comes from a directory, not the files themselves,
                            # so using a small workaround using Get-ChildItem to list and then delete
                            # all files helps to get rid of all files.
                            foreach ($item in (Get-ChildItem -LiteralPath $p.FullName -File -Recurse)) {
                                Remove-Item -LiteralPath $item.Fullname -Recurse -Force
                            }
                        } else {
                            Throw $_
                        }
                    }
                    New-Item -ItemType 'Directory' -Path $p.FullName -Force | Out-Null
                    $p.Refresh()
                }
            }
        }
    }

    end {}
}
<#
.SYNOPSIS
Executes msiexec.exe to perform the following actions for MSI & MSP files and MSI product codes: install, uninstall, patch, repair, active setup.
.DESCRIPTION
Executes msiexec.exe to perform the following actions for MSI & MSP files and MSI product codes: install, uninstall, patch, repair, active setup.
If the -Action parameter is set to "Install" and the MSI is already installed, the function will exit.
Sets default switches to be passed to msiexec based on the preferences in the XML configuration file.
Automatically generates a log file name and creates a verbose log file for all msiexec operations.
Expects the MSI or MSP file to be located in the "Files" sub directory of the App Deploy Toolkit. Expects transform files to be in the same directory as the MSI file.
.PARAMETER Action
The action to perform. Options: Install, Uninstall, Patch, Repair, ActiveSetup.
.PARAMETER Path
The path to the MSI/MSP file or the product code of the installed MSI.
.PARAMETER Transforms
The name of the transform file(s) to be applied to the MSI. Relational paths from the working dir, then the MSI are looked for ... in that order.
Multiple transforms can be specified; separated by a comma.
.PARAMETER Patches
The name of the patch (msp) file(s) to be applied to the MSI for use with the "Install" action. The patch file is expected to be in the same directory as the MSI file.
.PARAMETER MsiDisplay
Overrides the default MSI Display Settings.
.PARAMETER Parameters
Overrides the default parameters specified in the XML configuration file. Install default is: "REBOOT=ReallySuppress /QB!". Uninstall default is: "REBOOT=ReallySuppress /QN".
.PARAMETER SecureParameters
Hides all parameters passed to the MSI or MSP file from the toolkit Log file.
.PARAMETER LoggingOptions
Overrides the default logging options specified in the XML configuration file. Default options are: "/log" (aka: "/L*v")
.PARAMETER WorkingDirectory
Overrides the working directory. The working directory is set to the location of the MSI file.
.PARAMETER SkipMSIAlreadyInstalledCheck
Skips the check to determine if the MSI is already installed on the system. Default is: $false.
.PARAMETER PassThru
Returns ExitCode, StdOut, and StdErr output from the process.
.PARAMETER LogFileF
When using [Redstone], this will be overridden via $PSDefaultParameters.
.EXAMPLE
# Installs an MSI
Invoke-MSI 'Adobe_FlashPlayer_11.2.202.233_x64_EN.msi'
.EXAMPLE
# Installs an MSI, applying a transform and overriding the default MSI toolkit parameters
Invoke-MSI -Action 'Install' -Path 'Adobe_FlashPlayer_11.2.202.233_x64_EN.msi' -Transform 'Adobe_FlashPlayer_11.2.202.233_x64_EN_01.mst' -Parameters '/QN'
.EXAMPLE
# Installs an MSI and stores the result of the execution into a variable by using the -PassThru option
[psobject] $ExecuteMSIResult = Invoke-MSI -Action 'Install' -Path 'Adobe_FlashPlayer_11.2.202.233_x64_EN.msi' -PassThru
.EXAMPLE
# Uninstalls an MSI using a product code
Invoke-MSI -Action 'Uninstall' -Path '{26923b43-4d38-484f-9b9e-de460746276c}'
.EXAMPLE
# Installs an MSP
Invoke-MSI -Action 'Patch' -Path 'Adobe_Reader_11.0.3_EN.msp'
.EXAMPLE
$msi = @{
    Action = 'Install'
    Parameters = @(
        'USERNAME="{0}"' -f $settings.Installer.UserName
        'COMPANYNAME="{0}"' -f $settings.Installer.CompanyName
        'SERIALNUMBER="{0}"' -f $settings.Installer.SerialNumber
    )
}
 
if ([Environment]::Is64BitOperatingSystem) {
    Invoke-MSI @msi -Path 'Origin2016Sr2Setup32and64Bit.msi'
} else {
    Invoke-MSI @msi -Path 'Origin2016Sr2Setup32Bit.msi'
}
.NOTES
Copyright (C) 2015 - PowerShell App Deployment Toolkit Team
Copyright (C) 2023 - Raymond Piller (VertigoRay)
.LINK
https://github.com/VertigoRay/PSRedstone/wiki/Functions#invoke-msi
#>

function Invoke-MSI {
    [CmdletBinding()]
    [OutputType([hashtable])]
    Param (
        [Parameter(Mandatory = $false)]
        [ValidateSet('Install','Uninstall','Patch','Repair','ActiveSetup')]
        [string]
        $Action = 'Install',

        [Parameter(Position=0, Mandatory = $true, HelpMessage = 'Please enter either the path to the MSI/MSP file or the ProductCode')]
        [ValidateNotNullorEmpty()]
        [Alias('FilePath')]
        [string]
        $Path,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullorEmpty()]
        [string[]]
        $Transforms,

        [Parameter(Mandatory = $false)]
        [Alias('Arguments')]
        [ValidateNotNullorEmpty()]
        [string[]]
        $Parameters = @('REBOOT=ReallySuppress'),

        [Parameter(Mandatory = $false)]
        [ValidateNotNullorEmpty()]
        [switch]
        $SecureParameters = $false,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullorEmpty()]
        [string[]]
        $Patches,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullorEmpty()]
        [string]
        $LoggingOptions = '/log',

        [Parameter(Mandatory = $false)]
        [ValidateNotNullorEmpty()]
        [string]
        $WorkingDirectory = $PWD.Path,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullorEmpty()]
        [switch]
        $SkipMSIAlreadyInstalledCheck = $false,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullorEmpty()]
        [string]
        $MsiDisplay = '/qn',

        [Parameter(Mandatory = $false)]
        [ValidateNotNullorEmpty()]
        [string]
        $WindowStyle = 'Hidden',

        [Parameter(Mandatory = $false)]
        [ValidateNotNullorEmpty()]
        [bool]
        $PassThru = $true,

        [Parameter(Mandatory = $false, HelpMessage = 'When using [Redstone], this will be overridden via $PSDefaultParameters.')]
        [ValidateNotNullorEmpty()]
        [string]
        $LogFileF = "${env:Temp}\Invoke-Msi.{1}.{0}.log"
    )

    Write-Verbose "[Invoke-Msi] > $($MyInvocation.BoundParameters | ConvertTo-Json -Compress)"
    Write-Debug "[Invoke-Msi] Function Invocation: $($MyInvocation | Out-String)"


    ## Initialize variable indicating whether $Path variable is a Product Code or not
    $PathIsProductCode = ($Path -as [guid]) -as [bool]

    ## Build the MSI Parameters
    switch ($Action) {
        'Install' {
            $option = '/i'
            $msiDefaultParams = $MsiDisplay
        }
        'Uninstall' {
            $option = '/x'
            $msiDefaultParams = $MsiDisplay
        }
        'Patch' {
            $option = '/update'
            $msiDefaultParams = $MsiDisplay
        }
        'Repair' {
            $option = '/f'
            $msiDefaultParams = $MsiDisplay
        }
        'ActiveSetup' {
            $option = '/fups'
        }
    }

    ## If the MSI is in the Files directory, set the full path to the MSI
    if ($PathIsProductCode) {
        [string] $msiFile = $Path
        [string] $msiLogFile = $LogFileF -f "msi.${Action}", ($Path -as [guid]).Guid
    } else {
        [string] $msiFile = (Resolve-Path $Path -ErrorAction 'Stop').Path
        [string] $msiLogFile = $LogFileF -f "msi.${Action}", ($Path -as [IO.FileInfo]).BaseName
    }

    ## Set the working directory of the MSI
    if ((-not $PathIsProductCode) -and (-not $workingDirectory)) {
        [string] $workingDirectory = Split-Path -Path $msiFile -Parent
    }

    ## Enumerate all transforms specified, qualify the full path if possible and enclose in quotes
    [System.Collections.ArrayList] $mst = @()
    foreach ($transform in $Transforms) {
        try {
            $mst = Resolve-Path $transform -ErrorAction 'Stop'
        } catch [System.Management.Automation.ItemNotFoundException] {
            if ($workingDirectory) {
                $mst.Add((Join-Path "${workingDirectory}\${transform}" -Resolve -ErrorAction 'Stop')) | Out-Null
            } else {
                $mst.Add($transform) | Out-Null
            }
        }
    }
    [string] $mstFile = "`"$($mst -join ';')`""

    ## Enumerate all patches specified, qualify the full path if possible and enclose in quotes
    [System.Collections.ArrayList] $msp = @()
    foreach ($patch in $Patches) {
        try {
            $msp = Resolve-Path $patch -ErrorAction 'Stop'
        } catch [System.Management.Automation.ItemNotFoundException] {
            if ($workingDirectory) {
                $msp.Add((Join-Path "${workingDirectory}\${patch}" -Resolve -ErrorAction 'Stop')) | Out-Null
            } else {
                $msp.Add($patch) | Out-Null
            }
        }
    }
    [string] $mspFile = "`"$($msp -join ';')`""

    ## Get the ProductCode of the MSI
    if ($PathIsProductCode) {
        [string] $MSIProductCode = $Path
    } elseif ([IO.Path]::GetExtension($msiFile) -eq '.msi') {
        try {
            [hashtable] $Get_MsiTablePropertySplat = @{
                Path              = $msiFile;
                Table             = 'Property';
                ContinueOnError   = $false;
            }
            if ($mst) {
                $Get_MsiTablePropertySplat.Add('TransformPath', $mst)
            }

            [string] $MSIProductCode = Get-MsiTableProperty @Get_MsiTablePropertySplat | Select-Object -ExpandProperty 'ProductCode' -ErrorAction 'Stop'
            Write-Information "[Invoke-Msi] Got the ProductCode from the MSI file: ${MSIProductCode}"
        } catch {
            Write-Information "[Invoke-Msi] Failed to get the ProductCode from the MSI file. Continuing with requested action [${Action}].$([Environment]::NewLine)$([Environment]::NewLine)$_"
        }
    }

    ## Start building the MsiExec command line starting with the base action and file
    [System.Collections.ArrayList] $argsMSI = @()
    if ($msiDefaultParams) {
        $argsMSI.Add($msiDefaultParams) | Out-Null
    }
    $argsMSI.Add($option) | Out-Null
    ## Enclose the MSI file in quotes to avoid issues with spaces when running msiexec
    $argsMSI.Add("`"${msiFile}`"") | Out-Null
    if ($Transforms) {
        $argsMSI.Add("TRANSFORMS=${mstFile}") | Out-Null
        $argsMSI.Add("TRANSFORMSSECURE=1") | Out-Null
    }
    if ($Patches) {
        $argsMSI.Add("PATCH=${mspFile}") | Out-Null
    }
    if ($Parameters) {
        foreach ($param in $Parameters) {
            $argsMSI.Add($param) | Out-Null
        }
    }
    $argsMSI.Add($LoggingOptions) | Out-Null
    $argsMSI.Add("`"$msiLogFile`"") | Out-Null

    ## Check if the MSI is already installed. If no valid ProductCode to check, then continue with requested MSI action.
    [boolean] $IsMsiInstalled = $false
    if ($MSIProductCode -and (-not $SkipMSIAlreadyInstalledCheck)) {
        [psobject] $MsiInstalled = Get-InstalledApplication -ProductCode $MSIProductCode
        if ($MsiInstalled) {
            [boolean] $IsMsiInstalled = $true
        }
    } else {
        if ($Action -ine 'Install') {
            [boolean] $IsMsiInstalled = $true
        }
    }

    if ($IsMsiInstalled -and ($Action -ieq 'Install')) {
        Write-Information "[Invoke-Msi] The MSI is already installed on this system. Skipping action [${Action}]..."
    } elseif ($IsMsiInstalled -or ((-not $IsMsiInstalled) -and ($Action -eq 'Install'))) {
        Write-Information "[Invoke-Msi] Executing MSI action [${Action}]..."

        # Build the hashtable with the options that will be passed to Invoke-Run using splatting
        [hashtable] $invokeRun =  @{
            FilePath = (Get-Command 'msiexec' -ErrorAction 'Stop').Source
            ArgumentList = $argsMSI
            WindowStyle = $WindowStyle
            PassThru = $PassThru
            Wait = $true
        }
        if ($WorkingDirectory) {
            $invokeRun.Add( 'WorkingDirectory', $WorkingDirectory)
        }


        ## If MSI install, check to see if the MSI installer service is available or if another MSI install is already underway.
        ## Please note that a race condition is possible after this check where another process waiting for the MSI installer
        ## to become available grabs the MSI Installer mutex before we do. Not too concerned about this possible race condition.
        [boolean] $msiExecAvailable = Assert-IsMutexAvailable -MutexName 'Global\_MSIExecute'
        Start-Sleep -Seconds 1
        if (-not $msiExecAvailable) {
            # Default MSI exit code for install already in progress
            Write-Warning '[Invoke-Msi] Please complete in progress MSI installation before proceeding with this install.'
            $msg = Get-MsiExitCodeMessage 1618
            Write-Error "[Invoke-Msi] 1618: ${msg}"
            & $Redstone.Quit 1618 $false
        }


        # Call the Invoke-Run function
        if ($PassThru) {
            $result = Invoke-Run @invokeRun
            if ($result.Process.ExitCode -ne 0) {
                $msg = Get-MsiExitCodeMessage $result.Process.ExitCode -MsiLog $msiLogFile
                Write-Warning "[Invoke-Msi] $($result.Process.ExitCode): ${msg}"
            }
            Write-Information "[Invoke-Msi] Return: $($result | Out-String)"
            return $result
        } else {
            Invoke-Run @invokeRun | Out-Null
        }
    } else {
        Write-Warning "[Invoke-Msi] The MSI is not installed on this system. Skipping action [${Action}]..."
    }
}
<#
.SYNOPSIS
Invoke method on any object.
.DESCRIPTION
Invoke method on any object with or without using named parameters.
.PARAMETER InputObject
Specifies an object which has methods that can be invoked.
.PARAMETER MethodName
Specifies the name of a method to invoke.
.PARAMETER ArgumentList
Argument to pass to the method being executed. Allows execution of method without specifying named parameters.
.PARAMETER Parameter
Argument to pass to the method being executed. Allows execution of method by using named parameters.
.EXAMPLE
$ShellApp = New-Object -ComObject 'Shell.Application'
$null = Invoke-ObjectMethod -InputObject $ShellApp -MethodName 'MinimizeAll'
 
Minimizes all windows.
.EXAMPLE
$ShellApp = New-Object -ComObject 'Shell.Application'
$null = Invoke-ObjectMethod -InputObject $ShellApp -MethodName 'Explore' -Parameter @{'vDir'='C:\Windows'}
 
Opens the C:\Windows folder in a Windows Explorer window.
.NOTES
This is an internal script function and should typically not be called directly.
.LINK
https://github.com/VertigoRay/PSRedstone/wiki/Functions#invoke-objectmethod
#>

function Invoke-ObjectMethod {
    [CmdletBinding(DefaultParameterSetName='Positional')]
    Param (
        [Parameter(Mandatory=$true,Position=0)]
        [ValidateNotNull()]
        [object]$InputObject,
        [Parameter(Mandatory=$true,Position=1)]
        [ValidateNotNullorEmpty()]
        [string]$MethodName,
        [Parameter(Mandatory=$false,Position=2,ParameterSetName='Positional')]
        [object[]]$ArgumentList,
        [Parameter(Mandatory=$true,Position=2,ParameterSetName='Named')]
        [ValidateNotNull()]
        [hashtable]$Parameter
    )

    Begin { }
    Process {
        If ($PSCmdlet.ParameterSetName -eq 'Named') {
            ## Invoke method by using parameter names
            Write-Output -InputObject $InputObject.GetType().InvokeMember($MethodName, [Reflection.BindingFlags]::InvokeMethod, $null, $InputObject, ([object[]]($Parameter.Values)), $null, $null, ([string[]]($Parameter.Keys)))
        }
        Else {
            ## Invoke method without using parameter names
            Write-Output -InputObject $InputObject.GetType().InvokeMember($MethodName, [Reflection.BindingFlags]::InvokeMethod, $null, $InputObject, $ArgumentList, $null, $null, $null)
        }
    }
    End { }
}
<#
.SYNOPSIS
Run a scriptblock that contains Pester tests that can be used for MECM Application Detection.
.DESCRIPTION
 
```powershell
$ppv = 'VertigoRay Assert-IsElevated 1.2.3'
$sb = {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]
        $FunctionName
    )
 
    Describe $FunctionName {
        It 'Return Boolean' {
            {
                & $FunctionName | Should -BeOfType 'System.Boolean'
            } | Should -Not -Throw
        }
    }
}
$params = @{
    FunctionName = 'Assert-IsElevated'
}
Invoke-PesterDetect -PesterScriptBlock $sb -PesterScriptBlockParam $params -PublisherProductVersion $ppv
```
.PARAMETER PesterScriptBlock
Pass in a ScriptBlock that contains a fully functional Pester test.
Here's a simple example of creating the ScriptBlock:
 
```powershell
$sb = {
    Describe 'Assert-IsElevated' {
        It 'Return Boolean' {
            {
                Assert-IsElevated | Should -BeOfType 'System.Boolean'
            } | Should -Not -Throw
        }
    }
}
Invoke-PesterDetect -PesterScriptBlock $sb
```
.PARAMETER PesterScriptBlockParam
This allows you to pass parameters into your ScriptBlock.
Here's a simple example of creating the ScriptBlock with a parameter and passing a value into it.
This PowerShell code is functionally identical to the code in the `PesterScriptBlock` parameter.:
 
```powershell
$sb = {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]
        $FunctionName
    )
 
    Describe $FunctionName {
        It 'Return Boolean' {
            {
                & $FunctionName | Should -BeOfType 'System.Boolean'
            } | Should -Not -Throw
        }
    }
}
$params = @{
    FunctionName = 'Assert-IsElevated'
}
Invoke-PesterDetect -PesterScriptBlock $sb -PesterScriptBlockParam $params
```
.PARAMETER PublisherProductVersion
This a string containing the Publisher, Product, and Version separated by spaces.
 
```powershell
$PublisherProductVersion = "$($settings.Publisher) $($settings.Product) $($settings.Version)"
```
 
Really, you can provide whatever you want here, whatever you provide will be put on the end of a successful detection message.
For example, if you set this to "Peanut Brittle" because you think it's amusing, your successful detection message will be:
 
> Detection SUCCESSFUL: Peanut Brittle
.PARAMETER DevMode
This script allows additional output when you're in you development environment.
This is important to address because detections scripts have [very strict StdOut requirements](https://learn.microsoft.com/en-us/previous-versions/system-center/system-center-2012-R2/gg682159(v=technet.10)#to-use-a-custom-script-to-determine-the-presence-of-a-deployment-type).
 
```powershell
$devMode = if ($MyInvocation.MyCommand.Name -eq 'detect.ps1') { $true } else { $false }
```
 
This example assumes that in your development environment, you've named your detections script `detect.ps1`.
This is the InvocationName when we running the dev version of the script, like in Windows Sandbox.
When SCCM calls detection, the detection script is put in a file named as a guid.
    i.e. fae94777-2c0d-4dd0-94f0-407f7cd07858.ps1
.EXAMPLE
Invoke-PesterDetect -PesterScriptBlock $sb -PesterScriptBlockParam $params -PublisherProductVersion $ppv
 
This will run the PowerShell code block below returning ONLY the `Detection SUCCESSFUL` message if the detection was successful.
 
```text
Detection SUCCESSFUL: VertigoRay Assert-IsElevated 1.2.3
```
 
It will return nothing if the detection failed.
If you want to see where detection is failing, add the `DevMode` parameter.
 
**Note**: if your want to see what the variables are set to, take a look at the *Description*.
.EXAMPLE
Invoke-PesterDetect -PesterScriptBlock $sb -PesterScriptBlockParam $params -PublisherProductVersion $ppv -DevMode
 
This will the pass with verbose output.
 
```text
Pester v5.3.3
 
Starting discovery in 1 files.
Discovery found 1 tests in 25ms.
Running tests.
Describing Assert-IsElevated
  [+] Return Boolean 26ms (15ms|11ms)
Tests completed in 174ms
Tests Passed: 1, Failed: 0, Skipped: 0 NotRun: 0
Detection SUCCESSFUL: VertigoRay Assert-IsElevated 1.2.3
```
 
**Note**: if your want to see what the variables are set to, take a look at the *Description*.
.EXAMPLE
Invoke-PesterDetect -PesterScriptBlock $sb -PesterScriptBlockParam @{ FunctionName = 'This-DoesNotExist' } -PublisherProductVersion $ppv -DevMode
 
This will fail with verbose output.
This is useful in development, but you wouldn't want to send this to production.
The reason is described in the `DevMode` parameter section.
 
```text
Pester v5.4.0
 
Starting discovery in 1 files.
Discovery found 1 tests in 48ms.
Running tests.
Describing This-DoesNotExist
  [-] Return Boolean 250ms (241ms|9ms)
   Expected no exception to be thrown, but an exception "The term 'This-DoesNotExist' is not recognized as the name of a cmdlet, function, script file, or operable program. Check the spelling of the name, or if a path was included, verify that the path is correct and try again." was thrown from line:12 char:19
       + & $FunctionName | Should -BeOfType 'System.Boolean'
       + ~~~~~~~~~~~~~.
   at } | Should -Not -Throw, :13
   at <ScriptBlock>, <No file>:11
Tests completed in 593ms
Tests Passed: 0, Failed: 1, Skipped: 0 NotRun: 0
WARNING: [DEV MODE] Detection FAILED: VertigoRay Assert-IsElevated 1.2.3
```
 
**Note**: if your want to see what the variables are set to, take a look at the *Description*.
#>

function Invoke-PesterDetect {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [scriptblock]
        $PesterScriptBlock,

        [Parameter()]
        [hashtable]
        $PesterScriptBlockParam = @{},

        [Parameter(HelpMessage = '"$($settings.Publisher) $($settings.Product) $($settings.Version)"')]
        [string]
        $PublisherProductVersion = ':)',

        [Parameter()]
        [switch]
        $DevMode
    )

    $PesterPreference = [PesterConfiguration] @{
        Output = @{
            Verbosity = if ($DevMode) { 'Detailed' } else { 'None' }
        }
    }
    $container = New-PesterContainer -ScriptBlock $PesterScriptBlock -Data $PesterScriptBlockParam
    $testResults = Invoke-Pester -Container $container -PassThru

    if ($DevMode) {
        Write-Debug ('[Invoke-PesterDetect][DEV MODE] testResults: {0}' -f ($testResults | Out-String))
    }

    if ($testResults.Result -eq 'Passed') {
        Write-Output ('Detection SUCCESSFUL: {0}' -f $PublisherProductVersion)
    } elseif ($DevMode) {
        Write-Warning ('[Invoke-PesterDetect][DEV MODE] Detection FAILED: {0}' -f $PublisherProductVersion)
    }
}
<#
.NOTES
Ref: https://stackoverflow.com/a/35843420/17552750
.LINK
https://github.com/VertigoRay/PSRedstone/wiki/Functions#invoke-registrytakeownership
#>

function Invoke-RegistryTakeOwnership {
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory = $false)]
        [string]
        $RootKey,

        [Parameter(Mandatory = $true)]
        [string]
        $Key,

        [Parameter(Mandatory = $false)]
        [System.Security.Principal.SecurityIdentifier]
        $Sid,

        [Parameter(Mandatory = $false)]
        [bool]
        $Recurse = $true
    )

    Write-Information ('[Invoke-RegistryTakeOwnership] > {0}' -f ($MyInvocation.BoundParameters | ConvertTo-Json -Compress))
    Write-Debug ('[Invoke-RegistryTakeOwnership] Function Invocation: {0}' -f ($MyInvocation | Out-String))

    if (-not $RootKey -and ($Key -match '^(Microsoft\.PowerShell\.Core\\Registry\:\:|Registry\:\:)([^\\]+)\\(.*)')) {
        $RootKey = $Matches[2]
        $Key = $Matches[3]
    }

    switch -regex ($RootKey) {
        'HKCU|HKEY_CURRENT_USER'    { $RootKey = 'CurrentUser' }
        'HKLM|HKEY_LOCAL_MACHINE'   { $RootKey = 'LocalMachine' }
        'HKCR|HKEY_CLASSES_ROOT'    { $RootKey = 'ClassesRoot' }
        'HKCC|HKEY_CURRENT_CONFIG'  { $RootKey = 'CurrentConfig' }
        'HKU|HKEY_USERS'            { $RootKey = 'Users' }
    }

    # Escalate current process's privilege
    Invoke-ElevateCurrentProcess

    if (-not $Sid) {
        # Get Current User SID
        [System.Security.Principal.SecurityIdentifier] $Sid = (& whoami /USER | Select-Object -Last 1).Split(' ')[-1]
        Write-Verbose "[Invoke-RegistryTakeOwnership] Current User SID: $Sid"
    }
    Set-RegistryKeyPermissions $RootKey $Key $Sid $recurse
}
<#
.SYNOPSIS
Runs the given command.
.DESCRIPTION
This command sends a single command to `Start-Process` in a way that is standardized. For convenience, you can use the `Cmd` parameter, passing a single string that contains your executable and parameters; see examples.
 
The command will return a `[hashtable]` including the Process results, standard output, and standard error:
 
```
@{
    'Process' = $proc; # The result from Start-Process.
    'StdOut' = $stdout; # This is an array, as returned from `Get-Content`.
    'StdErr' = $stderr; # This is an array, as returned from `Get-Content`.
}
```
 
This function has been vetted for several years, but if you run into issues, try using `Start-Process`.
.PARAMETER Cmd
This is the command you wish to run, including arguments, as a single string.
.PARAMETER FilePath
Specifies the optional path and file name of the program that runs in the process. Enter the name of an executable file or of a document, such as a .txt or .doc file, that is associated with a program on the computer.
 
Passes Directly to `Start-Process`; see `Get-Help Start-Process`.
.PARAMETER ArgumentList
Specifies parameters or parameter values to use when this cmdlet starts the process.
 
Passes Directly to `Start-Process`; see `Get-Help Start-Process`.
.PARAMETER WorkingDirectory
Specifies the location of the executable file or document that runs in the process. The default is the current folder.
 
Passes Directly to `Start-Process`; see `Get-Help Start-Process`.
.PARAMETER PassThru
Returns a process object for each process that the cmdlet started. By default, this cmdlet does generate output.
 
Passes Directly to `Start-Process`; see `Get-Help Start-Process`.
.PARAMETER Wait
Indicates that this cmdlet waits for the specified process to complete before accepting more input. This parameter suppresses the command prompt or retains the window until the process finishes.
 
Passes Directly to `Start-Process`; see `Get-Help Start-Process`.
.PARAMETER WindowStyle
Specifies the state of the window that is used for the new process. The acceptable values for this parameter are: Normal, Hidden, Minimized, and Maximized.
 
Passes Directly to `Start-Process`; see `Get-Help Start-Process`.
.OUTPUTS
[hashtable]
.EXAMPLE
$result = Invoke-Run """${firefox_setup_exe}"" /INI=""${ini}"""
Use `Cmd` parameter
.EXAMPLE
$result = Invoke-Run -FilePath $firefox_setup_exe -ArgumentList @("/INI=""${ini}""")
Use `FilePath` and `ArgumentList` parameters
.EXAMPLE
$result.Process.ExitCode
Get the ExitCode
.LINK
https://github.com/VertigoRay/PSRedstone/wiki/Functions#invoke-run
#>

function Invoke-Run {
    [CmdletBinding()]
    [OutputType([hashtable])]
    param (
        [Parameter(Mandatory = $true, Position = 0, ParameterSetName = 'Cmd')]
        [string]
        $Cmd,

        [Parameter(Mandatory = $true, ParameterSetName = 'FilePath')]
        [string]
        $FilePath,

        [Parameter(Mandatory = $false, ParameterSetName = 'FilePath')]
        [string[]]
        $ArgumentList,

        [Parameter(Mandatory = $false)]
        [switch]
        $CaptureConsoleOut,

        [Parameter(Mandatory = $false)]
        [string]
        $WorkingDirectory,

        [Parameter(Mandatory = $false)]
        [boolean]
        $PassThru = $true,

        [Parameter(Mandatory = $false)]
        [boolean]
        $Wait = $true,

        [Parameter(Mandatory = $false)]
        [string]
        $WindowStyle = 'Hidden',

        [Parameter(Mandatory = $false)]
        [IO.FileInfo]
        $LogFile
    )

    Write-Information ('[Invoke-Run] > {0}' -f ($MyInvocation.BoundParameters | ConvertTo-Json -Compress)) -Tags 'Redstone','Invoke-Run'
    Write-Debug ('[Invoke-Run] Function Invocation: {0}' -f ($MyInvocation | Out-String))

    if ($PsCmdlet.ParameterSetName -ieq 'Cmd') {
        Write-Verbose ('[Invoke-Run] Executing: {0}' -f $cmd)
        if ($Cmd -match '^(?:"([^"]+)")$|^(?:"([^"]+)") (.+)$|^(?:([^\s]+))$|^(?:([^\s]+)) (.+)$') {
            # https://regex101.com/r/uU4vH1/1

            Write-Verbose "Cmd Match: $($Matches | Out-String)"

            if ($Matches[1]) {
                $FilePath = $Matches[1]
            } elseif ($Matches[2]) {
                $FilePath = $Matches[2]
                $ArgumentList = $Matches[3]
            } elseif ($Matches[4]) {
                $FilePath = $Matches[4]
            } elseif ($Matches[5]) {
                $FilePath = $Matches[5]
                $ArgumentList = $Matches[6]
            }
        } else {
            Throw [System.Management.Automation.ParameterBindingException] ('Cmd Match Error: {0}' -f $cmd)
        }
    }

    [hashtable] $startProcess = @{
        FilePath                  = $FilePath
        PassThru                  = $PassThru
        Wait                      = $Wait
        WindowStyle               = $WindowStyle
    }

    if ($ArgumentList) {
        $startProcess.Add('ArgumentList', $ArgumentList)
    }

    if ($WorkingDirectory) {
        $startProcess.Add('WorkingDirectory', $WorkingDirectory)
    }

    if ($CaptureConsoleOut.IsPresent) {
        [IO.FileInfo] $stdout = New-TemporaryFile
        [IO.FileInfo] $stderr = New-TemporaryFile

        while (-not $stdout.Exists -or -not $stderr.Exists) {
            # Sometimes this is too fast
            # Let's wait for the tmp file to show up.
            Start-Sleep -Milliseconds 100
            $stdout.Refresh()
            $stderr.Refresh()
        }

        $startProcess.Add('RedirectStandardOutput', $stdout.FullName)
        $startProcess.Add('RedirectStandardError', $stderr.FullName)

        $monScript = {
            Param ([string] $Std, [IO.FileInfo] $Tmp, [IO.FileInfo] $LogFile)
            Get-Content $Tmp.FullName -Wait | ForEach-Object {
                ('STD{0}: {1}' -f $Std.ToUpper(), $_) | Out-File -Encoding 'utf8' -LiteralPath $LogFile.FullName -Append -Force
            }
        }

        $stdoutMon = [powershell]::Create()
        [void] $stdoutMon.AddScript($monScript).AddParameters(@{
            Std = 'Out'
            Tmp = $stdout.FullName
            LogFile = $LogFile.FullName
        })
        [void] $stdoutMon.BeginInvoke()

        $stderrMon = [powershell]::Create()
        [void] $stderrMon.AddScript($monScript).AddParameters(@{
            Std = 'Err'
            Tmp = $stderr.FullName
            LogFile = $LogFile.FullName
        })
        [void] $stderrMon.BeginInvoke()
    }

    Write-Information ('[Invoke-Run] Start-Process: {0}' -f (ConvertTo-Json $startProcess)) -Tags 'Redstone','Invoke-Run'
    $proc = Start-Process @startProcess
    Write-Verbose ('[Invoke-Run] ExitCode:' -f $proc.ExitCode)

    $return = @{
        Process = $proc
    }

    if ($CaptureConsoleOut.IsPresent) {
        $return.Add('StdOut', ((Get-Content $stdout.FullName | Out-String).Trim().Split([System.Environment]::NewLine)))
        $return.Add('StdErr', ((Get-Content $stderr.FullName | Out-String).Trim().Split([System.Environment]::NewLine)))

        $stdoutMon.Dispose()
        $stderrMon.Dispose()

        $stdout.FullName | Remove-Item -ErrorAction 'SilentlyContinue' -Force
        $stderr.FullName | Remove-Item -ErrorAction 'SilentlyContinue' -Force
    }

    try {
        Write-Information ('[Invoke-Run] Return: {0}' -f (ConvertTo-Json $return -Depth 1 -ErrorAction 'Stop')) -Tags 'Redstone','Invoke-Run'
    } catch {
        Write-Information ('[Invoke-Run] Return: {0}' -f ($return | Out-String)) -Tags 'Redstone','Invoke-Run'
    }

    if ($return.Process.ExitCode -eq 0) {
        Write-Information '[Invoke-Run] ExitCode 0 usually means the execution was successful.'
    } elseif ($return.Process.ExitCode -eq 1641) {
        Write-Information '[Invoke-Run] ExitCode 1641 usually means the requested operation completed successfully; the system will be restarted so the changes can take effect.'
    } elseif ($return.Process.ExitCode -eq 3010) {
        Write-Information '[Invoke-Run] ExitCode 3010 usually means the requested operation is successful; changes will not be effective until the system is rebooted.'
    } else {
        Write-Warning ('[Invoke-Run] ExitCode {0} usually means the execution was *not* successful.' -f $return.Process.ExitCode)
    }

    return $return
}
<#
.SYNOPSIS
Touch - change file timestamps.
.DESCRIPTION
Update the access and modification times of the Path to the current time.
A path argument that does not exist is created empty, unless -c or is supplied.
.PARAMETER Path
Specifies a path to a file.
.PARAMETER AccessTimeOnly
Change only the access time.
.PARAMETER NoCreate
Do not create any files.
.PARAMETER Date
Use instead of current time.
.PARAMETER WriteTimeOnly
Change only the modification time.
.PARAMETER Reference
Use this file's times instead of current time.
.PARAMETER PassThru
Return the IO.FileInfo for the *touched* file.
.EXAMPLE
Invoke-Touch 'C:\Temp\foo.txt'
 
Update the access and modification times of `foo.txt` to the current time.
.EXAMPLE
Get-ChildItem $env:Temp -File | Invoke-Touch
 
Update the access and modification times of all files in the temp directory to the current time.
Not specifying the `-File` parameter may cause directories to be passed in; this will cause a `ParameterBindingException` to be thrown.
.EXAMPLE
Get-ChildItem $env:Temp -File | Invoke-Touch -PassThru | Invoke-MoreActions
 
Update the access and modification times of all files in the temp directory to the current time and pass the file info through on the pipeline.
Not specifying the `-File` parameter may cause directories to be passed in; this will cause a `ParameterBindingException` to be thrown.
.NOTES
Ref:
 
- [touch - Linux Manual Page](https://man7.org/linux/man-pages/man1/touch.1.html)
.LINK
#>

function Invoke-Touch {
    [CmdletBinding(DefaultParameterSetName = 'Now')]
    [OutputType([IO.FileInfo])]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = 'Now', Position = 0)]
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = 'NowAccessTimeOnly', Position = 0)]
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = 'NowWriteTimeOnly', Position = 0)]
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = 'DateAccessTimeOnly', Position = 0)]
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = 'DateWriteTimeOnly', Position = 0)]
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = 'ReferenceAccessTimeOnly', Position = 0)]
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = 'ReferenceWriteTimeOnly', Position = 0)]
        [ValidateNotNullOrEmpty()]
        [IO.FileInfo[]]
        $Path,

        [Parameter(ParameterSetName = 'NowAccessTimeOnly')]
        [Parameter(ParameterSetName = 'DateAccessTimeOnly')]
        [Parameter(ParameterSetName = 'ReferenceAccessTimeOnly')]
        [Alias('a')]
        [switch]
        $AccessTimeOnly,

        [Parameter(ParameterSetName = 'Now')]
        [Parameter(ParameterSetName = 'NowAccessTimeOnly')]
        [Parameter(ParameterSetName = 'NowWriteTimeOnly')]
        [Parameter(ParameterSetName = 'DateAccessTimeOnly')]
        [Parameter(ParameterSetName = 'DateWriteTimeOnly')]
        [Parameter(ParameterSetName = 'ReferenceAccessTimeOnly')]
        [Parameter(ParameterSetName = 'ReferenceWriteTimeOnly')]
        [Alias('c')]
        [switch]
        $NoCreate,

        [Parameter(HelpMessage = 'Use instead of current time.', ParameterSetName = 'DateAccessTimeOnly')]
        [Parameter(HelpMessage = 'Use instead of current time.', ParameterSetName = 'DateWriteTimeOnly')]
        [ValidateNotNullOrEmpty()]
        [Alias('d')]
        [datetime]
        $Date,

        [Parameter(ParameterSetName = 'NowWriteTimeOnly')]
        [Parameter(ParameterSetName = 'DateWriteTimeOnly')]
        [Parameter(ParameterSetName = 'ReferenceWriteTimeOnly')]
        [Alias('m')]
        [switch]
        $WriteTimeOnly,

        [Parameter(HelpMessage = 'Use this file''s times instead of current time.', ParameterSetName = 'ReferenceAccessTimeOnly')]
        [Parameter(HelpMessage = 'Use this file''s times instead of current time.', ParameterSetName = 'ReferenceWriteTimeOnly')]
        [Alias('r')]
        [IO.FileInfo]
        $Reference,

        [Parameter(ParameterSetName = 'Now')]
        [Parameter(ParameterSetName = 'NowAccessTimeOnly')]
        [Parameter(ParameterSetName = 'NowWriteTimeOnly')]
        [Parameter(ParameterSetName = 'DateAccessTimeOnly')]
        [Parameter(ParameterSetName = 'DateWriteTimeOnly')]
        [Parameter(ParameterSetName = 'ReferenceAccessTimeOnly')]
        [Parameter(ParameterSetName = 'ReferenceWriteTimeOnly')]
        [switch]
        $PassThru
    )

    Begin {
        if ($Date) {
            $lastAccessTime = $Date
            $lastWriteTime = $Date
        } elseif ($Reference) {
            if ($Reference.Exists) {
                $lastAccessTime = $Reference.LastAccessTime
                $lastWriteTime = $Reference.LastWriteTime
            } else {
                Write-Warning ('[Invoke-Touch] Reverting to current time, reference file does not exist: {0}' -f $Reference.FullName)
                $now = Get-Date
                $lastAccessTime = $now
                $lastWriteTime = $now
            }
        } else {
            $now = Get-Date
            $lastAccessTime = $now
            $lastWriteTime = $now
        }
    }

    Process {
        foreach ($p in $Path) {
            if (-not $p.Exists -and -not $NoCreate.IsPresent) {
                New-Item -Type 'File' -Path $p | Out-Null
            } elseif (-not $p.Exists -and $NoCreate.IsPresent) {
                Write-Verbose ('[Invoke-Touch] Path does not exist, but we cannot create it: {0}' -f $p.FullName)
            } else {
                if (-not $WriteTimeOnly.IsPresent) {
                    $p.LastAccessTime = $lastAccessTime
                }
                if (-not $AccessTimeOnly.IsPresent) {
                    $p.LastWriteTime = $lastWriteTime
                }
            }

            if ($PassThru.IsPresent) {
                $p.Refresh()
                Write-Output $p
            }
        }
    }

    End {}
}
<#
.SYNOPSIS
Mount a registry hive.
.DESCRIPTION
Mount a hive to the registry.
Return the destination in the registry where the hive was mounted.
 
If the *DefaultUser* parameter is provided, then all other parameters are discarded.
.OUTPUTS
[Microsoft.Win32.RegistryKey]
.PARAMETER FilePath
The path to the hive file.
 
If the *DefaultUser* parameter is provided, then this parameter is discarded.
.PARAMETER Hive
Registry location where to mount the hive.
If `{0}` is provided, a formatter will provide some randomness to the location.
 
If the *DefaultUser* parameter is provided, then this parameter is discarded.
.PARAMETER DefaultUser
Optionally, provide just this switch to mount the Default User hive.
.EXAMPLE
$hive = Mount-RegistryHive -DefaultUser
.EXAMPLE
$hive = Mount-RegistryHive -FilePath 'C:\Temp\NTUSER.DAT'
.EXAMPLE
$hive = Mount-RegistryHive -FilePath 'C:\Temp\NTUSER.DAT' -Hive 'HKEY_USERS\TEMP'
.EXAMPLE
$hive = Mount-RegistryHive -FilePath 'C:\Temp\NTUSER.DAT' -Hive 'HKEY_USERS\THING{0}'
#>

function Mount-RegistryHive ([IO.FileInfo] $FilePath, [string] $Hive = 'HKEY_USERS\TEMP{0}', [switch] $DefaultUser, [switch] $DoNotAutoDismount) {
    if ($DefaultUser.IsPresent) {
        $defaultp = (Get-ItemProperty 'registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList').Default

        [IO.FileInfo] $FilePath = [IO.Path]::Combine($defaultp, 'NTUSER.DAT')
        [string] $Hive = 'HKEY_USERS\DEFAULT'
        while (Test-Path ('Registry::{0}' -f $Hive)) {
            [string] $Hive = 'HKEY_USERS\DEFAULT{0}' -f (New-Guid).Guid.Split('-')[0]
        }
    }

    if (-not $FilePath.Exists) {
        Throw [System.IO.FileNotFoundException] ('Provided FilePath not found: {0}' -f $FilePath.FullName)
    }

    if ($Hive -like '*{0}*') {
        [string] $hiveF = $Hive -f (New-Guid).Guid.Split('-')[0]
        while (Test-Path ('Registry::{0}' -f  $hiveF)) {
            [string] $hiveF = $Hive -f (New-Guid).Guid.Split('-')[0]
        }
        $Hive = $hiveF
    }

    if (Test-Path $Hive) {
        Throw ('Hive location already in use: {0}' -f $Hive)
    }

    $regLoad = @{
        FilePath = (Get-Command 'reg.exe').Source
        ArgumentList = @(
            'LOAD'
            $Hive
            $FilePath.FullName
        )
    }
    $result = Invoke-Run $regLoad

    if (-not $DoNotAutoDismount.IsPresent) {
        Register-EngineEvent 'PowerShell.Exiting' -SupportEvent -Action {
            Dismount-RegistryHive -Hive $Hive
        }
    }

    if ($result.Process.ExitCode) {
        # Non-Zero Exit Code
        Throw ($result.StdErr | Out-String)
    } else {
        return (Get-Item ('Registry::{0}' -f $defaultHive))
    }
}
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Mount a WIM.
.DESCRIPTION
Mount a WIM to the provided mount path or one will be generated.
Automatically dismount the WIM when PowerShell exists, unless explicitly told to not auto-dismount.
.EXAMPLE
$mountPath = Mount-Wim -ImagePath thing.wim
 
This will mount to a unique folder in %TEMP%, returning the mounted path.
.EXAMPLE
Mount-RedstoneWim -ImagePath thing.wim -MountPath [IO.Path]::Combine($PSScriptRoot, $wim.BaseName)
.LINK
https://github.com/VertigoRay/PSRedstone/wiki/Functions#mount-wim
#>

function Mount-Wim {
    [CmdletBinding()]
    [OutputType([IO.DirectoryInfo])]
    param (
        [Parameter(Mandatory = $true, Position = 0, HelpMessage = 'Path to the WIM file.')]
        [Alias('PSPath')]
        [ValidateNotNullOrEmpty()]
        [IO.FileInfo]
        $ImagePath,

        [Parameter(Mandatory = $false, Position = 1, HelpMessage = 'Path the WIM will be mounted.')]
        [ValidateNotNullOrEmpty()]
        [IO.DirectoryInfo]
        $MountPath = [IO.Path]::Combine($env:TEMP, 'RedstoneMount', (New-Guid).Guid),

        [Parameter(Mandatory = $false, HelpMessage = 'Image index to mount.')]
        [int]
        $ImageIndex = 1,

        [Parameter(Mandatory = $false, HelpMessage = 'Do not auto-dismount when PowerShell exits.')]
        [switch]
        $DoNotAutoDismount,

        [Parameter(Mandatory = $false, HelpMessage = 'Full path for the DISM log with {0} formatter to inject "DISM".')]
        [IO.FileInfo]
        $LogFileF
    )

    begin {
        Write-Verbose "[Mount-Wim] > $($MyInvocation.BoundParameters | ConvertTo-Json -Compress)"
        Write-Debug "[Mount-Wim] Function Invocation: $($MyInvocation | Out-String)"

        if (-not $DoNotAutoDismount.IsPresent) {
            Register-EngineEvent 'PowerShell.Exiting' -SupportEvent -Action {
                Dismount-Wim -MountPath $MountPath
            }
        }
    }

    process {
        # $MyInvocation
        # $MountPath.FullName
        $MountPath.FullName | Invoke-ForceEmptyDirectory
        $MountPath.Refresh()

        $windowsImage = @{
            ImagePath = $ImagePath.FullName
            Index = $ImageIndex
            Path = $MountPath.FullName
        }

        if ($LogFileF) {
            $windowsImage.Add('LogPath', ($LogFileF -f 'DISM'))
        }

        Write-Verbose "[Mount-Wim] Mount-WindowImage: $($windowsImage | ConvertTo-Json)"
        Mount-WindowsImage @windowsImage
        $MountPath.Refresh()

        return $MountPath
    }

    end {}
}
<#
.SYNOPSIS
Create a RedStone Class.
.DESCRIPTION
Create a Redstone Class with an easy to use function.
.PARAMETER SettingsJson
Path to the settings.json file.
.PARAMETER Publisher
Name of the publisher, like "Mozilla".
.PARAMETER Product
Name of the product, like "Firefox ESR".
.PARAMETER Version
Version of the product, like "108.0.1".
This was deliberatly not cast as a [version] to allow handling of non-semantic versioning.
.PARAMETER Action
Action that is being taken.
This is purely cosmetic and directly affects the log name. For Example:
    - Using the examples from the Publisher, Product, and Version parameters.
    - Set action to 'install'
 
The log file name will be: Mozilla Firefox ESR 108.0.1 Install.log
 
If you don't specify an action, the action will be taken from the name of the script your calling this function from.
.OUTPUTS
`System.Array` with two Values:
    1. Redstone. The Redstone class
    2. PSObject. The results of parsing the provided settings.json file. Null if parameters supplied.
.NOTES
Allows access to the Redstone class without having to use `Using Module Redstone`.
    - Ref: https://stephanevg.github.io/powershell/class/module/DATA-How-To-Write-powershell-Modules-with-classes/
.LINK
https://github.com/VertigoRay/PSRedstone/wiki/Functions#new-redstone
#>

function New-Redstone {
    [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName='NoParams')]
    [OutputType([System.Object[]])]
    param (
        [Parameter(
            Mandatory = $true,
            Position = 1,
            ParameterSetName = 'SettingsJson',
            HelpMessage = 'Path to the settings.json file.'
        )]
        [IO.FileInfo]
        $SettingsJson,

        [Parameter(
            Mandatory = $true,
            Position = 1,
            ParameterSetName = 'Settings',
            HelpMessage = 'Pre-existing settings variable.'
        )]
        [PSObject]
        $Settings,

        [Parameter(
            Mandatory = $true,
            Position = 1,
            ParameterSetName = 'ManuallyDefined',
            HelpMessage = 'Name of the publisher, like "Mozilla".'
        )]
        [string]
        $Publisher,

        [Parameter(
            Mandatory = $true,
            Position = 2,
            ParameterSetName = 'ManuallyDefined',
            HelpMessage = 'Name of the product, like "Firefox ESR".'
        )]
        [string]
        $Product,

        [Parameter(
            Mandatory = $true,
            Position = 3,
            ParameterSetName = 'ManuallyDefined',
            HelpMessage = 'Version of the product, like "108.0.1".'
        )]
        [string]
        $Version,

        [Parameter(
            Mandatory = $true,
            Position = 4,
            ParameterSetName = 'ManuallyDefined',
            HelpMessage = 'Action that is being taken.'
        )]
        [string]
        $Action
    )

    switch ($PSCmdlet.ParameterSetName) {
        'SettingsJson' {
            $redstone = [Redstone]::new($SettingsJson)
            return @(
                $redstone
                $redstone.Settings.JSON.Data
            )
        }
        'Settings' {
            $redstone = [Redstone]::new($Settings)
            return @(
                $redstone
                $redstone.Settings.JSON.Data
            )
        }
        'ManuallyDefined' {
            $redstone = [Redstone]::new($Publisher, $Product, $Version, $Action)
            return @(
                $redstone
                $redstone.Settings.JSON.Data
            )
        }
        default {
            # NoParams
            $redstone = [Redstone]::new()
            return @(
                $redstone
                $redstone.Settings.JSON.Data
            )
        }
    }
}

New-Alias -Name 'New-' -Value 'New-Redstone'
<#
.SYNOPSIS
Create Scheduled Task that runs at logon for any user that logs on.
.DESCRIPTION
Create Scheduled Task that runs at logon for any user that logs on.
This uses the Schedule Service COM Obect because the `ScheduledTasks` module doesn't allow you to set "all users".
 
For other, less specific sceduled tasks needs, just use the `ScheduledTasks` module.
There's no reason to replace the work done on that module; this just makes this one thing a little easier.
.PARAMETER TaskName
The name of the task. If this value is NULL, the task will be registered in the root task folder and the task name will be a GUID value created by the Task Scheduler service.
 
A task name cannot begin or end with a space character. The '.' character cannot be used to specify the current task folder and the '..' characters cannot be used to specify the parent task folder in the path.
.PARAMETER Description
Sets the description of the task.
 
- [Description](https://learn.microsoft.com/en-us/windows/win32/api/taskschd/nf-taskschd-iregistrationinfo-put_description)
.PARAMETER Path
Sets the path to an executable file.
 
- [Path](https://learn.microsoft.com/en-us/windows/win32/api/taskschd/nf-taskschd-iexecaction-get_path)
.PARAMETER Arguments
Sets the arguments associated with the command-line operation.
 
- [Arguments](https://learn.microsoft.com/en-us/windows/win32/api/taskschd/nf-taskschd-iexecaction-put_arguments)
.PARAMETER WorkingDirectory
Sets the directory that contains either the executable file or the files that are used by the executable file.
 
- [WorkingDirectory](https://learn.microsoft.com/en-us/windows/win32/api/taskschd/nf-taskschd-iexecaction-put_workingdirectory)
.NOTES
- [Triggers Create](https://learn.microsoft.com/en-us/windows/win32/taskschd/triggercollection-create#parameters):
  - `TASK_TRIGGER_LOGON` (9): Triggers the task when a specific user logs on.
- [Actions Create](https://learn.microsoft.com/en-us/windows/win32/taskschd/actioncollection-create#parameters):
  - `TASK_ACTION_EXEC` (0): The action performs a command-line operation. For example, the action could run a script, launch an executable, or, if the name of a document is provided, find its associated application and launch the application with the document.
    - [ExecAction](https://learn.microsoft.com/en-us/windows/win32/taskschd/execaction):
      - [Path](https://learn.microsoft.com/en-us/windows/win32/api/taskschd/nf-taskschd-iexecaction-get_path): Sets the path to an executable file.
      - [Arguments](https://learn.microsoft.com/en-us/windows/win32/api/taskschd/nf-taskschd-iexecaction-put_arguments): Sets the arguments associated with the command-line operation.
      - [WorkingDirectory](https://learn.microsoft.com/en-us/windows/win32/api/taskschd/nf-taskschd-iexecaction-put_workingdirectory): Sets the directory that contains either the executable file or the files that are used by the executable file.
- [RegisterTaskDefinition](https://learn.microsoft.com/en-us/windows/win32/api/taskschd/nf-taskschd-itaskfolder-registertaskdefinition): `TASK_LOGON_INTERACTIVE_TOKEN_OR_PASSWORD` (6)
  - [Path](https://learn.microsoft.com/en-us/windows/win32/api/taskschd/nf-taskschd-itaskfolder-registertaskdefinition#parameters): *See TaskName parameter description.*
  - [Definition](https://learn.microsoft.com/en-us/windows/win32/api/taskschd/nf-taskschd-itaskfolder-registertaskdefinition#parameters): The definition of the registered task.
  - [Flags](https://learn.microsoft.com/en-us/windows/win32/api/taskschd/ne-taskschd-task_creation#constants): 6
    - `TASK_CREATE` (*0x2*): The Task Scheduler service registers the task as a new task.
    - `TASK_UPDATE` (*0x4*): The Task Scheduler service registers the task as an updated version of an existing task. When a task with a registration trigger is updated, the task will execute after the update occurs.
  - [UserId](https://learn.microsoft.com/en-us/windows/win32/api/taskschd/nf-taskschd-itaskfolder-registertaskdefinition#parameters): The user credentials used to register the task. If present, these credentials take priority over the credentials specified in the task definition object pointed to by the Definition parameter.
  - [LogonType](https://learn.microsoft.com/en-us/windows/win32/api/taskschd/nf-taskschd-itaskfolder-registertaskdefinition#parameters): Defines what logon technique is used to run the registered task.
    - `TASK_LOGON_GROUP` (4): Group activation. The groupId field specifies the group.
  #>

function New-ScheduledTaskTriggerLogonRunAsUser {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([void])]
    param(
        [Parameter(Mandatory = $false)]
        [string]
        $TaskName,

        [Parameter(Mandatory = $false)]
        [string]
        $Description,

        [Parameter(Mandatory = $true)]
        [IO.FileInfo]
        $Path,

        [Parameter(Mandatory = $false)]
        [string]
        $Arguments,

        [Parameter(Mandatory = $true)]
        [IO.DirectoryInfo]
        $WorkingDirectory
    )

    $shedService = New-Object -ComObject 'Schedule.Service'
    $shedService.Connect()

    $task = $shedService.NewTask(0)
    if ($Description) {
        $task.RegistrationInfo.Description = $Description
    }
    $task.Settings.Enabled = $true
    $task.Settings.AllowDemandStart = $true

    $trigger = $task.Triggers.Create(9)
    $trigger.Enabled = $true

    $action = $task.Actions.Create(0)
    $action.Path = $Path.FullName
    if ($Arguments) {
        $action.Arguments = $Arguments
    }
    if ($WorkingDirectory) {
        $action.WorkingDirectory = $WorkingDirectory.FullName
    }

    $taskFolder = $shedService.GetFolder('\')
    $taskFolder.RegisterTaskDefinition($TaskName, $task , 6, 'Users', $null, 4)
}
#Requires -RunAsAdministrator
<#
.EXAMPLE
New-Wim -ImagePath 'PSRedstone.wim' -CapturePath 'PSRedstone' -Name 'PSRedstone'
.LINK
https://github.com/VertigoRay/PSRedstone/wiki/Functions#new-wim
#>

function New-Wim {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([void])]
    param (
        [Parameter(Mandatory = $true)]
        [IO.FileInfo]
        $ImagePath,

        [Parameter(Mandatory = $true)]
        [IO.DirectoryInfo]
        $CapturePath,

        [Parameter(Mandatory = $true)]
        [String]
        $Name,

        [Parameter(Mandatory = $false)]
        [IO.FileInfo]
        $LogFileF
    )

    begin {
        Write-Verbose "[New-Wim] > $($MyInvocation.BoundParameters | ConvertTo-Json -Compress)"
        Write-Debug "[New-Wim] Function Invocation: $($MyInvocation | Out-String)"
    }

    process {
        if (-not $ImagePath.Directory.Exists) {
            New-Item -ItemType 'Directory' -Path $ImagePath.Directory.FullName -Force | Out-Null
            $ImagePath.Refresh()
        }

        $windowsImage = @{
            ImagePath = $ImagePath.FullName
            CapturePath = $CapturePath.FullName
            Name = $Name
        }

        if ($LogFileF) {
            $windowsImage.Add('LogPath', ($LogFileF -f 'DISM'))
        }

        if ($WhatIf.IsPresent) {
            Write-Information ('What if: Performing the operation "New-WindowsImage" with parameters: {0}' -f ($windowsImage | ConvertTo-Json)) -InformationAction Continue
        } else {
            New-WindowsImage @windowsImage
        }
    }

    end {}
}
<#
.SYNOPSIS
Run `Set-ItemProperty` on a mounted registry hive.
.DESCRIPTION
Run `Set-ItemProperty` on a mounted registry hive.
This process is non-trivial as it requires us to close handles after creating keys and do garbage cleanup when we're done.
Doing these extra steps allows unloading/dismounting of the hive.
Return the resulting ItemProperty.
.OUTPUTS
[PSCustomObject]
.PARAMETER Hive
The key object returned from `Mount-RegistryHive`.
.PARAMETER Key
Key path, within the hive, to edit.
This will be concatinated with the hive.
A leading `\` will be stripped.
 
If the `Hive` parameter is not provided, this should be the full Key.
Do NOT use PSDrive references like `HKCU:` or `HKLM:`.
Instead use normal registry paths, like `HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft`.
.PARAMETER Value
Specifies the name of the property.
.PARAMETER Type
This is a dynamic parameter made available by the Registry provider.
The Registry provider and this parameter are only available on Windows.
 
A registry type (aka [RegistryValueKind](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/set-itemproperty#-type)) as expected when using `Set-ItemProperty`.
.PARAMETER Data
Specifies the value of the property.
.EXAMPLE
$result = Set-RegistryHiveItemProperty -Hive $hive -Key 'Policies\Microsoft\Windows\Personalization' -Value 'NoChangingSoundScheme' -Type 'String' -Data 1
 
Where `$hive` was created with:
 
```powershell
$hive = Mount-RegistryHive -DefaultUser
```
#>

function Set-RegistryHiveItemProperty ([Microsoft.Win32.RegistryKey] $Hive, [string] $Key, [string] $Value, [string] $Type, $Data) {
    if ($Key.StartsWith('\')) {
        $Key = $Key.TrimStart([IO.Path]::DirectorySeparatorChar)
    }

    $path = if ($Hive) {
        Write-Output ('Registry::{0}' -f [IO.Path]::Combine($Hive, $Key))
    } else {
        Write-Output ('Registry::{0}' -f $Key)
    }

    if (-not (Test-Path $path)) {
        # New-Item will delete a registry path, if it exists, and create it empty.
        $item = New-Item -Path $path -Force
        $item.Handle.Close()
    }

    if ((Get-ItemProperty -Path $path -Name $Value -ErrorAction 'Ignore').$Value) {
        Set-ItemProperty -Path $path -Name $Value -PropertyType $Type -Value $Data
    } else {
        New-ItemProperty -Path $path -Name $Value -PropertyType $Type -Value $Data
    }

    #Garbage Collection
    [gc]::Collect()

    return (Get-ItemProperty -Path $path -Name $Value)
}


<#
.NOTES
Ref: https://stackoverflow.com/a/35843420/17552750
.LINK
https://github.com/VertigoRay/PSRedstone/wiki/Functions#set-regsitrykeypermissions
#>

function Set-RegsitryKeyPermissions {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([void])]
    param(
        [string]
        $RootKey,

        [string]
        $Key,

        [System.Security.Principal.SecurityIdentifier]
        $Sid,

        [bool]
        $Recurse,

        [int]
        $RecurseLevel = 0
    )

    Write-Information ('[Invoke-Download] > {0}' -f ($MyInvocation.BoundParameters | ConvertTo-Json -Compress))
    Write-Debug ('[Invoke-Download] Function Invocation: {0}' -f ($MyInvocation | Out-String))

    # Get ownerships of key - it works only for current key
    $regKey = [Microsoft.Win32.Registry]::$RootKey.OpenSubKey($Key, 'ReadWriteSubTree', 'TakeOwnership')
    $acl = New-Object System.Security.AccessControl.RegistrySecurity
    $acl.SetOwner($Sid)
    $regKey.SetAccessControl($acl)

    # Enable inheritance of permissions (not ownership) for current key from parent
    $acl.SetAccessRuleProtection($false, $false)
    $regKey.SetAccessControl($acl)

    # Only for top-level key, change permissions for current key and propagate it for subkeys
    # to enable propagations for subkeys, it needs to execute Steps 2-3 for each subkey (Step 5)
    if ($RecurseLevel -eq 0) {
        $regKey = $regKey.OpenSubKey('', 'ReadWriteSubTree', 'ChangePermissions')
        $rule = New-Object System.Security.AccessControl.RegistryAccessRule($Sid, 'FullControl', 'ContainerInherit', 'None', 'Allow')
        $acl.ResetAccessRule($rule)
        $regKey.SetAccessControl($acl)
    }

    # Recursively repeat steps 2-5 for subkeys
    if ($Recurse) {
        foreach($subKey in $regKey.OpenSubKey('').GetSubKeyNames()) {
            Set-RegsitryKeyPermissions $RootKey ($Key+'\'+$subKey) $Sid $Recurse ($RecurseLevel+1)
        }
    }
}
<#
 
.EXAMPLE
Show-ToastNotification @toastNotification
 
This displays a toast notification.
 
```powershell
if ($ScheduleJob) { $jobTimespan = New-TimeSpan -Start ([datetime]::Now) -End $ScheduleJob }
if ($ScheduleReboot) { $rebootTimespan = New-TimeSpan -Start ([datetime]::Now) -End $ScheduleReboot }
 
$toastNotification = @{
    ToastNotifier = 'Tech Solutions: Endpoint Solutions Engineering'
    ToastTitle = 'Windows Update'
    ToastText = 'This computer is overdue for {0} Windows Update{1} and the time threshold has exceeded. {2} being forced on your system {3}.{4}' -f @(
        $updateCount
        $(if ($updateCount -gt 1) { 's' } else { $null })
        $(if ($updateCount -eq 1) { 'Updates are' } else { 'The update is' })
        $(if ($ScheduleJob) { 'on {0}' -f $ScheduleJob } else { 'now' })
        $(if ($ScheduleReboot) { ' Reboot will occur on {0}.' -f $ScheduleReboot } else { $null })
    )
}
 
Show-ToastNotification @toastNotification
```
#>

function Show-ToastNotification {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]
        $ToastNotifier,

        [Parameter(Mandatory = $true)]
        [string]
        $ToastTitle,

        [Parameter(Mandatory = $true)]
        [string]
        $ToastText
    )

    [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
    $Template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)

    $RawXml = [xml] $Template.GetXml()
    ($RawXml.toast.visual.binding.text | Where-Object { $_.id -eq '1' }).AppendChild($RawXml.CreateTextNode($ToastTitle)) | Out-Null
    ($RawXml.toast.visual.binding.text | Where-Object { $_.id -eq '2' }).AppendChild($RawXml.CreateTextNode($ToastText)) | Out-Null

    $SerializedXml = New-Object 'Windows.Data.Xml.Dom.XmlDocument'
    $SerializedXml.LoadXml($RawXml.OuterXml)

    $Toast = [Windows.UI.Notifications.ToastNotification]::new($SerializedXml)
    $Toast.Tag = $ToastNotifier.Split(':')[0]
    $Toast.Group = $ToastNotifier.Split(':')[0]
    $Toast.ExpirationTime = [DateTimeOffset]::Now.AddMinutes(1)

    $Notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($ToastNotifier)
    $Notifier.Show($Toast);
}
<#
.SYNOPSIS
Kill a process and all of its child processes.
.DESCRIPTION
Kill a process and all of its child processes.
Returns any processes that failed to stop; returning nothing if everything stopped sucessfully.
.OUTPUTS
System.Diagnostics.Process
.EXAMPLE
$stillRunning = Get-Process 'overwolf' | Stop-ProcessTree -Force
.EXAMPLE
$stillRunning = Stop-ProcessTree -Id 12345 -Force
.EXAMPLE
$stillRunning = Stop-ProcessTree -Name 'overwolf' -Force
#>

function Stop-ProcessTree {
    [CmdletBinding()]
    param (
        [Parameter(Position = 0, Mandatory = $true, ParameterSetName = 'Process', HelpMessage = 'Provide a Process Object', ValueFromPipeline = $true)]
        [PSObject]
        $InputObject,

        [Parameter(Position = 0, Mandatory = $true, ParameterSetName = 'ProcessId', HelpMessage = 'Provide a Process ID')]
        [int]
        $Id,

        [Parameter(Position = 0, Mandatory = $true, ParameterSetName = 'ProcessName', HelpMessage = 'Provide a Process Name')]
        [string]
        $Name,

        [Parameter(ParameterSetName = 'Process', HelpMessage = 'Force the Stop-Process')]
        [Parameter(ParameterSetName = 'ProcessId', HelpMessage = 'Force the Stop-Process')]
        [Parameter(ParameterSetName = 'ProcessName', HelpMessage = 'Force the Stop-Process')]
        [switch]
        $Force
    )


    Begin {}

    Process {
        if ($MyInvocation.BoundParameters.Id) {
            $InputObject = Get-Process -Id $MyInvocation.BoundParameters.Id
        }
        if ($MyInvocation.BoundParameters.Name) {
            $InputObject = Get-Process -Name $MyInvocation.BoundParameters.Name
        }
        Write-Verbose ('InputObject: {0}' -f $InputObject)

        foreach ($process in $InputObject) {
            Write-Verbose ('process: {0}' -f $process)
            Get-CimInstance 'Win32_Process' | Where-Object { $_.ParentProcessId -eq $process.Id } | ForEach-Object { Stop-ProcessTree -Id $_.ProcessId }
            try {
                if ($Force.IsPresent) {
                    Write-Verbose ('[Stop-ProcessTree] Stop-Process -Force: ({0}) {1} ({2}, {3}, {4}) {5}' -f $process.Id, $process.Name, $process.Company, $process.Product, $process.Description, $process.Path)
                    $process | Stop-Process -Force -ErrorAction 'Stop'
                } else {
                    Write-Verbose ('[Stop-ProcessTree] Stop-Process: ({0}) {1} ({2}, {3}, {4}) {5}' -f $process.Id, $process.Name, $process.Company, $process.Product, $process.Description, $process.Path)
                    $process | Stop-Process -ErrorAction 'Stop'
                }
            } catch {
                Write-Warning $_.Exception.Message
                Write-Output $process
            }
        }
    }

    End {}
}
$psd1 = Import-PowerShellDataFile ([IO.Path]::Combine($PSScriptRoot, 'PSRedstone.psd1'))

# Check if the current context is elevated (Are we running as an administrator?)
if ((New-Object System.Security.Principal.WindowsPrincipal([System.Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)) {
    # Anytime this Module is used, the version and timestamp will be stored in the registry.
    # This will allow more intelligent purging of unused versions.
    $versionUsed = @{
        LiteralPath = 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\com.github.VertigoRay\PSRedstone\VersionsUsed'
        Name = $psd1.ModuleVersion
        Value = (Get-Date -Format 'O')
        Force = $true
    }
    Write-Debug ('Version Used: {0}' -f ($versionUsed | ConvertTo-Json))
    if (-not (Test-Path $versionUsed.LiteralPath)) {
        New-Item -ItemType 'Directory' -Path $versionUsed.LiteralPath -Force
    }
    Set-ItemProperty @versionUsed
}

# Load Module Members
$moduleMember = @{
    Cmdlet = $psd1.CmdletsToExport
    Function = $psd1.FunctionsToExport
    Alias = $psd1.AliasesToExport
}
if ($psd1.VariablesToExport) {
    $moduleMember.Set_Item('Variable', $psd1.VariablesToExport)
}
Export-ModuleMember @moduleMember