Public/Get-HDTConsoleBootImageSetting.ps1

function Get-HDTConsoleBootImageSetting {
    <#
        .SYNOPSIS
            Everything the WinPE window shows, worked out without a window.

        .DESCRIPTION
            HDTBootImage.xaml is four tabs over one YAML block. This is the
            question those tabs ask, answered once: what is in the General
            boxes, which optional components are ticked and which may not be
            unticked, what content and start commands the document declares, and
            THE CALL EACH CONTROL WOULD RUN.

            IT EXISTS SO THE ADAPTER STAYS AN ADAPTER. New-HDTConsoleHost is
            exempt from TDD as a thin wrapper over WPF, and the price of that
            exemption is that it stays branch-free - "because it is not unit
            tested". Deciding which rows are ticked, which are locked, what a
            size reads as and what a button would invoke is all decision, so it
            is here, where Pester reaches it with no display.

            THE ADK IS A PARAMETER, NOT A READ. Get-HDTAdkComponent needs an
            installed ADK and a registry hive; taking its output means the whole
            Features tab is testable on a machine with neither, and means the
            window can re-ask for a different architecture without this command
            knowing how.

            EVERY ROW CARRIES ITS OWN INVOCATION, which is DESIGN 12's rule:
            "the console may not do anything the cmdlets can't", and it shows
            the invocation so an administrator learns the automation surface by
            clicking. Where the value is typed at click time - a driver group, a
            content pair - the object carries a FORMAT rather than a finished
            string, and the adapter's whole contribution is -f.

            A DOCUMENT THAT WILL NOT PARSE THROWS. The browser's row already
            says so; a window that opened anyway would offer to Save over it.

        .PARAMETER Line
            The workspace.yaml lines, as read from disk. Nothing here writes;
            the editing commands splice these same lines.

        .PARAMETER Path
            The document the lines came from, for the banner. Read by nothing.

        .PARAMETER Component
            What Get-HDTAdkComponent returned for the architecture being shown.
            An empty list is legal and means the Features tab has no rows -
            which is what a build host with no ADK actually offers.

        .PARAMETER HasCertificatePassword
            Whether Control\certificate-password.json exists, as
            Test-HDTBootImageCertificatePassword answers it. INJECTED RATHER
            THAN READ, like the ADK list and the driver groups - and unlike
            them, deliberately a BOOLEAN rather than the value: a view model
            that read the password would hold a private key's password in an
            object a window binds to.

        .PARAMETER DriverGroup
            What Get-HDTDriverGroup returned for this share. An empty list is
            legal and means the only choice is "no drivers" - which is what a
            share with nothing imported yet honestly offers.

        .INPUTS
            None. This command does not accept pipeline input.

        .OUTPUTS
            System.Management.Automation.PSCustomObject with Title,
            DocumentPath, General, Component, SelectedSizeText, Driver, Content,
            StartCommand and the Add* formats.

        .EXAMPLE
            $line = Get-Content -LiteralPath 'C:\HDTLab\Share\workspace.yaml'
            Get-HDTConsoleBootImageSetting -Line $line -Path 'C:\HDTLab\Share\workspace.yaml' `
                -Component (Get-HDTAdkComponent -Architecture amd64)

        .EXAMPLE
            (Get-HDTConsoleBootImageSetting -Line $line -Path $path -Component $adk).Component |
                Where-Object { $_.Declared } | Format-Table Name, SizeText

            The whole Features tab, on a console, with no window.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [AllowEmptyCollection()]
        [AllowEmptyString()]
        [string[]] $Line,

        [Parameter(Mandatory = $true, Position = 1)]
        [ValidateNotNullOrEmpty()]
        [string] $Path,

        [Parameter()]
        [AllowNull()]
        [AllowEmptyCollection()]
        [object[]] $Component = @(),

        [Parameter()]
        [AllowNull()]
        [AllowEmptyCollection()]
        [object[]] $DriverGroup = @(),

        [Parameter()]
        [bool] $HasCertificatePassword = $false,

        [Parameter()]
        [AllowNull()]
        [AllowEmptyCollection()]
        [object[]] $TimeZone = @()
    )

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

    # Throws on a document that will not parse. See the header: that is the
    # answer, not something to recover from.
    $workspace = ConvertFrom-HDTWorkspaceLine -Line $Line
    $bootImage = $workspace.BootImage

    # -- the Features tab ----------------------------------------------------
    #
    # DECLARED IS "the document asks for it OR the engine always applies it".
    # Get-HDTAdkComponent's Required marks the six every image gets; they are
    # shown ticked and disabled rather than hidden, because an administrator
    # looking for WinPE-PowerShell in this list has to FIND it and see that it
    # is already there.

    $declaredName = @($bootImage.OptionalComponent | ForEach-Object { [string] $_ })

    $componentRow = New-Object -TypeName System.Collections.ArrayList
    $selectedBytes = [long] 0
    $selectedCount = 0

    foreach ($current in @($Component)) {
        $isRequired = [bool] $current.Required
        $isDeclared = $isRequired -or ($declaredName -contains [string] $current.Name)

        if ($isDeclared) {
            $selectedBytes += [long] $current.SizeBytes
            $selectedCount++
        }

        # WHAT IT NEEDS BESIDE IT, ON THE ROW. WinPE-PowerShell without
        # WinPE-WMI is a build that fails two and a half minutes in; saying so
        # where the tick is costs nothing.
        $requiresText = ''
        if (@($current.Requires).Count -gt 0) {
            $requiresText = 'needs {0}' -f (@($current.Requires) -join ', ')
        }
        if ($isRequired) {
            $requiresText = ('always applied. {0}' -f $requiresText).Trim()
        }

        # WHAT IT DOES, THEN WHAT IT COSTS YOU TO TICK IT, IN ONE COLUMN. The
        # description answers "what is this", the suffix answers "and what else
        # comes with it" - and they are read together, in that order, by
        # somebody deciding. Two columns would put the answer to the second
        # question off the right-hand edge on a narrow window.
        $description = [string] $current.Description
        $detailText = $description

        if (-not [string]::IsNullOrWhiteSpace($requiresText)) {
            if ([string]::IsNullOrWhiteSpace($detailText)) {
                $detailText = $requiresText
            } else {
                $detailText = '{0} ({1})' -f $description, $requiresText
            }
        }

        [void] $componentRow.Add([pscustomobject] @{
                Name          = [string] $current.Name
                Declared      = $isDeclared
                CanChange     = (-not $isRequired)
                SizeText      = '{0:N1} MB' -f ([double] $current.SizeBytes / 1MB)
                Description   = $description
                DetailText    = $detailText
                RequiresText  = $requiresText
                AddCommand    = "Add-HDTBootImageComponent -Line `$line -Name '{0}'" -f [string] $current.Name
                RemoveCommand = "Remove-HDTBootImageComponent -Line `$line -Name '{0}'" -f [string] $current.Name
            })
    }

    # THE TOTAL, BECAUSE THE COST OF THAT TAB IS THE THING IT HIDES. Every tick
    # is megabytes in a WIM transferred to every machine that PXE boots, and a
    # list gives no sense of that one row at a time.
    $selectedSizeText = '{0} of {1} components selected, {2:N1} MB of cabs before compression.' -f
        $selectedCount, @($Component).Count, ([double] $selectedBytes / 1MB)

    # -- the Customisations tab ----------------------------------------------

    $contentRow = New-Object -TypeName System.Collections.ArrayList

    foreach ($current in @($bootImage.ExtraContent)) {
        [void] $contentRow.Add([pscustomobject] @{
                Source        = [string] $current.Source
                Destination   = [string] $current.Destination

                # KEYED ON THE DESTINATION, which is what Remove takes: it is
                # what makes a row unique inside the image, and two sources can
                # land in one place.
                RemoveCommand = "Remove-HDTBootImageContent -Line `$line -Destination '{0}'" -f [string] $current.Destination
            })
    }

    $startCommandRow = New-Object -TypeName System.Collections.ArrayList

    foreach ($current in @($bootImage.StartCommand)) {
        # THE LINE AS THE DOCUMENT HOLDS IT, AND NOTHING ELSE. This view model
        # describes workspace.yaml; startnet.cmd is a different file, generated
        # from it at build time and carrying `call` in front of a batch file so
        # cmd.exe returns. An earlier version also published that generated
        # line here, and the window showed it - which put text on screen that
        # appears in no file this window can save. Get-HDTStartnetScript is
        # where that translation belongs, and it is the only caller of
        # ConvertTo-HDTStartnetCommandLine again.
        [void] $startCommandRow.Add([pscustomobject] @{
                Text          = [string] $current
                RemoveCommand = "Remove-HDTBootImageStartCommand -Line `$line -Command '{0}'" -f [string] $current
            })
    }

    # -- the time zone --------------------------------------------------------
    #
    # A LIST, BECAUSE tzutil TAKES AN ID AND NOBODY KNOWS THE IDS. What an
    # administrator is looking for is "(UTC+02:00) Jerusalem"; what the document
    # has to hold is "Israel Standard Time".
    #
    # "The hardware clock" IS THE FIRST ROW rather than an empty selection - it
    # is what every image built before this did, and a list you can only say it
    # in by clearing the box is a list you cannot say it in.
    #
    # THE DOCUMENT'S OWN ANSWER SURVIVES A MACHINE THAT HAS NEVER HEARD OF IT.
    # Windows adds time zones; a share edited on a patched machine and opened on
    # an older one would otherwise read as "no time zone", and a Save would make
    # that true.

    $zoneChoice = New-Object -TypeName System.Collections.ArrayList

    [void] $zoneChoice.Add([pscustomobject] @{
            Id      = ''
            Display = '(leave WinPE on the hardware clock)'
        })

    foreach ($current in @($TimeZone)) {
        [void] $zoneChoice.Add([pscustomobject] @{
                Id      = [string] $current.Id
                Display = [string] $current.Display
            })
    }

    $declaredZone = [string] $bootImage.TimeZone

    if (-not [string]::IsNullOrWhiteSpace($declaredZone) -and
        @($zoneChoice | Where-Object { $_.Id -eq $declaredZone }).Count -eq 0) {

        [void] $zoneChoice.Add([pscustomobject] @{
                Id      = $declaredZone
                Display = '{0} (this machine does not know it)' -f $declaredZone
            })
    }

    $timeZoneRow = [pscustomobject] @{
        Id                 = $declaredZone
        Choice             = [pscustomobject[]] @($zoneChoice)

        Hint               = 'WinPE has no time zone setting in its answer file, so it runs on the hardware clock - UTC in practice, which puts its log timestamps hours from the machine it just built. Named here, startnet.cmd runs tzutil, and the deployed machine''s unattend inherits the same answer.'

        ApplyCommandFormat = 'Set-HDTBootImageTimeZone -Line $line -Name ''{0}'''
        ClearCommand       = 'Set-HDTBootImageTimeZone -Line $line -Clear'
    }

    # -- the Certificates tab -------------------------------------------------
    #
    # TWO LISTS BECAUSE THEY ARE TWO DIFFERENT THINGS, and the Store column is
    # what says so on the screen: a certificate authority is trusted (Root) and
    # a machine certificate is presented (My). An administrator looking at a
    # page with two file boxes on it has to be told why it is not one.

    $certificateRow = New-Object -TypeName System.Collections.ArrayList

    foreach ($current in @($bootImage.RootCertificate)) {
        [void] $certificateRow.Add([pscustomobject] @{
                Path          = [string] $current
                Store         = 'Root'
                RemoveCommand = ("Remove-HDTBootImageCertificate -Line `$line -Path '{0}'" -f [string] $current)
            })
    }

    # THE COUNT, BECAUSE THE BOX SHOWS ONE ROW AT A TIME. A PKI is a chain - a
    # root and the subordinate that actually issued the certificate - and an
    # image trusting only the root still cannot validate what the issuing CA
    # signed. A drop-down that has to be opened to find out how many are in it
    # is a drop-down nobody opens.
    $certificateSummaryText = 'This image trusts no certificate authorities of its own, which is the ordinary case.'

    if (@($certificateRow).Count -eq 1) {
        $certificateSummaryText = '1 certificate authority trusted.'
    } elseif (@($certificateRow).Count -gt 1) {
        $certificateSummaryText = '{0} certificate authorities trusted - a chain is normally a root and the subordinate that issued from it.' -f
        @($certificateRow).Count
    }

    # THE WARNING IS COMPUTED HERE, NOT DRAWN IN THE MARKUP, because it is a
    # fact about this document: a .pfx named with no password stored is a build
    # Update-HDTBootImage refuses, and the refusal comes minutes after the press
    # if the window did not say so first.
    $clientPath = [string] $bootImage.ClientCertificate
    $certificateWarning = ''

    if (-not [string]::IsNullOrWhiteSpace($clientPath) -and -not $HasCertificatePassword) {
        $certificateWarning = 'This certificate has no password stored, and a .pfx will not import without one. Update Boot Image refuses the build until Set password has been used.'
    }

    $clientCertificate = [pscustomobject] @{
        Path                  = $clientPath
        HasPassword           = $HasCertificatePassword
        Warning               = $certificateWarning

        ApplyCommandFormat    = 'Set-HDTBootImageClientCertificate -Line $line -Path ''{0}'''
        ClearCommand          = 'Set-HDTBootImageClientCertificate -Line $line -Clear'

        # THE SHARE, AND NOT THE PASSWORD. What the window echoes is the command
        # an administrator could have typed, and this is the one command in the
        # console whose second argument must never appear in that box.
        # THE PROMPT IS Get-Credential's, and a contract test is what decided
        # that: no file in this module may name the console-reading cmdlet, at
        # all, because the engine runs where nobody is at the keyboard and a
        # prompt there is a deployment that hangs until somebody notices.
        #
        # THE RULE IS FILE-BLIND AND SO IS ITS SCAN. This view model never runs
        # in WinPE and the test does not care - and it also matched the COMMENT
        # that first explained the exception, which is why this one does not
        # spell the name either.
        PasswordCommandFormat = 'Set-HDTBootImageCertificatePassword -WorkspaceRoot ''{0}'' -Password (Get-Credential -UserName certificate -Message ''The .pfx password'').Password'
    }

    # -- the General tab, and the calls Save runs ----------------------------

    $general = [pscustomobject] @{
        Name                  = [string] $bootImage.Name
        Architecture          = [string] $bootImage.Architecture
        Language              = [string] $bootImage.Language

        # AS TEXT, because the combo matches on its Tag and SelectedValuePath
        # compares strings. An int here selects nothing, and the box comes up
        # blank on a document that set it.
        ScratchSpaceMB        = [string] $bootImage.ScratchSpaceMB
        Unattend              = [string] $bootImage.Unattend
        Background            = [string] $bootImage.Background

        # A REAL BOOLEAN, because the control is a CheckBox and IsChecked takes
        # one - every other value here is a string because the controls that
        # show them are text boxes and combo boxes.
        PromptForKey          = [bool] $bootImage.PromptForKey

        Command               = "Set-HDTWorkspaceProperty -Line `$line -BootImageName '{0}' -Architecture '{1}' -Language '{2}' -ScratchSpaceMB {3}" -f
        [string] $bootImage.Name, [string] $bootImage.Architecture,
        [string] $bootImage.Language, [string] $bootImage.ScratchSpaceMB

        UnattendCommandFormat = 'Set-HDTBootImageUnattend -Line $line -Path ''{0}'''
        UnattendClearCommand  = 'Set-HDTBootImageUnattend -Line $line -Clear'

        # THE FILE, NOT THE NAME OF IT. The box beside this takes a path to an
        # answer file that has to already exist, and a share that has never had
        # one has nothing to browse to - which left the ordinary case as "go and
        # write a windowsPE document from Microsoft's schema, by hand".
        UnattendTemplateCommandFormat = 'New-HDTBootImageUnattend -Workspace ''{0}'''

        BackgroundCommandFormat = 'Set-HDTBootImageBackground -Line $line -Path ''{0}'''
        BackgroundClearCommand  = 'Set-HDTBootImageBackground -Line $line -Clear'
    }

    # -- the Bootstrap tab ---------------------------------------------------
    #
    # MDT'S Bootstrap.ini IS THE FILE WinPE READS BEFORE IT HAS A SHARE: where
    # the share is, who to sign in as, whether to ask. HDT's equivalent is
    # bootstrap.json, written into X:\HDT\ by Update-HDTBootImage - so unlike
    # MDT's it is GENERATED, and a tab that edited it as text would be editing
    # something the next build overwrites.
    #
    # SO THIS TAB IS THE FACTS IT IS BUILT FROM. Same information, same place on
    # the window an MDT administrator would look, edited through the commands
    # that own those keys - the only shape that survives a rebuild.

    $credentialUser = ''
    if ($null -ne $workspace.Credential) {
        $credentialUser = [string] $workspace.Credential.Username
    }

    $hasCredential = -not [string]::IsNullOrWhiteSpace($credentialUser)

    # DECIDED HERE THE WAY THE BUILDER DECIDES IT, not asked as a second
    # question: Update-HDTBootImage calls a UNC deployRoot Smb and everything
    # else Local. Two places to state it is two places for them to disagree.
    $provider = 'Local'
    if (([string] $workspace.DeployRoot).StartsWith('\')) { $provider = 'Smb' }

    # A UNC SHARE WITH NO STORED CREDENTIAL ASKS THE TECHNICIAN, which is
    # LiteTouch's behaviour and Update-HDTBootImage's. It is worth saying on the
    # window because the two images behave very differently in front of
    # somebody: one runs unattended, one stops at a sign-in box.
    $promptForCredential = [bool] ((-not $hasCredential) -and $provider -eq 'Smb')

    # THE SENTENCES THE WINDOW SHOWS, WRITTEN HERE. ShowBootImage's rule about
    # itself is "the query, and only assignment after it": a host that built
    # 'Reached over SMB' out of a property would be computing something, and the
    # next window wanting the same sentence would word it differently.
    if ($provider -eq 'Smb') {
        $providerText = 'Reached over SMB. A UNC deployRoot is what a booted machine can resolve.'
    } else {
        $providerText = 'Reached as a local path - a build host, or standalone media.'
    }

    if ($hasCredential) {
        $credentialText = $credentialUser
    } else {
        $credentialText = '(not set)'
    }

    if ($promptForCredential) {
        $promptText = 'No credential is stored, so the technician is asked to sign in when the machine boots.'
    } elseif ($hasCredential) {
        $promptText = 'The stored credential is built into the boot image, so the deployment runs unattended.'
    } else {
        $promptText = 'A local deployRoot needs no credential.'
    }

    $bootstrap = [pscustomobject] @{
        WorkspaceId             = [string] $workspace.Id
        ShareName               = [string] $workspace.Name
        DeployRoot              = [string] $workspace.DeployRoot
        LogLevel                = [string] $workspace.LogLevel
        Provider                = $provider
        CredentialUser          = $credentialUser
        HasCredential           = $hasCredential
        PromptForCredential     = $promptForCredential
        ProviderText            = $providerText
        CredentialText          = $credentialText
        PromptText              = $promptText

        Command                 = "Set-HDTWorkspaceProperty -Line `$line -Name '{0}' -DeployRoot '{1}' -LogLevel '{2}'" -f
        [string] $workspace.Name, [string] $workspace.DeployRoot, [string] $workspace.LogLevel

        # THE PASSWORD IS NEVER IN THE COMMAND. Get-Credential in the echoed
        # line is the same rule the certificate password follows: nothing on a
        # window, and nothing in a command an administrator can read back, is
        # ever a secret in clear text.
        CredentialCommandFormat = "Set-HDTShareCredential -WorkspaceRoot '{0}' -Credential (Get-Credential -Message 'The account WinPE signs in to the share as')"
    }

    # -- the Drivers tab -----------------------------------------------------
    #
    # A LIST, NOT A BOX YOU TYPE INTO. A group is a folder under Drivers\ on the
    # share, so the set of legal answers is knowable - and a typed one that is
    # wrong produces a boot image with no drivers in it, warned about at build
    # time and discovered on a bench.
    #
    # THE EMPTY ANSWER IS AN ENTRY IN THE LIST. "No drivers" is a real choice,
    # not the absence of one, and a list whose only way to say it was to clear a
    # selection would be a list you cannot say it in.
    $driverChoice = New-Object -TypeName System.Collections.ArrayList

    [void] $driverChoice.Add([pscustomobject] @{
            Name    = ''
            Display = '(none - WinPE uses the drivers Microsoft ships)'
        })

    foreach ($current in @($DriverGroup)) {
        [void] $driverChoice.Add([pscustomobject] @{
                Name    = [string] $current.Name
                Display = [string] $current.Name
            })
    }

    # THE DOCUMENT'S OWN ANSWER, EVEN WHEN THE FOLDER IS GONE. A share whose
    # driver group was renamed still has to show what the document says, or the
    # window silently reads as "no drivers" and a Save makes that true.
    $declaredGroup = [string] $bootImage.Drivers

    if (-not [string]::IsNullOrWhiteSpace($declaredGroup) -and
        @($driverChoice | Where-Object { $_.Name -eq $declaredGroup }).Count -eq 0) {

        [void] $driverChoice.Add([pscustomobject] @{
                Name    = $declaredGroup
                Display = '{0} (not on the share)' -f $declaredGroup
            })
    }

    $driver = [pscustomobject] @{
        Group              = $declaredGroup
        Choice             = [pscustomobject[]] @($driverChoice)
        ApplyCommandFormat = 'Set-HDTBootImageDriver -Line $line -Name ''{0}'''
        ClearCommand       = 'Set-HDTBootImageDriver -Line $line -Clear'
    }

    return [pscustomobject] @{
        Title                     = 'Windows PE - {0}' -f [string] $bootImage.Name
        DocumentPath              = $Path

        # THE SHARE, WHICH IS THE DOCUMENT'S FOLDER. Everything the boot image
        # names - the answer file, the background, extraContent's sources - is
        # stored relative to it, and neither a text box nor a file picker hands
        # a root back. Worked out here so the window resolves nothing.
        WorkspaceRoot             = [string] (Split-Path -Path $Path -Parent)

        General                   = $general
        Bootstrap                 = $bootstrap
        Component                 = [pscustomobject[]] @($componentRow)

        # WHAT THE DOCUMENT CURRENTLY DECLARES, so a window can tell a real tick
        # from one WPF raised while building a row. A TabControl does not realise
        # an unselected tab's content, so every checkbox on the Features tab is
        # created - and raises Checked - the first time an administrator clicks
        # that tab. No timing guard can cover that; comparing against this can.
        DeclaredName              = [string[]] @($declaredName)
        SelectedSizeText          = $selectedSizeText
        Driver                    = $driver
        Content                   = [pscustomobject[]] @($contentRow)
        StartCommand              = [pscustomobject[]] @($startCommandRow)

        TimeZone                    = $timeZoneRow
        Certificate                 = [pscustomobject[]] @($certificateRow)
        CertificateSummaryText      = $certificateSummaryText
        ClientCertificate           = $clientCertificate
        AddCertificateCommandFormat = 'Add-HDTBootImageCertificate -Line $line -Path ''{0}'''

        AddContentCommandFormat   = 'Add-HDTBootImageContent -Line $line -Source ''{0}'' -Destination ''{1}'''
        AddStartCommandFormat     = 'Add-HDTBootImageStartCommand -Line $line -Command ''{0}'''
        AddStartCommandFirstFormat = 'Add-HDTBootImageStartCommand -Line $line -Command ''{0}'' -First'

        Command                   = "Get-HDTConsoleBootImageSetting -Line `$line -Path '{0}'" -f $Path
    }
}