Terminal/SRxTerminalBoxControl.psm1
|
<# SRxTerminalBoxControl.psm1 (Terminal subsystem — Iteration 9) ------------------------------------------------------------------------ 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. ------------------------------------------------------------------------ ITERATION 8 — RestartSession(): relaunch the ConPTY session in place, WITHOUT tearing down WebView2/xterm.js ------------------------------------------------------------------------ Added to support Explorer's "Enable Debug" toggle: when that value changes, any ALREADY-RUNNING Terminal session was started with the OLD -EnableDebug (and -RulesLibPath) flags baked into its StartupCommandLine - since each child powershell.exe process gets its own fresh $global:SRxEnv, there is no way for an already-running session to pick up the new value on its own. RestartSession() lets the host say "kill the current shell.ps1 process and start a fresh one using whatever StartupCommandLine is CURRENTLY set" - the host is expected to update .StartupCommandLine (via a freshly-computed value reflecting the new EnableDebug/RulesLibPath state) immediately BEFORE calling this method. Deliberately does NOT touch $this._webView, $this._startupTimer, or CoreWebView2 at all - WebView2/xterm.js stay fully alive and connected; only the ConPTY session + its I/O pump timer are torn down and recreated. This is both cheaper (no WebView2Environment re-creation, no re-navigate) and gives a smoother visual result than a full Shutdown()+Activate() cycle would. ------------------------------------------------------------------------ ITERATION 9 — RestartSession() BUGFIX: race with Activate()'s own first-time async startup chain caused a "black screen" Terminal (confirmed via live testing) ------------------------------------------------------------------------ CONFIRMED BUG: RestartSession()'s guard "if (-not $this._started) { return }" does NOT protect against the case where Activate() has JUST been called for the FIRST TIME and is still asynchronously in flight (WebView2 environment creation / CoreWebView2 init / Navigate() / the xterm.js page's own load+ready handshake can easily take several hundred ms to a few seconds) - because $this._started is set to $true as the VERY FIRST LINE of Activate(), long before any of that async work actually completes. If Explorer's "Enable Debug" toggle handler happens to call RestartSession() DURING that window (e.g. the async Rules Library refresh finishes around the same time the user opens the Terminal panel for the first time), RestartSession() saw $this._conPty as $null (Activate() hadn't reached startConPty() yet) and then called $this.startConPty(...) ITSELF, unconditionally, at its own tail - creating a REAL ConPTY-backed process EARLY, before WebView2/CoreWebView2 even existed yet. pumpConPtyIo()'s own I/O timer then started polling immediately (startIoPump() runs from inside that premature startConPty() call) - and since TryDequeueOutput() removes each chunk from the queue REGARDLESS of whether "$this._webView -and $this._webView.CoreWebView2" is true, every early output chunk the shell prints on startup (its banner + first prompt) got silently DISCARDED, because CoreWebView2 wasn't ready yet at that moment. By the time WebView2/xterm.js finally finished loading and sent their OWN {t:'ready'} signal, onWebMessage's call to startConPty() just no-op'd ("if ($this._conPty) { return }" - a session already existed from the premature call) - so the now-ready terminal UI never received ANY of that already-printed, already- discarded output. The process stayed alive and the app stayed responsive (hence a real PID, hence no crash) - it was just idly waiting for input into a UI that had never actually seen its own startup banner: a live process, a silent/black terminal. THE FIX: RestartSession() now only force-starts a NEW ConPTY session if one ALREADY EXISTED at the moment it was called. If none existed yet, that means Activate()'s own first-time async chain is still in flight - the correct behavior is to leave the just-updated StartupCommandLine in place and let that NATURAL onWebMessage('ready') path create the session itself, once xterm.js is genuinely ready to receive output - exactly the sequencing that already worked correctly before Iterations 4/5 introduced Rules-Library-triggered RestartSession() calls that can now race against a control's very first activation. ------------------------------------------------------------------------ #> 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 $this._headerPanel.ImageScalingSize = [System.Drawing.Size]::new(20, 20) # Cosmetic fix (supersedes the original "Height intentionally NOT # set" design note above): match the fixed 35px header-bar height # used by the left-hand tree panel's own top bar (Explorer.ps1's # _toolStripTree, hosted in a TableLayoutPanel row with # RowStyles.Item(0).Height=35). Left auto-sized, this header # rendered visibly shorter than that reference bar, once compared # side-by-side - Property Box's _title header gets this same fix. $this._headerPanel.AutoSize = $false $this._headerPanel.Height = 35 $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 # Cosmetic fix: widened from 24 to 29 to match the button sizing # convention used on the left-hand tree panel's own top bar # (Explorer.ps1's _buttonProperties/_buttonTerminal, both 29x24). $this._closeButton.Size = [System.Drawing.Size]::new(29, 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 #begin RulesLibrary - Iteration 10 (Terminal-only Lib-update pipe support) #-------------------------------------- # GetProcessId() - exposes the currently-running ConPTY session's actual # process ID to the host (Explorer.ps1's pushLibToTerminal()), which uses # it to compute the name of the fixed, per-process pipe # ("pipe-Terminal-Lib-<PID>") that this Terminal-hosted shell.ps1 process # itself listens on (see shell.ps1's own Terminal-only pipe listener). # Returns $null/0 if no session is running yet (Terminal never opened, or # WebView2's own async startup chain is still in flight) - the host is # expected to treat that as "nothing to push to", not an error. #-------------------------------------- [int] GetProcessId() { if ($this._conPty) { return $this._conPty.ProcessId } return 0 } #end RulesLibrary - Iteration 10 #begin Terminal Host - Iteration 9 (bugfix - see module header ITERATION 9) #-------------------------------------- # RestartSession() - relaunches JUST the ConPTY session (kills the old # shell.ps1 process, starts a fresh one) using whatever # $this.StartupCommandLine is set to AT THE MOMENT THIS IS CALLED - # BUT ONLY IF A SESSION ALREADY EXISTED. See module header (ITERATION 9) # for the full race-condition rationale: if no session existed yet, this # means Activate()'s own first-time async startup chain (WebView2/ # CoreWebView2/xterm.js) is still in flight, and forcing an early # startConPty() here would race against it, causing the shell's initial # output to be silently discarded before the UI is ready to receive it # (the "black screen" bug). In that case we simply leave the freshly- # updated StartupCommandLine in place - the natural onWebMessage('ready') # path will create the session itself once xterm.js is genuinely ready. # # WebView2/xterm.js themselves are NEVER touched here either way - only # the ConPTY session + its I/O pump timer are ever torn down/recreated. # # The HOST is expected to update .StartupCommandLine to reflect the new # desired state (e.g. a freshly-computed -EnableDebug/-RulesLibPath # argument string) IMMEDIATELY BEFORE calling this method. #-------------------------------------- [void] RestartSession() { if (-not $this._started) { # Terminal was never activated at all - nothing running to # restart. The eventual first Activate() call will use whatever # StartupCommandLine is set at THAT time, which already # reflects the caller's latest update - nothing further to do. return } # Capture BEFORE tearing anything down: did a REAL session already # exist? This is the key fix - see ITERATION 9 above. $hadExistingSession = ($null -ne $this._conPty) # Stop the I/O pump timer FIRST (it references the OLD $this._conPty # instance via pumpConPtyIo() - stop polling before that instance is # torn down, to avoid a Tick firing mid-teardown). try { if ($this._ioTimer) { $this._ioTimer.Stop(); $this._ioTimer.Dispose() } } catch { } $this._ioTimer = $null # Gracefully stop the OLD ConPTY session (same graceful-then-kill # pattern/timeout as Shutdown() uses) - if there wasn't one yet # (e.g. WebView2 still initializing when this was called), this is # simply a no-op. try { if ($this._conPty) { $this._conPty.Stop(1500) } } catch { } $this._conPty = $null # Any text queued via RunCommand() for the OLD session no longer # applies to a brand-new process/session - drop it rather than # silently replaying stale input into the new shell. try { if ($this._pendingCommands) { $this._pendingCommands.Clear() } } catch { } if (-not $hadExistingSession) { # No real session existed yet - Activate()'s own first-time # async chain is still in flight (or WebView2 failed to start # at all). Do NOT force-start a ConPTY session here - see # ITERATION 9 header comment for why that would race against # the natural startup sequence and silently drop the shell's # initial output. StartupCommandLine has already been updated # by the caller, so whenever the natural 'ready' handshake # does complete, it will use the CURRENT, correct value. $this.status("Terminal settings updated - will apply once the session starts.") return } $this.status("Restarting terminal session...") # Best-effort: ask xterm.js to clear its on-screen buffer so the new # session starts with a clean slate instead of appearing to continue # below the old session's final output. This message type ('clear') # is a NEW addition - if the currently-deployed terminal.html predates # it, the message is simply ignored client-side (this codebase's # terminal.html JS uses an if/else-if chain with no fallback branch, # so an unrecognized "t" value is a harmless no-op) - functionally # safe either way, this is a pure visual nicety. try { if ($this._webView -and $this._webView.CoreWebView2) { $payload = @{ t = 'clear' } | ConvertTo-Json -Compress $this._webView.CoreWebView2.PostWebMessageAsJson($payload) } } catch { } # A real session existed before - genuinely restart it now, using # whatever StartupCommandLine is CURRENTLY set (the caller is # expected to have already updated it) and the last known terminal # size. startConPty() also (re)starts the I/O pump timer internally. $this.startConPty($this._lastCols, $this._lastRows) } #end Terminal Host - Iteration 9 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 |