TM-WindowsUtility.psm1

using namespace System.Collections.Generic
using namespace System.IO

# Write verbose messages on import
if ((Get-PSCallStack)[1].Arguments -imatch 'Verbose=True') { $PSDefaultParameterValues['*:Verbose'] = $true }

# Ensure we're using the primary write commands from the Microsoft.PowerShell.Utility module.
Set-Alias -Name 'Write-Progress'    -Value 'Microsoft.PowerShell.Utility\Write-Progress'    -Scope Script
Set-Alias -Name 'Write-Debug'       -Value 'Microsoft.PowerShell.Utility\Write-Debug'       -Scope Script
Set-Alias -Name 'Write-Verbose'     -Value 'Microsoft.PowerShell.Utility\Write-Verbose'     -Scope Script
Set-Alias -Name 'Write-Host'        -Value 'Microsoft.PowerShell.Utility\Write-Host'        -Scope Script
Set-Alias -Name 'Write-Information' -Value 'Microsoft.PowerShell.Utility\Write-Information' -Scope Script
Set-Alias -Name 'Write-Warning'     -Value 'Microsoft.PowerShell.Utility\Write-Warning'     -Scope Script
Set-Alias -Name 'Write-Error'       -Value 'Microsoft.PowerShell.Utility\Write-Error'       -Scope Script

if ($env:OS -ne 'Windows_NT') {
    Write-Verbose 'TM-WindowsUtility should only be run on windows.'
    # Do not export any module commands.
    Export-ModuleMember
    return
}
# https://github.com/PowerShell/PowerShell/issues/17730#issuecomment-1190678484
$ExportedMembers = [List[string]]::new()
$ExportedAliases = [List[string]]::new()


Set-Alias -Name 'which' -Value 'where.exe'
$ExportedAliases.Add('which')


function Get-COMDetails {
<#
    .SYNOPSIS
    Gets extended COM file properties.
 
    .DESCRIPTION
    This function retrieves extended file properties using COM (Component Object Model) and returns
    the Key/Value pairs in a PSCustomObject.
 
    .PARAMETER File
    The FileInfo object that has extended properties to analyze.
#>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Alias('Path', 'PSPath')]
        [Validation.ValidatePathExists('File')]
        [FileInfo]$File
    )

    begin { $Output = [PSCustomObject]@{ FullName = $File.FullName } }

    process {
        $objShell = New-Object -ComObject Shell.Application
        $objFolder = $objShell.Namespace($File.DirectoryName)
        $objFile = $objFolder.ParseName($File.Name)

        if ($objFile) {
            for ($ColumnNumber = 0; $ColumnNumber -le 1000; $ColumnNumber++) {
                $ColumnName = $null
                $ColumnValue = $null
                $ColumnName = $objFolder.GetDetailsOf($null, $ColumnNumber)
                $ColumnValue = $objFolder.GetDetailsOf($ObjFile, $ColumnNumber)
                if ($ColumnValue) {
                    # Ensure we always have a property name.
                    if (-Not $ColumnName) { $ColumnName = "ColumnNumber$ColumnNumber" }
                    $Output.PSObject.Properties.Add( [PSNoteProperty]::New($ColumnName, $ColumnValue) )
                }
            }
        }
    }

    end { return $Output }
}
$ExportedMembers.Add('Get-COMDetails')


function Get-DNSNamesBySubnet {
<#
    .SYNOPSIS
    Attempts to retrieve DNS names for devices active within in a specified subnet.
 
    .DESCRIPTION
    This function queries DNS names for devices in a specified subnet and returns a list of objects
    containing their IPAddress, DeviceName, and ping statuses.
 
    .PARAMETER Subnet
    The subnet to query for DNS names. Should be a valid IP address.
 
    .PARAMETER CIDR
    The CIDR value for the subnet (default is 24). Acceptable values are between 24 and 30.
 
    .PARAMETER PassThru
    A switch that indicates that the output should be returned as a list of objects instead of a formatted table.
 
    .PARAMETER IncludeAllResults
    A switch that includes all results, including for IPs with empty DNS names.
#>

    [CmdletBinding()]
    [OutputType([void], [PSCustomObject])]
    param (
        [Parameter(
            Position = 0,
            Mandatory,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName
        )]
        [Validation.ValidateIPv4Format()]
        [string]$IPV4Address,

        [Parameter(
            Position = 1,
            Mandatory = $false,
            ValueFromPipeline
        )]
        [ValidateRange(24, 30)]
        [int]$CIDR = 24,

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

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

    begin {
        if ((Test-ApplicationExistsInPath -ApplicationName 'nslookup') -eq $false) {
            throw 'Get-DNSNamesBySubnet requires nslookup to run but it is not available in the PATH.'
        }

        $FirstThreeOctets = $IPV4Address -replace "$($IPV4Address.Split('.')[-1])$", ''
        [int]$FinalOctet = $IPV4Address -replace "^$FirstThreeOctets", ''
        $Output = [List[object]]::New()
        $CIDRAdressCount = switch ($CIDR) {
            24 { 254 }
            25 { 126 }
            26 { 62 }
            27 { 30 }
            28 { 14 }
            29 { 7 }
            30 { 2 }
        }

        $End = $FinalOctet + $CIDRAdressCount
        if ($End -gt 255) { $End = 255 }
    }

    process {
        $JobOutput = $FinalOctet..$End | ForEach-Object -Parallel {
            try {
                $Address = $stdout = $DNSName = $null
                $Address = "$using:FirstThreeOctets$($using:FinalOctet + $_)"
                $null = . { nslookup $Address | Set-Variable stdout } 2>&1 | ForEach-Object ToString
                try {
                    $selectStringSplat = @{
                        InputObject = $stdout
                        Pattern     = 'Name:'
                        SimpleMatch = $true
                        ErrorAction = 'Stop'
                    }
                    $DNSName = (Select-String @selectStringSplat)[0].line.split(':')[-1].trim()
                } catch { }

                if ($using:IncludeAllResults -or (-Not [string]::IsNullOrWhiteSpace($DNSName))) {
                    return [PSCustomObject]@{
                        FinalOctet = ($using:FinalOctet + $_)
                        IPAddress  = $Address
                        DeviceName = $DNSName
                        Connection = (Test-Connection $Address)
                    }
                }
            } catch { }
        }

        $JobOutput = $JobOutput | Sort-Object -Property FinalOctet
        foreach ($Job in $JobOutput) {
            $Output.Add(
                [PSCustomObject]@{
                    IPAddress  = $Job.IPAddress
                    DeviceName = $Job.DeviceName
                    Ping01     = $Job.Connection[0].status
                    Ping02     = $Job.Connection[1].status
                    Ping03     = $Job.Connection[2].status
                    Ping04     = $Job.Connection[3].status
                }
            )
        }
    }

    end {
        if ($Output.Length -eq 0) { Write-Host 'No DNS names found.' }
        if ($PassThru) { return $Output } else { ($Output | Format-Table -Property *) }
    }
}
$ExportedMembers.Add('Get-DNSNamesBySubnet')


function Get-ShortCutPath {
<#
    .SYNOPSIS
    Gets the path to the target of a windows shortcut (.lnk / .url) file.
 
    .DESCRIPTION
    Uses wscript to retrieve the TargetPath and Arguments, if set, of a windows shortcut (.lnk / .url) file.
 
    .PARAMETER Path
    The path(s) to the shortcut file to analyze.
#>

    [CmdletBinding()]
    [OutputType([System.String])]
    param(
        [Parameter(
            Mandatory,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName,
            Position = 0
        )]
        [Alias('FullName')]
        [string[]]$Path
    )

    begin { $wShell = New-Object -ComObject WScript.Shell }

    process {
        foreach ($Item in $Path){
            $ItemPath = [IO.Path]::GetFullPath($Item)
            $ItemExt = [IO.Path]::GetExtension($ItemPath)
            $VerboseMessage = "Processing '$Item'"
            if ($Item -ne $ItemPath) { $VerboseMessage += "as '$ItemPath'" }
            Write-Verbose -Message "$VerboseMessage."
            if ((Test-Path $ItemPath -PathType Leaf) -eq $false) {
                Write-Error "Path '$ItemPath' is not a file that exists."
                continue
            } elseif (($ItemExt -ine '.lnk') -and ($ItemExt -ine '.url')) {
                Write-Error "Path '$ItemPath' does not have the required '.lnk' or '.url' extension!"
                continue
            }
            $shortCut = $wShell.CreateShortcut($ItemPath)
            $Result = $shortCut.TargetPath
            if ($shortCut.Arguments){ $Result += " $($shortCut.Arguments)" }
            Write-Output $Result
        }
    }
}
$ExportedMembers.Add('Get-ShortCutPath')


function New-ConsoleArgWriter {
<#
    .SYNOPSIS
    Creates a new 'ConsoleArgWriter' console application if it does not already exist.
 
    .DESCRIPTION
    The New-ConsoleArgWriter function generates an executable, ConsoleArgWriter.exe, in the specified $ShellBinPath.
    Use this executable as a mock for other executables when testing. ConsoleArgWriter.exe will write the input
    arguments to the console, rather than executing the actual application. This is useful for testing and debugging
    scripts that utilize command-line applications when you don't actually want to run the executables.
 
    This function checks if the 'ConsoleArgWriter.exe' already exists in the specified path. If it doesn't,
    it uses the Add-Type cmdlet to compile a new console application from a C# script. The new application
    simply writes the provided arguments to the console.
 
    To use the executable after running this function, alias the desired executable name to ConsoleArgWriter.exe.
 
    For example:
    Set-Alias -Name 'gpg.exe' -Value (Join-Path $ShellPath 'bin' 'ConsoleArgWriter.exe')
    & 'gpg.exe' --pinentry-mode=loopback "--d=$true"
 
    In this case, any arguments passed to 'gpg.exe' would be written to the console instead of being passed to the
    actual gpg.exe executable.
 
    .PARAMETER ShellBinPath
    The path where the ConsoleArgWriter.exe will be created.
 
    .EXAMPLE
    New-ConsoleArgWriter -ShellBinPath C:\temp
    This will create a new ConsoleArgWriter.exe in the C:\temp directory.
 
    .NOTES
    The New-ConsoleArgWriter function should be used for debugging and testing purposes. Avoid using it
    in production environments as it could potentially expose sensitive command-line arguments.
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [Validation.ValidatePathExists('Folder')]
        [string]$ShellBinPath
    )

    $ArgWriter = Join-Path -Path $ShellBinPath -ChildPath 'ConsoleArgWriter.exe'

    if ((Test-Path -Path $ArgWriter) -eq $false) {
        if ($env:Path.Split([System.IO.Path]::PathSeparator) -notcontains $ShellBinPath) {
            Write-Warning (
                "The ShellBinPath '$ShellBinPath' is not in your `$Env:Path. To use the ConsoleArgWriter you will need " +
                "to alias the entire path '$ArgWriter'."
            )
        }

        # Starting this in Windows PowerShell because it can emit console applications.
        $CreateConsoleWriter = ([hashtable]@{
                FilePath         = 'powershell.exe'
                NoNewWindow      = $true
                WorkingDirectory = $ShellBinPath
                ArgumentList     = (
                    '-noprofile',
                    '-ec',
                    [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes(@'
[hashtable]$ConsoleWriterSplat = @{
    OutputType = 'ConsoleApplication'
    Language = 'CSharp'
    OutputAssembly = 'ConsoleArgWriter.exe'
    TypeDefinition = @"
using System;
public class Program {
    public static int Main(string[] args) {
        int count = 0;
        foreach (string arg in args) {
            if (null == arg) {
                Console.WriteLine("{0}: null", count);
            } else {
                Console.WriteLine("{0}: \"{1}\"", count, arg);
            }
            count++;
        }
        return 0;
    }
}
"@
}
Add-Type @ConsoleWriterSplat
'@
)))
            })
        Start-Process @CreateConsoleWriter
    }
}
$ExportedMembers.Add('New-ConsoleArgWriter')


function touch {
<#
    .SYNOPSIS
    Create a new file at the specified path.
 
    .DESCRIPTION
    The touch function emulates the Unix touch command and creates a new file at
    the specified path if it does not already exist.
 
    .PARAMETER Path
    The path where the new file will be created.
#>

    [CmdletBinding()]
    [OutputType([Void])]
    param (
        [Parameter(
            Mandatory,
            ValueFromPipeline,
            Position = 0
        )]
        [string]$Path
    )
    process {
        New-Item -ItemType File -Path $Path
    }
}
$ExportedMembers.Add('touch')


function tail {
<#
    .SYNOPSIS
    Display the last 'n' lines of a file.
 
    .DESCRIPTION
    The tail function emulates the Unix tail command and displays the last 'n' lines of a file.
 
    .PARAMETER Path
    The path of the file to display.
 
    .PARAMETER LiteralPath
    The literal path of the file to display.
 
    .PARAMETER follow
    A switch that indicates that the function should follow the file as it grows.
 
    .PARAMETER lines
    The number of lines to display from the end of the file. Default is 10.
 
    .EXAMPLE
    # Use Control C to exit the function and stop tailing the file.
    tail -f 'C:\Path\To\File.txt'
#>

    [CmdletBinding()]
    [OutputType([Void])]
    param (
        [Parameter(
            Mandatory,
            ValueFromPipeline,
            ParameterSetName = 'Path',
            Position = 0
        )]
        [Validation.ValidatePathExists('File')]
        [FileInfo]$Path,

        [Parameter(
            Mandatory,
            ValueFromPipeline,
            ParameterSetName = 'LiteralPath',
            Position = 0
        )]
        [Validation.ValidatePathExists('File')]
        [string]$LiteralPath,

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

        [Parameter(Mandatory = $false)]
        [Alias('n')]
        [int]$lines = 10
    )

    $ParameterSplat = @{}

    if ($Path) { $ParameterSplat.Add('Path', $Path) }
    if ($LiteralPath) { $ParameterSplat.Add('LiteralPath', $LiteralPath) }
    if ($follow) { $ParameterSplat.Add('Wait', $true) }
    $ParameterSplat.Add('Tail', $lines)

    Get-Content @ParameterSplat
}
$ExportedMembers.Add('tail')


function Search-Registry {
<#
.SYNOPSIS
    Search the Windows Registry for key names, key value names, or key value data matching specified criteria.
 
.DESCRIPTION
    The Search-Registry function allows you to search the Windows Registry for
    - Key names
    - Value names
    - Value data
    It outputs PSCustomObjects that contain the key, matched content type (KeyName, ValueName, or ValueData), and the
    matching content.
 
.PARAMETER Path
    The registry path to start the search from.
 
.PARAMETER Recurse
    Indicates whether to search in subkeys.
 
.PARAMETER SearchRegex
    The regular expression used as the search criteria.
 
.PARAMETER KeyName
    Used in conjunction with the SearchRegex parameter. Specifies that key names will be tested.
    By default, when the SingleSearchString switches have not been specified, all content types are evaluated.
    When using this switch, only the specified type(s) (KeyName, ValueName, and/or ValueData) will be evaluated.
 
.PARAMETER ValueName
    Used in conjunction with the SearchRegex parameter. Specifies that the value names will be tested.
    By default, when the SingleSearchString switches have not been specified, all content types are evaluated.
    When using this switch, only the specified type(s) (KeyName, ValueName, and/or ValueData) will be evaluated.
 
.PARAMETER ValueData
    Used in conjunction with the SearchRegex parameter. Specifies that the value data will be tested.
    By default, when the SingleSearchString switches have not been specified, all content types are evaluated.
    When using this switch, only the specified type(s) (KeyName, ValueName, and/or ValueData) will be evaluated.
 
.PARAMETER KeyNameRegex
    Specifies a regex that will be checked against key names only.
 
.PARAMETER ValueNameRegex
    Specifies a regex that will be checked against value names only.
 
.PARAMETER ValueDataRegex
    Specifies a regex that will be checked against value data only.
 
.EXAMPLE
    # Search for key names matching "Microsoft," "Adobe," or "Google" under the specified registry path that has value
    # data consisting of only numeric characters.
    Search-Registry -Path HKLM:\SOFTWARE -KeyNameRegex "Microsoft|Adobe|Google" -ValueDataRegex "^\d+$"
 
.EXAMPLE
    # Recursively search for value names matching "DNS*" or "IP*" under the given path, including subkeys.
    Search-Registry -Path HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters -Recurse -ValueNameRegex "DNS*|IP*"
 
.EXAMPLE
    # Search for key names containing the word "Security" under the specified registry path.
    Search-Registry -Path HKCU:\Software -KeyNameRegex "Security"
 
.EXAMPLE
    # Search for keys with empty value data (no data) under the specified registry path.
    Search-Registry -Path HKCU:\Software -ValueDataRegex "^$"
 
.EXAMPLE
    # Search for value names containing "Program Files" under the specified registry path.
    Search-Registry -Path HKLM:\SOFTWARE -ValueNameRegex "Program Files"
 
.EXAMPLE
    # Find key names that match "ProgramKeyName". This will not evaluate either ValueNames or ValueData.
    Search-Registry -Path HKLM:\SOFTWARE -SearchRegex "ProgramKeyName" -KeyName
#>

    [CmdletBinding()]
    param (
        [Alias('PsPath')]
        [Parameter(Mandatory, Position = 0, ValueFromPipelineByPropertyName)]
        [string[]]$Path,

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

        [Parameter(ParameterSetName = 'SingleSearchString', Mandatory)]
        [string]$SearchRegex,

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

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

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

        [Parameter(ParameterSetName = 'MultipleSearchStrings', Mandatory = $false)]
        [string]$KeyNameRegex,

        [Parameter(ParameterSetName = 'MultipleSearchStrings', Mandatory = $false)]
        [string]$ValueNameRegex,

        [Parameter(ParameterSetName = 'MultipleSearchStrings', Mandatory = $false)]
        [string]$ValueDataRegex
    )

    begin {
        if ($PSCmdlet.ParameterSetName -eq 'SingleSearchString') {
            $NoSwitchesSpecified = $false -eq (
                $PSBoundParameters.ContainsKey('KeyName') -or
                $PSBoundParameters.ContainsKey('ValueName') -or
                $PSBoundParameters.ContainsKey('ValueData')
            )
            if ($KeyName   -or $NoSwitchesSpecified) { $KeyNameRegex   = $SearchRegex }
            if ($ValueName -or $NoSwitchesSpecified) { $ValueNameRegex = $SearchRegex }
            if ($ValueData -or $NoSwitchesSpecified) { $ValueDataRegex = $SearchRegex }
        }
    }

    process {
        foreach ($CurrentPath in $Path) {
            foreach ($Key in (Get-ChildItem $CurrentPath -Recurse:$Recurse)) {
                if ($KeyNameRegex) {
                    Write-Verbose "$($Key.Name): Checking KeyNamesRegex"
                    if ($Key.PSChildName -match $KeyNameRegex) {
                        Write-Verbose "$($Key.Name): Found KeyName match for `"$KeyNameRegex`"!"
                        Write-Output -InputObject ([PSCustomObject]@{
                                Key          = $Key
                                ContentMatch = 'KeyName'
                                MatchContent = $Key.PSChildName
                            }
                        )
                    }
                }

                if ($ValueNameRegex -or $ValueDataRegex) {
                    foreach ($KeyValueName in $Key.GetValueNames()) {
                        if ($ValueNameRegex) {
                            Write-Verbose "$($Key.Name): Checking ValueNamesRegex"
                            if ($KeyValueName -match $ValueNameRegex) {
                                Write-Verbose "$($Key.Name): Found ValueName match for `"$ValueNameRegex`"!"
                                Write-Output -InputObject ([PSCustomObject]@{
                                        Key          = $Key
                                        ContentMatch = 'ValueName'
                                        MatchContent = $KeyValueName
                                    }
                                )
                            }
                        }
                        if ($ValueDataRegex) {
                            Write-Verbose "$($Key.Name): Checking ValueDataRegex"
                            $KeyValueData = $Key.GetValue($KeyValueName)
                            if ($KeyValueData -match $ValueDataRegex) {
                                Write-Verbose "$($Key.Name): Found ValueData match for `"$ValueDataRegex`"!"
                                Write-Output -InputObject ([PSCustomObject] @{
                                        Key          = $Key
                                        ContentMatch = 'ValueData'
                                        MatchContent = $KeyValueData
                                    }
                                )
                            }
                        }
                    }
                }
            }
        }
    }
}
$ExportedMembers.Add('Search-Registry')


function Set-WindowFocus {
<#
    .SYNOPSIS
    Sets the focus on a specified window.
 
    .DESCRIPTION
    This function shifts focus to a specified window by utilizing either the process name or the process object.
    By invoking Windows APIs, the function manages the visibility to show or hide GUI process windows.
 
    .PARAMETER ProcessName
    The name of the process whose window you want to focus.
 
    .PARAMETER Process
    The process object whose window you want to focus.
 
    .PARAMETER Minimize
    A switch that indicates if the window should be minimized after setting the focus.
#>

    [CmdletBinding()]
    [OutputType([Void])]
    param (
        [Parameter(
            Mandatory,
            ValueFromPipeline,
            ParameterSetName = 'ProcessName',
            Position = 0
        )]
        [string]$ProcessName,

        [Parameter(
            Mandatory,
            ValueFromPipeline,
            ParameterSetName = 'Process',
            Position = 0
        )]
        [ComponentModel.Component]$Process,

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

    # https://stackoverflow.com/a/58548853
    Add-Type -Namespace ProfileUtility -Name WindowVisibility -MemberDefinition @'
        [DllImport("user32.dll", SetLastError=true)]
        public static extern bool SetForegroundWindow(IntPtr hWnd);
        [DllImport("user32.dll", SetLastError=true)]
        public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
        [DllImport("user32.dll", SetLastError=true)]
        public static extern bool IsIconic(IntPtr hWnd);
'@


    switch ($PSCmdlet.ParameterSetName) {
        'ProcessName' {
            $ProcessName = $ProcessName -replace '\.exe$'
            $Processes = (Get-Process -ErrorAction Ignore $ProcessName)
            if ($Processes.Count -gt 1) {
                $Index = 0
                $ProcessList = Foreach ($Process in $Processes) {
                    [PSCustomObject]@{
                        Number      = $Index
                        WindowTitle = $Process.MainWindowTitle
                    }
                    ++$Index
                }
                $PromptAnswer = Read-Host -Prompt (
                    "Found multiple processes for '$ProcessName'. Which would you like to select.`n" +
                    ($ProcessList | Format-Table | Out-String)
                )
                $hWnd = $Processes[$PromptAnswer].MainWindowHandle
            } else {
                try { $hWnd = $Processes[0].MainWindowHandle } catch { <# Do Nothing #> }
            }
        }
        'Process' { $hWnd = $Process.MainWindowHandle }
    }

    if (-not $hWnd) { Throw 'Failed to retrieve MainWindowHandle.' }

    # https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-showwindow
    if ($Minimize) {
        [ProfileUtility.WindowVisibility]::ShowWindow($hwnd, 6) | Out-Null
    } else {
        # Set the focus on the window.
        [ProfileUtility.WindowVisibility]::SetForegroundWindow($hWnd) | Out-Null
        # If the window is minimized, restore it.
        if ([ProfileUtility.WindowVisibility]::IsIconic($hwnd)) {
            [ProfileUtility.WindowVisibility]::ShowWindow($hwnd, 9) | Out-Null
        }
    }
}
$ExportedMembers.Add('Set-WindowFocus')


function Wait-Active {
<#
    .SYNOPSIS
    Keeps the computer active for a specified amount of time.
 
    .DESCRIPTION
    This function opens Notepad and sends a key press to keep the computer active.
    The duration can be specified in minutes, as a TimeSpan, or as a DateTime.
 
    .PARAMETER StopTime
    The DateTime at which the function should stop keeping the computer active.
 
    .PARAMETER Duration
    The TimeSpan duration to keep the computer active.
 
    .PARAMETER Minutes
    The number of minutes to keep the computer active.
 
    .PARAMETER SleepSeconds
    The number of seconds to sleep between each activity (default is 60 seconds).
#>

    [CmdletBinding(DefaultParameterSetName = 'Minutes')]
    [OutputType([Void])]
    param (
        [Parameter(
            Position = 0,
            ParameterSetName = 'DateTime',
            Mandatory = $false,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName
        )]
        [DateTime]$StopTime = (Get-Date).AddMinutes(5),

        [Parameter(
            Position = 0,
            ParameterSetName = 'TimeSpan',
            Mandatory = $false,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName
        )]
        [ValidateRange(0, [int]::MaxValue)]
        [TimeSpan]$Duration = (New-TimeSpan -Start (Get-Date) -End (Get-Date).AddMinutes(5)),

        [Parameter(
            Position = 0,
            ParameterSetName = 'Minutes',
            Mandatory = $false,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName
        )]
        [ValidateRange(0, [int]::MaxValue)]
        [int]$Minutes = 5,

        [Parameter(
            Position = 1,
            Mandatory = $false,
            ValueFromPipelineByPropertyName
        )]
        [ValidateRange(0, [int]::MaxValue)]
        [Int]$SleepSeconds = 60
    )

    $WaitTill = switch ($PSCmdlet.ParameterSetName) {
        'DateTime' { $StopTime }
        'TimeSpan' { (Get-Date).AddSeconds($timespan.TotalSeconds) }
        'Minutes' { (Get-Date).AddMinutes($Minutes) }
    }
    $Process = Start-Process 'Notepad.exe' -PassThru

    Start-Sleep -Seconds 5
    do {
        if ((Get-Process -InputObject $Process).HasExited) {
            Write-Warning 'Notepad has exited.'
            return
        }
        Set-WindowFocus $Process
        (New-Object -ComObject wscript.shell).SendKeys('.')
        Set-WindowFocus $Process -Minimize
        Start-Sleep -Seconds $SleepSeconds
        $CurrentTime = Get-Date
    } while ($CurrentTime -lt $WaitTill)

    Stop-Process $Process
}
$ExportedMembers.Add('Wait-Active')


Export-ModuleMember -Function $ExportedMembers.ToArray() -Alias $ExportedAliases.ToArray()