Terminal/SRxTerminalBoxControl.psm1.txt

<#
    SRxTerminalBoxControl.psm1 (Terminal subsystem — Iteration 7)
    ------------------------------------------------------------------------
    Self-contained WinForms UserControl hosting a WebView2 (xterm.js) surface
    wired to a ConPTY-backed PowerShell child process (SRxConPty.psm1).
 
    MECHANICS (same convention as SRxPropertyBoxControl / SRxFilterBoxControl):
      - Host uses this by COMPOSITION + DYNAMIC DISPATCH (untyped field on the
        Explorer form). No 'using module'.
      - WinForms/Drawing must be loaded BEFORE this module is imported (same
        requirement as the other *Box controls).
      - Create via factory New-SRxTerminalBoxControl.
      - Import the logic modules (SRxConPty.psm1) INSIDE this module (scope
        isolation), exactly like SRxFilterBoxControl imports SRxFilterBox.psm1.
 
    ------------------------------------------------------------------------
    GOTCHA #1: PowerShell classes are compiled at PARSE TIME
    ------------------------------------------------------------------------
    ANY `[Namespace.TypeName]` bracket-literal written ANYWHERE inside the
    `class SRxTerminalBoxControl { ... }` body must already be resolvable the
    instant this module is Import-Module'd — even if buried in a method that
    is never called. Since WebView2's assemblies are only Add-Type'd lazily
    (the first time the panel opens - see Import-SRxWebView2Assemblies), any
    `[Microsoft.Web.WebView2.*]` bracket-literal anywhere in this class would
    make Import-Module fail immediately with "Unable to find type [...]".
    FIX: never write such a literal in this class. Use New-Object -TypeName
    '<string>' to construct instances, and resolve static
    types/methods/enums via reflection helpers (Get-SRxWebView2Type below)
    instead of bracket-literals.
 
    ------------------------------------------------------------------------
    GOTCHA #2: CoreWebView2EnvironmentOptions has no New-Object-friendly ctor
    ------------------------------------------------------------------------
    `New-Object -TypeName '...CoreWebView2EnvironmentOptions'` fails on some
    SDK builds ("A constructor was not found"). CreateAsync's 3rd parameter
    is optional/nullable per Microsoft's own docs/samples - we just pass
    $null (use-default-options) instead of constructing this object at all.
 
    ------------------------------------------------------------------------
    GOTCHA #3: [type]::GetType('Name, SimpleAssembly') can't see LoadFrom
               context assemblies
    ------------------------------------------------------------------------
    `Add-Type -Path <dll>` loads the assembly into .NET's "LoadFrom" binding
    context. `[type]::GetType('TypeName, SimpleAssemblyName')` internally
    calls `Assembly.Load(SimpleAssemblyName)`, which only searches the
    "Load" context - it does NOT see LoadFrom-context assemblies, even
    though they ARE fully loaded in the AppDomain.
    FIX: Get-SRxWebView2Type enumerates [AppDomain]::CurrentDomain.GetAssemblies()
    directly and calls .GetType(fullName) on the matching Assembly object.
 
    ------------------------------------------------------------------------
    GOTCHA #4: PowerShell scriptblock-as-delegate invoked from a background
               .NET thread can silently hang/fail
    ------------------------------------------------------------------------
    A PowerShell scriptblock cast to a .NET delegate (e.g. `[Action]{...}`)
    executes via ScriptBlock.Invoke() internally, which requires
    [Runspace]::DefaultRunspace to be set on the CALLING thread. Threads with
    no PowerShell runspace attached (a raw ThreadPool worker thread, or a
    plain background System.Threading.Thread) cannot reliably invoke such a
    delegate - the failure can be silent rather than a visible exception.
    FIX: never subscribe a PowerShell scriptblock directly to an event or
    Task continuation that fires on a background .NET thread. Instead, use
    a System.Windows.Forms.Timer that POLLS for completion/new data.
    Timer.Tick ALWAYS fires on the UI thread's own message loop.
      - CoreWebView2Environment.CreateAsync's Task is polled via
        _startupTimer (checks $task.IsCompleted).
      - ConPTY output/exit is polled via _ioTimer, draining
        $this._conPty.TryDequeueOutput(...) and checking $this._conPty.HasExited.
 
    ------------------------------------------------------------------------
    GOTCHA #5: CoreWebView2 (unlike the WinForms WebView2 Control and unlike
               System.Windows.Forms.Timer) has NO .Tag property at all
    ------------------------------------------------------------------------
    `$this._webView` (the WinForms CONTROL) inherits System.Windows.Forms.
    Control and has .Tag. `$this._startupTimer`/`$this._ioTimer` also have
    their own .Tag (Timer defines it directly). But `$this._webView.CoreWebView2`
    is a plain COM-wrapper class with NO .Tag property - setting it throws
    "The property 'Tag' cannot be found on this object". FIX: capture
    $control directly via a `.GetNewClosure()` scriptblock instead of
    round-tripping through a (non-existent) .Tag property on $core.
 
    ------------------------------------------------------------------------
    Header construction (close button / title) DELIBERATELY MIRRORS
    SRxPropertyBoxControl's own ToolStrip-based header exactly (confirmed
    against that module's real source): a System.Windows.Forms.ToolStrip
    (auto-sizing height, ImageScalingSize=(20,20) controls the RENDERED icon
    size) hosting a ToolStripLabel (title) + ToolStripButton (close, 24x24,
    DisplayStyle=Image, ImageScaling=SizeToFit) - NOT a plain Panel+Button.
    ------------------------------------------------------------------------
 
    ------------------------------------------------------------------------
    ITERATION 7 — RunCommand(): programmatic input from the Explorer host
    ------------------------------------------------------------------------
    Added to support Explorer's "Enable Terminal" redirect (onBeginCommand()):
    lets the HOST type a command into the terminal's already-running
    interactive shell.ps1 session, exactly as if the user had typed it. Since
    the ConPTY session may not exist yet the very first time this is called
    (WebView2/environment creation is still asynchronously in flight via
    _startupTimer, and the ConPTY session itself is only created once the
    xterm.js page signals 'ready' - see onWebMessage), pending text is queued
    in $_pendingCommands (a plain List[string], only ever touched from the UI
    thread - both RunCommand() and the flush point in startConPty() run on
    the UI thread, so no additional synchronization is needed) and flushed
    as soon as $this._conPty is assigned.
    ------------------------------------------------------------------------
#>
 
Import-Module (Join-Path $PSScriptRoot 'SRxConPty.psm1') -DisableNameChecking -Force -ErrorAction SilentlyContinue
 
#region ---- WebView2 SDK bootstrap (lazy, tolerant of missing deployment) --
function Get-SRxLoadedAssembly {
    <#
        .SYNOPSIS
        Finds an already-loaded assembly by its simple name, by enumerating
        the current AppDomain directly (works regardless of Load/LoadFrom
        binding context - see GOTCHA #3 above).
    #>
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$SimpleName)
    return [System.AppDomain]::CurrentDomain.GetAssemblies() |
        Where-Object { $_.GetName().Name -eq $SimpleName } |
        Select-Object -First 1
}
 
function Import-SRxWebView2Assemblies {
    <#
        .SYNOPSIS
        Loads the Microsoft.Web.WebView2 Core/WinForms assemblies from
        <ModuleRoot>\Lib\WebView2 (idempotent). Returns $true on success.
        .NOTES
        The native WebView2Loader.dll (matching process bitness) must live in
        the SAME folder as these managed dlls, or somewhere on the process's
        native DLL search path. If the DLLs were downloaded via a browser,
        Windows Mark-of-the-Web will block loading them (HRESULT 0x80131515)
        until you run Unblock-File on all three.
    #>
    [CmdletBinding()]
    param(
        [string]$LibPath = (Join-Path $PSScriptRoot 'Lib\WebView2')
    )
    if (Get-SRxLoadedAssembly 'Microsoft.Web.WebView2.WinForms') {
        return $true
    }
    try {
        $core = Join-Path $LibPath 'Microsoft.Web.WebView2.Core.dll'
        $winf = Join-Path $LibPath 'Microsoft.Web.WebView2.WinForms.dll'
        if (-not (Test-Path $core) -or -not (Test-Path $winf)) {
            Write-Host "Import-SRxWebView2Assemblies - SDK assemblies not found under '$LibPath'."
            return $false
        }
        Add-Type -Path $core -ErrorAction Stop
        Add-Type -Path $winf -ErrorAction Stop
        return $true
    }
    catch {
        Write-Host "Import-SRxWebView2Assemblies - $($_.Exception.Message)"
        return $false
    }
}
 
function Get-SRxWebView2Type {
    <#
        .SYNOPSIS
        Resolves a Microsoft.Web.WebView2.* type by its short name. Never
        via a bracket-literal — safe to call from inside the
        SRxTerminalBoxControl CLASS body (see GOTCHA #1).
        .PARAMETER ShortName
        e.g. 'Core.CoreWebView2Environment' or 'Core.CoreWebView2HostResourceAccessKind'
        or 'WinForms.WebView2'
    #>
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$ShortName)
    $assemblySimpleName = if ($ShortName -like 'WinForms.*') { 'Microsoft.Web.WebView2.WinForms' } else { 'Microsoft.Web.WebView2.Core' }
    $fullTypeName = "Microsoft.Web.WebView2.$ShortName"
 
    $asm = Get-SRxLoadedAssembly $assemblySimpleName
    if ($asm) {
        $t = $asm.GetType($fullTypeName)
        if ($t) { return $t }
    }
    try { return [type]::GetType("$fullTypeName, $assemblySimpleName") } catch { return $null }
}
#endregion
 
class SRxTerminalBoxControl : System.Windows.Forms.UserControl {
    #--------------------------------------
    # public configuration surface (mirrors SRxPropertyBoxControl conventions)
    #--------------------------------------
    [string]$SourcePath = ""
    [hashtable]$Icons = @{}
    [System.Management.Automation.ScriptBlock]$OnStatus = $null # param($text)
    [System.Management.Automation.ScriptBlock]$OnClose = $null # param() -- host unchecks its toggle button
    [string]$StartupCommandLine = "powershell.exe -NoLogo -NoExit"
 
    #--------------------------------------
    # internal state (kept UNTYPED on purpose — see GOTCHA #1 above: none of
    # these may ever be declared as [Microsoft.Web.WebView2.*] or
    # [SRx.Terminal.ConPtySession], or Import-Module fails immediately)
    #--------------------------------------
    hidden $_headerPanel = $null # System.Windows.Forms.ToolStrip
    hidden $_titleLabel = $null # System.Windows.Forms.ToolStripLabel
    hidden $_closeButton = $null # System.Windows.Forms.ToolStripButton
    hidden $_placeholder = $null # plain Label shown when WebView2/ConPTY unavailable
    hidden $_webView = $null # Microsoft.Web.WebView2.WinForms.WebView2 (dynamic)
    hidden $_conPty = $null # SRx.Terminal.ConPtySession (dynamic)
    hidden $_startupTimer = $null # System.Windows.Forms.Timer - polls CreateAsync's Task (GOTCHA #4)
    hidden $_ioTimer = $null # System.Windows.Forms.Timer - polls ConPTY output queue + exit (GOTCHA #4)
    hidden $_pendingCommands = $null # System.Collections.Generic.List[string] - queued RunCommand() text (Iteration 7)
    hidden [bool]$_started = $false
    hidden [bool]$_webViewReady = $false
    hidden [int]$_lastCols = 80
    hidden [int]$_lastRows = 24
 
    SRxTerminalBoxControl() : base() {
        $this._pendingCommands = New-Object 'System.Collections.Generic.List[string]'
        $this.buildLayout()
    }
 
    #--------------------------------------
    # Header construction DELIBERATELY MIRRORS SRxPropertyBoxControl's own
    # ToolStrip-based header exactly (see module header comment above for
    # the full rationale / confirmed property values).
    #--------------------------------------
    hidden [void] buildLayout() {
        $this.Dock = [System.Windows.Forms.DockStyle]::Fill
 
        $this._headerPanel = [System.Windows.Forms.ToolStrip]::new()
        $this._headerPanel.Dock = [System.Windows.Forms.DockStyle]::Top
        $this._headerPanel.GripStyle = [System.Windows.Forms.ToolStripGripStyle]::Hidden
        $this._headerPanel.BackColor = [System.Drawing.SystemColors]::ControlLight
        # Height is intentionally NOT set - a ToolStrip auto-sizes its own
        # height from ImageScalingSize + default padding, exactly like
        # Property Box's _title does (which also never sets Height).
        $this._headerPanel.ImageScalingSize = [System.Drawing.Size]::new(20, 20)
 
        $this._titleLabel = [System.Windows.Forms.ToolStripLabel]::new('Terminal')
 
        $this._closeButton = [System.Windows.Forms.ToolStripButton]::new()
        $this._closeButton.Alignment = [System.Windows.Forms.ToolStripItemAlignment]::Right
        $this._closeButton.DisplayStyle = [System.Windows.Forms.ToolStripItemDisplayStyle]::Image
        $this._closeButton.AutoSize = $false
        $this._closeButton.Size = [System.Drawing.Size]::new(24, 24)
        $this._closeButton.ImageScaling = [System.Windows.Forms.ToolStripItemImageScaling]::SizeToFit
        $this._closeButton.AutoToolTip = $false
        $this._closeButton.Tag = $this
        $this._closeButton.add_Click({
            param($source, $e)
            $_this = $source.Tag
            if ($_this.OnClose) { $_this.OnClose.Invoke() }
        })
 
        [void]$this._headerPanel.Items.Add($this._titleLabel)
        [void]$this._headerPanel.Items.Add($this._closeButton)
 
        $this._placeholder = [System.Windows.Forms.Label]::new()
        $this._placeholder.Dock = [System.Windows.Forms.DockStyle]::Fill
        $this._placeholder.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter
        $this._placeholder.Text = "Terminal is starting..."
        $this._placeholder.Visible = $false
 
        [void]$this.Controls.Add($this._placeholder)
        [void]$this.Controls.Add($this._headerPanel)
    }
 
    #--------------------------------------
    # icons (Close 'X' image) - just assign the raw bitmap; the ToolStrip's
    # ImageScalingSize (set in buildLayout above) handles scaling the
    # rendered icon automatically, regardless of the source bitmap's native
    # resolution (e.g. the shared 30x30 icons8-multiply-30.png also used
    # elsewhere in Explorer.ps1).
    #--------------------------------------
    [void] ApplyIcons() {
        try {
            if ($this.Icons.ContainsKey('Close') -and $this.Icons['Close']) {
                $this._closeButton.Image = $this.Icons['Close']
            }
        } catch { Write-Host "SRxTerminalBoxControl.ApplyIcons - $($_.Exception.Message)" }
    }
 
    hidden [void] status([string]$text) {
        try { if ($this.OnStatus) { $this.OnStatus.Invoke($text) } } catch { }
    }
 
    #--------------------------------------
    # Activate() - call this each time the host makes the panel visible.
    # Lazily boots WebView2 + the ConPTY session on first activation; on
    # subsequent activations it just re-focuses the existing session.
    # Idempotent - safe to call repeatedly (RunCommand() below relies on this).
    #--------------------------------------
    [void] Activate() {
        if ($this._started) {
            try { if ($this._webView) { $this._webView.Focus() } } catch { }
            return
        }
        $this._started = $true
        $this.status("Starting terminal...")
 
        if (-not (Test-SRxConPtySupported)) {
            $this.showPlaceholder("Terminal unavailable - this Windows build does not support ConPTY (needs 1809+).")
            $this.status("Terminal unavailable (ConPTY unsupported).")
            return
        }
        if (-not (Import-SRxWebView2Assemblies)) {
            $this.showPlaceholder("Terminal unavailable - WebView2 SDK assemblies are not deployed. See Terminal\Lib\WebView2.")
            $this.status("Terminal unavailable (WebView2 SDK missing).")
            return
        }
 
        try {
            # 'Microsoft.Web.WebView2.WinForms.WebView2' below is a plain
            # STRING argument to New-Object, not a [bracket-literal] cast -
            # only resolved at runtime. Safe inside the class body (GOTCHA #1).
            $this._webView = New-Object -TypeName 'Microsoft.Web.WebView2.WinForms.WebView2'
            $this._webView.Dock = [System.Windows.Forms.DockStyle]::Fill
            [void]$this.Controls.Add($this._webView)
            $this._webView.BringToFront()
 
            $userDataFolder = Join-Path $(if ($this.SourcePath) { $this.SourcePath } else { $env:TEMP }) 'WebView2UserData'
            if (-not (Test-Path $userDataFolder)) { New-Item -ItemType Directory -Path $userDataFolder -Force | Out-Null }
 
            $envType = Get-SRxWebView2Type 'Core.CoreWebView2Environment'
            if (-not $envType) {
                $this.showPlaceholder("Terminal unavailable - CoreWebView2Environment type not found after loading WebView2 SDK.")
                return
            }
            # 3rd arg is $null (GOTCHA #2): CoreWebView2EnvironmentOptions'
            # constructor isn't reliably New-Object'able, and this param is
            # optional/nullable per Microsoft's own docs - $null = defaults.
            $task = $envType::CreateAsync($null, $userDataFolder, $null)
 
            # ---- poll for completion via a UI-thread Timer (GOTCHA #4) ----
            $this._startupTimer = New-Object System.Windows.Forms.Timer
            $this._startupTimer.Interval = 50
            $state = [pscustomobject]@{ Control = $this; Task = $task }
            $this._startupTimer.Tag = $state
            $this._startupTimer.add_Tick({
                param($source, $e)
                $ctx = $source.Tag
                $t = $ctx.Task
                if (-not $t.IsCompleted) { return }
                $source.Stop()
                $source.Dispose()
                $ctx.Control.clearStartupTimer()
                if ($t.IsFaulted -or -not $t.Result) {
                    $detail = ""
                    try { if ($t.Exception) { $detail = " ($($t.Exception.GetBaseException().Message))" } } catch { }
                    $ctx.Control.showPlaceholder("Terminal unavailable - WebView2 runtime not found or failed to initialize$detail. Install the Evergreen WebView2 Runtime.")
                    return
                }
                $ctx.Control.onEnvironmentReady($t.Result)
            })
            $this._startupTimer.Start()
        }
        catch {
            $this.showPlaceholder("Terminal failed to start: $($_.Exception.Message)")
            $this.status("Terminal start failed: $($_.Exception.Message)")
        }
    }
 
    hidden [void] clearStartupTimer() {
        $this._startupTimer = $null
    }
 
    hidden [void] onEnvironmentReady($environment) {
        try {
            $this._webView.add_CoreWebView2InitializationCompleted({
                param($source, $e)
                $ctrl = $source.Tag
                if (-not $e.IsSuccess) {
                    $ctrl.showPlaceholder("Terminal unavailable - CoreWebView2 initialization failed: $($e.InitializationException.Message)")
                    return
                }
                $ctrl.wireCoreWebView2()
            })
            $this._webView.Tag = $this
            $this._webView.EnsureCoreWebView2Async($environment) | Out-Null
        }
        catch { $this.showPlaceholder("Terminal failed to initialize: $($_.Exception.Message)") }
    }
 
    hidden [void] wireCoreWebView2() {
        try {
            $core = $this._webView.CoreWebView2
            $wwwPath = Join-Path $PSScriptRoot 'www'
 
            $kindType = Get-SRxWebView2Type 'Core.CoreWebView2HostResourceAccessKind'
            $allowKind = if ($kindType) { [Enum]::Parse($kindType, 'Allow') } else { 1 } # 1 == Allow, documented enum value, last-resort fallback
 
            $core.SetVirtualHostNameToFolderMapping('srx-terminal.local', $wwwPath, $allowKind)
 
            # $core (CoreWebView2) has NO .Tag property (GOTCHA #5) - capture
            # $control directly via .GetNewClosure() instead.
            $control = $this
            $onWebMessage = {
                param($source, $e)
                try { $control.onWebMessage($e.TryGetWebMessageAsString()) } catch { Write-Host "onWebMessage - $($_.Exception.Message)" }
            }.GetNewClosure()
            $core.add_WebMessageReceived($onWebMessage)
 
            $this._webViewReady = $true
            $core.Navigate('https://srx-terminal.local/terminal.html')
            $this.status("Terminal ready.")
        }
        catch { $this.showPlaceholder("Terminal failed to wire CoreWebView2: $($_.Exception.Message)") }
    }
 
    #--------------------------------------
    # JS -> Host messages: {"t":"ready"} | {"t":"in","d":"..."} | {"t":"resize","cols":N,"rows":N}
    #--------------------------------------
    hidden [void] onWebMessage([string]$json) {
        if ([string]::IsNullOrEmpty($json)) { return }
        $msg = $null
        try { $msg = $json | ConvertFrom-Json -ErrorAction Stop } catch { return }
        switch ([string]$msg.t) {
            'ready' {
                $this.startConPty($this._lastCols, $this._lastRows)
            }
            'in' {
                if ($this._conPty -and $msg.d) { $this._conPty.Write([string]$msg.d) }
            }
            'resize' {
                $cols = [int]$msg.cols; $rows = [int]$msg.rows
                if ($cols -gt 0 -and $rows -gt 0) {
                    $this._lastCols = $cols; $this._lastRows = $rows
                    if ($this._conPty) { $this._conPty.Resize($cols, $rows) }
                }
            }
            default { }
        }
    }
 
    hidden [void] startConPty([int]$cols, [int]$rows) {
        if ($this._conPty) { return }
        try {
            $workDir = if ($this.SourcePath) { $this.SourcePath } else { $null }
            $this._conPty = Start-SRxConPtySession -CommandLine $this.StartupCommandLine -WorkingDirectory $workDir -Columns $cols -Rows $rows
            if (-not $this._conPty) {
                $this.status("Terminal: failed to start ConPTY session.")
                return
            }
            $this.status("Terminal session started (PID $($this._conPty.ProcessId)).")
            $this.startIoPump()
 
            #begin Terminal Host - Iteration 7
            # Flush any RunCommand() calls that arrived before the ConPTY
            # session actually existed (e.g. the very first Activate(), while
            # WebView2/environment creation was still in flight). Runs on the
            # UI thread (this method is only ever reached via the _ioTimer/
            # onWebMessage 'ready' path, both UI-thread), same thread
            # RunCommand() itself runs on - no extra synchronization needed.
            if ($this._pendingCommands -and $this._pendingCommands.Count -gt 0) {
                foreach ($pending in $this._pendingCommands) {
                    $this._conPty.Write($pending + "`r")
                }
                $this._pendingCommands.Clear()
            }
            #end Terminal Host - Iteration 7
        }
        catch {
            $this.status("Terminal: ConPTY start failed: $($_.Exception.Message)")
        }
    }
 
    #begin Terminal Host - Iteration 7
    #--------------------------------------
    # RunCommand() - lets the HOST (Explorer.onBeginCommand(), when "Enable
    # Terminal" is checked) type a command into this control's already-
    # running interactive session, exactly as if the user had typed it
    # themselves + pressed Enter. See module header comment for the queueing
    # rationale (ConPTY may not exist yet on the very first call).
    #--------------------------------------
    [void] RunCommand([string]$text) {
        if ([string]::IsNullOrEmpty($text)) { return }
        $this.Activate() # idempotent: boots WebView2/ConPTY on first call; just re-focuses otherwise
        if ($this._conPty) {
            $this._conPty.Write($text + "`r")
        }
        else {
            # ConPTY not started yet - queue for startConPty() to flush once
            # the session object is actually assigned (see startConPty() above).
            if (-not $this._pendingCommands) { $this._pendingCommands = New-Object 'System.Collections.Generic.List[string]' }
            $this._pendingCommands.Add($text)
        }
    }
    #end Terminal Host - Iteration 7
 
    hidden [void] pumpConPtyIo() {
        if (-not $this._conPty) { return }
        try {
            $chunk = $null
            $budget = 64 # drain at most N chunks per tick so the UI stays responsive under heavy output
            while ($budget -gt 0 -and $this._conPty.TryDequeueOutput([ref]$chunk)) {
                $budget--
                if ($chunk -and $this._webView -and $this._webView.CoreWebView2) {
                    # JSON int array so JS rebuilds an exact Uint8Array (no re-encoding of control bytes)
                    $ints = New-Object 'System.Collections.Generic.List[int]'
                    foreach ($b in $chunk) { [void]$ints.Add([int]$b) }
                    $payload = @{ t = 'out'; d = $ints } | ConvertTo-Json -Compress -Depth 4
                    $this._webView.CoreWebView2.PostWebMessageAsJson($payload)
                }
            }
            if ($this._conPty.HasExited) {
                $code = $this._conPty.LastExitCode
                $this.status("Terminal session ended (exit code $code).")
                try {
                    if ($this._webView -and $this._webView.CoreWebView2) {
                        $payload = @{ t = 'exit'; code = $code } | ConvertTo-Json -Compress
                        $this._webView.CoreWebView2.PostWebMessageAsJson($payload)
                    }
                } catch { }
                $this._conPty = $null
                if ($this._ioTimer) { $this._ioTimer.Stop(); $this._ioTimer.Dispose(); $this._ioTimer = $null }
            }
        }
        catch { Write-Host "pumpConPtyIo - $($_.Exception.Message)" }
    }
 
    #--------------------------------------
    # I/O pump: polls the ConPTY session's thread-safe output queue and exit
    # flag from a UI-thread Timer (GOTCHA #4).
    #--------------------------------------
    hidden [void] startIoPump() {
        if ($this._ioTimer) { return }
        $this._ioTimer = New-Object System.Windows.Forms.Timer
        $this._ioTimer.Interval = 30
        $this._ioTimer.Tag = $this
        $this._ioTimer.add_Tick({
            param($source, $e)
            $ctrl = $source.Tag
            $ctrl.pumpConPtyIo()
        })
        $this._ioTimer.Start()
    }
 
    hidden [void] showPlaceholder([string]$text) {
        try {
            if ($this._webView) { $this._webView.Visible = $false }
            $this._placeholder.Text = $text
            $this._placeholder.Visible = $true
            $this._placeholder.BringToFront()
        } catch { }
    }
 
    #--------------------------------------
    # Shutdown() - call from the host's FormClosed cleanup (mirrors
    # $this._propBox.Shutdown()) so no orphan powershell.exe or Timer survives.
    #--------------------------------------
    [void] Shutdown() {
        try { if ($this._startupTimer) { $this._startupTimer.Stop(); $this._startupTimer.Dispose() } } catch { }
        $this._startupTimer = $null
        try { if ($this._ioTimer) { $this._ioTimer.Stop(); $this._ioTimer.Dispose() } } catch { }
        $this._ioTimer = $null
        try { if ($this._conPty) { $this._conPty.Stop(1500) } } catch { }
        $this._conPty = $null
        try { if ($this._webView) { $this._webView.Dispose() } } catch { }
        $this._webView = $null
    }
}
 
function New-SRxTerminalBoxControl {
    [OutputType([object])]
    param()
    return [SRxTerminalBoxControl]::new()
}
 
Export-ModuleMember -Function New-SRxTerminalBoxControl, Import-SRxWebView2Assemblies, Get-SRxWebView2Type, Get-SRxLoadedAssembly