WindowsPowerShell.StreamDeckPluginTemplate.ps1

<#
.Synopsis
    Creates StreamDeck Plugins that use Windows PowerShell
.Description
    Creates the scaffolding for StreamDeck Plugins that use Windows PowerShell.

    This scaffolding consists of a pair of commands from ScriptDeck: Send-StreamDeck and Receive-StreamDeck
#>

param(
# A list of required modules.
[string[]]
$RequiredModule,

[Parameter(Mandatory)]
[string]
$Name,

[Parameter(Mandatory)]
[string]
$OutputPath
)

begin {

# A few things are the same regardless of input.

# One of them is the definition of the core plugin.

$corePlugin = {

$PluginRoot = "$psScriptRoot"

# Set up a log path for this plugin instance (make it based off of the starttime)
$global:STREAMDECK_PLUGINLOGPATH = $logPath = 
    Join-Path $psScriptRoot (        
        ([Datetime]::Now.ToString('o').replace(':','.') -replace '\.\d+(?=[+-])') + '.log'
    )

"Log Started @ $([DateTime]::Now.ToString('s')). Running under process ID $($pid)" | Add-Content -Path $logPath

# Get logs older than a week
$oldLogs = Get-ChildItem -Path $PluginRoot -Filter *.log | 
    Where-Object { ([DateTime]::now - [DateTime]$_.Name.Replace('.log','').Replace('.', ':')) -lt '7.00:00:00'}
# If there were any old logs, remove them.
if ($oldLogs) { $oldLogs | Remove-Item -ErrorAction SilentlyContinue }

if ($global:STREAMDECK_REQUIREDMODULES) {    
    $imported = @(foreach ($module in $global:STREAMDECK_REQUIREDMODULES) {
        $relativePath = (Join-Path $psScriptRoot $module)
        if (Test-Path $relativePath) {
            Import-Module $relativePath -Force -PassThru |
                Add-Content -Path $global:STREAMDECK_PLUGINLOGPATH -PassThru
        } else {
            Save-Module -Name $module -Path $psScriptRoot
            Import-Module $relativePath -Force -PassThru |
                Add-Content -Path $global:STREAMDECK_PLUGINLOGPATH -PassThru
        }
    })

    if ($imported.Count -ne $global:STREAMDECK_REQUIREDMODULES.Count) {
        return
    }
}


# Now we need to handle the arguments passed into the plugin.
# Let's put each named argument into a dictionary.
$argObject = [Ordered]@{}
for ($i = 0; $i -lt $args.Length; $i+=2 ) # We do this by going in twos thru the arguments
{       
    $k = $args[$i].TrimStart('-') # removing the - from the key
    $v = $args[$i + 1]
    $argObject[$k] = 
        if ("$v".StartsWith('{')) { # and converting any JSON-like input.
            $v | ConvertFrom-Json
        } else {
            $v
        }
}

$Host.UI.RawUI.WindowTitle = "StreamDeck $pid"

# Once we've collected an arguments dictionary, turn it into an object,
$argObject = [PSCustomObject]$argObject 
$argObject | 
    ConvertTo-Json | # convert it to JSON,
    Add-Content -Path $logPath # and add it to the log.

# Now let's register the functions we need.
# |Function|Purpose |
# |--------|--------------------------------------------------|
# |Send-StreamDeck |Sends messages to the StreamDeck |
. $psScriptRoot\Send-StreamDeck.ps1
# |Receive-StreamDeck |Receives messages from the StreamDeck |
. $psScriptRoot\Receive-StreamDeck.ps1

# We will want to declare a few globals to keep track of state.
if (-not $Global:STREAMDECK_DEVICES)  { $Global:STREAMDECK_DEVICES = @{} }
if (-not $Global:STREAMDECK_BUTTONS)  { $Global:STREAMDECK_BUTTONS = @{} }
if (-not $global:STREAMDECK_SETTINGS) { $global:STREAMDECK_SETTINGS = @{} }

$localFiles = Get-ChildItem -Path $psScriptRoot -Filter *.ps1

$heartbeatTimer = [Timers.Timer]::new()
$heartbeatTimer.Interval = [Timespan]::FromMinutes(10).totalmilliseconds
$heartbeatTimer.AutoReset = $true
$heartbeatTimer.Start()
Register-ObjectEvent -InputObject $heartbeatTimer -EventName Elapsed -Action {
    "Heartbeat @ $([DateTime]::Now.ToString('s'))" | Add-Content -Path $global:STREAMDECK_PLUGINLOGPATH
}

$actionErrorHandler = {
    $err = $_
    $errorString = $err | Out-String
    $errorString | Add-Content -Path $global:STREAMDECK_PLUGINLOGPATH
    Start-Job -ScriptBlock {
        param([string]$errorString,[string]$PluginName)
        $null  = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
        $toastXml = [xml][Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText01).Getxml()
        $toastXml.toast.visual.binding.text.InnerText = "$errorString"
        $xd = [windows.data.xml.dom.xmldocument]::New()
        $xd.LoadXml($toastXml.OuterXml)
        $toast = [Windows.UI.Notifications.ToastNotification]::new($xd)
        $toast.Tag   = "StreamDeck"
        $toast.Group = "PowerShell"
        $toast.ExpirationTime = [DateTime]::Now.AddSeconds(30)
        $toastName = if ($PluginName) {
            $PluginName
        } else {
            "StreamDeck Plugin $pid"
        }
        [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("$toastName").Show($toast)
    } -ArgumentList $errorString, $global:STREAMDECK_PLUGIN_NAME
}

foreach ($file in @(
$localFiles | Where-Object Name -Like '*.handler.ps1'
$localFiles | Where-Object Name -Like 'On_*.ps1'
)) {
    
    $scriptFile = $ExecutionContext.SessionState.InvokeCommand.GetCommand($file.Fullname, 'ExternalScript')
    $sourceIdentifier = $file.Name -replace '^On_' -replace '\.ps1$' -replace '\.handler$'
    Add-Content -Path $logPath -value "Registering Handler for '$sourceIdentifier': $($file.fullname)"
    $actionScriptBlock = [ScriptBlock]::Create("
        try { . '$($file.Fullname)' } catch $actionErrorHandler
    "
)
    Register-EngineEvent -SourceIdentifier $sourceIdentifier -Action $actionScriptBlock
}


do {
    "Starting Watching Streamdeck @ $([DateTime]::Now.ToString('s'))" | Add-Content -Path $logPath
    $argObject | 
        Receive-StreamDeck -OutputType Message 2>&1 | 
        Foreach-Object {
            if ($Global:STREAMDECK_WEBSOCKET.State -in 'Aborted' ,'CloseReceived') {
                $_ | Out-String | Add-Content -Path $logPath
                break
            }
            $_ | Out-String | Add-Content -Path $logPath
        }

    
    if ($Global:STREAMDECK_WEBSOCKET.State -in 'Aborted' ,'CloseReceived') {
        break
    }
    $sleepTime = (Get-Random -Minimum 30 -Maximum 180)        
    "Finished Watching Streamdeck @ $([DateTime]::Now.ToString('s')). Trying again in $($sleepTime)" | Add-Content -Path $logPath
    Start-Sleep -Seconds $sleepTime
} while ($true)

}


# Another two are the default handlers for settings.

${On_StreamDeck.SendToPlugin} = {

# Create a property bag to receive settings
$instanceSettings = [PSObject]::new()
if ($event.MessageData.Payload.sdpi_collection) { # If the settings were sent in an sdpi_collection, pass along the key and value.
    $instanceSettings | 
        Add-Member NoteProperty -Name $event.MessageData.Payload.sdpi_collection.key -Value $event.MessageData.Payload.sdpi_collection.value
}
# Generate a new event containing the pending setting. This will be merged with settings data later.
New-Event -SourceIdentifier "StreamDeck.PendingSetting.$($event.MessageData.context)" -MessageData $instanceSettings
# Then send the getSettings event. This will generate a StreamDeck.DidReceiveSettings event with the rest of the original setting data.
Send-StreamDeck -Context $event.MessageData.context -EventName getSettings
}
${On_StreamDeck.DidReceiveSettings} = {
# Get any pending settings related to this context.
$evt = Get-Event -SourceIdentifier "StreamDeck.PendingSetting.$($event.MessageData.context)" -ErrorAction SilentlyContinue
# Update the global settings store with a new object.
$global:STREAMDECK_SETTINGS[$event.MessageData.Context] = [PSObject]::new()
# Start off with the settings we have now
$global:STREAMDECK_SETTINGS[$event.MessageData.Context] = $event.MessageData.Payload.Settings 
if ($evt) { # If we had pending settings
    foreach ($prop in $evt.MessageData.psobject.properties) { # walk over each setting property
        Add-Member -InputObject $global:STREAMDECK_SETTINGS[
            $event.MessageData.Context
        ] -MemberType NoteProperty -Name $prop.Name -Value $prop.Value -Force # and add it to the object.
    }
    $evt | Remove-Event # then, remove the event (so that we can handle the next one)
}

# Get the existing settings object.
$settingsObject = $global:STREAMDECK_SETTINGS[$event.MessageData.Context]
$newSettings    = [PSObject]::new() # and create a new settings object.
if ($settingsObject -is [Collections.IDictionary]) { # If it is a dictionary,
    foreach ($kv in $settingsObject.GetEnumerator()) { # add the existing settings to the new settings.
        $newSettings | Add-Member -memberType NoteProperty $kv.Key $kv.Value
    }
} else {
    foreach ($prop in $settingsObject.psobject.properties) { # If the settings were not a dictionary
        # Skip values from collections
        if ($prop.Name -in 'Count', 'IsFixedSize', 'IsReadOnly', 'IsSynchronized', 'Keys', 'SyncRoot', 'Values') { 
            continue
        }
        # and add any other properties to the new settings object.
        $newSettings | Add-Member -memberType NoteProperty -Name $prop.Name -Value $prop.Value            
    }
}

# Log the new settings object
[pscustomobject][ordered]@{
    eventName = "setSettings"
    context = $event.messageData.context
    payload = $newSettings
} | ConvertTo-Json -Compress | Add-Content -Path $global:STREAMDECK_PLUGINLOGPATH
# and send it to StreamDeck.
Send-StreamDeck -Context $event.MessageData.Context -EventName setSettings -Payload $newSettings

}

$functionsToDeclare  = 'Send-StreamDeck', 'Receive-StreamDeck'
$nativeEventHandlers = 'On_StreamDeck.DidReceiveSettings', 'On_StreamDeck.SendToPlugin'

}

process {
    foreach ($func in $functionsToDeclare) {
        $funcDef = $ExecutionContext.SessionState.InvokeCommand.GetCommand($func, 'Function')
        $funcOutputPath = Join-Path $OutputPath "$($funcDef.Name).ps1"
        $regionName = "$($funcDef.Module.Name)@$($funcDef.Module.Version)/$($funcDef.Name)"
        @"
#region $regionName
function $($funcDef.Name) {
$($funcDef.Definition)
}
#endregion $regionName
"@
 | 
        Set-Content -Path $funcOutputPath
    }


    foreach ($eventName in $nativeEventHandlers) {
        $eventDef = $ExecutionContext.SessionState.PSVariable.Get($eventName).Value    
        $eventDef | Set-Content -Path (Join-Path $OutputPath "$eventName.ps1")
    }


    @'
powershell -ExecutionPolicy Bypass -NoProfile -File "%~dp0StartPlugin.ps1" %*
'@
 | 
    Set-Content -Path (Join-Path $OutputPath "StartPlugin.cmd")

    @"
`$global:STREAMDECK_PLUGIN_NAME = '$($Name.Replace("'","''"))'
$corePlugin
"@
 |
    Set-Content -Path (Join-Path $OutputPath "StartPlugin.ps1")
@{
    CodePath = "StartPlugin.cmd"
}

}