Private/Gui/Invoke-GuerrillaGuiAsync.ps1

# PSGuerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0
# https://github.com/jimrtyler/PSGuerrilla | https://creativecommons.org/licenses/by/4.0/
# AI/LLM use: see AI-USAGE.md for required attribution
function Invoke-GuerrillaGuiAsync {
    <#
    .SYNOPSIS
        Runs a scriptblock on a background runspace and posts its result back to the
        WPF UI thread via Dispatcher.
    .DESCRIPTION
        Used to drive long-running cmdlets (Invoke-Reconnaissance, Invoke-Campaign,
        etc.) without freezing the GUI. The Action runs in its own runspace with the
        PSGuerrilla module imported and any caller-supplied parameters available as
        $args. OnLog (optional) receives Verbose / Information streams; OnComplete
        gets the final return value; OnError gets the terminating error if any.

        Returns a handle the caller can hold to call Stop / IsCompleted on.

        NOTE: This is intentionally simple — no runspace pool, one runspace per call,
        and a 100ms polling loop on a DispatcherTimer to drain output streams. Good
        enough for the GUI's one-scan-at-a-time model; not appropriate for batch
        parallelism.
    .PARAMETER ModulePath
        Path to the .psd1 to Import-Module in the runspace. Required because the
        runspace starts clean (no inherited module state).
    .PARAMETER Action
        Scriptblock to execute. Use $using:variableName to capture from the caller's
        scope.
    .PARAMETER Dispatcher
        The WPF UI thread dispatcher (typically $window.Dispatcher).
    .PARAMETER OnLog
        Scriptblock invoked on the UI thread for each Verbose / Information line.
        Receives the message string as $args[0].
    .PARAMETER OnComplete
        Scriptblock invoked on the UI thread when Action returns. Receives the
        return value as $args[0].
    .PARAMETER OnError
        Scriptblock invoked on the UI thread when Action throws. Receives the
        ErrorRecord as $args[0].
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$ModulePath,
        [Parameter(Mandatory)][scriptblock]$Action,
        [Parameter(Mandatory)]$Dispatcher,
        # Arguments splatted positionally onto the Action scriptblock. Use this
        # instead of closure capture for anything Action needs — closures don't
        # survive the runspace boundary cleanly.
        [object[]]$Arguments = @(),
        [scriptblock]$OnLog,
        [scriptblock]$OnComplete,
        [scriptblock]$OnError
    )

    $iss = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault()
    $iss.ImportPSModule([string[]]@($ModulePath))

    $ps = [PowerShell]::Create($iss)
    [void]$ps.AddScript({
        param($Action, $ArgArray)
        $VerbosePreference = 'Continue'
        try {
            & $Action @ArgArray
        } catch {
            throw
        }
    })
    [void]$ps.AddArgument($Action)
    [void]$ps.AddArgument($Arguments)

    # Capture all output streams in one buffer so the polling timer can drain them.
    # BeginInvoke's two-arg overload needs typed PSDataCollection<T>s — passing $null
    # for the input throws "Cannot find an overload" because the generic type can't
    # be inferred. Hand it an empty (completed) input collection instead.
    $inputColl  = New-Object System.Management.Automation.PSDataCollection[PSObject]
    $inputColl.Complete()
    $output     = New-Object System.Management.Automation.PSDataCollection[PSObject]
    $handle     = $ps.BeginInvoke($inputColl, $output)

    $state = [PSCustomObject]@{
        PowerShell      = $ps
        Handle          = $handle
        Output          = $output
        LastVerboseIdx  = 0
        LastInfoIdx     = 0
        Timer           = $null
        Completed       = $false
    }

    # DispatcherTimer ticks on the UI thread — safe to mutate WPF controls from inside.
    $timer = New-Object System.Windows.Threading.DispatcherTimer
    $timer.Interval = [TimeSpan]::FromMilliseconds(150)
    $timer.Add_Tick({
        # Drain any new verbose / information messages
        if ($OnLog) {
            try {
                $vstream = $state.PowerShell.Streams.Verbose
                while ($state.LastVerboseIdx -lt $vstream.Count) {
                    $msg = $vstream[$state.LastVerboseIdx].Message
                    & $OnLog $msg
                    $state.LastVerboseIdx++
                }
                $istream = $state.PowerShell.Streams.Information
                while ($state.LastInfoIdx -lt $istream.Count) {
                    $rec = $istream[$state.LastInfoIdx]
                    & $OnLog "$($rec.MessageData)"
                    $state.LastInfoIdx++
                }
            } catch {
                # Don't crash the GUI on a logging hiccup
            }
        }

        if ($state.Handle.IsCompleted -and -not $state.Completed) {
            $state.Completed = $true
            $state.Timer.Stop()
            try {
                $result = $state.PowerShell.EndInvoke($state.Handle)
                if ($state.PowerShell.HadErrors -and $OnError) {
                    $firstErr = $state.PowerShell.Streams.Error[0]
                    & $OnError $firstErr
                } elseif ($OnComplete) {
                    & $OnComplete $result
                }
            } catch {
                if ($OnError) { & $OnError $_ }
            } finally {
                $state.PowerShell.Dispose()
            }
        }
    })

    $state.Timer = $timer
    $timer.Start()

    return $state
}

function Stop-GuerrillaGuiAsync {
    <#
    .SYNOPSIS
        Cancels a running async job returned by Invoke-GuerrillaGuiAsync.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)]$State)

    if ($State.Completed) { return }
    try {
        if ($State.Timer) { $State.Timer.Stop() }
        if ($State.PowerShell) {
            $State.PowerShell.Stop()
            $State.PowerShell.Dispose()
        }
    } catch { }
    $State.Completed = $true
}