Public/Show-WindowsDeviceLink.ps1
|
function Show-WindowsDeviceLink { <# .SYNOPSIS Opens the WindowsDeviceLink operator dashboard. .DESCRIPTION Opens a compact Windows 11 Settings-inspired WinForms dashboard on Windows 11 and supported Windows PE environments. The GUI focuses on inspecting Device Association state, onboarding, and offboarding. Lifecycle actions delegate to existing WindowsDeviceLink public cmdlets. Interactive authentication is the default on full Windows. Windows PE defaults to DeviceCode because Interactive browser authentication is unavailable there. Use -Method and the corresponding authentication parameters to select another supported authentication flow. Use -Tenants to provide friendly tenant names for the tenant selector: @{'Management'='11111111-1111-1111-1111-111111111111'; 'Customer A'='22222222-2222-2222-2222-222222222222'} Use -TenantsUri to load the same friendly-name-to-tenant-ID mapping from a trusted HTTPS JSON endpoint. Use -TenantsPath to load the same JSON format from a local file. Precedence is: TenantsUri, then TenantsPath, then explicit -Tenants values. .EXAMPLE Show-WindowsDeviceLink .EXAMPLE Show-WindowsDeviceLink -Method DeviceCode .EXAMPLE Show-WindowsDeviceLink -Tenants @{ 'Management' = '11111111-1111-1111-1111-111111111111' 'Customer A' = '22222222-2222-2222-2222-222222222222' } .EXAMPLE Show-WindowsDeviceLink -TenantsUri 'https://config.example.com/windowsdevicelink/tenants.json' .EXAMPLE Show-WindowsDeviceLink -TenantsPath 'E:\Config\tenants.json' #> [CmdletBinding()] param( [ValidateSet( 'DeviceCode','Interactive','ClientSecret','AccessToken','Certificate', 'CertificateThumbprint','CertificateSubjectName','EnvironmentVariable','ManagedIdentity' )] [string]$Method, [ValidateNotNullOrEmpty()] [string]$TenantId, [hashtable]$Tenants, [ValidateNotNull()] [uri]$TenantsUri, [ValidateNotNullOrEmpty()] [string]$TenantsPath, [ValidateNotNullOrEmpty()] [string]$ClientId, [securestring]$AccessToken, [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, [ValidateNotNullOrEmpty()] [string]$CertificateThumbprint, [ValidateNotNullOrEmpty()] [string]$CertificateSubjectName, [bool]$SendCertificateChain = $false, [securestring]$ClientSecret, [ValidateNotNullOrEmpty()] [string]$Environment = 'Global', [ValidateRange(1,600)] [double]$ClientTimeout = 100, [ValidateNotNullOrEmpty()] [string]$WindowsManagementServicePath ) $outerBoundParameters = @{} foreach ($key in $PSBoundParameters.Keys) { $outerBoundParameters[$key] = $PSBoundParameters[$key] } $isWinPE = Test-Path -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\MiniNT' if (-not $outerBoundParameters.ContainsKey('Method')) { $Method = if ($isWinPE) { 'DeviceCode' } else { 'Interactive' } } if ($isWinPE -and $Method -eq 'Interactive') { throw 'Interactive authentication is not available in Windows PE. Use -Method DeviceCode or a supported app-only authentication method.' } Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop Add-Type -AssemblyName System.Drawing -ErrorAction Stop [System.Windows.Forms.Application]::EnableVisualStyles() $loadedModule = Get-Module WindowsDeviceLink | Select-Object -First 1 $baseVersion = if ($loadedModule) { $loadedModule.Version.ToString() } else { 'unknown' } $prerelease = $null if ($loadedModule -and $loadedModule.PrivateData -and $loadedModule.PrivateData.PSData) { $prerelease = [string]$loadedModule.PrivateData.PSData.Prerelease } $displayVersion = if ([string]::IsNullOrWhiteSpace($prerelease)) { $baseVersion } else { "$baseVersion-$prerelease" } $workingArea = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea $targetWidth = [Math]::Max(820,[Math]::Min(1080,$workingArea.Width - 32)) $targetHeight = [Math]::Max(620,[Math]::Min(760,$workingArea.Height - 40)) $form = New-Object System.Windows.Forms.Form $form.Text = "WindowsDeviceLink $displayVersion (Preview)" $form.StartPosition = 'CenterScreen' $form.Size = [System.Drawing.Size]::new($targetWidth,$targetHeight) $form.MinimumSize = [System.Drawing.Size]::new(820,620) $form.AutoScaleMode = [System.Windows.Forms.AutoScaleMode]::Dpi $form.BackColor = [System.Drawing.Color]::FromArgb(243,243,243) $content = New-Object System.Windows.Forms.Panel $content.Dock = [System.Windows.Forms.DockStyle]::Fill $content.AutoScroll = $false $content.BackColor = $form.BackColor $form.Controls.Add($content) $toolTip = New-Object System.Windows.Forms.ToolTip $toolTip.AutoPopDelay = 10000 $toolTip.InitialDelay = 400 $toolTip.ReshowDelay = 200 $toolTip.ShowAlways = $true function New-Card { param( [string]$Title, [int]$X, [int]$Y, [int]$Width, [int]$Height ) $panel = New-Object System.Windows.Forms.Panel $panel.Location = [System.Drawing.Point]::new($X,$Y) $panel.Size = [System.Drawing.Size]::new($Width,$Height) $panel.BackColor = [System.Drawing.Color]::White $panel.BorderStyle = [System.Windows.Forms.BorderStyle]::None $content.Controls.Add($panel) if ($Title) { $label = New-Object System.Windows.Forms.Label $label.Text = $Title $label.Font = New-Object System.Drawing.Font('Segoe UI',10,[System.Drawing.FontStyle]::Bold) $label.Location = [System.Drawing.Point]::new(14,9) $label.AutoSize = $true $panel.Controls.Add($label) } $panel } function New-ValuePair { param( [System.Windows.Forms.Control]$Parent, [string]$Caption, [int]$Y, [int]$CaptionWidth = 112 ) $captionLabel = New-Object System.Windows.Forms.Label $captionLabel.Text = $Caption $captionLabel.Font = New-Object System.Drawing.Font('Segoe UI',8.5) $captionLabel.ForeColor = [System.Drawing.Color]::FromArgb(102,102,102) $captionLabel.Location = [System.Drawing.Point]::new(14,$Y) $captionLabel.Size = [System.Drawing.Size]::new($CaptionWidth,20) $Parent.Controls.Add($captionLabel) $valueLabel = New-Object System.Windows.Forms.Label $valueLabel.Text = '-' $valueLabel.Font = New-Object System.Drawing.Font('Segoe UI',9,[System.Drawing.FontStyle]::Bold) $valueLabel.Location = [System.Drawing.Point]::new(($CaptionWidth + 20),($Y - 1)) $valueLabel.Size = [System.Drawing.Size]::new(350,22) $valueLabel.AutoEllipsis = $true $Parent.Controls.Add($valueLabel) $valueLabel } function New-ActionRow { param( [System.Windows.Forms.Control]$Parent, [string]$Title, [string]$Description, [int]$Y, [string[]]$Buttons ) $row = New-Object System.Windows.Forms.Panel $row.Location = [System.Drawing.Point]::new(0,$Y) $row.Size = [System.Drawing.Size]::new(900,46) $row.BackColor = [System.Drawing.Color]::White $Parent.Controls.Add($row) $titleLabel = New-Object System.Windows.Forms.Label $titleLabel.Text = $Title $titleLabel.Font = New-Object System.Drawing.Font('Segoe UI',8.3,[System.Drawing.FontStyle]::Bold) $titleLabel.Location = [System.Drawing.Point]::new(14,5) $titleLabel.AutoSize = $true $row.Controls.Add($titleLabel) $descriptionLabel = New-Object System.Windows.Forms.Label $descriptionLabel.Text = $Description $descriptionLabel.Font = New-Object System.Drawing.Font('Segoe UI',8.2) $descriptionLabel.ForeColor = [System.Drawing.Color]::FromArgb(108,108,108) $descriptionLabel.Location = [System.Drawing.Point]::new(14,23) $descriptionLabel.AutoSize = $true $row.Controls.Add($descriptionLabel) $buttonList = New-Object System.Collections.Generic.List[object] $count = $Buttons.Count $buttonWidth = if ($count -eq 1) { 118 } elseif ($count -eq 2) { 112 } else { 88 } $gap = 7 $right = 884 for ($i = $count - 1; $i -ge 0; $i--) { $button = New-Object System.Windows.Forms.Button $button.Text = $Buttons[$i] $button.Font = New-Object System.Drawing.Font('Segoe UI',8.6) $button.Size = [System.Drawing.Size]::new($buttonWidth,28) $right -= $buttonWidth $button.Location = [System.Drawing.Point]::new($right,9) $button.FlatStyle = [System.Windows.Forms.FlatStyle]::System $button.UseVisualStyleBackColor = $true $row.Controls.Add($button) $buttonList.Insert(0,$button) $right -= $gap } $separator = New-Object System.Windows.Forms.Panel $separator.BackColor = [System.Drawing.Color]::FromArgb(232,232,232) $separator.Location = [System.Drawing.Point]::new(14,45) $separator.Size = [System.Drawing.Size]::new(860,1) $row.Controls.Add($separator) [pscustomobject]@{ Panel = $row Buttons = $buttonList.ToArray() } } $effectiveTenants = @{} if ($outerBoundParameters.ContainsKey('TenantsUri')) { if (-not $TenantsUri.IsAbsoluteUri -or $TenantsUri.Scheme -ne 'https') { throw '-TenantsUri must be an absolute HTTPS URI.' } try { $remoteTenantObject = Invoke-RestMethod -Uri $TenantsUri.AbsoluteUri -Method Get -TimeoutSec 15 -ErrorAction Stop } catch { throw "Unable to load tenant JSON from '$($TenantsUri.AbsoluteUri)': $($_.Exception.Message)" } if ($null -eq $remoteTenantObject -or $remoteTenantObject -is [System.Array]) { throw 'The tenant JSON must be a JSON object that maps friendly tenant names to tenant GUIDs.' } foreach ($property in @($remoteTenantObject.PSObject.Properties)) { $name = [string]$property.Name $id = [string]$property.Value $parsedTenantId = [guid]::Empty if ([string]::IsNullOrWhiteSpace($name) -or [string]::IsNullOrWhiteSpace($id) -or -not [guid]::TryParse($id.Trim(),[ref]$parsedTenantId)) { throw "Invalid tenant entry '$name' in '$($TenantsUri.AbsoluteUri)'. Each value must be a tenant GUID." } $effectiveTenants[$name.Trim()] = $parsedTenantId.ToString() } if ($effectiveTenants.Count -eq 0) { throw "The tenant JSON at '$($TenantsUri.AbsoluteUri)' did not contain any tenant entries." } } if ($outerBoundParameters.ContainsKey('TenantsPath')) { if (-not (Test-Path -LiteralPath $TenantsPath -PathType Leaf)) { throw "Tenant JSON file '$TenantsPath' was not found." } try { $localTenantObject = Get-Content -LiteralPath $TenantsPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop } catch { throw "Unable to load tenant JSON from '$TenantsPath': $($_.Exception.Message)" } if ($null -eq $localTenantObject -or $localTenantObject -is [System.Array]) { throw 'The tenant JSON must be a JSON object that maps friendly tenant names to tenant GUIDs.' } foreach ($property in @($localTenantObject.PSObject.Properties)) { $name = [string]$property.Name $id = [string]$property.Value $parsedTenantId = [guid]::Empty if ([string]::IsNullOrWhiteSpace($name) -or [string]::IsNullOrWhiteSpace($id) -or -not [guid]::TryParse($id.Trim(),[ref]$parsedTenantId)) { throw "Invalid tenant entry '$name' in '$TenantsPath'. Each value must be a tenant GUID." } $effectiveTenants[$name.Trim()] = $parsedTenantId.ToString() } if ($localTenantObject.PSObject.Properties.Count -eq 0) { throw "The tenant JSON file '$TenantsPath' did not contain any tenant entries." } } if ($Tenants) { foreach ($name in @($Tenants.Keys)) { $id = [string]$Tenants[$name] $parsedTenantId = [guid]::Empty if ([string]::IsNullOrWhiteSpace([string]$name) -or [string]::IsNullOrWhiteSpace($id) -or -not [guid]::TryParse($id.Trim(),[ref]$parsedTenantId)) { throw "Invalid -Tenants entry '$name'. Each value must be a tenant GUID." } $effectiveTenants[[string]$name] = $parsedTenantId.ToString() } } $tenantChoiceLookup = @{} $tenantChoices = New-Object System.Collections.Generic.List[string] $autoLabel = if ($outerBoundParameters.ContainsKey('TenantId')) { 'Default tenant parameter' } else { 'Automatic / detected tenant' } $tenantChoiceLookup[$autoLabel] = $null $tenantChoices.Add($autoLabel) if ($effectiveTenants.Count -gt 0) { foreach ($name in @($effectiveTenants.Keys | Sort-Object)) { $id = [string]$effectiveTenants[$name] $label = [string]$name if ($tenantChoiceLookup.ContainsKey($label)) { $label = "$label ($id)" } $tenantChoiceLookup[$label] = $id $tenantChoices.Add($label) } } function Get-SelectedTenantId { if ($tenantSelector.SelectedItem) { $selected = [string]$tenantSelector.SelectedItem if ($tenantChoiceLookup.ContainsKey($selected) -and $tenantChoiceLookup[$selected]) { return [string]$tenantChoiceLookup[$selected] } } if ($outerBoundParameters.ContainsKey('TenantId')) { return [string]$TenantId } $null } function Get-GuiAuthParameters { $parameters = @{ Method = $Method Environment = $Environment ClientTimeout = $ClientTimeout } foreach ($name in @( 'ClientId','AccessToken','Certificate','CertificateThumbprint', 'CertificateSubjectName','SendCertificateChain','ClientSecret' )) { if ($outerBoundParameters.ContainsKey($name)) { $parameters[$name] = $outerBoundParameters[$name] } } $selectedTenant = Get-SelectedTenantId if ($selectedTenant) { $parameters.TenantId = $selectedTenant } $parameters } function Get-GuiRuntimeParameters { $parameters = @{} if ($outerBoundParameters.ContainsKey('WindowsManagementServicePath')) { $parameters.WindowsManagementServicePath = $WindowsManagementServicePath } $parameters } $ui = @{} $script:WdlGuiBusy = $false $script:WdlGuiLocalAssociation = $null $script:WdlGuiCloudStatus = $null $script:WdlGuiSupport = $null $deviceCard = New-Card -Title 'Device' -X 14 -Y 12 -Width 508 -Height 126 $associationCard = New-Card -Title 'Association' -X 536 -Y 12 -Width 508 -Height 126 $ui.DeviceName = New-ValuePair -Parent $deviceCard -Caption 'Device' -Y 34 $ui.Serial = New-ValuePair -Parent $deviceCard -Caption 'Serial number' -Y 56 $ui.Environment = New-ValuePair -Parent $deviceCard -Caption 'Environment' -Y 78 $ui.Auth = New-ValuePair -Parent $deviceCard -Caption 'Authentication' -Y 100 $ui.Firmware = New-ValuePair -Parent $associationCard -Caption 'Firmware' -Y 34 $ui.LocalState = New-ValuePair -Parent $associationCard -Caption 'Local state' -Y 56 $ui.TenantId = New-ValuePair -Parent $associationCard -Caption 'Tenant ID' -Y 78 $ui.Source = New-ValuePair -Parent $associationCard -Caption 'Source' -Y 100 $cloudCard = New-Card -Title 'Cloud association' -X 14 -Y 150 -Width 1030 -Height 84 $ui.CloudState = New-ValuePair -Parent $cloudCard -Caption 'State' -Y 34 -CaptionWidth 80 $ui.CloudState.Text = 'Not checked' $ui.CloudTenant = New-ValuePair -Parent $cloudCard -Caption 'Tenant ID' -Y 56 -CaptionWidth 80 $ui.CloudTenant.Text = 'Unavailable' $cloudIdCaption = New-Object System.Windows.Forms.Label $cloudIdCaption.Text = 'Association ID' $cloudIdCaption.Font = New-Object System.Drawing.Font('Segoe UI',8.5) $cloudIdCaption.ForeColor = [System.Drawing.Color]::FromArgb(102,102,102) $cloudIdCaption.Location = [System.Drawing.Point]::new(500,34) $cloudIdCaption.Size = [System.Drawing.Size]::new(96,20) $cloudCard.Controls.Add($cloudIdCaption) $ui.CloudId = New-Object System.Windows.Forms.Label $ui.CloudId.Text = 'Unavailable' $ui.CloudId.Font = New-Object System.Drawing.Font('Segoe UI',8.3,[System.Drawing.FontStyle]::Bold) $ui.CloudId.Location = [System.Drawing.Point]::new(600,33) $ui.CloudId.Size = [System.Drawing.Size]::new(380,22) $ui.CloudId.AutoEllipsis = $true $cloudCard.Controls.Add($ui.CloudId) $tenantCaption = New-Object System.Windows.Forms.Label $tenantCaption.Text = 'Tenant' $tenantCaption.Font = New-Object System.Drawing.Font('Segoe UI',8.5) $tenantCaption.ForeColor = [System.Drawing.Color]::FromArgb(102,102,102) $tenantCaption.Location = [System.Drawing.Point]::new(500,56) $tenantCaption.Size = [System.Drawing.Size]::new(96,18) $cloudCard.Controls.Add($tenantCaption) $tenantSelector = New-Object System.Windows.Forms.ComboBox $tenantSelector.DropDownStyle = [System.Windows.Forms.ComboBoxStyle]::DropDownList $tenantSelector.Font = New-Object System.Drawing.Font('Segoe UI',8.5) $tenantSelector.Location = [System.Drawing.Point]::new(600,53) $tenantSelector.Size = [System.Drawing.Size]::new(380,24) foreach ($choice in $tenantChoices) { [void]$tenantSelector.Items.Add($choice) } $tenantSelector.SelectedIndex = 0 $cloudCard.Controls.Add($tenantSelector) $actionsTitle = New-Object System.Windows.Forms.Label $actionsTitle.Text = 'Actions' $actionsTitle.Font = New-Object System.Drawing.Font('Segoe UI',11.5,[System.Drawing.FontStyle]::Bold) $actionsTitle.Location = [System.Drawing.Point]::new(16,246) $actionsTitle.AutoSize = $true $content.Controls.Add($actionsTitle) $actionsPanel = New-Card -Title '' -X 14 -Y 272 -Width 1030 -Height 232 $rowRefresh = New-ActionRow -Parent $actionsPanel -Title 'Refresh' -Description 'Refresh local DeviceLink and firmware information.' -Y 0 -Buttons @('Refresh') $rowOnline = New-ActionRow -Parent $actionsPanel -Title 'Check online' -Description 'Query the tenant-side Device Association using the selected tenant context.' -Y 46 -Buttons @('Check online') $rowExport = New-ActionRow -Parent $actionsPanel -Title 'Export DeviceLink CSV' -Description 'Export the Microsoft-generated .devicelink.csv.' -Y 92 -Buttons @('Export CSV') $rowOnboard = New-ActionRow -Parent $actionsPanel -Title 'Onboarding' -Description 'Create only the pre-association, or perform the full association flow.' -Y 138 -Buttons @('Pre-associate','Full associate') $rowOffboard = New-ActionRow -Parent $actionsPanel -Title 'Offboarding' -Description 'Remove cloud state, local state, or both.' -Y 184 -Buttons @('Cloud','Local','Full') $offboardSeparator = @( $rowOffboard.Panel.Controls | Where-Object { $_ -is [System.Windows.Forms.Panel] -and $_.Height -eq 1 } ) | Select-Object -First 1 if ($offboardSeparator) { $rowOffboard.Panel.Controls.Remove($offboardSeparator) $offboardSeparator.Dispose() } function Get-ActionButtonByText { param( [Parameter(Mandatory)][System.Windows.Forms.Control]$Row, [Parameter(Mandatory)][string]$Text ) $button = @( $Row.Controls | Where-Object { $_ -is [System.Windows.Forms.Button] -and $_.Text -eq $Text } ) | Select-Object -First 1 if (-not $button) { throw "GUI action button '$Text' could not be resolved." } $button } $btnRefresh = Get-ActionButtonByText -Row $rowRefresh.Panel -Text 'Refresh' $btnOnline = Get-ActionButtonByText -Row $rowOnline.Panel -Text 'Check online' $btnExport = Get-ActionButtonByText -Row $rowExport.Panel -Text 'Export CSV' $btnPreassociate = Get-ActionButtonByText -Row $rowOnboard.Panel -Text 'Pre-associate' $btnFullAssociate = Get-ActionButtonByText -Row $rowOnboard.Panel -Text 'Full associate' $btnCloudOffboard = Get-ActionButtonByText -Row $rowOffboard.Panel -Text 'Cloud' $btnLocalOffboard = Get-ActionButtonByText -Row $rowOffboard.Panel -Text 'Local' $btnFullOffboard = Get-ActionButtonByText -Row $rowOffboard.Panel -Text 'Full' $allActionButtons = @( $btnRefresh, $btnOnline, $btnExport, $btnPreassociate, $btnFullAssociate, $btnCloudOffboard, $btnLocalOffboard, $btnFullOffboard ) $activityTitle = New-Object System.Windows.Forms.Label $activityTitle.Text = 'Activity' $activityTitle.Font = New-Object System.Drawing.Font('Segoe UI',11,[System.Drawing.FontStyle]::Bold) $activityTitle.Location = [System.Drawing.Point]::new(16,518) $activityTitle.AutoSize = $true $content.Controls.Add($activityTitle) $btnClearActivity = New-Object System.Windows.Forms.Button $btnClearActivity.Text = 'Clear' $btnClearActivity.Font = New-Object System.Drawing.Font('Segoe UI',8.3) $btnClearActivity.Size = [System.Drawing.Size]::new(64,24) $btnClearActivity.FlatStyle = [System.Windows.Forms.FlatStyle]::System $content.Controls.Add($btnClearActivity) $activityCard = New-Card -Title '' -X 14 -Y 544 -Width 1030 -Height 118 $consoleBox = New-Object System.Windows.Forms.TextBox $consoleBox.Location = [System.Drawing.Point]::new(12,10) $consoleBox.Size = [System.Drawing.Size]::new(1006,96) $consoleBox.Multiline = $true $consoleBox.ReadOnly = $true $consoleBox.ScrollBars = [System.Windows.Forms.ScrollBars]::Vertical $consoleBox.WordWrap = $false $consoleBox.Font = New-Object System.Drawing.Font('Consolas',8) $consoleBox.BackColor = [System.Drawing.Color]::FromArgb(250,250,250) $consoleBox.BorderStyle = [System.Windows.Forms.BorderStyle]::None $activityCard.Controls.Add($consoleBox) $statusStrip = New-Object System.Windows.Forms.StatusStrip $statusStrip.Dock = [System.Windows.Forms.DockStyle]::Bottom $statusLabel = New-Object System.Windows.Forms.ToolStripStatusLabel $statusLabel.Text = 'Ready' $statusLabel.Spring = $true $statusLabel.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft $statusProgress = New-Object System.Windows.Forms.ToolStripProgressBar $statusProgress.Style = [System.Windows.Forms.ProgressBarStyle]::Marquee $statusProgress.MarqueeAnimationSpeed = 30 $statusProgress.Size = [System.Drawing.Size]::new(140,16) $statusProgress.Visible = $false [void]$statusStrip.Items.Add($statusLabel) [void]$statusStrip.Items.Add($statusProgress) $form.Controls.Add($statusStrip) function Set-GuiStatus { param([string]$Text) $statusLabel.Text = $Text [System.Windows.Forms.Application]::DoEvents() } function Write-GuiConsole { param( [AllowNull()][AllowEmptyString()][string]$Message, [switch]$Command, [switch]$ErrorMessage ) if ([string]::IsNullOrWhiteSpace($Message)) { return } $timestamp = (Get-Date).ToString('HH:mm:ss') $prefix = if ($Command) { '>' } elseif ($ErrorMessage) { '!' } else { '-' } $line = "[$timestamp] $prefix $Message" $consoleBox.AppendText($line + [Environment]::NewLine) $consoleBox.SelectionStart = $consoleBox.TextLength $consoleBox.ScrollToCaret() [System.Windows.Forms.Application]::DoEvents() Write-Host $line } function Write-GuiObject { param($InputObject) if ($null -eq $InputObject) { return } foreach ($item in @($InputObject)) { if ($null -eq $item) { continue } $properties = @($item.PSObject.Properties) if ($properties.Count -eq 0) { $text = [string]$item if (-not [string]::IsNullOrWhiteSpace($text)) { Write-GuiConsole -Message $text } continue } $rows = New-Object System.Collections.Generic.List[object] $compactHiddenProperties = @( 'RegistrationResult', 'BeforeStatus', 'AfterStatus', 'FullAssociationDetails' ) foreach ($property in $properties) { if ([string]$property.Name -in $compactHiddenProperties) { continue } $value = $property.Value if ($null -eq $value) { continue } if ($value -is [string] -and [string]::IsNullOrWhiteSpace($value)) { continue } if ($value -is [datetime]) { $displayValue = $value.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } elseif ($value -is [datetimeoffset]) { $displayValue = $value.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } elseif ($value -is [System.Collections.IEnumerable] -and -not ($value -is [string])) { $items = @($value) if ($items.Count -eq 0) { continue } $displayValue = ($items | ForEach-Object { [string]$_ }) -join ', ' } else { $displayValue = [string]$value } if ([string]::IsNullOrWhiteSpace($displayValue)) { continue } $rows.Add([pscustomobject]@{ Name = [string]$property.Name Value = $displayValue }) } if ($rows.Count -eq 0) { continue } $nameWidth = [Math]::Min( 34, [Math]::Max( 8, (@($rows | ForEach-Object { $_.Name.Length }) | Measure-Object -Maximum).Maximum ) ) foreach ($row in $rows) { $name = $row.Name if ($name.Length -gt $nameWidth) { $name = $name.Substring(0,$nameWidth) } Write-GuiConsole -Message (("{0,-$nameWidth} : {1}" -f $name,$row.Value)) } } } function Invoke-GuiInformationCommand { param( [Parameter(Mandatory)] [scriptblock]$ScriptBlock ) $resultObjects = New-Object System.Collections.Generic.List[object] & $ScriptBlock 6>&1 | ForEach-Object { if ($_ -is [System.Management.Automation.InformationRecord]) { $message = [string]$_.MessageData if (-not [string]::IsNullOrWhiteSpace($message)) { Write-GuiConsole -Message $message } } else { $resultObjects.Add($_) } [System.Windows.Forms.Application]::DoEvents() } return @($resultObjects.ToArray()) } function Show-GuiError { param([string]$Message) Write-GuiConsole -Message $Message -ErrorMessage [void][System.Windows.Forms.MessageBox]::Show( $form,$Message,'WindowsDeviceLink', [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Error ) } function Confirm-GuiAction { param( [string]$Title, [string]$Message ) $result = [System.Windows.Forms.MessageBox]::Show( $form, $Message, $Title, [System.Windows.Forms.MessageBoxButtons]::YesNo, [System.Windows.Forms.MessageBoxIcon]::Warning ) return ($result -eq [System.Windows.Forms.DialogResult]::Yes) } function Set-GuiCapabilities { $support = $script:WdlGuiSupport $local = $script:WdlGuiLocalAssociation $cloud = $script:WdlGuiCloudStatus $runtimeReady = $support -and [bool]$support.Supported $canFullAssociation = $runtimeReady -and [string]$support.Environment -ne 'WindowsPE' $cloudState = if ($cloud) { ([string]$cloud.AssociationState).Trim().ToLowerInvariant() } else { '' } $cloudKnownAbsent = $cloud -and ( $cloud.AssociationPresent -eq $false -or $cloudState -eq 'notassociated' ) $cloudAlreadyPresent = $cloudState -in @('preassociated','associated') $localFullyAssociated = $local -and [string]$local.FirmwareState -eq '4/4' $alreadyFullyAssociated = $localFullyAssociated -and $cloudState -eq 'associated' $btnRefresh.Enabled = $true $btnOnline.Enabled = $runtimeReady $btnExport.Enabled = $runtimeReady $btnPreassociate.Enabled = $runtimeReady -and -not $cloudAlreadyPresent $btnFullAssociate.Enabled = $canFullAssociation -and -not $alreadyFullyAssociated $btnCloudOffboard.Enabled = -not $cloudKnownAbsent $btnLocalOffboard.Enabled = $true $btnFullOffboard.Enabled = $runtimeReady $toolTip.SetToolTip($btnPreassociate, 'Create the tenant-side Device Association pre-association.') $toolTip.SetToolTip($btnCloudOffboard, 'Remove only the tenant-side Device Association record.') $toolTip.SetToolTip($btnFullAssociate, 'Ensure pre-association exists and perform full Device Association on this device.') if ($cloudAlreadyPresent) { $toolTip.SetToolTip($btnPreassociate, 'The cloud association already exists; pre-association is not required.') } if ($alreadyFullyAssociated) { $toolTip.SetToolTip($btnFullAssociate, 'The device is already fully associated.') } if ($cloudKnownAbsent) { $toolTip.SetToolTip($btnCloudOffboard, 'Cloud state is already known to be Not associated; there is nothing to remove.') } if ($support -and [string]$support.Environment -eq 'WindowsPE') { $toolTip.SetToolTip( $btnFullAssociate, 'Full association is not currently supported in Windows PE. Pre-associate the device and let Windows complete Device Association during OOBE.' ) $toolTip.SetToolTip( $btnOnline, "Windows PE online operations use authentication method '$Method'. Interactive browser authentication is not supported in WinPE." ) } if (-not $runtimeReady -and $support) { $runtimeReason = if ([string]::IsNullOrWhiteSpace([string]$support.Reason)) { 'The local DeviceLink runtime is unavailable.' } else { [string]$support.Reason } foreach ($button in @($btnOnline,$btnExport,$btnPreassociate,$btnFullOffboard)) { $toolTip.SetToolTip($button,$runtimeReason) } } } function Set-GuiBusy { param( [Parameter(Mandatory)][bool]$Busy, [string]$StatusText ) $script:WdlGuiBusy = $Busy if ($Busy) { foreach ($button in $allActionButtons) { $button.Enabled = $false } $actionsPanel.Enabled = $false $tenantSelector.Enabled = $false } else { $actionsPanel.Enabled = $true $tenantSelector.Enabled = $true Set-GuiCapabilities } $btnClearActivity.Enabled = -not $Busy $statusProgress.Visible = $Busy $form.UseWaitCursor = $Busy if (-not [string]::IsNullOrWhiteSpace($StatusText)) { Set-GuiStatus $StatusText } foreach ($button in $allActionButtons) { $button.Invalidate() $button.Update() } $actionsPanel.Invalidate($true) $actionsPanel.Update() $tenantSelector.Invalidate() $tenantSelector.Update() [System.Windows.Forms.Application]::DoEvents() } function Refresh-LocalView { Set-GuiStatus 'Refreshing local state...' Write-GuiConsole -Message 'Refresh local state' -Command $bios = Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop $cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop $os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction SilentlyContinue $runtimeParameters = Get-GuiRuntimeParameters $support = Test-WindowsDeviceLinkSupport @runtimeParameters $local = Get-WindowsDeviceLinkLocalAssociation $script:WdlGuiSupport = $support $script:WdlGuiLocalAssociation = $local $ui.DeviceName.Text = "$($cs.Manufacturer) $($cs.Model)".Trim() $ui.Serial.Text = [string]$bios.SerialNumber if ($support.Environment -eq 'WindowsPE') { $environmentText = 'Windows PE' } elseif ($os -and [string]$os.Caption -match 'Windows 11') { $environmentText = 'Windows 11' } elseif ($os -and -not [string]::IsNullOrWhiteSpace([string]$os.Caption)) { $environmentText = ([string]$os.Caption).Replace('Microsoft ','') } else { $environmentText = [string]$support.Environment } $ui.Environment.Text = $environmentText $environmentToolTip = if ($support.Supported) { @( $environmentText "Activation: $($support.ActivationMode)" "Runtime: $($support.DllVersion)" ) -join [Environment]::NewLine } else { @( $environmentText "Runtime unavailable: $($support.Reason)" ) -join [Environment]::NewLine } $toolTip.SetToolTip($ui.Environment,$environmentToolTip) if (-not $support.Supported) { Write-GuiConsole -Message "DeviceLink runtime unavailable: $($support.Reason)" } $selectedTenant = Get-SelectedTenantId $ui.Auth.Text = if ($selectedTenant) { "$Method | $selectedTenant" } else { $Method } $ui.Firmware.Text = [string]$local.FirmwareState $friendlyLocalState = switch ([string]$local.LocalAssociationState) { 'CompleteAssociationFirmwareState' { 'Full association' } 'BaseIdentity' { 'Base identity' } 'NoFirmwareState' { 'No firmware state' } 'IncompleteFirmwareState' { 'Incomplete firmware state' } default { [string]$local.LocalAssociationState } } $ui.LocalState.Text = $friendlyLocalState $ui.TenantId.Text = if ($local.TenantId) { [string]$local.TenantId } else { 'Unavailable' } $technicalSource = if ([string]$local.TrustLevel -eq 'Unavailable' -and [string]$local.Source -eq 'Unavailable') { 'Unavailable' } elseif ([string]::IsNullOrWhiteSpace([string]$local.TrustLevel)) { [string]$local.Source } elseif ([string]::IsNullOrWhiteSpace([string]$local.Source)) { [string]$local.TrustLevel } else { "$($local.TrustLevel) | $($local.Source)" } $friendlySource = switch ([string]$local.TrustLevel) { 'CorrelatedLocalSources' { 'Registry + JWT' } 'LocalRegistryHint' { 'Registry' } 'StructurallyObservedJwtClaim' { 'JWT' } 'Conflict' { 'Conflict' } 'Unavailable' { 'Unavailable' } default { $technicalSource } } $ui.Source.Text = $friendlySource $toolTip.SetToolTip($ui.TenantId, [string]$ui.TenantId.Text) $toolTip.SetToolTip($ui.Source, $technicalSource) Set-GuiCapabilities Set-GuiStatus "Local state refreshed | $($local.FirmwareState)" Write-GuiConsole -Message "Local state: $($local.FirmwareState), tenant source: $($local.Source)" } function Get-GuiCloudStateText { param([AllowNull()][object]$State) switch (([string]$State).Trim().ToLowerInvariant()) { 'associated' { return 'Associated' } 'preassociated' { return 'Pre-associated' } 'notassociated' { return 'Not associated' } default { if ([string]::IsNullOrWhiteSpace([string]$State)) { return 'Not checked' } return [string]$State } } } function Refresh-CloudView { param( [switch]$WriteCommand ) $parameters = Get-GuiAuthParameters $runtimeParameters = Get-GuiRuntimeParameters foreach ($key in $runtimeParameters.Keys) { $parameters[$key] = $runtimeParameters[$key] } $parameters.Online = $true if ($WriteCommand) { Write-GuiConsole -Message "Get-WindowsDeviceLinkStatus -Online -Method $Method" -Command } $cloudResults = @(Invoke-GuiInformationCommand -ScriptBlock { Get-WindowsDeviceLinkStatus @parameters }) $cloud = $cloudResults | Select-Object -Last 1 $script:WdlGuiCloudStatus = $cloud $ui.CloudState.Text = Get-GuiCloudStateText -State $cloud.AssociationState $ui.CloudTenant.Text = if ($cloud.TenantId) { [string]$cloud.TenantId } else { 'Unavailable' } $ui.CloudId.Text = if ($cloud.AssociationId) { [string]$cloud.AssociationId } else { 'Unavailable' } $toolTip.SetToolTip($ui.CloudTenant, [string]$ui.CloudTenant.Text) $toolTip.SetToolTip($ui.CloudId, [string]$ui.CloudId.Text) return $cloud } function Invoke-GuiRefresh { if ($script:WdlGuiBusy) { return } Set-GuiBusy -Busy $true -StatusText 'Refreshing local state...' try { Refresh-LocalView } catch { Show-GuiError $_.Exception.Message } finally { Set-GuiBusy -Busy $false } } function Invoke-GuiOnline { if ($script:WdlGuiBusy) { return } Set-GuiBusy -Busy $true -StatusText 'Checking tenant-side Device Association...' try { $cloud = Refresh-CloudView -WriteCommand Write-GuiObject $cloud Set-GuiStatus 'Cloud lookup completed' } catch { Set-GuiStatus 'Cloud lookup failed' Show-GuiError $_.Exception.Message } finally { Set-GuiBusy -Busy $false } } function Select-GuiExportDirectory { if (-not $isWinPE) { $dialog = New-Object System.Windows.Forms.FolderBrowserDialog $dialog.Description = 'Choose a folder for the DeviceLink CSV' try { if ($dialog.ShowDialog($form) -ne [System.Windows.Forms.DialogResult]::OK) { return $null } return [string]$dialog.SelectedPath } finally { $dialog.Dispose() } } $defaultPath = $null if ($outerBoundParameters.ContainsKey('WindowsManagementServicePath')) { try { $defaultPath = Split-Path -Parent $WindowsManagementServicePath } catch {} } if ([string]::IsNullOrWhiteSpace($defaultPath)) { try { $defaultPath = (Get-Location).Path } catch {} } if ([string]::IsNullOrWhiteSpace($defaultPath)) { $defaultPath = 'X:\' } $pathForm = New-Object System.Windows.Forms.Form $pathForm.Text = 'Export DeviceLink CSV' $pathForm.StartPosition = 'CenterParent' $pathForm.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::FixedDialog $pathForm.MinimizeBox = $false $pathForm.MaximizeBox = $false $pathForm.ShowInTaskbar = $false $pathForm.Size = [System.Drawing.Size]::new(520,155) $pathLabel = New-Object System.Windows.Forms.Label $pathLabel.Text = 'Output folder' $pathLabel.Font = New-Object System.Drawing.Font('Segoe UI',9) $pathLabel.Location = [System.Drawing.Point]::new(14,14) $pathLabel.AutoSize = $true $pathForm.Controls.Add($pathLabel) $pathBox = New-Object System.Windows.Forms.TextBox $pathBox.Text = $defaultPath $pathBox.Font = New-Object System.Drawing.Font('Consolas',9) $pathBox.Location = [System.Drawing.Point]::new(16,40) $pathBox.Size = [System.Drawing.Size]::new(472,24) $pathForm.Controls.Add($pathBox) $okButton = New-Object System.Windows.Forms.Button $okButton.Text = 'Export' $okButton.DialogResult = [System.Windows.Forms.DialogResult]::OK $okButton.Location = [System.Drawing.Point]::new(308,78) $okButton.Size = [System.Drawing.Size]::new(86,30) $pathForm.Controls.Add($okButton) $cancelButton = New-Object System.Windows.Forms.Button $cancelButton.Text = 'Cancel' $cancelButton.DialogResult = [System.Windows.Forms.DialogResult]::Cancel $cancelButton.Location = [System.Drawing.Point]::new(402,78) $cancelButton.Size = [System.Drawing.Size]::new(86,30) $pathForm.Controls.Add($cancelButton) $pathForm.AcceptButton = $okButton $pathForm.CancelButton = $cancelButton try { while ($true) { if ($pathForm.ShowDialog($form) -ne [System.Windows.Forms.DialogResult]::OK) { return $null } $selectedPath = [string]$pathBox.Text if (-not [string]::IsNullOrWhiteSpace($selectedPath)) { $selectedPath = $selectedPath.Trim() } if (-not [string]::IsNullOrWhiteSpace($selectedPath) -and [System.IO.Directory]::Exists($selectedPath)) { return $selectedPath } [void][System.Windows.Forms.MessageBox]::Show( $pathForm, 'The specified output folder does not exist. Enter an existing folder path.', 'WindowsDeviceLink', [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Warning ) } } finally { $pathForm.Dispose() } } function Invoke-GuiExport { if ($script:WdlGuiBusy) { return } Set-GuiBusy -Busy $true -StatusText 'Choose export folder...' try { $selectedPath = Select-GuiExportDirectory if ([string]::IsNullOrWhiteSpace($selectedPath)) { Set-GuiStatus 'Export cancelled' return } Set-GuiStatus 'Exporting DeviceLink CSV...' Write-GuiConsole -Message "Get-WindowsDeviceLink -OutputDirectory '$selectedPath'" -Command $deviceLinkParameters = Get-GuiRuntimeParameters $deviceLinkParameters.OutputDirectory = $selectedPath $file = Get-WindowsDeviceLink @deviceLinkParameters Write-GuiObject $file [void][System.Windows.Forms.MessageBox]::Show( $form, ('Exported to:' + [Environment]::NewLine + $file.FullName), 'WindowsDeviceLink', [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Information ) Refresh-LocalView } catch { Show-GuiError $_.Exception.Message } finally { Set-GuiBusy -Busy $false } } function Invoke-GuiPreassociate { if ($script:WdlGuiBusy) { return } if (-not (Confirm-GuiAction -Title 'Create pre-association' -Message 'Ensure the tenant-side Device Association pre-association exists for this device?')) { return } Set-GuiBusy -Busy $true -StatusText 'Creating pre-association...' try { $parameters = Get-GuiAuthParameters $runtimeParameters = Get-GuiRuntimeParameters foreach ($key in $runtimeParameters.Keys) { $parameters[$key] = $runtimeParameters[$key] } $parameters.Confirm = $false Write-GuiConsole -Message "Initialize-WindowsDeviceLink -Method $Method" -Command $resultObjects = @(Invoke-GuiInformationCommand -ScriptBlock { Initialize-WindowsDeviceLink @parameters }) $result = $resultObjects | Select-Object -Last 1 Write-GuiObject $result $script:WdlGuiCloudStatus = $null Refresh-LocalView if ($result -and $result.PSObject.Properties.Name -contains 'AfterStatus' -and $result.AfterStatus) { $cloud = $result.AfterStatus $script:WdlGuiCloudStatus = $cloud $ui.CloudState.Text = [string]$cloud.AssociationState $ui.CloudTenant.Text = if ($cloud.TenantId) { [string]$cloud.TenantId } else { 'Unavailable' } $ui.CloudId.Text = if ($cloud.AssociationId) { [string]$cloud.AssociationId } else { 'Unavailable' } } $statusText = 'Pre-association completed' if ($result) { if ($result.PSObject.Properties.Name -contains 'Changed' -and -not [bool]$result.Changed) { if ($result.PSObject.Properties.Name -contains 'AfterState' -and [string]$result.AfterState -eq 'Preassociated') { $statusText = 'Already pre-associated - no change made' } elseif ($result.PSObject.Properties.Name -contains 'AfterState' -and [string]$result.AfterState -eq 'Associated') { $statusText = 'Already associated - no change made' } elseif ($result.PSObject.Properties.Name -contains 'Message' -and -not [string]::IsNullOrWhiteSpace([string]$result.Message)) { $statusText = [string]$result.Message } } elseif ($result.PSObject.Properties.Name -contains 'Changed' -and [bool]$result.Changed) { $statusText = 'Pre-association created successfully' } elseif ($result.PSObject.Properties.Name -contains 'Message' -and -not [string]::IsNullOrWhiteSpace([string]$result.Message)) { $statusText = [string]$result.Message } } Set-GuiStatus $statusText Write-GuiConsole -Message $statusText } catch { Set-GuiStatus 'Pre-association failed' Show-GuiError $_.Exception.Message } finally { Set-GuiBusy -Busy $false } } function Invoke-GuiFullAssociate { if ($script:WdlGuiBusy) { return } if ($isWinPE) { Show-GuiError 'Full DeviceLink association is not supported in Windows PE. Use Pre-associate in WinPE and let full Windows/OOBE complete Device Association.' return } if (-not (Confirm-GuiAction -Title 'Full association' -Message 'Ensure pre-association exists and complete Device Association on this device?')) { return } Set-GuiBusy -Busy $true -StatusText 'Completing onboarding...' try { $parameters = Get-GuiAuthParameters $runtimeParameters = Get-GuiRuntimeParameters foreach ($key in $runtimeParameters.Keys) { $parameters[$key] = $runtimeParameters[$key] } $parameters.FullAssociation = $true $parameters.Confirm = $false Write-GuiConsole -Message "Initialize-WindowsDeviceLink -Method $Method -FullAssociation" -Command $resultObjects = New-Object System.Collections.Generic.List[object] & { Initialize-WindowsDeviceLink @parameters } 6>&1 | ForEach-Object { if ($_ -is [System.Management.Automation.InformationRecord]) { $message = [string]$_.MessageData if ($message) { Write-GuiConsole -Message $message } } else { $resultObjects.Add($_) } [System.Windows.Forms.Application]::DoEvents() } $result = @($resultObjects.ToArray()) | Select-Object -Last 1 Write-GuiObject $result $script:WdlGuiCloudStatus = $null Refresh-LocalView $verifiedCloud = Refresh-CloudView -WriteCommand Write-GuiObject $verifiedCloud Set-GuiStatus 'Full association completed' } catch { Set-GuiStatus 'Full association failed' Show-GuiError $_.Exception.Message } finally { Set-GuiBusy -Busy $false } } function Invoke-GuiCloudOffboard { if ($script:WdlGuiBusy) { return } if (-not (Confirm-GuiAction -Title 'Cloud offboarding' -Message 'Remove only the tenant-side Device Association record? Local DeviceLink firmware will remain unchanged.')) { return } Set-GuiBusy -Busy $true -StatusText 'Removing cloud association...' try { $parameters = Get-GuiAuthParameters $parameters.Confirm = $false Write-GuiConsole -Message "Remove-WindowsDeviceLinkAssociation -Method $Method" -Command $removalResults = @(Invoke-GuiInformationCommand -ScriptBlock { Remove-WindowsDeviceLinkAssociation @parameters }) $result = $removalResults | Select-Object -Last 1 Write-GuiObject $result $script:WdlGuiCloudStatus = $null $ui.CloudState.Text = 'Not checked' $ui.CloudTenant.Text = 'Unavailable' $ui.CloudId.Text = 'Unavailable' Refresh-LocalView Set-GuiStatus 'Cloud offboarding completed' } catch { $message = [string]$_.Exception.Message if ($message -like "No Device Association record was found for serial number *") { $noChangeMessage = 'No cloud association was found. Nothing was removed.' Write-GuiConsole -Message $noChangeMessage $script:WdlGuiCloudStatus = $null $ui.CloudState.Text = 'Not associated' $ui.CloudTenant.Text = 'Unavailable' $ui.CloudId.Text = 'Unavailable' Refresh-LocalView Set-GuiStatus $noChangeMessage [void][System.Windows.Forms.MessageBox]::Show( $form, $noChangeMessage, 'WindowsDeviceLink', [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Information ) } else { Set-GuiStatus 'Cloud offboarding failed' Show-GuiError $message } } finally { Set-GuiBusy -Busy $false } } function Invoke-GuiLocalOffboard { if ($script:WdlGuiBusy) { return } if (-not (Confirm-GuiAction -Title 'Local offboarding' -Message 'Reset all known local DeviceLink UEFI variables? The tenant-side Device Association record will remain unchanged.')) { return } Set-GuiBusy -Busy $true -StatusText 'Resetting local DeviceLink state...' try { Write-GuiConsole -Message 'Reset-WindowsDeviceLinkFirmwareState' -Command $result = Reset-WindowsDeviceLinkFirmwareState -Confirm:$false Write-GuiObject $result $script:WdlGuiCloudStatus = $null Refresh-LocalView Set-GuiStatus 'Local offboarding completed' } catch { Set-GuiStatus 'Local offboarding failed' Show-GuiError $_.Exception.Message } finally { Set-GuiBusy -Busy $false } } function Invoke-GuiFullOffboard { if ($script:WdlGuiBusy) { return } $message = @( 'This performs full DeviceLink offboarding:' '' '1. Verify tenant-side Device Association state' '2. Remove the cloud Device Association if present' '3. Reset all known local DeviceLink UEFI variables' '' 'This does not remove the Entra device or MDM enrollment record.' '' 'Continue?' ) -join [Environment]::NewLine if (-not (Confirm-GuiAction -Title 'Full DeviceLink offboarding' -Message $message)) { return } Set-GuiBusy -Busy $true -StatusText 'Performing full offboarding...' $effectiveAccessToken = $null try { $auth = Get-GuiAuthParameters $effectiveAuth = @{} foreach ($key in $auth.Keys) { $effectiveAuth[$key] = $auth[$key] } if ($Method -eq 'DeviceCode') { Write-GuiConsole -Message 'Acquiring one DeviceCode token for the complete offboarding flow.' -Command $tokenParameters = @{} if ($auth.ContainsKey('TenantId')) { $tokenParameters.TenantId = $auth.TenantId } if ($auth.ContainsKey('ClientId')) { $tokenParameters.ClientId = $auth.ClientId } $tokenResults = @(Invoke-GuiInformationCommand -ScriptBlock { Get-WindowsDeviceLinkDeviceCodeToken @tokenParameters }) $token = $tokenResults | Select-Object -Last 1 if (-not $token -or [string]::IsNullOrWhiteSpace([string]$token.AccessToken)) { throw 'Device code authentication did not return an access token.' } $effectiveAccessToken = ConvertTo-SecureString ([string]$token.AccessToken) -AsPlainText -Force $effectiveAuth = @{ Method = 'AccessToken' AccessToken = $effectiveAccessToken Environment = $Environment ClientTimeout = $ClientTimeout } if ($token.TenantId) { $effectiveAuth.TenantId = [string]$token.TenantId } $token = $null Write-GuiConsole -Message 'DeviceCode token acquired once; reusing it for cloud verification and removal.' } $statusParameters = @{} foreach ($key in $effectiveAuth.Keys) { $statusParameters[$key] = $effectiveAuth[$key] } $runtimeParameters = Get-GuiRuntimeParameters foreach ($key in $runtimeParameters.Keys) { $statusParameters[$key] = $runtimeParameters[$key] } $statusParameters.Online = $true $displayMethod = [string]$effectiveAuth.Method Write-GuiConsole -Message "Get-WindowsDeviceLinkStatus -Online -Method $displayMethod" -Command $cloudResults = @(Invoke-GuiInformationCommand -ScriptBlock { Get-WindowsDeviceLinkStatus @statusParameters }) $cloud = $cloudResults | Select-Object -Last 1 Write-GuiObject $cloud if ($null -eq $cloud.AssociationPresent -or [string]$cloud.AssociationState -eq 'Unknown') { throw 'Cloud Device Association state is indeterminate. Local firmware was not reset.' } if ($cloud.AssociationPresent) { $removeParameters = @{} foreach ($key in $effectiveAuth.Keys) { $removeParameters[$key] = $effectiveAuth[$key] } $removeParameters.Confirm = $false Write-GuiConsole -Message "Remove-WindowsDeviceLinkAssociation -Method $displayMethod" -Command $removeResults = @(Invoke-GuiInformationCommand -ScriptBlock { Remove-WindowsDeviceLinkAssociation @removeParameters }) $removed = $removeResults | Select-Object -Last 1 Write-GuiObject $removed } else { Write-GuiConsole -Message 'No tenant-side Device Association exists. Cloud removal skipped.' } Write-GuiConsole -Message 'Reset-WindowsDeviceLinkFirmwareState' -Command $reset = Reset-WindowsDeviceLinkFirmwareState -Confirm:$false Write-GuiObject $reset $script:WdlGuiCloudStatus = $null $ui.CloudState.Text = 'Not checked' $ui.CloudTenant.Text = 'Unavailable' $ui.CloudId.Text = 'Unavailable' Refresh-LocalView Set-GuiStatus 'Full offboarding completed' } catch { Set-GuiStatus 'Full offboarding failed' Show-GuiError $_.Exception.Message } finally { $effectiveAccessToken = $null Set-GuiBusy -Busy $false } } $btnRefresh.Add_Click({ Invoke-GuiRefresh }) $btnOnline.Add_Click({ Invoke-GuiOnline }) $btnExport.Add_Click({ Invoke-GuiExport }) $btnPreassociate.Add_Click({ Invoke-GuiPreassociate }) $btnFullAssociate.Add_Click({ Invoke-GuiFullAssociate }) $btnCloudOffboard.Add_Click({ Invoke-GuiCloudOffboard }) $btnLocalOffboard.Add_Click({ Invoke-GuiLocalOffboard }) $btnFullOffboard.Add_Click({ Invoke-GuiFullOffboard }) $tenantSelector.Add_SelectedIndexChanged({ if (-not $script:WdlGuiBusy) { $selectedTenant = Get-SelectedTenantId $ui.Auth.Text = if ($selectedTenant) { "$Method | $selectedTenant" } else { $Method } } }) $btnClearActivity.Add_Click({ $consoleBox.Clear() Write-GuiConsole -Message 'Activity log cleared.' }) $form.Add_FormClosing({ param($sender,$eventArgs) if ($script:WdlGuiBusy) { $eventArgs.Cancel = $true Write-GuiConsole -Message 'Close request ignored because an operation is still running.' } }) $form.Add_Shown({ $environmentName = if ($isWinPE) { 'Windows PE' } else { 'Windows' } Write-GuiConsole -Message "WindowsDeviceLink dashboard opened in $environmentName. Authentication method: $Method." Set-GuiBusy -Busy $true -StatusText 'Loading local state...' try { Refresh-LocalView } catch { Show-GuiError $_.Exception.Message } finally { Set-GuiBusy -Busy $false } }) function Resize-GuiLayout { $fullWidth = [Math]::Max(760,$content.ClientSize.Width - 32) $gap = 14 $halfWidth = [Math]::Floor(($fullWidth - $gap) / 2) if ($fullWidth -ge 900) { $deviceCard.Location = [System.Drawing.Point]::new(14,12) $deviceCard.Size = [System.Drawing.Size]::new([int]$halfWidth,126) $associationCard.Location = [System.Drawing.Point]::new((14 + $halfWidth + $gap),12) $associationCard.Size = [System.Drawing.Size]::new([int]$halfWidth,126) $cloudY = 150 } else { $deviceCard.Location = [System.Drawing.Point]::new(14,12) $deviceCard.Size = [System.Drawing.Size]::new([int]$fullWidth,126) $associationCard.Location = [System.Drawing.Point]::new(14,150) $associationCard.Size = [System.Drawing.Size]::new([int]$fullWidth,126) $cloudY = 288 } $cloudCard.Location = [System.Drawing.Point]::new(14,$cloudY) $cloudCard.Width = $fullWidth $actionsY = $cloudY + 96 $actionsTitle.Location = [System.Drawing.Point]::new(16,$actionsY) $actionsPanel.Location = [System.Drawing.Point]::new(14,($actionsY + 26)) $actionsPanel.Width = $fullWidth $activityY = $actionsY + 270 $activityTitle.Location = [System.Drawing.Point]::new(16,$activityY) # Align Clear to the same right edge used by the action buttons. # Action buttons sit 16 px inside the right edge of the Actions panel. $clearX = $actionsPanel.Right - 16 - $btnClearActivity.Width $clearY = $activityY - 5 $btnClearActivity.Location = [System.Drawing.Point]::new($clearX,$clearY) $activityCard.Location = [System.Drawing.Point]::new(14,($activityY + 26)) $activityCard.Width = $fullWidth $consoleBox.Width = $fullWidth - 24 foreach ($row in @($rowRefresh,$rowOnline,$rowExport,$rowOnboard,$rowOffboard)) { $row.Panel.Width = $fullWidth $buttons = @($row.Buttons) $right = $fullWidth - 16 for ($i = $buttons.Count - 1; $i -ge 0; $i--) { $right -= $buttons[$i].Width $buttons[$i].Left = $right $right -= 7 } $row.Panel.Controls | Where-Object { $_ -is [System.Windows.Forms.Panel] -and $_.Height -eq 1 } | ForEach-Object { $_.Width = [Math]::Max(480,$fullWidth - 28) } } $requiredHeight = $activityCard.Bottom + 2 $availableHeight = [Math]::Max(0,$content.ClientSize.Height - 2) $scrollTolerance = 10 if ($requiredHeight -gt ($availableHeight + $scrollTolerance)) { $content.AutoScroll = $true $content.HorizontalScroll.Enabled = $false $content.HorizontalScroll.Visible = $false $content.AutoScrollMinSize = [System.Drawing.Size]::new(1,$requiredHeight) } else { $content.AutoScrollMinSize = [System.Drawing.Size]::Empty $content.AutoScroll = $false $content.VerticalScroll.Value = 0 } } $form.Add_Resize({ Resize-GuiLayout }) try { Resize-GuiLayout [void]$form.ShowDialog() } finally { $toolTip.Dispose() $form.Dispose() Remove-Variable WdlGuiLocalAssociation -Scope Script -ErrorAction SilentlyContinue Remove-Variable WdlGuiCloudStatus -Scope Script -ErrorAction SilentlyContinue Remove-Variable WdlGuiBusy -Scope Script -ErrorAction SilentlyContinue Remove-Variable WdlGuiSupport -Scope Script -ErrorAction SilentlyContinue } } |