WindowsLab.psm1

#Requires -RunAsAdministrator
<#
.SYNOPSIS
    WindowsLab, tools to admin a Windows based Lab
#>


# -- Init Module Vars ---

# Get this script name without extension
$thisModuleName = [System.IO.Path]::GetFileNameWithoutExtension($MyInvocation.MyCommand.Path)

# Set path to $HOME\AppData\Roaming\config.json
New-Item -Path $env:APPDATA -Name "$thisModuleName" -ItemType Directory -ErrorAction SilentlyContinue
$configPath = Join-Path -Path $env:APPDATA -ChildPath $thisModuleName 'config.json'
$selectedIconPath = Join-Path -Path $PSScriptRoot -ChildPath "selectedTab.ico"

if (Test-Path -Path $configPath -PathType Leaf) {
    # Import config.json
    $config = Get-Content -Path $configPath -Raw | ConvertFrom-Json
    $currentLab = $config.Labs[$config.LastSelectedLab]
}
else {
    $currentLab = $null
}

# -- End Init Vars --

function Test-NoLabPcName {
    # Test if LabPc names are not set in config.json
    param ()
    Write-Host "Selected Lab: $($currentLab.Name) `n"  -ForegroundColor DarkCyan
    if ($currentlab.PcNames.Length -eq 0) {
        Write-Host "LabPc names not found" -ForegroundColor Red
        Write-Host "Run Set-LabPcName to set LabPc names`n" -ForegroundColor DarkYellow
        break
    }
}

function Set-LabPcName {# the GUI cmdlet
    <#
    .SYNOPSIS
        GUI to manage LabPcs names
 
    .DESCRIPTION
        Allows to set/update config.json file through a GUI
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param()

    # Load the Windows Forms assembly
    Add-Type -AssemblyName System.Windows.Forms
    Add-Type -AssemblyName System.Drawing

    # Create the form
    $form = New-Object System.Windows.Forms.Form
    $form.Text = "WindowsLab - Lab Settings"
    $form.Size = New-Object System.Drawing.Size(800, 600)
    $form.StartPosition = "CenterScreen"

    # Create TabControl
    $tabControl = New-Object System.Windows.Forms.TabControl
    $tabControl.Location = New-Object System.Drawing.Point(10, 10)
    $tabControl.Size = New-Object System.Drawing.Size(765, 500)
    $form.Controls.Add($tabControl)

    # Create an ImageList, set icon size, and load an icon
    $imageList = New-Object System.Windows.Forms.ImageList
    $imageList.ImageSize = New-Object System.Drawing.Size(10, 10)
    $imageList.Images.Add([System.Drawing.Image]::FromFile($selectedIconPath))

    # Assign the ImageList to the TabControl
    $tabControl.ImageList = $imageList

    # Create "Add Lab" button
    $addLabButton = New-Object System.Windows.Forms.Button
    $addLabButton.Location = New-Object System.Drawing.Point(10, 520)
    $addLabButton.Size = New-Object System.Drawing.Size(100, 30)
    $addLabButton.Text = "Add Lab"
    $form.Controls.Add($addLabButton)

    # Create "Remove Lab" button
    $removeLabButton = New-Object System.Windows.Forms.Button
    $removeLabButton.Location = New-Object System.Drawing.Point(120, 520)
    $removeLabButton.Size = New-Object System.Drawing.Size(100, 30)
    $removeLabButton.Text = "Remove Lab"
    $form.Controls.Add($removeLabButton)

    # Add Save button
    $saveNamesButton = New-Object System.Windows.Forms.Button
    $saveNamesButton.Location = New-Object System.Drawing.Point(230, 520)
    $saveNamesButton.Size = New-Object System.Drawing.Size(100, 30)
    $saveNamesButton.Text = "Save"
    $form.Controls.Add($saveNamesButton)

    # Function to create embedded PowerShell console
    function New-EmbeddedConsole {
        param (
            [System.Windows.Forms.Control]$parent,
            [int]$x,
            [int]$y,
            [int]$width,
            [int]$height
        )

        $richTextBox = New-Object System.Windows.Forms.RichTextBox
        $richTextBox.Location = New-Object System.Drawing.Point($x, $y)
        $richTextBox.Size = New-Object System.Drawing.Size($width, $height)
        $richTextBox.BackColor = [System.Drawing.Color]::Black
        $richTextBox.ForeColor = [System.Drawing.ColorTranslator]::FromHtml("#45C4B0")
        $richTextBox.Font = New-Object System.Drawing.Font("Consolas", 11)
        $richTextBox.ReadOnly = $true
        $richTextBox.Multiline = $true
        $richTextBox.ScrollBars = "Vertical"
        $richTextBox.WordWrap = $true

        $parent.Controls.Add($richTextBox)
        return $richTextBox
    }

    function Update-Config {
        <#
        Update config module var (non-persistent memory)
        #>


        $cfg = @{
            Labs = @()
            LastSelectedLab = $tabControl.SelectedIndex
        }

        foreach ($tab in $tabControl.TabPages) {
            $textField = $tab.Controls | Where-Object { $_ -is [System.Windows.Forms.TextBox] }
            if ($textField.Text -eq "Enter comma separated LabPc Names") { $pcNames = @() }
            else {
                # Split up names to an array
                $pcNames = $textField.Text -split ",\s*"

                # Remove empty values
                $pcNames = $pcNames | Where-Object {$_ -ne ""}

                # Force PS treating a single name as an array
                $pcNames = $pcNames -as [System.Array]
            }

            $pcMacs = $config.Labs[$tab.TabIndex].PcMacs
            if ($pcMacs) {
                # Force PS treating a single MAC as an array
                $pcMacs = $pcMacs -as [System.Array]
            }
            else {$pcMacs = @()}


            $cfg.Labs += @{
                # Take values from GUI
                Name = $tab.Text
                PcNames = $pcNames
                # Keep saved MACs
                PcMacs = $pcMacs
            }
        }

        $Script:config = $cfg
        $script:currentLab = $config.Labs[$config.LastSelectedLab]
    }

    # Function to create a new tab
    function Add-NewTab {
        param(
            [string]$tabName = "",
            [string]$textContent = ""
        )

        # LabName mini input form
        if ([string]::IsNullOrWhiteSpace($tabName)) {
            $labNameForm = New-Object System.Windows.Forms.Form
            $labNameForm.Text = "Enter Lab Name"
            $labNameForm.Size = New-Object System.Drawing.Size(300, 150)
            $labNameForm.StartPosition = "CenterScreen"

            $labNameField = New-Object System.Windows.Forms.TextBox
            $labNameField.Location = New-Object System.Drawing.Point(10, 20)
            $labNameField.Size = New-Object System.Drawing.Size(260, 20)
            $labNameForm.Controls.Add($labNameField)

            $okButton = New-Object System.Windows.Forms.Button
            $okButton.Location = New-Object System.Drawing.Point(100, 70)
            $okButton.Size = New-Object System.Drawing.Size(75, 23)
            $okButton.Text = "OK"
            $okButton.DialogResult = [System.Windows.Forms.DialogResult]::OK
            $labNameForm.Controls.Add($okButton)
            $labNameForm.AcceptButton = $okButton

            $result = $labNameForm.ShowDialog()

            if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
                $tabName = $labNameField.Text
                if ([string]::IsNullOrWhiteSpace($tabName)) {
                    $randomLabName = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 3 | ForEach-Object { [char]$_ })
                    $tabName = $randomLabName
                }
            } else {
                return
            }
        }

        # Create new TabPage
        $tabPage = New-Object System.Windows.Forms.TabPage
        $tabPage.Text = $tabName

        # Add (single line) TextField to the tab
        $textField = New-Object System.Windows.Forms.TextBox
        $textField.Multiline = $false
        $textField.Location = New-Object System.Drawing.Point(10, 10)
        $textField.Size = New-Object System.Drawing.Size(730, 20)
        $textField.Text = $textContent

        # Add placeholder text to single-line field
        if ([string]::IsNullOrWhiteSpace($textContent)) {
            $textField.ForeColor = [System.Drawing.Color]::Gray
            $textField.Text = "Enter comma separated LabPc Names"

            $textField.Add_GotFocus({
                if ($this.Text -eq "Enter comma separated LabPc Names") {
                    $this.Text = ""
                    $this.ForeColor = [System.Drawing.Color]::Black
                }
            })

            $textField.Add_LostFocus({
                if ([string]::IsNullOrWhiteSpace($this.Text)) {
                    $this.Text = "Enter comma separated LabPc Names"
                    $this.ForeColor = [System.Drawing.Color]::Gray
                }
            })
        }

        $tabPage.Controls.Add($textField)

        # Add [Get MAcs] button to the tab
        $showMacsButton = New-Object System.Windows.Forms.Button
        $showMacsButton.Location = New-Object System.Drawing.Point(10, 40)
        $showMacsButton.Text = "Get MACs"
        $w = ($showMacsButton.Text.Length + 4)*6
        $showMacsButton.Size = New-Object System.Drawing.Size($w, 25)
        $tabPage.Controls.Add($showMacsButton)

        # Add embedded console to the tab
        $console = New-EmbeddedConsole -parent $tabPage -x 10 -y 72 -width 730 -height 378

        # Get [MAcs button] click event
        $showMacsButton.Add_Click({
            if ($tabControl.TabCount -gt 0) {

                $currentTab = $tabControl.SelectedTab
                $console = $currentTab.Controls | Where-Object { $_ -is [System.Windows.Forms.RichTextBox] }

                # Clear previous output
                $console.Clear()

                # Add new output
                $console.AppendText("Lab $($currentLab.Name)")
                if ($currentLab.PcNames) {
                    $console.AppendText("`n`nSearching for MAC addresses of physically connected Ethernet adapters")
                    $console.AppendText("`n`nWait ...`n")

                    # Display Get-LabPcMac output to console
                    $output = Get-LabPcMac
                    $console.AppendText($output)
                }
                else {
                    $console.AppendText("`n`nFirst, enter the LabPC names, then press the [Get MACs] button again.")
                }
            }
        })

        # Add the new tab to TabControl
        $tabControl.TabPages.Add($tabPage)
    }

    # Add Lab button click event
    $addLabButton.Add_Click({
        Add-NewTab
        $tabControl.SelectedIndex = $tabControl.TabCount - 1
        $tabControl.Focus() # move focus out of single-line field to see the placeholder text
        Update-Config
    })

    # Delete Lab button click event
    $removeLabButton.Add_Click({
        if ($tabControl.TabCount -gt 0) {
            $currentTabName = $tabControl.SelectedTab.Text
            $result = [System.Windows.Forms.MessageBox]::Show(
                "Are you sure you want to remove '$currentTabName'?",
                "Confirm Remove",
                [System.Windows.Forms.MessageBoxButtons]::YesNo,
                [System.Windows.Forms.MessageBoxIcon]::Question)

            if ($result -eq [System.Windows.Forms.DialogResult]::Yes) {
                $tabControl.TabPages.RemoveAt($tabControl.SelectedIndex)
                Update-Config
            }
        }
    })

    # Save button click event
    $saveNamesButton.Add_Click({
        Update-Config
        $config | ConvertTo-Json -Depth 3 | Set-Content -Path $configPath -Encoding UTF8
        [System.Windows.Forms.MessageBox]::Show(
            "LabPc Names saved!",
            "Success",
            [System.Windows.Forms.MessageBoxButtons]::OK,
            [System.Windows.Forms.MessageBoxIcon]::Information)
    })

    # Tab selection change event
    $tabControl.Add_Selected({ # fires after tab change
        # Remove Icon from the previuos selected tab if exist
        if ($tabControl.TabPages[[Int32]$config.LastSelectedLab]) {
            $tabControl.TabPages[[Int32]$config.LastSelectedLab].ImageIndex = -1
        }

        # Add the icon for the new selected tab
        if ($tabControl.SelectedTab) {
            $tabControl.SelectedTab.ImageIndex = 0
        }

        Update-Config
    })

    # Form closing event - save configuration
    $form.Add_FormClosing({
        Update-Config
        $config | ConvertTo-Json -Depth 3 | Set-Content -Path $configPath -Encoding UTF8
    })

    # Display tabs saved in config
    if ($config -and $config.Labs) {
        foreach ($tab in $config.Labs) {
            Add-NewTab -tabName $tab.Name -textContent ($tab.PcNames -join ', ')
        }

        # Restore last selected tab
        $tabControl.SelectedIndex = $config.LastSelectedLab

        # Add the icon for the new selected tab
        $tabControl.TabPages[[Int32]$config.LastSelectedLab].ImageIndex = 0
    }

    # Show the form
    $form.ShowDialog()
}


function Test-LabPcPrompt {
    <#
    .SYNOPSIS
        Tests for each LabPC if the WinRM service is running.
 
    .DESCRIPTION
        This cmdlet informs you which LabPCs are ready to accept cmdlets from Main computer.
 
    .EXAMPLE
        Test-LabPcPrompt
    #>

    [CmdletBinding()]
    param ()

    Test-NoLabPcName
    foreach ($pc in $currentlab.PcNames) {
        try {
            Test-WSMan -ComputerName $pc -ErrorAction Stop | Out-Null
            Write-Host "$pc " -ForegroundColor DarkYellow -NoNewline
            Write-Host "ready" -ForegroundColor Green
        }
        catch [System.InvalidOperationException] {
            Write-Host "$pc " -ForegroundColor DarkYellow -NoNewline
            Write-Host "not ready" -ForegroundColor Red
        }
    }
}

function Sync-LabPcDate {
    <#
    .SYNOPSIS
        Sync the date with the NTP time for each computer.
 
        .EXAMPLE
        Sync-LabPcDate
 
    .NOTES
        The NtpTime module is required on MasterComputer (https://www.powershellgallery.com/packages/NtpTime/1.1)
 
        Set-Date requires admin privilege to run
    #>

    [CmdletBinding()]
    param ()

    Test-NoLabPcName

    # check if NtpTime module is installed
    if ($null -eq (Get-Module -ListAvailable -Name NtpTime)) {
        Write-Host "`nNtpTime Module missing. Install the module with:" -ForegroundColor Yellow
        Write-Host " Install-Module -Name NtpTime`n"
        Break
    }

    # get datetime from default NTP server
    try {
        $currentDate = (Get-NtpTime -MaxOffset 60000).NtpTime
        Write-Host "`n(NTP time: $currentdate)`n" -ForegroundColor Yellow

        Set-Date -Date $currentDate | Out-Null
        Write-Host "MasterComputer synchronized" -ForegroundColor Green
        Invoke-Command -ComputerName $currentLab.PcNames -ScriptBlock {
            Set-Date -Date $Using:currentDate | Out-Null
            Write-Host "$env:computername synchronized" -ForegroundColor Green
        }
    }
    catch {
        Write-Host "`nTry again later ..." -ForegroundColor Yellow
    }
}

function Deploy-Item {
    <#
    .SYNOPSIS
        Deploy a file or folder from AdminPC to LabPCs
 
    .DESCRIPTION
        Copy a file or folder to all LabUser desktops, folders are copied recursively.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$True, HelpMessage="Enter Path to file or folder")]
        [string]$Path,
        [Parameter(Mandatory=$True, HelpMessage="Enter LabUser name")]
        [string]$UserName
    )

    Test-NoLabPcName
    Resolve-Path -Path $Path -ErrorAction Stop | Out-Null

    $currentlab.PcNames | ForEach-Object -Parallel {
        $session = New-PSSession -ComputerName $_
        $labUserprofilePath = Invoke-Command -Session $session -ScriptBlock {
            param($UName)
            try {
                # LabUser exist?
                $labUser = Get-LocalUser -Name $UName -ErrorAction Stop

                # LabUser signed-in?
                $labUserProfilePath = (Get-CimInstance -Class Win32_UserProfile |
                                    Where-Object { $_.SID -eq $labUser.SID.Value }).LocalPath

                if ($null -eq $labUserProfilePath) {
                    Write-Host "$UName exist but never signed-in on $env:computername" -ForegroundColor Yellow
                    Write-Host "Deployment to $env:computername failed" -ForegroundColor Red
                }
            }
            catch [Microsoft.PowerShell.Commands.UserNotFoundException] {
                Write-Host "$UName NOT exist on $env:computername" -ForegroundColor Yellow
                Write-Host "Deployment to $env:computername failed" -ForegroundColor Red
                $labUserProfilePath = $null
            }
            finally {
                $labUserProfilePath
            }
        } -ArgumentList $using:UserName

        if ($null -ne $labUserprofilePath) {
            $labUserDesktopPath = Join-Path -Path $labUserprofilePath -ChildPath 'Desktop'
            Copy-Item -Path $using:Path -Destination $labUserDesktopPath -ToSession $session -Recurse -Force
            Write-Host "Deployment to $_ success" -ForegroundColor Green
        }
        Remove-PSSession $session
    } -ThrottleLimit 5
}

function Disconnect-User {
    <#
    .SYNOPSIS
        Disconnect any connected user from each LabPC
 
    .EXAMPLE
        Disconnect-User
 
    .NOTES
        Windows Home edition doesn't include query.exe (https://superuser.com/a/1646775)
 
        Quser.exe emit a non-terminating error in case of no user logged-in,
        to catch the error force PS to raise an exception, set $ErrorActionPreference = 'Stop'
        because quser, being not a cmdlet, has not -ErrorAction parameter.
    #>

    [CmdletBinding()]
    param()

    Test-NoLabPcName
    Invoke-Command -ComputerName $currentLab.PcNames -ScriptBlock {
        $ErrorActionPreference = 'Stop' # NOTE: it is valid only for this function scope
        try {
            # check if quser command exist
            Get-Command -Name quser -ErrorAction Stop | Out-Null

            # get array of logged-in users, skip 1st row (the head)
            quser | Select-Object -Skip 1 |
            ForEach-Object {
                # logoff by session ID
                logoff ($_ -split "\s+")[2]
                Write-Host "User", ($_ -split "\s+")[1], "logged out $($env:COMPUTERNAME)"  -ForegroundColor Green
            }
        }
        catch [System.Management.Automation.CommandNotFoundException] {
            Write-Host "Cannot disconnect any user: quser command not found on $env:computername" -ForegroundColor Red
            Write-Host "is it a windows Home edition?"
        }
        catch {
            Write-host "No user logged in $($env:COMPUTERNAME)" -ForegroundColor Yellow
        }
    }
}

# -- LabUser section --

function New-LabUser {
    <#
    .SYNOPSIS
        Create a Standard Lab user with a blank never-expiring password
 
    .EXAMPLE
        New-LabUser -UserName "Alunno"
 
    .NOTES
        I just want to clarify the usage of the New-LocalUser cmdlet's switch parameters
        -NoPassword and -UserMayNotChangePassword. According to Microsoft, the -NoPassword
        parameter indicates that the user account doesn't have a password. However, in my
        tests, the user was prompted to provide a password when signing in for the first time.
        This indicates that -NoPassword is different from a blank password. Consequently, using
        -NoPassword along with -UserMayNotChangePassword results in a deadlock.
 
        Windows Groups' description: https://ss64.com/nt/syntax-security_groups.html
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param (
      [Parameter(Mandatory=$True, HelpMessage="Enter username for Lab User")]
      [string]$UserName
    )

    Test-NoLabPcName
    Invoke-Command -ComputerName $currentLab.PcNames -ScriptBlock {
        try {
            $blankPassword = [securestring]::new()
            New-LocalUser -Name $Using:UserName -Password $blankPassword -PasswordNeverExpires `
            -UserMayNotChangePassword -AccountNeverExpires -ErrorAction Stop | Out-Null

            Add-LocalGroupMember -Group "Users" -Member $Using:UserName

            Write-Host "$Using:UserName created on $env:computername" -ForegroundColor Green
        }
        catch [Microsoft.PowerShell.Commands.UserExistsException] {
          Write-Host "$Using:UserName already exist on $env:computername" -ForegroundColor Yellow
        }
    }
}

function Remove-LabUser {
    <#
    .SYNOPSIS
        Remove specified Lab User, also remove registry entry and user profile folder if they exist
 
    .DESCRIPTION
        This cmdlet log out the lab user if he is logged in and completely remove it
 
    .EXAMPLE
        Remove-LabUser -Username "Alunno"
 
    .NOTES
        Inspiration: https://adamtheautomator.com/powershell-delete-user-profile/
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param (
      [Parameter(Mandatory=$True, HelpMessage="Enter username for Lab User")]
      [string]$UserName
    )

    Test-NoLabPcName
    Invoke-Command -ComputerName $currentLab.PcNames -ScriptBlock {
        try {
            # check if quser command exist
            Get-Command -Name quser -ErrorAction Stop | Out-Null

            # log out if logged in otherwise silently continue
            $ErrorActionPreference = 'SilentlyContinue'
            quser $Using:UserName | Select-Object -Skip 1 |
            ForEach-Object {
                # logoff by session ID
                logoff ($_ -split "\s+")[2]
                Write-Host "User", ($_ -split "\s+")[1], "logged out $($env:COMPUTERNAME)"  -ForegroundColor Green
            }
            $ErrorActionPreference = 'Continue'
        }
        catch [System.Management.Automation.CommandNotFoundException] {
            Write-Host "quser command not found on $env:computername" -ForegroundColor Red
            Write-Host "is it a windows Home edition? I'll try to remove $using:UserName anyway ...`n"
        }

        try {
            $localUser = Get-LocalUser -Name $Using:UserName -ErrorAction Stop

            # Remove the sign-in entry in Windows
            Remove-LocalUser -SID $localUser.SID.Value

            # Remove %USERPROFILE% folder and registry entry if exist
            Get-CimInstance -Class Win32_UserProfile | Where-Object { $_.SID -eq $localUser.SID.Value } | Remove-CimInstance

            Write-Host "$Using:UserName removed on $env:computername" -ForegroundColor Green
        }
        catch [Microsoft.PowerShell.Commands.UserNotFoundException] {
            <#Do this if a terminating exception happens#>
            Write-Host "$Using:UserName NOT exist on $env:computername" -ForegroundColor Yellow
        }
    }
}

function Set-LabUser {
    <#
    .SYNOPSIS
        Set password and account type for the LabUser specified
 
    .EXAMPLE
        Set-LabUser -UserName "Alunno"
        Set-LabUser -UserName "Alunno" -SetPassword
        Set-LabUser -UserName "Alunno" -SetPassword -AccountType Administrator
 
    .NOTES
        LabUser Administrators can't change the password like standard users
 
        Windows Groups description: https://ss64.com/nt/syntax-security_groups.html
    #>

    [CmdletBinding(DefaultParameterSetName = 'Set0', SupportsShouldProcess = $True)]
    param (
        [Parameter(Mandatory=$True, HelpMessage="Enter the username for Lab User")]
        [string]$UserName,
        [switch]$SetPassword,
        [validateSet('StandardUser', 'Administrator')]
        [string]$AccountType,
        [Parameter(ParameterSetName = 'Set1')]
        [switch]$BackupDesktop,
        [Parameter(ParameterSetName = 'Set2')]
        [switch]$RestoreDesktop
    )

    Test-NoLabPcName
    switch ($PSCmdlet.ParameterSetName) {
        'Set0' {$password = $null
                if ($SetPassword.IsPresent) {
                    # Prompt and read new password
                    $password = Read-Host -Prompt 'Enter the new password' -AsSecureString
                }
                Invoke-Command -ComputerName $currentlab.PcNames  -ScriptBlock {
                    try {
                        if ($Using:SetPassword.IsPresent) {
                            # change password
                            Set-LocalUser -Name $Using:UserName -Password $Using:Password -PasswordNeverExpires $True `
                            -UserMayChangePassword $False -ErrorAction Stop
                            Write-Host "$Using:UserName on $env:computername password changed" -ForegroundColor Green
                        }
                        if ($Using:AccountType -eq 'Administrator') {
                            # change to an Administrator
                            Add-LocalGroupMember -Group "Administrators" -Member $Using:UserName -ErrorAction Stop
                            Write-Host "$Using:UserName on $env:computername is now an Administrator" -ForegroundColor Green
                        }
                        if ($Using:AccountType -eq 'StandardUser') {
                            # change to a Standard User
                            Remove-LocalGroupMember -Group "Administrators" -Member $Using:UserName -ErrorAction Stop
                            Write-Host "$Using:UserName on $env:computername is now a Standard User" -ForegroundColor Green
                        }
                    }
                    catch [Microsoft.PowerShell.Commands.UserNotFoundException] {
                        Write-Host "$Using:UserName NOT exist on $env:computername" -ForegroundColor Yellow
                    }
                    catch [Microsoft.PowerShell.Commands.MemberExistsException] {
                        Write-Host "$Using:UserName on $env:computername is already an Administrator" -ForegroundColor Yellow
                    }
                    catch [Microsoft.PowerShell.Commands.MemberNotFoundException] {
                        Write-Host "$Using:UserName on $env:computername is already a Standard User" -ForegroundColor Yellow
                    }
                    catch {
                        $_.exception.GetType().fullname
                    }
                }
        }
        'Set1' {Backup-LabUserDesktop -UserName $UserName} # -BackupDesktop provided
        'Set2' {Restore-LabUserDesktop -UserName $UserName} # -RestoreDesktop provided
    }
}

function Backup-LabUserDesktop {
    <#
        Back up LabUser desktop into ROOT:\LabPc folder
 
        This cmdlet copies LabUser desktop files and folders into into ROOT:|LabPc folder and deletes any previous item.
 
        Backup-LabUserDesktop -UserName Alunno
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$True, HelpMessage="Enter LabUser name")]
        [string]$UserName
    )
    Invoke-Command -ComputerName $currentLab.PcNames -ScriptBlock {
        try {
            # get specified Lab user
            $localUser = Get-LocalUser -Name $Using:UserName -ErrorAction Stop

            # get Lab user USERPROFILE path
            $userProfilePath = (Get-CimInstance -Class Win32_UserProfile | Where-Object { $_.SID -eq $localUser.SID.Value }).LocalPath
            # Test-Path -Path $userProfilePath -ErrorAction Stop | Out-Null

            $userDesktopPath = Join-Path -Path $userprofilePath -ChildPath 'Desktop'

            # create LabPc folder if not exist
            $labPcPath = Join-Path -Path $env:SystemDrive -ChildPath 'LabPc'
            New-Item -Path $labPcPath -ItemType "directory" -ErrorAction SilentlyContinue

            # copy labuser desktop
            Remove-Item -Path $labPcPath -Force -Recurse -ErrorAction SilentlyContinue # delete any previous saved desktop
            Copy-Item -Path "$userDesktopPath\" -Destination $labPcPath -Recurse -Force

            Write-Host "$Using:Username Desktop saved for $env:computername" -ForegroundColor Green
        }
        catch [Microsoft.PowerShell.Commands.UserNotFoundException] {
            Write-Host "$Using:UserName @ $env:computername does NOT exist" -ForegroundColor Yellow
            Write-Host "$Using:Username Desktop save failed for $env:computername" -ForegroundColor Red
        }
        catch [System.Management.Automation.ParameterBindingException] {
            # user exist USERPROFILE path no
            Write-Host "$Using:UserName exist but never signed-in on $env:computername" -ForegroundColor Yellow
            Write-Host "$Using:Username Desktop save failed for $env:computername" -ForegroundColor Red
        }
    }
}

function Restore-LabUserDesktop {
    <#
        Restore LabUser desktop backup from ROOT:\LabPc
 
        This cmdlet copies back the LabUser desktop backup from ROOT:\LabPc folder, overwrite any existing items.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$True, HelpMessage="Enter LabUser name")]
        [string]$UserName
    )
    Invoke-Command -ComputerName $currentLab.PcNames -ScriptBlock {
        try {
            # get specified Lab user
            $localUser = Get-LocalUser -Name $Using:UserName -ErrorAction Stop

            # get Lab user USERPROFILE path
            $userProfilePath = (Get-CimInstance -Class Win32_UserProfile | Where-Object { $_.SID -eq $localUser.SID.Value }).LocalPath
            Test-Path -Path $userProfilePath -ErrorAction Stop | Out-Null

            $userDesktopPath = Join-Path -Path $userprofilePath -ChildPath 'Desktop'

            # copy lab user desktop back
            $sourcePath = Join-Path -Path $env:SystemDrive -ChildPath "LabPc"
            Copy-Item -Path "$sourcePath\*" -Destination $userDesktopPath -Recurse -Force

            Write-Host "$Using:Username Desktop restored for $env:computername" -ForegroundColor Green
        }
        catch [Microsoft.PowerShell.Commands.UserNotFoundException] {
            Write-Host "$Using:UserName @ $env:computername does NOT exist" -ForegroundColor Yellow
            Write-Host "$Using:Username Desktop restore failed for $env:computername" -ForegroundColor Red
        }
        catch [System.Management.Automation.ParameterBindingException] {
            Write-Host "$Using:UserName exist but never signed-in on $env:computername" -ForegroundColor Yellow
            Write-Host "$Using:Username Desktop restore failed for $env:computername" -ForegroundColor Red
        }
    }
}


# -- LabPc section --

function Get-LabPcMac {
    <#
    .SYNOPSIS
        Show info into GUI console about Ethernet PcLab MAC addresses.
 
    .DESCRIPTION
        Get-LabPcMac searches for LabPC Ethernet MAC addresses. When
        a MAC address is found, it is saved to the configuration file.
        MAC addresses are required for the Start-LabPc cmdlet to use
        Wake-on-LAN (WoL).
 
    .NOTES
        This cmdlet uses Write-Output to send messages to the pipeline,
        allowing them to be displayed in the GUI console.
    #>


    Update-Config
    $foundMacs = @()
    $currentlab.PcNames | ForEach-Object {
        try {
            Write-Output "`n$_"
            $pcNameLen = $_.Length

            # Search for Physical, connected (Up), ethernet (standard 802.3) adapter
            $netAdapter = Get-NetAdapter -Physical -CimSession $_ -ErrorAction Stop |
            Where-Object {
                $_.Status -eq "Up" -and ($_.PhysicalMediaType -like "*802.3*" -or $_.Name -like "*Ethernet*")
            } | Select-Object MacAddress

            if ($netAdapter.Length -eq 0) {
                # Connected, but not via an Ethernet adapter.
                $foundMacs += $null
                Write-Output "is not connected via an Ethernet adapter. Please connect.`n$('-' * $pcNameLen)"
            }
            elseif ($netAdapter.Length -eq 1) {
                # Connected via an Ethernet adapter.
                $foundMacs += $netAdapter.MacAddress
                Write-Output "$($netAdapter.MacAddress)`n$('-' * $pcNameLen)"
            }
            else {
                # Connected via multiple adapters, including Ethernet.
                $foundMacs += $null
                Write-Output "appears to have $($netAdapter.MacAddress.count) Ethernet adapters. Disconnect all but one.`n$('-' * $pcNameLen)"
            }

        }
        catch [Microsoft.PowerShell.Cmdletization.Cim.CimJobException] {
            # LabPC is unreachable because it is either off, not connected, or not ready.
            $foundMacs += $null
            Write-Output "is unreachable because it is either off, not connected, or not ready.`n$('-' * $pcNameLen)"
        }
        catch {
            Write-Output $_.exception.GetType().fullname
        }
    }

    $script:currentLab.PcMacs = $foundMacs
    $script:config.Labs[$config.LastSelectedLab] = $currentLab

    # Save to JSON file
    $config | ConvertTo-Json -Depth 10 | Set-Content -Path $configPath
    Write-Output "`n`nAny found MAC addresses have been saved and are available for Start-LabPc cmdlet."
    if ($foundMacs -contains $null) {
        Write-Output "`nTo retrieve any missing MAC addresses, resolve the issues above and press again [Get MACs] button."
    }
}

function Start-LabPc {
    <#
    .SYNOPSIS
        Turn on each computers if WoL setting is present and enabled in BIOS/UEFI
 
    .EXAMPLE
        Start-LabPc
 
    .NOTES
        https://www.pdq.com/blog/wake-on-lan-wol-magic-packet-powershell/
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param ()

    Test-NoLabPcName
    Write-Host "Remember, Start-LabPc works only if the LabPCs support WoL (Wake-on-LAN)." -ForegroundColor DarkYellow

    # Send Magic Packet over LAN
    for ($i = 0; $i -lt $currentlab.PcNames.Count; $i++) {
        $PcName = $currentlab.PcNames[$i]
        $Mac = $currentlab.PcMacs[$i]
        if ($Mac) {
            $MacByteArray = $Mac -split "[:-]" | ForEach-Object { [Byte] "0x$_"}
            [Byte[]] $MagicPacket = (,0xFF * 6) + ($MacByteArray * 16)
            $UdpClient = New-Object System.Net.Sockets.UdpClient
            $UdpClient.Connect(([System.Net.IPAddress]::Broadcast),7)
            $UdpClient.Send($MagicPacket,$MagicPacket.Length) | Out-Null
            $UdpClient.Close()
            Write-Host $PcName, "Started" -ForegroundColor Green
        }
        else {
            Write-Host $PcName, "is missing MAC Address." -ForegroundColor Red
            Write-host "(Run Set-LabPcNames and press [Get MACs] button for more information.)" -ForegroundColor Gray
        }
    }

}

function Stop-LabPc {
    <#
    .SYNOPSIS
        Force an immediate shut down of each computer
 
    .EXAMPLE
        Stop-LabPc
 
    .NOTES
    #>

    [CmdletBinding(DefaultParameterSetName = 'Set0', SupportsShouldProcess = $true)]
    param (
        [Parameter(ParameterSetName = 'Set1')]
        [switch]$When, # Get scheduled LabPcs daily stops

        [Parameter(ParameterSetName = 'Set2')]
        [string]$DailyAt, # Schedule a new LabPc daily stop

        [Parameter(ParameterSetName = 'Set3')]
        [string]$NoMoreAt, # Remove a LabPc daily stop

        [Parameter(ParameterSetName = 'Set4')]
        [switch]$AndRestart # Restart LabPcs
    )

    Test-NoLabPcName
    Rename-TaskPath
    switch ($PSCmdlet.ParameterSetName) {
        'Set0' {Stop-Computer -ComputerName $currentlab.PcNames -Force} # no parameter provided
        'Set1' {Get-LabPcStop} # -When provided
        'Set2' {New-LabPcStop -DailyTime $DailyAt} # -DailyAt provided
        'Set3' {Remove-LabPcStop -DailyTime $NoMoreAt} # -NoMoreAt provided
        'Set4' {Restart-LabPc} # -AndRestart provided
    }
}

function Rename-TaskPath {
    <#
    This function exists for backward compatibility and will be silently executed
    for six months (November 15, 2024 - May 15, 2025) during each Stop-LabPc call.
 
    - Moves the StopThisComputer task to the new folder and deletes the old folder.
    - If the old folder is not found, no action is taken.
    - If the old folder is empty, it is deleted.
    #>

    param ()

    $oldFolderPath = "\WinLabAdmin\"
    $newFolderPath = "\WindowsLab\"

    Invoke-Command -ComputerName $currentLab.PcNames -ScriptBlock {
        # Try to delete the folder and capture the output, actually delete the folder if empty
        $result = & schtasks.exe /DELETE /TN "$using:oldFolderPath".Trim('\') /F 2>&1

        if ($result -match "ERROR: The directory is not empty") {

            # Create the new folder by adding and removing a temporary task, the new folder remain
            $action = New-ScheduledTaskAction -Execute "cmd.exe"
            $trigger = New-ScheduledTaskTrigger -AtStartup
            Register-ScheduledTask -TaskName "TempTask" -TaskPath $using:newFolderPath -Action $action -Trigger $trigger -Force
            Unregister-ScheduledTask -TaskName "TempTask" -TaskPath $using:newFolderPath -Confirm:$false

            # Move tasks from the old folder to the new folder
            $tasks = Get-ScheduledTask -TaskPath $using:oldFolderPath -ErrorAction SilentlyContinue
            foreach ($task in $tasks) {
                Register-ScheduledTask -TaskName $task.TaskName -TaskPath $using:newFolderPath -InputObject $task -Force
                Unregister-ScheduledTask -TaskName $task.TaskName -TaskPath $using:oldFolderPath -Confirm:$false
            }

            # Delete emptied old folder
            & schtasks.exe /DELETE /TN "$using:oldFolderPath".Trim('\') /F 2>&1
        }
    } | Out-Null
}

function Restart-LabPc {
    <#
        Force an immediate restart of each computer and wait for them to be on again
 
        Restart-LabPc
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param()
    Restart-Computer -ComputerName $currentlab.PcNames -Force
}


function New-LabPcStop {
    <#
        Schedule a new LabPC daily stop at given local-time i.e. the
        task’s execution time will adjust automatically with DST changes.
 
        This cmdlet creates the StopThisComputer task with the specified stop time as a trigger.
        If the task already exists, it adds the stop time as an additional trigger.
 
        New-LabPcStop -DailyTime '14:15'
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(Mandatory=$True, HelpMessage="Enter the daily stop time")]
        [string]$DailyTime
    )

    # -DailyTime parsing
    try {
        # Validate the time format
        if (-not ($DailyTime -match "^\d{1,2}:\d{2}$")) {
            throw "Invalid time format. Please use HH:mm format (e.g., '09:00')"
        }

        $hours = [int]($DailyTime.Split(":")[0])
        $minutes = [int]($DailyTime.Split(":")[1])

        # Validate hours and minutes ranges
        if (($hours -lt 0 -or $hours -gt 23) -or ($minutes -lt 0 -or $minutes -gt 59)) {
            throw "Hours must be between 0 and 23 and minutes must be between 0 and 59"
        }

        # Create a local [DateTime] object based on the provided DailyTime parameter.
        $dailyTimeObj = Get-Date -Hour $hours -Minute $minutes -Second 0 -Millisecond 0
    }
    catch {
        Write-Host "$_" -ForegroundColor Red
        return $null
    }

    # Set the trigger
    $trigger = New-ScheduledTaskTrigger -Daily -At $dailyTimeObj

    # Set the action
    $action = New-ScheduledTaskAction -Execute 'Powershell' -Argument '-NoProfile -ExecutionPolicy Bypass -Command "& {Stop-Computer -Force}"'

    # Extract the time from DateTime obj as TimeSpan object
    $givenTimeTrigger = $dailyTimeObj.TimeOfDay

    Invoke-Command -ComputerName $currentLab.PcNames -ScriptBlock {

        # Set principal contex for SYSTEM account to run as a service with the highest privileges
        $principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest

        try {
            # Get scheduled StopThisComputer task if exist
            $stopThisComputerTask = Get-ScheduledTask -TaskName:'StopThisComputer' -TaskPath:'\WindowsLab\' -ErrorAction Stop
        }
        catch [Microsoft.PowerShell.Cmdletization.Cim.CimJobException] {
            # Register the task (-TaskPath is the folder)
            Register-ScheduledTask -TaskName:'StopThisComputer' -TaskPath:'\WindowsLab\' -Action $using:action -Trigger $using:trigger -Principal $principal | Out-Null
            Write-Host "First stop daily time $using:DailyTime just set on $env:computername" -ForegroundColor Green
            Write-Host " ... and StopThisComputer task set`n"
            Return $null
        }

        # Get all time triggers as TimeSpan objects
        $allTimeTriggers = @()
        foreach ($trg in $stopThisComputerTask.Triggers) {
            $allTimeTriggers += ([datetime] $trg.StartBoundary).TimeOfDay
        }

        # Check if the new time trigger is already present
        if ($using:givenTimeTrigger -in $allTimeTriggers) {
            Write-Host "A stop at daily time $using:DailyTime already exist on $env:computername" -ForegroundColor Red
        } else {
            # Add the new stop time
            $stopThisComputerTask.Triggers += $using:trigger
            Set-ScheduledTask -TaskName:'StopThisComputer' -TaskPath:'\WindowsLab\' -Trigger $stopThisComputerTask.Triggers -Principal $principal | Out-Null
            Write-Host "A stop at daily time $using:DailyTime added to $env:computername" -ForegroundColor Green
        }
    }
}

function Get-LabPcStop {
    <#
        Gets LabPC daily stops
 
        This cmdlet gets all trigger times for StopThisComputer scheduled task
 
        Get-LabPcStop
    #>

    [CmdletBinding()]
    param ()

    Invoke-Command -ComputerName $currentLab.PcNames -ScriptBlock {

        $formattedTime = "${env:COMPUTERNAME} stop(s):`n "
        try {
            # Get scheduled StopThisComputer task if exist
            $stopThisComputerTask = Get-ScheduledTask -TaskName:'StopThisComputer' -TaskPath:'\WindowsLab\' -ErrorAction Stop
        }
        catch [Microsoft.PowerShell.Cmdletization.Cim.CimJobException] {
            # $_.exception.GetType().fullname
            $formattedTime += "None"
            Write-Host $formattedTime
            Return $null
        }

        # Get all time triggers as TimeSpan objects
        $allTimeTriggers = @()
        foreach ($trg in $stopThisComputerTask.Triggers) {
            $allTimeTriggers += ([datetime] $trg.StartBoundary).TimeOfDay
        }

        # Print the array in "hh:mm" format
        foreach ($timeSpan in $allTimeTriggers) {
            $formattedTime += "{0:hh\:mm\,\ }" -f $timeSpan
        }
        $formattedTime = $formattedTime.Substring(0, $formattedTime.Length - 2)
        Write-Host $formattedTime "(local time)"
    }
}

function Remove-LabPcStop {
    <#
        Removes a LabPC daily stop
 
        This cmdlet removes if exist the trigger from StopThisComputer scheduled task with time -DailyTime
 
        Remove-LabPcStop -DailyTime '14:14'
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(Mandatory=$True, HelpMessage="Enter daily stop time to remove")]
        [string]$DailyTime
    )

    # Time parameter parsing
    try {
        $dailyTimeObj = [DateTime]::ParseExact($DailyTime, "HH:mm", [System.Globalization.CultureInfo]::InvariantCulture)
    }
    catch {
        Write-Error "-DailyTime $DailyTime must be in HH:mm format"
        return $null
    }

    # Extract the time from DateTime obj as TimeSpan object
    $givenTimeTrigger = $dailyTimeObj.TimeOfDay

    Invoke-Command -ComputerName $currentLab.PcNames -ScriptBlock {

        try {
            # Get scheduled StopThisComputer task if exist
            $stopThisComputerTask = Get-ScheduledTask -TaskName:'StopThisComputer' -TaskPath:'\WindowsLab\' -ErrorAction Stop
        }
        catch [Microsoft.PowerShell.Cmdletization.Cim.CimJobException] {
            # $_.exception.GetType().fullname
            Write-Host "Stop daily time $Using:DailyTime not exist on $env:computername" -ForegroundColor Red
            Return $null
        }

        # Set principal contex for SYSTEM account to run as a service with with the highest privileges
        $principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest

        # Remove the given time trigger
        $allTriggersButTheGiven = @()
        foreach ($trg in $stopThisComputerTask.Triggers) {
            if (([datetime] $trg.StartBoundary).TimeOfDay -ne $Using:givenTimeTrigger) {
                $allTriggersButTheGiven += $trg
            }
        }

        if ($allTriggersButTheGiven.Count -eq 0) {
            Unregister-ScheduledTask -TaskName:'StopThisComputer' -TaskPath:'\WindowsLab\' -Confirm:$false
            Write-Host "Last Stop daily time $Using:DailyTime removed on $env:computername" -ForegroundColor Green
            Write-Host " ... and StopThisComputer Task deleted`n"
        }
        elseif ($allTriggersButTheGiven.count -lt $stopThisComputerTask.Triggers.count) {
            Set-ScheduledTask -TaskName:'StopThisComputer' -TaskPath:'\WindowsLab\' -Trigger $allTriggersButTheGiven -Principal $principal | Out-Null
            Write-Host "Stop daily time $Using:DailyTime removed on $env:computername" -ForegroundColor Green
        } else {
            Write-Host "Stop daily time $Using:DailyTime not exist on $env:computername" -ForegroundColor Red
        }

    }
}