Public/New-SpecScheduledTask.ps1

Function New-SpecScheduledTask {
    <#
    .SYNOPSIS
    Creates a new scheduled task.
 
    .DESCRIPTION
        The New-SpecScheduledTask function creates a new scheduled task based on the provided parameters.
 
    .PARAMETER TaskName
        Specifies the name of the task.
 
    .PARAMETER TaskDescription
        Specifies the description of the task.
 
    .PARAMETER Trigger
        Specifies the trigger type for the task. Valid options are 'AtLogon', 'AtStartup', and 'Daily'.
 
    .PARAMETER Time
        Specifies the time at which the task should run. Applicable only if the trigger is set to 'Daily'.
 
    .PARAMETER RandomiseTaskUpTo
        Specifies the random delay duration for the task. Valid options are '15m', '30m', and '1h'. Applicable only if the trigger is set to 'Daily'.
 
    .PARAMETER AllowedUser
        Specifies the user account under which the task should run. Valid options are 'BUILTIN\Users' and 'NT AUTHORITY\SYSTEM'.
 
    .PARAMETER ScriptPath
        Specifies the path to the PowerShell script to be executed by the task.
 
    .PARAMETER Program
        Specifies the path to the program to be executed by the task.
 
    .PARAMETER Arguments
        Specifies the arguments to be passed to the program. Applicable only if the 'Program' parameter is used.
 
    .PARAMETER StartIn
        Specifies the working directory for the program. Applicable only if the 'Program' parameter is used.
 
    .PARAMETER DelayTask
        Specifies the delay before the task starts. Valid options are '30s', '1m', '30m', and '1h'.
 
    .PARAMETER TaskFolder
        Specifies the folder path where the task should be created. Default is 'Specsavers'.
 
    .PARAMETER RunWithHighestPrivilege
        Indicates whether the task should run with the highest privilege.
 
    .PARAMETER StartTaskImmediately
        Indicates whether the task should be started immediately after registration.
 
    .PARAMETER IgnoreTestPath
        Indicates whether the task should be created even if the script path, program path, or startin directory does not exist.
 
    .EXAMPLE
        New-SpecScheduledTask -TaskName "MyTask" -TaskDescription "Description of my task" -Trigger "AtLogon" -AllowedUser "BUILTIN\Users" -ScriptPath "C:\Scripts\MyScript.ps1" -StartTaskImmediately
        Creates a scheduled task that runs the specified PowerShell script at user logon, allowing only users in the 'BUILTIN\Users' group to run the task. The task will start immediately after registration.
 
    .EXAMPLE
        New-SpecScheduledTask -TaskName 'System Restart' -TaskDescription 'Scheduled task to restart the system' -Trigger Daily -Time '1am' -RandomiseTaskUpTo 30m -AllowedUser 'NT AUTHORITY\SYSTEM' -Program 'Shutdown.exe' -StartIn 'C:\Windows\System32' -Arguments '-r -f -t 10' -RunWithHighestPrivilege
        In this example, the function creates a scheduled task named "System Restart" with the description "Scheduled task to restart the system". The trigger is set to 'Daily', indicating that the task should run daily. The Time parameter is set to "01:00", specifying that the task should run at 1:00 AM each day.
 
        Additionally, the RandomiseTaskUpTo parameter is set to "30m", indicating that the task will be randomized for up to 30 minutes from 1:00 AM. This means that the task will have a random delay of up to 30 minutes before it executes, helping to distribute the task execution across systems and avoid simultaneous execution.
 
    .NOTES
        Author: owen.heaume
        Date: 26-May-2023
        Version:
            1.0 - Initial Script
            1.1 - Add -NoLogo -NonInteractive switches to $taskAction for security of PS Scripts
            1.2 - Add ability to set daily schedule at a predefined time with new 'Daily' trigger
            1.3 - Add ability to randomise task start when using 'Daily' trigger
            1.4 - Split out 'Daily' to it's own parameter so parameter sets work more effectively
            1.5 - Parameter sets not working effectively so coded logic instead and moved 'Daily' back to $Trigger parameter
                - Rewrite Comment-based help
            1.6 - Change task action to: $taskAction = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoLogo -NonInteractive -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$ScriptPath`""
                  (includes -executionPolicy Bypass switches otherwise doesn't work on devices)
            1.7 - Assign $taskSettings var to Register-Scheduled task as this had been missed.
                - Added compatibility = win8 to settings
                - Fixed -ExecutionTimeLimit as a setting as for some reason it only worked if time was converted
            1.8 - Added to NewTaskScheduleSettingsSet: -RunOnlyIfNetworkAvailable
                  Cleaned up new-taskscheduledsettingsset so it is easier to see what settings have are being applied. (All settings now applied under the cmdlet definition)
            1.9 - Add Switch 'IgnoreTestPath' and refactored code to use it. If used and the script path, program path or startin dir does not exist then it will continue to create the task anyway.
    #>


    [CmdletBinding(SupportsShouldProcess = $true, DefaultParameterSetName = "Script")]
    param(
        [Parameter()]
        [string]$TaskName,

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

        [Parameter(Mandatory = $true)]
        [ValidateSet('AtLogon', 'AtStartup', 'Daily')]
        [string]$Trigger = "AtStartup",

        [Parameter()]
        [string]$Time,

        [Parameter()]
        [ValidateSet('15m', '30m', '1h')]
        [string]$RandomiseTaskUpTo,

        [Parameter()]
        [ValidateSet('BUILTIN\Users', 'NT AUTHORITY\SYSTEM')]
        [string]$AllowedUser,

        [Parameter()]
        [ValidateScript({ $_ -match '\.ps1$' })]
        [string]$ScriptPath,

        [Parameter()]
        [ValidateScript({ $_ -match '\.(exe|bat|cmd)$' })]
        [string]$Program,

        [Parameter()]
        [string]$Arguments,

        [Parameter()]
        [string]$StartIn,

        [Parameter()]
        [ValidateSet('30s', '1m', '30m', '1h')]
        [string]$DelayTask,

        [Parameter()]
        [string]$TaskFolder = "Specsavers",

        [Parameter()]
        [switch]$RunWithHighestPrivilege,

        [Parameter()]
        [switch]$StartTaskImmediately,

        [parameter()]
        [switch]$IgnoreTestPath
    )

    #region Parameter logic
    #Parameter sets were not flexible enough to meet the goal so code logic introduced instead to ensure correct params used together

    if ($Program -and $ScriptPath) {
        throw "Invalid combination of parameters. 'ScriptPath' and 'Program' cannot be selected together."
    }

    if ($ScriptPath -and ($Arguments -or $StartIn)) {
        throw "Invalid combination of parameters. When using 'ScriptPath', 'Arguments', and 'StartIn' should not be selected."
    }

    if ($trigger -eq 'AtLogon' -or $trigger -eq 'AtStartup' -and ($Time -or $RandomiseTaskUpTo)) {
        throw "Invalid combination of parameters. When using 'AtLogon' or 'AtStartup' as a trigger, 'Time' or 'RandomiseTaskUpTo' should not be selected."
    }

    #endregion parameter logic


    if ($ScriptPath) {
        try {
            $result = Test-Path $ScriptPath -ea Stop
            if ($result -eq $false -and  $IgnoreTestPath) {
                write-verbose "Ignoring test-path checks..."
            } else {
                Write-Warning "Script path not found: $ScriptPath"
                return
            }
        } catch {
            if ($IgnoreTestPath) {
                write-verbose "Ignoring test-path checks..."
            } else {
                Write-Warning "Script path not found: $ScriptPath"
                return
            }
        }


        # If a -TaskName was not used, then get the leaf name of the script path to use instead
        if ($TaskName -eq "") {
            $TaskName = $(Split-Path $ScriptPath -Leaf -Resolve).Replace('.ps1', "")
        }
    }
    elseif ($program) {
       try {
            Test-Path (Join-Path $StartIn $Program -ea stop) -ea stop
        } catch {
            if ($IgnoreTestPath) {
                write-verbose "Ignoring test-path checks..."
            } else {
                Write-Warning "Program path not found: $Program"
                return
            }
        }

        # If a -TaskName was not used, then get the leaf name of the program path to use instead
        if ($TaskName -eq "") {
            $TaskName = $(Split-Path $Program -Leaf -Resolve)
        }
    }

    if (!(Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue)) {
        # Task action
        if ($ScriptPath) {
            $taskAction = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoLogo -NonInteractive -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$ScriptPath`""
        }
        elseif ($Program) {
            $taskAction = New-ScheduledTaskAction -Execute $Program
            if ($Arguments -ne $null -and $Arguments -ne '') {
                #$taskAction.Argument = $Arguments <<<--- For some reason, this doesn't work
                $taskAction = New-ScheduledTaskAction -Execute $Program -Argument $Arguments
            }
            if ($StartIn -ne $null -and $StartIn -ne '') {
                $taskAction.WorkingDirectory = $StartIn  #<<<--- Yet this does!
            }
        }

        # Task Trigger
        switch ($trigger) {
            'AtStartup' { $taskTrigger = New-ScheduledTaskTrigger -AtStartup }
            'AtLogon' { $taskTrigger = New-ScheduledTaskTrigger -AtLogOn }
            'Daily' {
                $taskTrigger = New-ScheduledTaskTrigger -Daily -At $time
                if ($RandomiseTaskUpTo) {
                    $randomDelay = New-TimeSpan -Minutes 0
                    switch ($RandomiseTaskUpTo) {
                        '15m' { $randomDelay = New-TimeSpan -Minutes 15 }
                        '30m' { $randomDelay = New-TimeSpan -Minutes 30 }
                        '1h' { $randomDelay = New-TimeSpan -Hours 1 }
                    }
                    $randomDelayString = 'PT' + $randomDelay.Hours.ToString('00') + 'H' + $randomDelay.Minutes.ToString('00') + 'M' + $randomDelay.Seconds.ToString('00') + 'S'
                    $taskTrigger.RandomDelay = $randomDelayString
                }
            }
        }

        # Task principal
        $taskPrincipal = New-ScheduledTaskPrincipal -GroupId $AllowedUser

        # task settings
       # $taskSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -WakeToRun -Compatibility Win8
        $taskSettings = New-ScheduledTaskSettingsSet

        $taskSettings.Compatibility = 'Win8'
        $taskSettings.WakeToRun = $true
        $taskSettings.DisallowStartIfOnBatteries = $false
        $taskSettings.StopIfGoingOnBatteries = $false
        $taskSettings.ExecutionTimeLimit = 'PT0S'
        $taskSettings.RunOnlyIfNetworkAvailable = $true
        $taskSettings.Hidden = $false
        $taskSettings.Priority = 7


        # For some reason, adding -ExecutionTimeLimit (New-TimeSpan - Minutes 30) to the New-ScheduledTaskSettingsSet doesn't work
        # and I have to convert it first.
        $eTime = New-TimeSpan -Minutes 30
        $MaxTime = "PT" + $eTime.ToString('hh') + "H" + $eTime.ToString('mm') + "M" + $eTime.ToString('ss') + "S"
        $taskSettings.executiontimelimit = $MaxTime

        # Add a task delay if selected
        if ($DelayTask) {
            $taskDelay = New-TimeSpan -Seconds 1
            switch ($DelayTask) {
                '30s' { $taskDelay = New-TimeSpan -Seconds 30 }
                '1m' { $taskDelay = New-TimeSpan -Minutes 1 }
                '30m' { $taskDelay = New-TimeSpan -Minutes 30 }
                '1h' { $taskDelay = New-TimeSpan -Hours 1 }
            }
            $delayTime = "PT" + $taskDelay.ToString('hh') + "H" + $taskDelay.ToString('mm') + "M" + $taskDelay.ToString('ss') + "S"
            $taskTrigger.Delay = $delayTime
        }

        # Run with highest privileges if selected
        if ($RunWithHighestPrivilege) {
            $taskPrincipal.RunLevel = "Highest"
        }
        else {
            $taskPrincipal.RunLevel = "Limited"
        }

        # Register the new PowerShell scheduled task
        Register-ScheduledTask `
            -TaskName $TaskName `
            -Action $taskAction `
            -Trigger $taskTrigger `
            -Principal $taskPrincipal `
            -Description $TaskDescription `
            -TaskPath "\$taskFolder" `
            -Settings $taskSettings

        # Verify the task has been registered and if so start immediately if the switch was
        try {
            if ($(Get-ScheduledTask -TaskName $TaskName -ErrorAction stop -ev x).TaskName -eq $TaskName) {
                write-verbose "Task registered successfully!"
                # If the switch has been used, then start the task straight away
                if ($StartTaskImmediately) {
                    Write-Verbose "Starting the task immediately."
                    Start-ScheduledTask -TaskName $TaskName -TaskPath "\$taskFolder"
                }
            }
        }
        catch {
            Write-Warning "The task was not registered"
            $x
            #exit
        }
    }
    else {
        write-verbose "Scheduled Task $taskname already exists so taking no action"
    }
}