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 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 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()