Public/New-HDTWizardHost.ps1
|
function New-HDTWizardHost { <# .SYNOPSIS The real IWizardHost: loads XAML with XamlReader and shows the window. .DESCRIPTION THIS IS AN ADAPTER OVER AN EXTERNAL TOOL AND IS DELIBERATELY THIN. HDT's only exception to TDD is a thin adapter over something that cannot be faked - here WPF itself - and THE PRICE OF THAT EXEMPTION IS THAT THE ADAPTER MUST HAVE NOTHING IN IT WORTH TESTING. That is a condition, not a description, and this file stopped meeting it once: it read the network, decided which named boxes were present, decided what belonged in each, and swallowed every failure. The first time it was ever really executed, it crashed - on a bench, in WinPE, which is exactly where an untested adapter's bugs get found. SO THE DECISIONS LEFT. Get-HDTWizardField works out what every box should say and is unit tested against fakes; Show-HDTWizard owns what an answer means and is unit tested against New-HDTFakeWizardHost. What is left here is load the markup, set text by name, attach handlers by name, show it - and that is what the exemption was written for. WPF IN WinPE IS NOT A GUESS. friendsOfMDT/PSD ships Scripts/PSDWizard.xaml and loads it exactly this way inside WinPE (PSDWizardNew.psm1), which is the proof that PresentationFramework is usable there. What makes it possible is WinPE-NetFx, one of the six required components Get-HDTBootImageComponent always injects - so no boot image change is needed to show a window. XamlReader::Load PARSES MARKUP ONLY. There is no compiler in WinPE, so the window carries no x:Class and no code-behind; handlers are attached here, by name, after the tree exists. Every name this file reaches for must exist in a shipped window, and tests/contract/WinPeUiStack.Contract.Tests.ps1 asserts that - a name nothing answers to is a control that silently does nothing on the one machine nobody can debug. THE DEFAULT ANSWER IS EMPTY, NOT 'Cancel'. A window closed with the X never runs any handler, so this returns what it was given - nothing - and Show-HDTWizard is what turns that into a Cancel. The adapter does not get to make that decision, because then two places would have an opinion about what a dismissed window means. .OUTPUTS A PSCustomObject with: Show(xaml, title, field, pane) one window, one answer. ShowShell(shellXaml, themeXaml, title, state, field, pane, navigator) MDT's LiteTouch shell: ONE window whose page is swapped in place. The navigator - Step-HDTWizardPage, passed in - decides every move, so page order never enters this adapter. Apply(root, field, pane) the by-name work both of them do, and that ShowShell repeats for each page it swaps in. Show and ShowShell return 'Next', 'Cancel', 'CommandPrompt', or an empty string. .EXAMPLE Show-HDTWizard -XamlPath 'X:\HDT\UI\HDTWizard.xaml' -WizardHost (New-HDTWizardHost) .EXAMPLE $field = Get-HDTWizardField -NetworkConfiguration (Get-HDTNetworkConfiguration) Show-HDTWizard -XamlPath 'X:\HDT\UI\HDTWelcome.xaml' -Field $field The Welcome screen with the lease the machine actually got in it. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds a stateless service adapter object; it changes no state. Show is where a window appears, and it is a method.')] # ShowShell's Field, Pane and Navigator ARE used - inside the $render and # Add_Click scriptblocks, every one of which is .GetNewClosure(). The # analyzer does not follow a parameter into a closure and reports all three # as unused. Removing them to satisfy it would remove the navigation. [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Used inside GetNewClosure scriptblocks, which PSReviewUnusedParameter does not follow.')] [CmdletBinding()] [OutputType([pscustomobject])] param() Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $service = [pscustomobject] @{ Answer = '' # WHAT THE TECHNICIAN FILLED IN, read off the controls as each page was # left. Show-HDTWizardShell hands it back to the caller, which puts it # into the variable engine as the Wizard source (DESIGN 3.1) - so a # typed computer name reaches the deployment and the provenance says it # was typed. # # THE WELCOME SCREEN USES THE SAME BAG, and did not until a real boot # showed why it had to: that screen answered with an Action alone, so a # technician who corrected an unreachable share watched the machine fail # on the address that was already wrong. Show fills this from # Get-HDTWizardHarvest's list exactly as ShowShell fills it from each # page's collect declarations. Value = @{} } # EVERYTHING DONE BY NAME, IN ONE PLACE. Both Show and ShowShell fill boxes, # collapse panes and wire the password eye, and ShowShell has to do it again # for every page it swaps in - so it lives here rather than being written # twice and drifting once. # # $Root IS NOT ALWAYS THE WINDOW. A page loaded into HDTPageHost carries its # own name scope, so the window cannot find the controls inside it; the page # root can. Passing the root in is what makes one method serve both. $service | Add-Member -MemberType ScriptMethod -Name Apply -Value { param([object] $Root, [object[]] $Field, [object[]] $Pane) # WHAT GOES IN THE BOXES WAS DECIDED SOMEWHERE ELSE. Get-HDTWizardField # works it out - the lease, the share, the account - and this applies it # by name and interprets none of it. That is what put this adapter back # inside its own TDD exemption: the exemption is conditional on being # branch-free, and the version that read the network here was not. # # A NAME NOTHING ANSWERS TO IS SKIPPED, because the same host shows # every page and no page has all the controls. foreach ($current in @($Field)) { $control = $Root.FindName([string] $current.Name) if ($null -eq $control) { continue } # THE PROPERTY THE FIELD NAMED, defaulting to Text for a producer # that predates the pair - a PasswordBox has no Text at all, so a # host that always wrote Text could not prefill one. $property = 'Text' if ($null -ne $current.PSObject.Properties['Property']) { $property = [string] $current.Property } # A FIELD MAY CARRY ROWS AS WELL AS A VALUE - W3's task sequence # picker is a field like any other - AND THE ROWS GO FIRST. # SelectedValue means nothing until the item carrying that value # exists, which is the same order the console's driver list needs. if ($null -ne $current.PSObject.Properties['Item']) { # ANY ROWS THE PAGE DECLARED ARE CLEARED FIRST, AND A REAL SHARE # IS WHY. WPF throws "Items collection must be empty before # using ItemsSource" when a ListBox carries inline # <ListBoxItem>s, and every page written before W3 carries them # - the picker was a hand-typed list. On a share not yet updated # that exception is thrown while the wizard is opening, in # WinPE, on a machine whose console has just been hidden: a # technician gets a black screen instead of a page. if ($control.Items.Count -gt 0) { $control.Items.Clear() } $control.ItemsSource = @($current.Item) } $control.$property = [string] $current.Text } # WHICH PANES EXIST WAS ALSO DECIDED SOMEWHERE ELSE. Get-HDTWizardSkip # resolves MDT's Skip* rules and hands over Visible flags, so this never # has to reason about the word "skip". # # COLLAPSED, NOT HIDDEN: Visibility.Hidden leaves the space behind, so # a screen with two panes turned off would be mostly gap. Collapsed # takes the space back and the remaining panes close up. foreach ($current in @($Pane)) { $control = $Root.FindName([string] $current.Name) if ($null -eq $control) { continue } $control.Visibility = [System.Windows.Visibility]::Collapsed if ($current.Visible) { $control.Visibility = [System.Windows.Visibility]::Visible } } # THE EYE, AND WHY IT IS WIRED HERE RATHER THAN IN THE MARKUP. # PasswordBox.Password is not a DependencyProperty, so it cannot be # bound, styled or DataTriggered - there is no declarative way to reveal # it, and there is no code-behind to put one in. So the markup carries # two boxes in one cell and this swaps which is visible, copying the # text across so the technician never loses what they typed. # # BOTH DIRECTIONS, because a password typed while revealed has to # survive being hidden again just as much as the other way round. $revealToggle = $Root.FindName('HDTPasswordRevealToggle') $passwordBox = $Root.FindName('HDTPasswordBox') $revealBox = $Root.FindName('HDTPasswordRevealBox') if ($null -ne $revealToggle -and $null -ne $passwordBox -and $null -ne $revealBox) { # THE TWO WORDS THE TOGGLE SWAPS BETWEEN. It is the one caption on # these windows that changes while the window is open, so the table # cannot simply be applied to it once - it is read here and the # handlers below close over it. The table has already been applied # to the window by then, so the toggle's own tooltip is the shipped # 'Show the password' and the other one is looked up beside it. $revealShow = [string] $revealToggle.ToolTip $revealHide = '' $string = Get-HDTStringTable -Page 'Welcome' if ($string.ContainsKey('HDTPasswordRevealToggle.ToolTip')) { $revealShow = [string] $string['HDTPasswordRevealToggle.ToolTip'] } if ($string.ContainsKey('HDTPasswordRevealToggle.HideToolTip')) { $revealHide = [string] $string['HDTPasswordRevealToggle.HideToolTip'] } $revealToggle.Add_Checked({ $revealBox.Text = $passwordBox.Password $passwordBox.Visibility = [System.Windows.Visibility]::Collapsed $revealBox.Visibility = [System.Windows.Visibility]::Visible $revealToggle.ToolTip = $revealHide }.GetNewClosure()) $revealToggle.Add_Unchecked({ $passwordBox.Password = $revealBox.Text $revealBox.Visibility = [System.Windows.Visibility]::Collapsed $passwordBox.Visibility = [System.Windows.Visibility]::Visible $revealToggle.ToolTip = $revealShow }.GetNewClosure()) } } $service | Add-Member -MemberType ScriptMethod -Name Show -Value { param([string] $Xaml, [string] $Title, [object[]] $Field, [object[]] $Pane, [scriptblock] $CommandPrompt, [object[]] $Collect, [hashtable] $String) Add-Type -AssemblyName PresentationFramework Add-Type -AssemblyName PresentationCore Add-Type -AssemblyName WindowsBase $reader = New-Object -TypeName System.Xml.XmlNodeReader -ArgumentList ([xml] $Xaml) $window = [System.Windows.Markup.XamlReader]::Load($reader) # THE TEXT, BEFORE ANYTHING IS WRITTEN OVER IT. Show-HDTWizard chose the # block; this puts it on the window and reads none of it. Apply comes # after, because a field carries what a technician typed and the table # carries only what the window says about itself. if ($null -ne $String -and $String.Count -gt 0) { [void] (Set-HDTWindowText -Root $window -String $String) } $window.Title = $Title $this.Answer = '' # THE HOST, CAPTURED BY NAME. Inside an Add_Click handler $this is the # BUTTON that raised the event, not this object - and the enclosing # function's $service is not in scope inside a ScriptMethod at all, so # the handlers below were closing over a variable that did not exist. # Under StrictMode that throws, which is exactly what it did the first # time this adapter was ever really run. # # W1 did not catch it because W1's WinPE probe loads the XAML itself to # answer "does WPF render here"; the desktop tool is the first thing to # go through this host end to end. An adapter is exempt from unit tests # (CLAUDE.md rule 1) precisely because it is thin - so the first real # execution IS its test, and this is what that test found. $wizardHost = $this $this.Apply($window, $Field, $Pane) # DRAG BY THE BANNER. WindowStyle=None removes the title bar - which is # right for WinPE, where an X is a third way out of a deployment wizard - # but it also removes the thing you grab to move the window. A banner # that answers DragMove gives that back without giving back the X. # # OPTIONAL BY DESIGN: a page with no HDTDragBanner simply cannot be # moved, which is fine on a machine with nothing else on screen. $banner = $window.FindName('HDTDragBanner') if ($null -ne $banner) { $banner.Add_MouseLeftButtonDown({ # DragMove throws unless the primary button is genuinely # down, and a stray synthetic event would otherwise take the # whole wizard down with it. if ($_.ButtonState -eq 'Pressed') { $window.DragMove() } }.GetNewClosure()) } # THE BUTTON MAP, and it is the whole of what this host decides: which # named control reports which answer. Show-HDTWizard owns what those # answers MEAN, including that anything else is a Cancel - so a page # missing a button simply cannot produce that answer, rather than # producing a different one. foreach ($pair in @( @{ Name = 'HDTNextButton'; Answer = 'Next' }, @{ Name = 'HDTCancelButton'; Answer = 'Cancel' }, @{ Name = 'HDTOpenCmdButton'; Answer = 'CommandPrompt' })) { $button = $window.FindName([string] $pair.Name) if ($null -eq $button) { continue } $answer = [string] $pair.Answer # GetNewClosure snapshots $answer per iteration; without it every # handler would report whatever the loop variable held last. $button.Add_Click({ $wizardHost.Answer = $answer # READ BEFORE IT CLOSES, and read for EVERY answer rather # than only for Next: a technician who typed the right share # and then pressed Open CMD has still told us the right # share. What any of it MEANS is Show-HDTWizard's business # and the payload's; this only reads the named property off # the named control, which is why it may stay untested. foreach ($wanted in @($Collect)) { $control = $window.FindName([string] $wanted.Name) if ($null -eq $control) { continue } $wizardHost.Value[[string] $wanted.Name] = $control.([string] $wanted.Property) } $window.Close() }.GetNewClosure()) } # F8 - see ShowShell below for why this key exists at all. It is here as # well because the Welcome screen is the window shown when the SHARE # CANNOT BE REACHED, which is the single most likely moment in a # deployment for a technician to want a prompt. if ($null -ne $CommandPrompt) { $window.Add_PreviewKeyDown({ if ($_.Key -ne [System.Windows.Input.Key]::F8) { return } $_.Handled = $true & $CommandPrompt }.GetNewClosure()) } [void] $window.ShowDialog() return [string] $this.Answer } # THE MULTI-PAGE SHELL. Show opens a window and reports a button; this opens # HDTWizardShell.xaml ONCE and swaps the page inside it - MDT's LiteTouch # behaviour, and the reason a technician can press Back without the window # closing and reopening underneath them. # # IT DECIDES NOTHING ABOUT ORDER. Step-HDTWizardPage is handed in as # $Navigator and answers "which page now, what does the rail show, is Back # available, what does Next say, is this the end" - so page order never # enters this adapter, which is the condition of its TDD exemption # (CLAUDE.md rule 1). What is left here is: load markup, set things by name, # attach handlers, show it. $service | Add-Member -MemberType ScriptMethod -Name ShowShell -Value { param( [string] $ShellXaml, [string] $ThemeXaml, [string] $Title, [object] $State, [object[]] $Field, [object[]] $Pane, [scriptblock] $Navigator, [scriptblock] $CommandPrompt, [hashtable] $String) Add-Type -AssemblyName PresentationFramework Add-Type -AssemblyName PresentationCore Add-Type -AssemblyName WindowsBase $reader = New-Object -TypeName System.Xml.XmlNodeReader -ArgumentList ([xml] $ShellXaml) $window = [System.Windows.Markup.XamlReader]::Load($reader) # The shell's own text. See Show for why it is applied here and nowhere # else. if ($null -ne $String -and $String.Count -gt 0) { [void] (Set-HDTWindowText -Root $window -String $String) } $window.Title = $Title # THE THEME IS MERGED AT RUNTIME, NOT BY ResourceDictionary Source=. # A Source= reference is a second file that must reach the RAM disk # intact, and a half-copied one is a XamlParseException where a wizard # should be. Merging here means the shell's markup names no other file # at all - which is what WinPeUiStack.Contract.Tests.ps1 enforces. if (-not [string]::IsNullOrWhiteSpace($ThemeXaml)) { $themeReader = New-Object -TypeName System.Xml.XmlNodeReader -ArgumentList ([xml] $ThemeXaml) $window.Resources.MergedDictionaries.Add([System.Windows.Markup.XamlReader]::Load($themeReader)) } $this.Answer = '' $wizardHost = $this # A HASHTABLE, BECAUSE GetNewClosure SNAPSHOTS. The click handlers below # have to see the CURRENT page, not the one that was current when they # were attached - and a closed-over [int] would be frozen at 0 forever, # so Next would walk from page one every time. # # Closing IS THE WIZARD SAYING "THIS ONE IS MINE". See the Closing # handler below: every close this window did not ask for is refused. # Value IS WHAT THE TECHNICIAN HAS FILLED IN SO FAR, and Root is the # page it is currently on screen in - the only place those controls can # be read from, because a loaded page has its own name scope. $trip = @{ State = $State; Closing = $false; Value = @{}; Root = $null } # ALT+F4 IS THE ONLY WAY OUT LEFT, AND IT IS NOT ONE. WindowStyle=None # already took the title bar and its X away - right for WinPE, where a # deployment wizard should not have a corner that makes it disappear - # but Alt+F4 still closed it, and a technician who does that in WinPE # lands on the console the payload hid behind the window. # # IT WAS NEVER DANGEROUS: Show-HDTWizardShell reads a window that # answered nothing as Cancel and never as Next, so a dismissed wizard # could not start a deployment. What it was is UNACCOUNTABLE - a window # that vanished with nothing anywhere recording why. Now it can only # leave through Cancel, Open CMD or Deploy, each of which reports an # answer the payload acts on. # # ONLY THE SHELL. Show above still closes on WM_CLOSE, deliberately: # tests/e2e/payload/Start-HDTWizardProbe.ps1 dismisses the Welcome # screen exactly that way in an unattended lab run, and asserts the # answer is Cancel. Refusing it there would hang the probe rather than # protect anything. $window.Add_Closing({ if (-not $trip.Closing) { $_.Cancel = $true } }.GetNewClosure()) $heading = $window.FindName('HDTPageHeading') $subheading = $window.FindName('HDTPageSubheading') $pageHost = $window.FindName('HDTPageHost') $pageList = $window.FindName('HDTPageList') $backButton = $window.FindName('HDTBackButton') $nextButton = $window.FindName('HDTNextButton') $messageText = $window.FindName('HDTMessageText') # RENDER ONE STATE. Everything it touches was decided by the navigator; # nothing here chooses what to show, only where to put it. $render = { param([object] $Current) if ($null -ne $heading) { $heading.Text = [string] $Current.Heading } if ($null -ne $subheading) { $subheading.Text = [string] $Current.Subheading } if ($null -ne $pageList) { $pageList.ItemsSource = @($Current.Rail) } if ($null -ne $backButton) { $backButton.IsEnabled = [bool] $Current.BackEnabled } if ($null -ne $nextButton) { $nextButton.Content = [string] $Current.NextCaption } # EVERY PAGE STARTS CLEAN. A message left over from the page before # would accuse this one of a fault it does not have, and a Next left # disabled by an earlier page's refusal would strand the technician # on a page with nothing wrong with it - with no way to fix it, # because the box that was refused is on a different page. if ($null -ne $messageText) { $messageText.Text = '' } if ($null -ne $nextButton) { $nextButton.IsEnabled = $true } if ($null -eq $pageHost -or $null -eq $Current.Page) { return } # THE PAGE CARRIES ITS OWN NAME SCOPE once it is loaded, so the # window cannot find the controls inside it and Apply is given the # page root instead. Fields and panes are re-applied on every swap # because a page reached twice - Back, then Next - is parsed twice. $pageReader = New-Object -TypeName System.Xml.XmlNodeReader -ArgumentList ([xml] ([string] $Current.Page.Xaml)) $pageRoot = [System.Windows.Markup.XamlReader]::Load($pageReader) $wizardHost.Apply($pageRoot, $Field, $Pane) $pageHost.Content = $pageRoot $trip.Root = $pageRoot # THE SUMMARY'S ROWS ARE COMPUTED BY THE NAVIGATOR, NOT HERE. This # puts them where the page said to put them and nothing else - the # same ItemsSource mechanism the rail uses, for the same reason. if ($null -ne $Current.Page.Summary) { $rowControl = $pageRoot.FindName([string] $Current.Page.Summary.RowControl) if ($null -ne $rowControl) { $rowControl.ItemsSource = @($Current.SummaryRow) } $snippetControl = $pageRoot.FindName([string] $Current.Page.Summary.SnippetControl) if ($null -ne $snippetControl) { $snippetControl.Text = [string] $Current.SummarySnippet } } # WHAT THE PAGE SAID IT VALIDATES. The page named a control and a # rule; Show-HDTWizardShell resolved the rule to this scriptblock. # This runs it and paints the answer, and knows nothing about # computer names, lengths or legal characters. # # ON EVERY KEYSTROKE, AND ONCE BEFORE ANY. A box that is wrong the # moment the page opens - empty, or prefilled from a rule that built # too long a name - must say so then, not after the technician has # typed and deleted a character to find out. $validator = $null if ($null -ne $Current.Page.PSObject.Properties['Validator']) { $validator = $Current.Page.Validator } if ($null -eq $validator -or $null -eq $Current.Page.Validate) { return } $watched = $pageRoot.FindName([string] $Current.Page.Validate.Control) if ($null -eq $watched) { return } $judge = { $judgement = & $validator ([string] $watched.Text) if ($null -ne $messageText) { # THE SEVERITY GOES IN Tag AND THE MARKUP PAINTS IT. A # warning is not a refusal and must not be the same red; # deciding that here would put the palette back in the # engine, which is the thing the rail template exists to # avoid. $messageText.Tag = [string] $judgement.Severity $messageText.Text = [string] $judgement.Reason } # NEXT IS THE GATE, AND ONLY A REFUSAL CLOSES IT. IsValid is # false only for a real refusal - a name DNS cannot carry is # still a legal computer name, and blocking it here would stop a # deployment over something Windows itself permits. if ($null -ne $nextButton) { $nextButton.IsEnabled = [bool] $judgement.IsValid } }.GetNewClosure() $watched.Add_TextChanged($judge) & $judge # A CHARACTER THAT CANNOT BE IN THE ANSWER CANNOT BE TYPED INTO THE # BOX. The page said RestrictInput, which means "judge a keystroke # with the same rule" - and the rule is the only place the legal # characters are written down, so the keyboard is not checking # against a second list that could disagree with the first. # # PreviewTextInput IS BEFORE THE CHARACTER LANDS, which is the whole # point: Handled = true and it never appears. TextChanged would fire # after, leaving the technician to watch the wizard delete what they # just typed. # # IT COVERS PASTE TOO, because a pasted block is judged whole - one # illegal character refuses the paste rather than half-applying it. if (-not $Current.Page.RestrictInput) { return } $watched.Add_PreviewTextInput({ $_.Handled = -not [bool] (& $validator ([string] $_.Text)).IsValid }.GetNewClosure()) # SPACE ARRIVES AS A KEY, NOT AS TEXT INPUT, on a TextBox - it is # handled by the control before PreviewTextInput sees it, so a space # would be the one illegal character that still got in. $watched.Add_PreviewKeyDown({ if ($_.Key -eq [System.Windows.Input.Key]::Space) { $_.Handled = $true } }.GetNewClosure()) }.GetNewClosure() # WHAT THIS PAGE WAS FILLED IN WITH, READ BEFORE LEAVING IT. The page # declared the control, the property to read and the variable it fills, # so a ListBox and a TextBox are the same thing here - a named property # on a named control. Nothing in this adapter knows what a task sequence # or a computer name is. # # ON THE WAY OUT, NOT ON EVERY KEYSTROKE: the page is about to be # replaced and its controls are about to stop existing, and a value # captured then is the one the technician settled on. $harvest = { if ($null -eq $trip.Root -or $null -eq $trip.State.Page) { return } $collect = $trip.State.Page.Collect if ($null -eq $collect) { return } # ONE PAGE, SEVERAL VARIABLES. MDT's Computer Details pane collects # a name, a domain or a workgroup, an OU and the account that joins. foreach ($declaration in @($collect)) { $control = $trip.Root.FindName([string] $declaration.Control) if ($null -eq $control) { continue } # A DISABLED CONTROL COLLECTS NOTHING, and this is not a # nicety. The Computer Details page offers a domain OR a # workgroup and disables whichever was not chosen; without this, # a machine joining corp.contoso.com also wrote # HDTJoinWorkgroup: WORKGROUP into the summary - the box's own # default, which the technician never saw, never chose and could # not have removed. A rules.yaml carrying both is a rules.yaml # that says two contradictory things about one machine. # # AND IT NEEDS NO NEW DECLARATION. The markup already disables # the half that was not chosen, declaratively, off the radio - # so the fact is already on the page and this reads it rather # than restating it in PowerShell. if (-not $control.IsEnabled) { continue } $property = 'Text' if (-not [string]::IsNullOrWhiteSpace([string] $declaration.Property)) { $property = [string] $declaration.Property } $raw = [string] $control.$property # ONE BOX, TWO VARIABLES. The join account is typed as # CORP\svc-hdt-join and DESIGN 4.5.3 wants HDTDomainAdmin and # HDTDomainAdminDomain out of it. The splitting is # Split-HDTAccountName's, resolved by Show-HDTWizardShell; this # calls it and puts the parts where the declaration says. $splitter = $null if ($null -ne $declaration.PSObject.Properties['Splitter']) { $splitter = $declaration.Splitter } if ($null -ne $splitter) { $part = & $splitter $raw $domain = [string] $part.Domain # WHERE THE ACCOUNT'S DOMAIN COMES FROM, MOST SPECIFIC # FIRST: # # 1. a DOMAIN\ prefix the technician typed. It wins over # the box below, following the rule # Get-HDTWizardCredential already set: "a UserID that # already carries a domain is left alone", so # 'CORP\svc' never becomes 'CORP\CORP\svc'. # 2. what the account-domain box was filled in with. It # is collected first, so it is already in the bag. # 3. the domain being joined - because a bare account # belongs to it, and that fact is on this same page. # # EMPTY NEVER OVERWRITES A REAL ANSWER. Without the check # against the bag, a technician who filled the box and typed # a bare account would have their box erased by the split. if ([string]::IsNullOrWhiteSpace($domain)) { $already = '' if ($trip.Value.ContainsKey([string] $declaration.SplitVariable)) { $already = [string] $trip.Value[[string] $declaration.SplitVariable] } if (-not [string]::IsNullOrWhiteSpace($already)) { $domain = $already } elseif (-not [string]::IsNullOrWhiteSpace([string] $declaration.SplitDefaultFrom)) { $from = [string] $declaration.SplitDefaultFrom if ($trip.Value.ContainsKey($from)) { $domain = [string] $trip.Value[$from] } } } $trip.Value[[string] $declaration.Variable] = [string] $part.User $trip.Value[[string] $declaration.SplitVariable] = $domain continue } $trip.Value[[string] $declaration.Variable] = $raw } }.GetNewClosure() & $render $trip.State # DRAG BY THE BANNER, exactly as Show does it: WindowStyle=None takes # the title bar away, and with it the thing you grab to move the window. $banner = $window.FindName('HDTDragBanner') if ($null -ne $banner) { $banner.Add_MouseLeftButtonDown({ if ($_.ButtonState -eq 'Pressed') { $window.DragMove() } }.GetNewClosure()) } # NAVIGATION. Back and Next ask the navigator and re-render; they are # the only two buttons that do not end the window - except for the one # Next that does, which is the Next the navigator calls Done. foreach ($pair in @( @{ Name = 'HDTBackButton'; Action = 'Back' }, @{ Name = 'HDTNextButton'; Action = 'Next' })) { $button = $window.FindName([string] $pair.Name) if ($null -eq $button) { continue } $action = [string] $pair.Action $button.Add_Click({ & $harvest $moved = & $Navigator $trip.State.Index $action $trip.Value # DONE IS THE ONLY WAY THIS WINDOW REPORTS 'Next', and the # navigator is the only thing that says Done. A shell that # answered Next on any click of the Next button would start # a deployment from page one. if ($moved.Done) { $wizardHost.Answer = 'Next' $trip.Closing = $true $window.Close() return } $trip.State = $moved & $render $moved }.GetNewClosure()) } # F8, AND IT IS NOT A BUTTON. MDT's boot image has "Enable command # support (testing only)" and F8 is what it buys: a command prompt ON # TOP of what is running. Nothing in WinPE provides that key - ConfigMgr's # boot shell implements it and so does MDT's, so this one does too. # # PreviewKeyDown ON THE WINDOW, so it fires wherever the focus is. A # technician who needs a prompt is halfway through typing in a box, and # a handler on the page would never see the key. # # HANDLED, SO NOTHING ELSE SEES IT. Left unhandled, F8 keeps travelling # and a control that has its own meaning for it acts as well. # # THE WINDOW DOES NOT CLOSE, which is the whole difference from the Open # CMD button below. F8 opens a prompt beside the wizard; the technician # closes the prompt and is still on the same page with everything they # had typed. A key that ended the deployment would be a trap. if ($null -ne $CommandPrompt) { $window.Add_PreviewKeyDown({ if ($_.Key -ne [System.Windows.Input.Key]::F8) { return } $_.Handled = $true & $CommandPrompt }.GetNewClosure()) } # LEAVING. Cancel and Open CMD are not navigation - they close the # window and report what was asked for. Show-HDTWizardShell owns what # those answers mean, including that anything else is a Cancel. foreach ($pair in @( @{ Name = 'HDTCancelButton'; Answer = 'Cancel' }, @{ Name = 'HDTOpenCmdButton'; Answer = 'CommandPrompt' })) { $button = $window.FindName([string] $pair.Name) if ($null -eq $button) { continue } $answer = [string] $pair.Answer $button.Add_Click({ $wizardHost.Answer = $answer $trip.Closing = $true $window.Close() }.GetNewClosure()) } [void] $window.ShowDialog() # THE LAST PAGE IS HARVESTED ON THE WAY OUT. Cancel and Open CMD close # the window without navigating, and Deploy closes it from inside the # Next handler - so without this, whatever was typed on the page the # technician was standing on when they left is read off a control that # is about to stop existing, or not at all. & $harvest $this.Value = $trip.Value return [string] $this.Answer } return $service } |