Public/Show-HDTConsole.ps1
|
function Show-HDTConsole { <# .SYNOPSIS Opens the HDT admin console on one or more deployment shares. .DESCRIPTION The Deployment Workbench equivalent, showing what is on a share - its deployRoot, its task sequences, its operating systems, and the boot image with its build date and hashes. SEVERAL SHARES AT ONCE. -Path takes a list, and every share appears under the one 'Deployment Shares' root, the way Deployment Workbench roots them. An administrator with a lab share and a production share has them side by side rather than in two windows. A SHARE THAT WILL NOT OPEN DOES NOT CLOSE THE CONSOLE. Each share is read in its own try/catch and a failure becomes a row naming the path and the reason. Get-HDTConsoleWorkspace still throws when it is called directly on one bad share - that is the right answer for a command - but a window is not a command, and three good shares must not disappear because of a fourth. THE WINDOW IS NOT IN THIS FUNCTION. An injected IConsoleHost owns everything WPF - Add-Type, XamlReader, ShowDialog - and this function owns the decisions, exactly as Show-HDTWizard does for the technician wizard. New-HDTConsoleHost is the real one, and it is branch-free BECAUSE it is not unit tested; what is on the screen is decided by Get-HDTConsoleWorkspace and Get-HDTConsoleTreeNode, both of which are. WPF NEEDS A SINGLE-THREADED APARTMENT. Windows PowerShell 5.1 and pwsh 7.5 both start STA, so this guard is normally a no-op - but 'pwsh -MTA', and any host that runs a script on an MTA thread, do not, and a WPF window created there fails in the worst way a UI can: with no window and nothing said. Refusing with a sentence naming the switch costs one comparison and turns that into something an administrator can act on. Start-HDTConsole.ps1 re-launches itself with -STA so they never meet it at all. THE XAML IS CHECKED BEFORE THE WINDOW IS SHOWN. A file that is not there, that is empty, or that is not well-formed, is refused by name. What is checked is XML well-formedness, not XAML semantics: a tag WPF dislikes still fails at Show, but a truncated or half-written file fails here, with a sentence naming the file. A DISMISSED WINDOW IS A CLOSE, AND THAT IS NOT WHAT THE WIZARD DOES. Show-HDTWizard reads anything that is not an explicit Next as a Cancel, because its Next leads to a task sequence that partitions a disk and silence must never read as approval. C1 of the console reads the shares and writes nothing to them, so there is no approval to withhold and an empty answer is simply a window that was shut. The asymmetry is deliberate; when the console grows an action that changes a share, that action gets the wizard's rule, not this one. IT IS READ-ONLY, ON PURPOSE. Nothing in C1 writes to a share. .PARAMETER Path The deployment shares to open - local paths or UNC shares, in the order they should appear. .PARAMETER Workspace Already-read shares from Get-HDTConsoleWorkspace, when the caller has them and does not want the shares read again. .PARAMETER XamlPath The window to show. Defaults to the HDTConsole.xaml that ships beside this module. .PARAMETER Title The window title. .PARAMETER Theme Light or Dark. LIGHT IS THE DEFAULT: the console is a desktop application sitting beside Explorer and the Workbench it replaces, in an office. The WinPE wizard keeps its dark palette, because that one is read on a bench with nothing else on the screen. .PARAMETER ConsoleHost An IConsoleHost. Defaults to the real adapter. .PARAMETER RefreshSecond How often the Monitoring branch re-reads Logs\_active\, in seconds. Fifteen by default: short enough that a technician watching a build sees it move, long enough that a console left open on somebody's second monitor is not hammering an SMB share all afternoon. The engine writes a heartbeat per STEP, so polling faster would mostly re-read the same file. .PARAMETER FileSystem An IFileSystem. Defaults to the real adapter. .PARAMETER Environment An IEnvironmentProvider, used to find the remembered window size under the user profile. Defaults to the real adapter. .PARAMETER Screen An IScreen, used to fit the remembered size to the desktop the window has to open on. Defaults to the real adapter. .PARAMETER ApartmentState The apartment the window would be created on. Defaults to the calling thread's, and exists so the refusal above is provable without a second process. .INPUTS None. This command does not accept pipeline input. .OUTPUTS System.Management.Automation.PSCustomObject with Action ('Close'), Title, XamlPath, NodeCount and Workspace - the shares that were shown, so a caller can go on working with exactly what the administrator saw. .EXAMPLE Show-HDTConsole -Path 'C:\HDTLab\Share' Opens the console on the lab share. .EXAMPLE Show-HDTConsole -Path 'C:\HDTLab\Share', '\\192.168.2.108\HDTShare' Two shares, one window. .EXAMPLE $answer = Show-HDTConsole -Path 'C:\HDTLab\Share' $answer.Workspace[0].BootImage.HashMatch The window closed, and the fact it showed, still in hand. #> [CmdletBinding(DefaultParameterSetName = 'FromPath')] [OutputType([pscustomobject])] param( # NOT MANDATORY ANY MORE. A console opened with no path opens the # shares it was last closed on, the way Workbench comes back to the ones # somebody added - and on a machine that has never opened one, it opens # empty with New and Open on the root row's menu, which is a window # somebody can act on rather than a parameter binding error. [Parameter(Position = 0, ParameterSetName = 'FromPath')] [AllowEmptyCollection()] [string[]] $Path = @(), [Parameter(Mandatory = $true, ParameterSetName = 'FromWorkspace')] [ValidateNotNull()] [object[]] $Workspace, [Parameter()] [ValidateNotNullOrEmpty()] [string] $XamlPath = (Join-Path -Path $script:HDTModuleRoot -ChildPath 'UI\Console\HDTConsole.xaml'), [Parameter()] [ValidateNotNullOrEmpty()] [string] $NewSequenceXamlPath = (Join-Path -Path $script:HDTModuleRoot -ChildPath 'UI\Console\HDTNewSequence.xaml'), [Parameter()] [ValidateNotNullOrEmpty()] [string] $ImportOperatingSystemXamlPath = (Join-Path -Path $script:HDTModuleRoot -ChildPath 'UI\Console\HDTImportOperatingSystem.xaml'), [Parameter()] [ValidateNotNullOrEmpty()] [string] $ImportApplicationXamlPath = (Join-Path -Path $script:HDTModuleRoot -ChildPath 'UI\Console\HDTImportApplication.xaml'), [Parameter()] [ValidateNotNullOrEmpty()] [string] $ApplicationDependencyXamlPath = (Join-Path -Path $script:HDTModuleRoot -ChildPath 'UI\Console\HDTApplicationDependency.xaml'), [Parameter()] [ValidateNotNullOrEmpty()] [string] $ApplicationDetectionXamlPath = (Join-Path -Path $script:HDTModuleRoot -ChildPath 'UI\Console\HDTApplicationDetection.xaml'), [Parameter()] [ValidateNotNullOrEmpty()] [string] $NewWorkspaceXamlPath = (Join-Path -Path $script:HDTModuleRoot -ChildPath 'UI\Console\HDTNewWorkspace.xaml'), [Parameter()] [ValidateNotNullOrEmpty()] [string] $Title = 'Hephaestus Deployment Toolkit', [Parameter()] [AllowNull()] [object] $ConsoleHost, [Parameter()] [ValidateRange(2, 3600)] [int] $RefreshSecond = 15, [Parameter()] [AllowNull()] [object] $FileSystem, [Parameter()] [AllowNull()] [object] $Environment, [Parameter()] [AllowNull()] [object] $Screen, [Parameter()] [ValidateSet('Light', 'Dark')] [string] $Theme = 'Light', [Parameter()] [System.Threading.ApartmentState] $ApartmentState = [System.Threading.Thread]::CurrentThread.GetApartmentState() ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' if ($null -eq $FileSystem) { $FileSystem = New-HDTFileSystem } if ($null -eq $ConsoleHost) { $ConsoleHost = New-HDTConsoleHost } # -- the apartment, before anything else ------------------------------- if ($ApartmentState -ne [System.Threading.ApartmentState]::STA) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -ErrorId 'HDTEnvironmentError' ` -Category InvalidOperation ` -Message ("WPF needs a single-threaded apartment and this thread is {0}. Run the console with 'pwsh -STA', with windows powershell, or through Start-HDTConsole.ps1, which arranges it. A window created on an MTA thread never appears and reports nothing." -f $ApartmentState))) } # -- the window file, before anything is read off a share -------------- # # Deliberately first: a share can take a moment over the network, and a # missing window file is a mistake in the install rather than in the share. if (-not $FileSystem.TestPath($XamlPath)) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $XamlPath -Category ObjectNotFound ` -Message 'the console window is not there, so there is nothing to show. It ships as UI\Console\HDTConsole.xaml beside the Hephaestus module.')) } $xaml = [string] $FileSystem.ReadAllText($XamlPath) # An empty file is checked separately because [xml] '' does NOT throw - it # yields an empty document - so the well-formedness check below would pass a # zero-byte window straight through to XamlReader, which fails with an # exception about a root element that reads like a XAML problem. A file that # was copied badly is the way this happens. if ([string]::IsNullOrWhiteSpace($xaml)) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $XamlPath -Category InvalidData ` -Message 'the console window is empty, so there is nothing to show.')) } try { [void] ([xml] $xaml) } catch { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $XamlPath -Category InvalidData ` -Message ('the console window is not well-formed XML, so it could not be shown: {0}' -f [string] $_.Exception.Message))) } # -- what it shows ----------------------------------------------------- # THE SIZE IT WAS LEFT AT, FITTED TO THE SCREEN IT HAS TO OPEN ON, AND THE # CORNER IT OPENS IN. The window is placed rather than centred, so a # remembered size larger than this desktop would hang off the right and the # bottom - and the bottom is where the Close button is. The position is not # remembered: it is the origin of today's work area, measured every time. # # READ HERE RATHER THAN JUST BEFORE THE WINDOW, because the same file # remembers which SHARES this console was last closed on, and those decide # what the tree is built from. $size = Get-HDTConsoleSetting -FileSystem $FileSystem -Environment $Environment -Screen $Screen # NO PATH MEANS THE ONES IT WAS LAST CLOSED ON, which is how Workbench # behaves: the shares somebody added are there the next morning. A machine # that has never opened one gets an empty window with New Deployment Share # and Open Deployment Share on the root row - something to act on, rather # than a parameter binding error. if ($PSCmdlet.ParameterSetName -eq 'FromPath' -and @($Path).Count -eq 0) { $Path = [string[]] @($size.Share) } # THE WINDOW OPENS BEFORE THE SHARE IS READ. # # Get-HDTConsoleWorkspace costs 820ms on the lab share - it reads and # validates every task sequence in it - and until this was deferred that was # 820ms with nothing on screen at all, because the tree had to exist before # the window could be shown. The window now comes up holding one row saying # it is reading, and the reading happens once it is up. # # NOT A BACKGROUND RUNSPACE. The read runs on the dispatcher after the first # paint, so the window is visible and busy for that second rather than # absent for it. A second runspace would need this module imported into it - # which is the OTHER second the console used to spend - before it could read # anything, and would then have to marshal every row back. # # A WORKSPACE HANDED IN HAS ALREADY BEEN READ, so that parameter set builds # its tree here exactly as it always did. $share = New-Object -TypeName System.Collections.ArrayList $carried = @{ Node = @() } $fill = $null if ($PSCmdlet.ParameterSetName -eq 'FromWorkspace') { foreach ($current in @($Workspace)) { [void] $share.Add($current) } $carried.Node = @(Get-HDTConsoleTreeNode -Workspace ([object[]] @($share))) # THE HOST IS HANDED THE ROOTS, NOT EVERY ROW. WPF builds the branches # from each row's Children, so the depth-0 rows are the whole # ItemsSource. Which rows those are is decided here rather than in the # adapter, which is not unit tested and must therefore not be the thing # that knows. $treeRoot = @($carried.Node | Where-Object { $_.Depth -eq 0 }) } else { $treeRoot = @(New-HDTConsolePendingNode -Path ([string[]] @($Path))) # WHAT THE RESULT REPORTS COMES OUT OF HERE TOO. This command returns # NodeCount and Workspace, and neither is known until the read has # happened - so both are carried in objects the block writes into rather # than read from variables that were still empty when the window opened. # # THE BLOCK IS BUILT ELSEWHERE, and New-HDTConsoleShareReader's own # notes say why: a plain script block would resolve $share against the # window host that invokes it, and GetNewClosure called here would choke # on the other parameter set's validated, empty parameter. $fill = New-HDTConsoleShareReader -Path ([string[]] @($Path)) -FileSystem $FileSystem ` -Share $share -Carried $carried } # -- show it ----------------------------------------------------------- # THE WIZARD'S MARKUP TRAVELS WITH THE CONSOLE'S, so the host never touches # the file system - the same reason the console's own markup arrives as a # string. Absent, the New Task Sequence button hides itself rather than # promising a window that cannot open. $newSequenceXaml = '' if (Test-Path -LiteralPath $NewSequenceXamlPath) { $newSequenceXaml = [System.IO.File]::ReadAllText($NewSequenceXamlPath) } $importOperatingSystemXaml = '' if (Test-Path -LiteralPath $ImportOperatingSystemXamlPath) { $importOperatingSystemXaml = [System.IO.File]::ReadAllText($ImportOperatingSystemXamlPath) } $importApplicationXaml = '' if (Test-Path -LiteralPath $ImportApplicationXamlPath) { $importApplicationXaml = [System.IO.File]::ReadAllText($ImportApplicationXamlPath) } $applicationDependencyXaml = '' if (Test-Path -LiteralPath $ApplicationDependencyXamlPath) { $applicationDependencyXaml = [System.IO.File]::ReadAllText($ApplicationDependencyXamlPath) } $applicationDetectionXaml = '' if (Test-Path -LiteralPath $ApplicationDetectionXamlPath) { $applicationDetectionXaml = [System.IO.File]::ReadAllText($ApplicationDetectionXamlPath) } $newWorkspaceXaml = '' if (Test-Path -LiteralPath $NewWorkspaceXamlPath) { $newWorkspaceXaml = [System.IO.File]::ReadAllText($NewWorkspaceXamlPath) } $answer = [string] $ConsoleHost.Show($xaml, $Title, [object[]] $treeRoot, (Get-HDTConsoleTheme -Name $Theme), $size, $Theme, $RefreshSecond, $newSequenceXaml, $importOperatingSystemXaml, $importApplicationXaml, $applicationDependencyXaml, $applicationDetectionXaml, $fill, $newWorkspaceXaml) # THE SIZE IT WAS LEFT AT, REMEMBERED. Save-HDTConsoleSetting refuses a size # below the window's minimum and never throws, so a closing window cannot # fail because a preference could not be written. # AND THE SHARES IT ENDED UP WITH, so the next console comes back to them. # Only when the window said - a host that never reported any is a window # that was never filled, and forgetting the list on that would lose it to # any failure at all. $settingSplat = @{ Width = [int] $ConsoleHost.Width Height = [int] $ConsoleHost.Height FileSystem = $FileSystem Environment = $Environment } if (@($ConsoleHost.PSObject.Properties.Match('OpenShare')).Count -gt 0 -and $null -ne $ConsoleHost.OpenShare) { $settingSplat['Share'] = [string[]] @($ConsoleHost.OpenShare) } [void] (Save-HDTConsoleSetting @settingSplat) # A window that was shut and a window whose Close button was pressed are the # same outcome here. See the header for why that differs from the wizard. $action = 'Close' if (-not [string]::IsNullOrWhiteSpace($answer)) { $action = $answer } return [pscustomobject] @{ Action = $action Title = $Title XamlPath = $XamlPath NodeCount = @($carried.Node).Count Workspace = [object[]] @($share) } } |