Public/Show-HDTWizardShell.ps1

function Show-HDTWizardShell {
    <#
        .SYNOPSIS
            Shows the multi-page technician wizard and returns what the
            technician chose.
 
        .DESCRIPTION
            MDT'S LITETOUCH WIZARD, DRIVEN. Show-HDTWizard shows ONE window and
            reports the button that was pressed. This shows
            HDTWizardShell.xaml - a rail down the left, the current page to the
            right of it, Back / Next / Cancel / Open CMD along the bottom - and
            drives it across every page the deployment still has to ask.
 
            ONE WINDOW, AND THE PAGE INSIDE IT IS SWAPPED. Not one dialog per
            page: a window that closed and reopened between pages would flicker,
            lose wherever the technician had dragged it, and make Back feel like
            a different program. The host opens the shell once and replaces the
            content of HDTPageHost; this decides what that content is.
 
            THE DECISIONS ARE NOT IN THE HOST AND MUST NOT BE. Step-HDTWizardPage
            is the navigator and is unit tested; this command owns which file is
            refused and what an answer means; New-HDTWizardHost owns WPF and is
            exempt from TDD only for as long as it has nothing in it worth
            testing (CLAUDE.md rule 1). The host is handed a NAVIGATOR to call
            on each click, so page order never lives inside the adapter.
 
            EVERY PAGE IS CHECKED BEFORE THE FIRST ONE IS SHOWN. A missing or
            half-written page file is refused here, by name, while a human can
            still read the message - not two clicks into a deployment, in WinPE,
            on a machine whose console has been hidden to put this window on
            screen. That is the same rule Show-HDTWizard holds for its single
            window, applied to all of them.
 
            A DISMISSED WINDOW IS A CANCEL, and the allow-list is the same three
            answers for the same reason: Next leads to a task sequence that
            partitions a disk, so anything that is not exactly 'Next', 'Cancel'
            or 'CommandPrompt' comes back as 'Cancel'.
 
            OPENING THE PROMPT IS STILL THE CALLER'S JOB. 'CommandPrompt' is
            MDT's "Exit to Command Prompt": the window closes and the technician
            is left at a prompt - which in WinPE means the caller restores the
            console it hid (Hide-HDTShellWindow -Restore). This command reports
            what was asked for and opens nothing.
 
        .PARAMETER ShellXamlPath
            The shell window. X:\HDT\UI\HDTWizardShell.xaml inside a boot image.
 
        .PARAMETER Page
            The ordered pages this deployment will actually ask - already
            filtered, because a skipped page does not appear in the rail either
            (DESIGN 11.2). Each entry carries Id, Title, Heading, Subheading and
            XamlPath; the markup at XamlPath is read here and handed over, so the
            host never touches the file system.
 
            A page may also carry Validate - a Control name and a Rule name -
            and the rule is resolved to a validator here. The page names a RULE
            and never a command, because pages live on the share: one that could
            name a command would be one that could run one. A rule this engine
            does not implement is refused rather than ignored.
 
        .PARAMETER Title
            The window title.
 
        .PARAMETER ThemeXamlPath
            HDTTheme.xaml, merged into the shell at runtime so every page is
            styled from one place. Omitted, the shell renders on whatever its own
            markup declares.
 
        .PARAMETER Field
            What every box should say, from Get-HDTWizardField. Applied by name
            after each page is loaded; a name no page answers to is skipped.
 
        .PARAMETER Pane
            Which panes are visible, from Get-HDTWizardSkip.
 
        .PARAMETER WizardHost
            An IWizardHost. Defaults to the real adapter.
 
        .PARAMETER FileSystem
            An IFileSystem. Defaults to the real adapter.
 
        .INPUTS
            None. This command does not accept pipeline input.
 
        .OUTPUTS
            System.Management.Automation.PSCustomObject with Action ('Next',
            'Cancel' or 'CommandPrompt'), Title, ShellXamlPath and PageCount.
 
        .EXAMPLE
            Show-HDTWizardShell -ShellXamlPath 'X:\HDT\UI\HDTWizardShell.xaml' -Page $page
 
            What the payload calls in WinPE.
 
        .EXAMPLE
            $answer = Show-HDTWizardShell -ShellXamlPath $p -Page $page
            if ($answer.Action -eq 'CommandPrompt') { [void] (Hide-HDTShellWindow -Restore); return }
            if ($answer.Action -ne 'Next') { return }
 
            How every caller must read it: a prompt is not a cancel, and only an
            explicit Next deploys.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [string] $ShellXamlPath,

        # EMPTY IS ALLOWED THROUGH THE BINDER SO IT CAN BE REFUSED BY NAME below.
        [Parameter(Mandatory = $true)]
        [AllowNull()]
        [AllowEmptyCollection()]
        [object[]] $Page,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string] $Title = 'Hephaestus Deployment Toolkit',

        [Parameter()]
        [AllowEmptyString()]
        [string] $ThemeXamlPath = '',

        [Parameter()]
        [AllowNull()]
        [object[]] $Field,

        [Parameter()]
        [AllowNull()]
        [object[]] $Pane,

        [Parameter()]
        [AllowNull()]
        [object] $WizardHost,

        [Parameter()]
        [AllowNull()]
        [object] $FileSystem,

        # WHAT F8 RUNS, AND IT IS PASSED IN FOR THE SAME REASON THE NAVIGATOR IS.
        # The host is an adapter no Pester test can open a window against, so
        # anything decided inside it is decided where nothing can check it. The
        # decision here is small and worth checking anyway: F8 opens a prompt
        # OVER the wizard and the wizard stays - MDT's "Enable command support",
        # which every technician who has debugged a deployment already knows.
        # The Open CMD button is the other thing: an EXIT to a prompt, answered
        # back to the caller.
        [Parameter()]
        [AllowNull()]
        [scriptblock] $CommandPrompt
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    if ($null -eq $FileSystem) { $FileSystem = New-HDTFileSystem }
    if ($null -eq $WizardHost) { $WizardHost = New-HDTWizardHost }

    # THE DEFAULT IS THE REAL PROMPT, so a caller that says nothing still gets
    # F8. Start-HDTCommandPrompt already refuses to throw - a prompt that will
    # not open must not take the wizard with it - so nothing here needs a guard.
    if ($null -eq $CommandPrompt) { $CommandPrompt = { [void] (Start-HDTCommandPrompt) } }

    if (@($Page).Count -eq 0) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Category InvalidArgument `
                    -Message ('the wizard has no pages to show. Every page being skipped means NOT SHOWING THE WIZARD, which is the caller''s decision - see DESIGN 11.2 and HDTSkipWizard.')))
    }

    # THE CLOSED SET OF VALIDATION RULES a page may name. Each one wraps a
    # command that already exists and is tested where it lives - this is a
    # lookup, not a second implementation.
    #
    # RestrictInput MEANS "JUDGE A KEYSTROKE WITH THE SAME RULE". A character is
    # typeable when a name consisting of just that character would be accepted -
    # which is exactly true for this rule, because its character set is a
    # per-character test. So an unusable character CANNOT BE TYPED, and there is
    # still only ONE copy of what the legal characters are: nothing anywhere
    # holds a second list for the keyboard to check against.
    #
    # That mattered the moment a technician asked why the wizard let them type an
    # underscore at all. Refusing it after the fact is a message; refusing the
    # keystroke is an answer.
    # ONE CONTROL, MORE THAN ONE VARIABLE. A page may declare Split on a Collect
    # entry: the typed string is put through the named splitter and the parts
    # land in the variables the declaration names. The join account is the case
    # that needed it - a technician types CORP\svc-hdt-join in one box, and
    # DESIGN 4.5.3 wants HDTDomainAdmin and HDTDomainAdminDomain out of it.
    #
    # A CLOSED SET, LIKE THE RULES BELOW. A page on the share names a splitter
    # and never a command.
    $knownSplit = @{
        AccountName = {
            param([string] $Value)

            return Split-HDTAccountName -Name $Value
        }
    }

    $knownRule = @{
        ComputerName = [pscustomobject] @{
            Validator     = {
                param([string] $Value)

                return Test-HDTComputerName -Name $Value
            }
            RestrictInput = $true
        }
    }

    # -- every file, before anything is shown ------------------------------
    #
    # See the header: a wizard that opens and then dies on page four is worse
    # than one that refuses to open and says which file is broken.

    $read = {
        param([string] $Path, [string] $What)

        if (-not $FileSystem.TestPath($Path)) {
            throw (New-HDTErrorRecord -Path $Path -Category ObjectNotFound `
                    -Message ('{0} is not there, so the wizard cannot be shown. In a boot image it is staged to X:\HDT\UI\ by Update-HDTBootImage.' -f $What))
        }

        $text = [string] $FileSystem.ReadAllText($Path)

        # WELL-FORMEDNESS ONLY, not XAML semantics - a tag WPF dislikes still
        # fails at Show. What is caught here is the truncated or half-written
        # file a bad copy into a boot image produces.
        try {
            [void] ([xml] $text)
        } catch {
            throw (New-HDTErrorRecord -Path $Path -Category InvalidData `
                    -Message ('{0} is not well-formed XML, so it could not be shown: {1}' -f
                        $What, [string] $_.Exception.Message))
        }

        return $text
    }

    try {
        $shellXaml = & $read $ShellXamlPath 'the wizard shell'

        $themeXaml = ''
        if (-not [string]::IsNullOrWhiteSpace($ThemeXamlPath)) {
            $themeXaml = & $read $ThemeXamlPath 'the wizard theme'
        }

        # THE PAGE MARKUP IS READ HERE, NOT IN THE HOST. The host is a WPF
        # adapter; an adapter that reads files has something in it worth
        # testing, which is the exemption it would then no longer qualify for.
        $loaded = @()
        foreach ($current in @($Page)) {
            $id = [string] $current.Id

            # A PAGE DECLARES WHAT IT VALIDATES; IT DOES NOT CARRY THE RULE.
            # Pages live on the SHARE (DESIGN 11.2) and are edited by
            # administrators, so a page that could name a command would be a
            # page that could run one. It names a control and a RULE, and the
            # closed set below is what those names may be.
            #
            # AN UNKNOWN RULE IS REFUSED RATHER THAN IGNORED. A control that
            # silently never validates looks, on a bench, like a wizard that
            # accepts anything - and the value on the other side of it is a
            # machine's identity.
            $validate = $null
            $validator = $null
            $restrictInput = $false

            if ($null -ne $current.PSObject.Properties['Validate'] -and $null -ne $current.Validate) {
                $validate = $current.Validate
                $ruleName = [string] $validate.Rule

                if (-not $knownRule.Contains($ruleName)) {
                    throw (New-HDTErrorRecord -Category InvalidArgument `
                            -Message ('wizard page ''{0}'' declares the validation rule ''{1}'', which this engine does not implement. Known rules: {2}.' -f
                                $id, $ruleName, ((@($knownRule.Keys) | Sort-Object) -join ', ')))
                }

                $validator = $knownRule[$ruleName].Validator
                $restrictInput = [bool] $knownRule[$ruleName].RestrictInput
            }

            # WHAT THIS PAGE FILLS IN, AND WHAT HIDES IT. Collect names a
            # control, the variable it fills and the property to read it from -
            # so a ListBox and a TextBox are the same to the host, which reads
            # the named property and knows nothing about either. Skip is the
            # variable that suppresses this page, and exists so the summary can
            # TELL a technician what it is (DESIGN 11.2's skip model was
            # documented and undiscoverable).
            $collect = $null
            if ($null -ne $current.PSObject.Properties['Collect']) { $collect = $current.Collect }

            # A DECLARATION THAT SPLITS gets its splitter attached here, for the
            # same reason a validation rule does: the page names one, and an
            # unknown name is refused rather than silently doing nothing.
            $resolvedCollect = @()
            foreach ($declaration in @($collect)) {
                if ($null -eq $declaration) { continue }

                $splitName = ''
                if ($null -ne $declaration.PSObject.Properties['Split']) { $splitName = [string] $declaration.Split }

                if (-not [string]::IsNullOrWhiteSpace($splitName)) {
                    if (-not $knownSplit.Contains($splitName)) {
                        throw (New-HDTErrorRecord -Category InvalidArgument `
                                -Message ('wizard page ''{0}'' declares the splitter ''{1}'', which this engine does not implement. Known splitters: {2}.' -f
                                    $id, $splitName, ((@($knownSplit.Keys) | Sort-Object) -join ', ')))
                    }

                    $declaration | Add-Member -MemberType NoteProperty -Name 'Splitter' `
                        -Value $knownSplit[$splitName] -Force
                }

                $resolvedCollect += $declaration
            }

            if (@($resolvedCollect).Count -gt 0) { $collect = $resolvedCollect }

            $skip = ''
            if ($null -ne $current.PSObject.Properties['Skip']) { $skip = [string] $current.Skip }

            $summary = $null
            if ($null -ne $current.PSObject.Properties['Summary']) { $summary = $current.Summary }

            $loaded += [pscustomobject] @{
                Id         = $id
                Title      = [string] $current.Title
                Heading    = [string] $current.Heading
                Subheading = [string] $current.Subheading
                Collect    = $collect
                Skip       = $skip
                Summary    = $summary
                XamlPath   = [string] $current.XamlPath
                Xaml          = (& $read ([string] $current.XamlPath) ('wizard page ''{0}''' -f $id))
                Validate      = $validate
                Validator     = $validator
                RestrictInput = $restrictInput
            }
        }
    } catch {
        $PSCmdlet.ThrowTerminatingError($_)
    }

    # -- show it -----------------------------------------------------------

    # THE NAVIGATOR THE HOST CALLS ON EVERY CLICK. GetNewClosure captures
    # $loaded, so the host is handed a question it can ask rather than a list it
    # would have to reason about.
    #
    # IT TAKES WHAT HAS BEEN COLLECTED SO FAR, because one page is not like the
    # others: the summary has to state what every EARLIER page ended up holding,
    # and it has to be right at the moment it is shown rather than at the moment
    # the wizard opened. A technician who presses Back, changes the name and
    # comes forward again must see the new one.
    $navigator = {
        param([int] $Index, [string] $Action, [hashtable] $Value)

        $next = Step-HDTWizardPage -Page $loaded -Index $Index -Action $Action

        if ($null -ne $next.Page -and $null -ne $next.Page.Summary) {
            $built = Get-HDTWizardSummary -Page $loaded -Value $Value

            # ADDED TO THE STATE, NOT BAKED INTO THE PAGE. The page object is
            # reused every time it is reached; writing onto it would leave the
            # previous visit's rows behind on a Back that changed nothing.
            $next | Add-Member -MemberType NoteProperty -Name 'SummaryRow' -Value $built.Row -Force
            $next | Add-Member -MemberType NoteProperty -Name 'SummarySnippet' -Value $built.Snippet -Force
        }

        return $next
    }.GetNewClosure()

    $state = & $navigator 0 'Start' @{}

    # THE SHELL'S OWN TEXT - its title, its subtitle and the four buttons on
    # the rail. The PAGES inside it carry their own, and each page is parsed
    # into its own name scope, so the shell's block cannot reach them.
    #
    # THE FILE NAME IS THE BLOCK NAME, the same rule Show-HDTWizard uses.
    $string = @{}

    try {
        $string = Get-HDTStringTable -Page (
            [System.IO.Path]::GetFileNameWithoutExtension($ShellXamlPath) -replace '^HDT', '')
    } catch {
        Write-Verbose ("no string table block for '{0}': {1}" -f $ShellXamlPath, [string] $_.Exception.Message)
    }

    # NULLS ARE STRIPPED for the same reason Show-HDTWizard strips them: @($null)
    # is a one-element array carrying $null, and the host reads .Name off every
    # element under Set-StrictMode.
    $answer = [string] $WizardHost.ShowShell($shellXaml, $themeXaml, $Title, $state,
        @($Field | Where-Object { $null -ne $_ }),
        @($Pane | Where-Object { $null -ne $_ }),
        $navigator, $CommandPrompt, $string)

    # THE ALLOW-LIST, and it is the same one Show-HDTWizard holds for the same
    # reason. Matched case-sensitively, and the ALLOW-LIST's spelling is what is
    # returned - never the host's string.
    $action = 'Cancel'
    foreach ($allowed in @('Next', 'Cancel', 'CommandPrompt')) {
        if ($answer -ceq $allowed) {
            $action = $allowed
            break
        }
    }

    # WHAT WAS TYPED COMES BACK WITH THE ANSWER. A wizard that reported Next and
    # dropped the values would be a wizard that asked a technician for a
    # computer name and then deployed the machine without it.
    #
    # THE HOST READ THEM, SO THE HOST HANDS THEM BACK - it is the only thing
    # that ever touched the controls. This forwards them and interprets none of
    # them; the payload puts them into the variable engine as the Wizard source
    # (DESIGN 3.1), where provenance records that they were typed.
    #
    # THEY COME BACK ON A CANCEL TOO. A cancel is not an erasure: what was typed
    # before it is worth logging, and what a cancel MEANS is the caller's
    # decision, not this command's.
    $value = @{}
    if ($null -ne $WizardHost.PSObject.Properties['Value'] -and $null -ne $WizardHost.Value) {
        $value = $WizardHost.Value
    }

    return [pscustomobject] @{
        Action        = $action
        Title         = $Title
        ShellXamlPath = $ShellXamlPath
        PageCount     = @($loaded).Count
        Value         = $value
    }
}