[void] [System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") if($PSVersionTable.PSVersion.Major -ge 7) { Add-Type -AssemblyName PresentationCore Add-Type -AssemblyName PresentationFramework Add-Type -AssemblyName WindowsBase } else { [void] [System.Reflection.Assembly]::LoadWithPartialName("PresentationFramework") } $script:viewObjects = @() $onAssemblyResolveEventHandler = [System.ResolveEventHandler] { param($S, $E) $assemblyName = ([System.Reflection.AssemblyName]::new($E.Name)).Name + ".dll"; $assemblyPath = [IO.Path]::Combine($script:AppRootFolder,"\Bin",$assemblyName); if([io.File]::Exists($assemblyPath)) { Write-Log "Load from module $($E.Name)" return [System.Reflection.Assembly]::LoadFrom($assemblyPath); } $assemblies = [System.AppDomain]::CurrentDomain.GetAssemblies() foreach($assembly in ($assemblies | Where-Object FullName -eq $E.Name)) { Write-LogDebug "FullName resolution of $($E.Name)" return $assembly } $name = ($E.Name -split ",")[0] foreach($assembly in $assemblies) { $assemblyName, $version, $extra = ($assembly.FullName -split ", ") if ($assemblyName -eq $name) { Write-LogDebug "Name resolution of $assemblyName. Using $version" return $assembly } } #Write-Log "Unable to resolve $($E.Name)" 3 return $null } #!!![System.AppDomain]::CurrentDomain.add_AssemblyResolve($onAssemblyResolveEventHandler) #region Menu functions ##################################################################################################### # # Menu functions # ##################################################################################################### function Get-ViewObject { param([string]$ViewId) $viewObject = $script:viewObjects | Where-Object { $_.Id -eq $ViewId } if(-not $viewObject) { Write-Log "Could not find View with id $($ViewId)" 3 return } $viewObject } function Add-ViewObject { param([ViewObjectBase]$ViewObject) if($ViewObject) { $script:viewObjects += $ViewObject } else { Write-Log "Add-ViewObject called with empty ViewObject" } } function Invoke-ViewObjectFunction { param($FunctionName, $FunctionArguments = $null) Write-LogDebug "Trigger $FunctionName on ViewObjects" foreach($viewObject in $script:viewObjects) { $viewObject.$FunctionName($FunctionArguments) | Out-Null } } function Add-MenuItem { param($MenuItem, $Index) # ToDo: Add binding support? $script:mnuMain.Items.Insert($Index,$MenuItem) | Out-Null } <# function Add-ViewItem { param($ViewItem) $viewObject = Get-ViewObject $ViewItem.ViewID if(-not $viewObject) { if(($arrMenuInlcude -and $arrMenuInlcude -notcontains $ViewItem.ViewID) -or ($arrMenuExlcude -and $arrMenuExlcude -contains $ViewItem.ViewID)) { return } Write-Log "Could not find menu with id $($ViewItem.ViewID). Item $($ViewItem.Title) not added" 2 return } foreach($scope in $ViewItem.Permissions) { if($viewObject.Permissions -is [Object[]] -and $viewObject.Permissions -notcontains $scope) { $viewObject.Permissions += $scope } } if($ViewItem.Icon -or [IO.File]::Exists(($script:AppUIRootFolder + "\Xaml\Icons\$($ViewItem.Id).xaml"))) { $ctrl = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\Icons\$((?? $ViewItem.Icon $ViewItem.Id)).xaml")) $ViewItem | Add-Member -NotePropertyName "IconImage" -NotePropertyValue $ctrl } $viewObject.ViewItems += $ViewItem } #> function Show-View { param($ViewId = "IntuneManagement") if(($script:viewObjects | Measure-Object).Count -eq 0) { Write-Log "No View Objects loaded!" 3 return } if(-not $ViewId) { # Use first View if not specified # ToDo: Use last used or default view $ViewId = $script:viewObjects[0].Id } if($script:ActiveView.ID -eq $ViewId) { return } # Current view already selected # Get the View object $viewObject = Get-ViewObject $ViewId if(-not $viewObject) { return } Write-Log "Change view to $($viewObject.Title)" Write-LogDebug "Dectivating View $($script:ActiveView.Title)" $viewObject.OnDeactivating($script:ActiveView) $currentViewObject = $script:ActiveView $script:ActiveView = $viewObject #!!! currentViewObject Show-ViewMenu $lblMenuTitle = $script:window.FindName("lblMenuTitle") $lblMenuTitle.Content = $viewObject.Title $grdViewPanel = $script:window.FindName("grdViewPanel") $grdViewPanel.Children.Clear() Write-LogDebug "Activating View $($viewObject.Title)" $viewObject.OnActivating($currentViewObject) if($viewObject.ViewPanel) { $grdViewPanel.Children.Add($viewObject.ViewPanel) | Out-Null } Set-MainTitle #Show-AuthenticationInfo if($viewObject.HideMenu -eq $true) { $script:UIProvider.SetXamlProperty($script:window, "grdViewItemMenu", "Visibility", "Collapsed") } else { $script:UIProvider.SetXamlProperty($script:window, "grdViewItemMenu", "Visibility", "Visible") } #Invoke-ViewObjectFunction "ViewActivated" $viewObject.OnActivated() } function Show-ViewMenu { $items = @($script:ActiveView.GetViewItems()) # Icons are parsed here, after the splash is up, not at module import. Initialize-MenuItemIcons -Items $items # When any item declares a Category, render as an expandable-group tree # (PropertyGroupDescription drives the GroupStyle in MainWindow.xaml). # Otherwise bind the raw array — same behaviour the menu had before # grouping landed. $hasCategory = $false foreach($it in $items) { if($it -and $it.PSObject.Properties['Category'] -and $it.Category) { $hasCategory = $true; break } } if($hasCategory) { # ListCollectionView directly (not via CollectionViewSource) — the # source-collection list must be an IList so wrap the array in a # [List[object]]. Retained on $script: so GC can't drop the view + # its GroupDescriptions while the ListBox still references it. $backing = [System.Collections.Generic.List[object]]::new() foreach($it in $items) { [void]$backing.Add($it) } $view = [System.Windows.Data.ListCollectionView]::new($backing) [void]$view.GroupDescriptions.Add( [System.Windows.Data.PropertyGroupDescription]::new("Category")) $script:_lstMenuItemsView = $view $lstMenuItems.ItemsSource = $view } else { $script:_lstMenuItemsView = $null $lstMenuItems.ItemsSource = $items } # Per-view title-config button visibility. Default to Collapsed; opt-in # views show + wire it. Kept here (rather than in each view) so a view # that doesn't know about the button can't accidentally leave it visible # from a previous active view. if($script:btnMenuTitleConfig) { $script:btnMenuTitleConfig.Visibility = "Collapsed" } if($script:ActiveView -and $script:ActiveView.ID -eq "IntuneManagement") { try { Update-MenuTitleConfigForIntuneView } catch { Write-LogDebug "Update-MenuTitleConfigForIntuneView failed: $($_.Exception.Message)" } } } function Get-CurrentViewObject { return $script:ActiveView } #endregion function Initialize-UI { Initialize-SplashScreen } function Show-MainWindow { param($View) # if($script:MainUIStarted -and $script:Window) { # throw "Can't show the windows multiple times in the same session." # return # } if(-not $script:window) { $script:SplashScreen = $null $script:useDefaultFolderDialog = $false $script:WindowsAPICodePackLoaded = $false $script:proxyURI = $null $script:AllUIViewObjects = @() Initialize-UI Set-SplashWindowText "Initialize views" $script:AllUIViewObjects += Get-SubClasses "ViewObjectBase" | ForEach-Object { try { Get-SingletonObject $_.Name } catch {} } $script:AllUIViewObjects | Where-Object AddToMenu -eq $true | ForEach-Object { Add-ViewObject $_ } <# # ToDo: Remove. Kept for reference. $script:TmpViewObject = Get-SingletonObject "TmpViewObject" Add-ViewObject $script:TmpViewObject #> #This will load the main window Set-SplashWindowText "Load main window" } Get-MainWindow if($script:window) { Set-CacheObject "ShowUI" $true Set-SplashWindowText "Open default view" Show-View $View if((Get-SettingValue "CheckForUpdates") -eq $true) { Get-IsLatestVersion } Set-SplashWindowText "Open main window" # Set a custom Application User Model ID before the window is shown. # Without this, Windows groups the taskbar button under powershell.exe and uses # the PowerShell icon regardless of WM_SETICON. Must be called before ShowDialog. try { if(-not ([System.Management.Automation.PSTypeName]'AppUserModelHelper').Type) { Add-Type -TypeDefinition @" using System; using System.Runtime.InteropServices; public class AppUserModelHelper { [DllImport("shell32.dll", CharSet = CharSet.Unicode)] public static extern int SetCurrentProcessExplicitAppUserModelID(string AppID); } "@ -ErrorAction Stop } [AppUserModelHelper]::SetCurrentProcessExplicitAppUserModelID("Intune.IntuneManagement") | Out-Null } catch { } $script:window.ShowDialog() | Out-Null } } function Get-LogViewPanel { $viewPanel = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\LogInfo.xaml")) $script:UIProvider.SetXamlProperty($viewPanel, "dgLogInfo", "ItemsSource", $script:LogItems) $viewPanel } function Get-CachedObjectsViewPanel { $viewPanel = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\CachedObjects.xaml")) Update-CachedObjectsView -ViewPanel $viewPanel $script:UIProvider.AddXamlEvent($viewPanel, "dgCachedObjects", "add_selectionChanged", { $Obj = $this.Parent.FindName("txtCacheObjectInfo") if($Obj) { if($this.SelectedValue.Value -is [Hashtable]) { $Obj.Text = ($this.SelectedValue.Value.Keys | ForEach-Object { "$_ - $($this.SelectedValue.Value[$_].Count) objects" }) -join "`n" } else { $Obj.Text = $this.SelectedValue.Value } } }) $script:UIProvider.AddXamlEvent($viewPanel, "btnClearCachedObject", "add_click", { $selectedItem = $this.Parent.DataContext if($selectedItem -and $selectedItem.Persistent -ne $true) { if([System.Windows.MessageBox]::Show("Are you sure you want to remove $($selectedItem.Name)?", "Clear object from cache?", "YesNo", "Question") -eq "Yes") { Clear-CacheObject -Name $selectedItem.Name $rootPanel = $this.Parent while($rootPanel.Parent) { $rootPanel = $rootPanel.Parent } Update-CachedObjectsView -ViewPanel $rootPanel } } }) $btnRefresh = $viewPanel.FindName("btnRefreshCachedObjects") if($btnRefresh) { $btnRefresh.Tag = $viewPanel $btnRefresh.Add_Click({ Update-CachedObjectsView -ViewPanel $this.Tag }) } $viewPanel } function Format-CacheByteSize { param([long]$Bytes) if($Bytes -lt 1KB) { return ("{0:N0} B" -f $Bytes) } if($Bytes -lt 1MB) { return ("{0:N1} KB" -f ($Bytes / 1KB)) } if($Bytes -lt 1GB) { return ("{0:N2} MB" -f ($Bytes / 1MB)) } return ("{0:N2} GB" -f ($Bytes / 1GB)) } function Update-CachedObjectsView { param($ViewPanel) if(-not $ViewPanel) { return } $totalBytes = [long]0 # Project the raw cache entries with computed Size / SizeText fields. SizeText drives # the per-row column; Size is kept numeric so it sorts correctly. $rows = @($script:cacheObjects.Values | ForEach-Object { $sz = Get-CacheObjectSize $_.Value $totalBytes += $sz $tagsText = if($_.Tags) { ($_.Tags -join ", ") } else { "" } [PSCustomObject]@{ Name = $_.Name Tags = $_.Tags TagsText = $tagsText Value = $_.Value Persistent = $_.Persistent TimeOut = $_.TimeOut Size = $sz SizeText = (Format-CacheByteSize $sz) } }) # Direct assignment for ItemsSource — see Update-GraphCallsView for the same # reason (PS param binding unwraps single-element arrays and breaks IEnumerable). $dgCachedObjects = $ViewPanel.FindName("dgCachedObjects") if($dgCachedObjects) { $dgCachedObjects.ItemsSource = $null $dgCachedObjects.ItemsSource = [System.Collections.IEnumerable]$rows } $stats = Get-CacheStats $script:UIProvider.SetXamlProperty($ViewPanel, "txtCacheEntries", "Text", ("{0:N0}" -f $stats.Entries)) $script:UIProvider.SetXamlProperty($ViewPanel, "txtCacheHits", "Text", ("{0:N0}" -f $stats.Hits)) $script:UIProvider.SetXamlProperty($ViewPanel, "txtCacheMisses", "Text", ("{0:N0}" -f $stats.Misses)) $script:UIProvider.SetXamlProperty($ViewPanel, "txtCacheBytes", "Text", (Format-CacheByteSize $totalBytes)) } function Get-GraphCallsViewPanel { $xamlPath = (Join-Path $script:AppUIRootFolder "Xaml\GraphCallsPanel.xaml") $viewPanel = $script:UIProvider.GetXamlObject($xamlPath) if(-not $viewPanel) { # Get-XamlObject swallows parse errors and returns nothing on failure. Surface it # so a black/empty Graph Calls panel doesn't look like a working but empty view. Write-LogError "Get-GraphCallsViewPanel: XAML failed to load from '$xamlPath'. The Graph Calls view will be empty." $null return $null } try { Update-GraphCallsView -ViewPanel $viewPanel } catch { Write-LogError "Get-GraphCallsViewPanel: Update-GraphCallsView threw" $_.Exception } $btnRefresh = $viewPanel.FindName("btnRefreshGraphCalls") if($btnRefresh) { $btnRefresh.Tag = $viewPanel $btnRefresh.Add_Click({ # Refresh wipes any active filter; user explicitly asked for a fresh read. $script:_graphCallsFilter = $null Update-GraphCallsView -ViewPanel $this.Tag }) } $btnClearFilter = $viewPanel.FindName("btnClearGraphCallsFilter") if($btnClearFilter) { $btnClearFilter.Tag = $viewPanel $btnClearFilter.Add_Click({ $script:_graphCallsFilter = $null Update-GraphCallsView -ViewPanel $this.Tag }) } $viewPanel } function Update-GraphCallsView { param($ViewPanel) if(-not $ViewPanel) { return } $calls = if($script:AllGraphCalls) { @($script:AllGraphCalls) } else { @() } # Apply the active code filter (if any). A call matches when the top-level # StatusCode equals the code OR any of its batch sub-requests does. Stays # outside the totals/breakdown loop below so the breakdown always shows the # full distribution — that's how the user discovers what to filter on. $filterCode = $script:_graphCallsFilter $rowsForGrid = $calls if($filterCode) { $rowsForGrid = @($calls | Where-Object { if("$($_.StatusCode)" -eq $filterCode) { return $true } if($_.IsBatch -and $_.BatchRequests) { foreach($br in $_.BatchRequests) { if("$($br.Response.StatusCode)" -eq $filterCode) { return $true } } } return $false }) } # Rebind the call list so newly-added entries show up. Assign ItemsSource # directly via FindName rather than Set-XamlProperty: PS parameter binding # unwraps single-element arrays when an untyped parameter takes the value, # and an unwrapped PSCustomObject can't satisfy ItemsSource's IEnumerable # requirement. Direct assignment preserves the array's identity. $dgGraphCalls = $ViewPanel.FindName("dgGraphCalls") if($dgGraphCalls) { $dgGraphCalls.ItemsSource = $null $dgGraphCalls.ItemsSource = [System.Collections.IEnumerable]$rowsForGrid } $totalBytes = [long]0 $totalObjects = [long]0 $totalDurationMs = [double]0 $httpStatuses = [System.Collections.Generic.Dictionary[string,int]]::new() $batchStatuses = [System.Collections.Generic.Dictionary[string,int]]::new() foreach($call in $calls) { if($call.KB) { $totalBytes += [long]([double]$call.KB * 1024) } if($call.ObjectCount) { $totalObjects += [long]$call.ObjectCount } if($call.Duration) { $totalDurationMs += [double]$call.Duration } $code = if($null -ne $call.StatusCode -and $call.StatusCode -ne "") { "$($call.StatusCode)" } else { "n/a" } if(-not $httpStatuses.ContainsKey($code)) { $httpStatuses[$code] = 0 } $httpStatuses[$code]++ # BatchErrorSummary feeds the "Batch Errors" column. Computed on every # refresh and stored as a NoteProperty (Add-Member -Force) on the call # object so the column binding is plain {Binding BatchErrorSummary}. # Stays blank for non-batch calls and for batches with no sub-failures. # Format: "401:2, 429:1" — codes with per-code counts, sorted by code. $errSummary = "" if($call.IsBatch -and $call.BatchRequests) { $errorCounts = [System.Collections.Generic.Dictionary[string,int]]::new() foreach($item in $call.BatchRequests) { $bcode = if($null -ne $item.Response.StatusCode) { "$($item.Response.StatusCode)" } else { "n/a" } if(-not $batchStatuses.ContainsKey($bcode)) { $batchStatuses[$bcode] = 0 } $batchStatuses[$bcode]++ # Treat any 4xx/5xx OR missing status as an error for column display. $isErr = $false if($bcode -eq "n/a") { $isErr = $true } else { $n = 0 if([int]::TryParse($bcode, [ref]$n) -and $n -ge 400) { $isErr = $true } } if($isErr) { if(-not $errorCounts.ContainsKey($bcode)) { $errorCounts[$bcode] = 0 } $errorCounts[$bcode]++ } } if($errorCounts.Count -gt 0) { $errSummary = (($errorCounts.Keys | Sort-Object | ForEach-Object { "$($_):$($errorCounts[$_])" }) -join ", ") } } $call | Add-Member -MemberType NoteProperty -Name "BatchErrorSummary" -Value $errSummary -Force } $script:UIProvider.SetXamlProperty($ViewPanel, "txtTotalCalls", "Text", ("{0:N0}" -f $calls.Count)) $script:UIProvider.SetXamlProperty($ViewPanel, "txtTotalMB", "Text", ("{0:N2} MB" -f ($totalBytes / 1MB))) $script:UIProvider.SetXamlProperty($ViewPanel, "txtTotalObjects", "Text", ("{0:N0}" -f $totalObjects)) $script:UIProvider.SetXamlProperty($ViewPanel, "txtTotalTime", "Text", (Format-GraphTotalDuration $totalDurationMs)) # Build the clickable status-code panels. Each code becomes a small LinkButton # that sets the filter on click. Highlights the active filter code so it's # obvious what's currently selected. Build-GraphStatusCodePanel -ViewPanel $ViewPanel -PanelName "pnlHttpStatuses" -Counts $httpStatuses -ActiveCode $filterCode Build-GraphStatusCodePanel -ViewPanel $ViewPanel -PanelName "pnlBatchStatuses" -Counts $batchStatuses -ActiveCode $filterCode # Filter indicator row: hidden when no filter active. $indicator = $ViewPanel.FindName("pnlFilterIndicator") if($indicator) { if($filterCode) { $shown = if($rowsForGrid) { $rowsForGrid.Count } else { 0 } $script:UIProvider.SetXamlProperty($ViewPanel, "txtActiveFilter", "Text", ("status code {0} - showing {1:N0} of {2:N0} call(s)" -f $filterCode, $shown, $calls.Count)) $indicator.Visibility = "Visible" } else { $script:UIProvider.SetXamlProperty($ViewPanel, "txtActiveFilter", "Text", "") $indicator.Visibility = "Collapsed" } } } function Build-GraphStatusCodePanel { # Populates a WrapPanel with one clickable button per status code. Click sets # $script:_graphCallsFilter and re-runs Update-GraphCallsView so the grid + the # indicator both refresh in one pass. param( $ViewPanel, [string]$PanelName, [System.Collections.Generic.Dictionary[string,int]]$Counts, [string]$ActiveCode ) $panel = $ViewPanel.FindName($PanelName) if(-not $panel) { return } $panel.Children.Clear() if(-not $Counts -or $Counts.Count -eq 0) { $tb = [System.Windows.Controls.TextBlock]::new() $tb.Text = "-" $tb.Margin = "0,0,4,0" [void]$panel.Children.Add($tb) return } foreach($key in ($Counts.Keys | Sort-Object)) { $btn = [System.Windows.Controls.Button]::new() $btn.Content = ("{0}: {1:N0}" -f $key, $Counts[$key]) $btn.Margin = "0,0,12,0" $btn.Padding = "0" $btn.Cursor = [System.Windows.Input.Cursors]::Hand $btn.ToolTip = (Get-HttpStatusDescription $key) + "`n`nClick to filter the grid by this status code (includes batch jobs containing a matching sub-request)." # Style as a hyperlink so the breakdown still LOOKS like text but is # clickable. Falls back to a default button if the LinkButton style isn't # registered (paranoia — should always be there). $linkStyle = $null if($script:window) { $linkStyle = $script:window.TryFindResource("LinkButton") } if($linkStyle) { $btn.Style = $linkStyle } # Bold the currently-active filter code so the user can see what's selected. if($ActiveCode -and $key -eq $ActiveCode) { $btn.FontWeight = "Bold" } $btn.Tag = [PSCustomObject]@{ Code = $key; ViewPanel = $ViewPanel } $btn.Add_Click({ $script:_graphCallsFilter = $this.Tag.Code Update-GraphCallsView -ViewPanel $this.Tag.ViewPanel }) [void]$panel.Children.Add($btn) } } function Format-GraphTotalDuration { param([double]$Milliseconds) if($Milliseconds -lt 1000) { return ("{0:N0} ms" -f $Milliseconds) } $totalSeconds = [int]($Milliseconds / 1000) if($totalSeconds -lt 60) { return ("{0:N1} s" -f ($Milliseconds / 1000)) } $h = [int]($totalSeconds / 3600) $m = [int]((($totalSeconds) % 3600) / 60) $s = $totalSeconds % 60 if($h -gt 0) { return ("{0}h {1:D2}m {2:D2}s" -f $h, $m, $s) } return ("{0}m {1:D2}s" -f $m, $s) } function Get-HttpStatusDescription { param([string]$Code) # Descriptions oriented toward what each code means specifically for Microsoft Graph traffic. switch ($Code) { "200" { "OK - Request succeeded" } "201" { "Created - New resource was created" } "202" { "Accepted - Request accepted; processing is async" } "204" { "No Content - Request succeeded; nothing to return (typical for DELETE, PATCH)" } "301" { "Moved Permanently - Resource has a new permanent URL" } "302" { "Found - Temporary redirect" } "304" { "Not Modified - Cached copy is still valid (conditional GET)" } "307" { "Temporary Redirect - Use new URL for this request only" } "308" { "Permanent Redirect - Use new URL going forward" } "400" { "Bad Request - Malformed request (invalid filter/expand/JSON)" } "401" { "Unauthorized - Token missing, expired, or invalid" } "402" { "Payment Required - Tenant license issue" } "403" { "Forbidden - Caller lacks permission for this resource" } "404" { "Not Found - Resource (policy, user, group) doesn't exist" } "405" { "Method Not Allowed - HTTP verb not supported on this endpoint" } "406" { "Not Acceptable - Server cannot produce the requested format" } "408" { "Request Timeout - Client took too long to send the request" } "409" { "Conflict - State conflict (e.g. duplicate name, concurrent modification)" } "410" { "Gone - Resource was deleted" } "411" { "Length Required - Missing Content-Length header" } "412" { "Precondition Failed - If-Match/If-None-Match header didn't match" } "413" { "Payload Too Large - Request body exceeds the limit" } "415" { "Unsupported Media Type - Content-Type not accepted" } "416" { "Range Not Satisfiable - Requested byte range is invalid" } "422" { "Unprocessable Entity - Request is well-formed but semantically wrong" } "423" { "Locked - Resource is locked by another operation" } "429" { "Too Many Requests - Graph throttling; back off and retry" } "500" { "Internal Server Error - Graph backend failure" } "501" { "Not Implemented - Endpoint or feature not supported" } "502" { "Bad Gateway - Upstream service error" } "503" { "Service Unavailable - Graph or downstream service is unavailable" } "504" { "Gateway Timeout - Upstream service timed out" } "507" { "Insufficient Storage - Service-side storage limit exceeded" } "509" { "Bandwidth Limit Exceeded - Tenant or app bandwidth quota hit" } "n/a" { "No response - Request failed before a status code was returned" } default { "HTTP $Code" } } } function Format-GraphStatusTooltip { param([System.Collections.Generic.Dictionary[string,int]]$Counts) if(-not $Counts -or $Counts.Count -eq 0) { return $null } $lines = @() foreach($key in ($Counts.Keys | Sort-Object)) { $desc = Get-HttpStatusDescription $key $lines += ("{0,4} ({1,5:N0}) {2}" -f $key, $Counts[$key], $desc) } return ($lines -join [System.Environment]::NewLine) } function Format-GraphStatusBreakdown { param([System.Collections.Generic.Dictionary[string,int]]$Counts) if(-not $Counts -or $Counts.Count -eq 0) { return "-" } $pairs = @() foreach($key in ($Counts.Keys | Sort-Object)) { $pairs += ("{0}: {1:N0}" -f $key, $Counts[$key]) } return ($pairs -join " ") } #region Main Window function Update-AuthDependentMenuState { # Enable/disable menu items that need an authenticated session. Currently: # Tenant Settings — the settings form filters values to TenantSettings=true, # which has no useful meaning without a tenant context. Provider-agnostic so # MSAL and MgGraph sessions both flip this on. Called from File-menu open # AND from auth events so the state stays current without requiring the user # to close and re-open the menu after sign-in. [CmdletBinding()] param($EventArg) # AppEvent handlers always get the event payload; unused. if(-not $script:window) { return } $signedIn = $false try { $provider = Get-AuthProvider if($provider -and $provider.GetUserInfo(0)) { $signedIn = $true } } catch { } try { $script:UIProvider.SetXamlProperty($script:window, "mnuTenantSettings", "IsEnabled", $signedIn) } catch { Write-LogDebug "Update-AuthDependentMenuState: $($_.Exception.Message)" } } function Set-MainTitle { if(-not $script:window -or -not $script:ActiveView.Title) { return } Write-LogDebug "Set main title to $($script:ActiveView.Title)" $title = ?? $script:ActiveView.Title "Intune Management" $script:window.Title = $title # Also paint the centered title-bar caption. FindName each call (cheap) so # callers that swap out the window — should never happen, but cheap insurance — # don't end up pointing at a stale reference. $txtTitleViewName = $script:window.FindName("txtTitleViewName") if($txtTitleViewName) { $txtTitleViewName.Text = $title } } function Get-MainWindow { $script:window = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\MainWindow.xaml"), $false, $true) if($null -eq $script:window) { Write-LogError "Failed to initialize main window" $_.Exception return } $mnuViews = $script:window.FindName("mnuViews") $script:lstMenuItems = $script:window.FindName("lstMenuItems") $script:grdModal = $script:window.FindName("grdModal") $script:grdMenu = $script:window.FindName("grdMenu") $script:mnuMain = $script:window.FindName("mnuMain") $script:borderEnvBadge = $script:window.FindName("borderEnvBadge") $script:txtEnvBadge = $script:window.FindName("txtEnvBadge") # View-specific menu-title config button. The button itself is generic # (any view can opt in by becoming visible). Sub-menu items / click # semantics are owned by the view that surfaces it (currently IntuneView # via Update-MenuTitleConfigForIntuneView). # # ContextMenu lives in its own NameScope so window.FindName can't reach # the menu items — pull them off the Button's ContextMenu.Items instead. $script:btnMenuTitleConfig = $script:window.FindName("btnMenuTitleConfig") $script:mnuMenuTitleConfigGroup = $null $script:mnuMenuTitleConfigType = $null $script:mnuMenuTitleConfigShowCounts = $null $script:mnuMenuTitleConfigShowInfo = $null $script:mnuMenuTitleConfigRefresh = $null $script:mnuMenuTitleConfigFilterPlatforms = $null if($script:btnMenuTitleConfig -and $script:btnMenuTitleConfig.ContextMenu) { foreach($it in $script:btnMenuTitleConfig.ContextMenu.Items) { # Separators come through as ContextMenu items but don't have the # Name property we're matching on — they're skipped silently. switch ($it.Name) { "mnuMenuTitleConfigGroup" { $script:mnuMenuTitleConfigGroup = $it } "mnuMenuTitleConfigType" { $script:mnuMenuTitleConfigType = $it } "mnuMenuTitleConfigShowCounts" { $script:mnuMenuTitleConfigShowCounts = $it } "mnuMenuTitleConfigShowInfo" { $script:mnuMenuTitleConfigShowInfo = $it } "mnuMenuTitleConfigRefresh" { $script:mnuMenuTitleConfigRefresh = $it } "mnuMenuTitleConfigFilterPlatforms" { $script:mnuMenuTitleConfigFilterPlatforms = $it } } } # Buttons don't open their ContextMenu on left-click by default. $script:btnMenuTitleConfig.Add_Click({ if($script:btnMenuTitleConfig.ContextMenu) { $script:btnMenuTitleConfig.ContextMenu.PlacementTarget = $script:btnMenuTitleConfig $script:btnMenuTitleConfig.ContextMenu.IsOpen = $true } }) } # Title bar icon + taskbar icon $imgTitleIcon = $script:window.FindName("imgTitleIcon") $iconPath = Join-Path $script:AppUIRootFolder "Assets\intune.png" if(Test-Path $iconPath) { try { $bi = [System.Windows.Media.Imaging.BitmapImage]::new() $bi.BeginInit() $bi.UriSource = [Uri]::new((Resolve-Path $iconPath).Path) $bi.EndInit() $bi.Freeze() if($imgTitleIcon) { $imgTitleIcon.Source = $bi } $script:window.Icon = $bi } catch { } # WM_SETICON overrides the PowerShell process icon for this window's taskbar button. # Must run in Loaded (not SourceInitialized) so WPF's own icon init doesn't overwrite it. # # The taskbar's icon is EXPLORER'S cached copy of what we send here - it does not # survive a shell rebuild. After resume from long sleep/hibernate (GPU reset, DWM # restart, monitor topology change) Explorer recreates the taskbar, broadcasts the # registered "TaskbarCreated" message and re-queries every window's icon; if that # re-query misses (busy message pump), the button falls back to the PROCESS icon - # which for a script-hosted app is the PowerShell logo. So the icon must be # RE-ASSERTED, not set once: TaskbarIconKeeper is a self-contained WndProc hook # (no PowerShell callback, so no runspace concerns on either PS edition) that # re-sends WM_SETICON on TaskbarCreated and on WM_POWERBROADCAST resume events. # The HICON is created once and kept for the process lifetime (also fixes the # previous create-per-call handle leak). $script:appIconPath = (Resolve-Path $iconPath).Path $script:window.add_Loaded({ try { if(-not ([System.Management.Automation.PSTypeName]'TaskbarIconKeeper').Type) { Add-Type -TypeDefinition @" using System; using System.Runtime.InteropServices; public class TaskbarIconKeeper { [DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern uint RegisterWindowMessage(string message); public const uint WM_SETICON = 0x0080; public const int WM_POWERBROADCAST = 0x0218; public const long PBT_APMRESUMESUSPEND = 0x7; public const long PBT_APMRESUMEAUTOMATIC = 0x12; public static IntPtr WindowHandle; public static IntPtr IconHandle; public static uint TaskbarCreatedMsg; public static void ApplyIcon() { if (WindowHandle == IntPtr.Zero || IconHandle == IntPtr.Zero) { return; } SendMessage(WindowHandle, WM_SETICON, (IntPtr)0, IconHandle); // ICON_SMALL SendMessage(WindowHandle, WM_SETICON, (IntPtr)1, IconHandle); // ICON_BIG } // HwndSourceHook-compatible. Self-contained: re-applies the icon without any // callback into PowerShell. public static IntPtr WndProcHook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) { if ((TaskbarCreatedMsg != 0 && msg == (int)TaskbarCreatedMsg) || (msg == WM_POWERBROADCAST && (wParam.ToInt64() == PBT_APMRESUMESUSPEND || wParam.ToInt64() == PBT_APMRESUMEAUTOMATIC))) { ApplyIcon(); } return IntPtr.Zero; } } "@ -ErrorAction Stop } Add-Type -AssemblyName System.Drawing $bmp = [System.Drawing.Bitmap]::new($script:appIconPath) [TaskbarIconKeeper]::IconHandle = $bmp.GetHicon() $bmp.Dispose() $hwnd = [System.Windows.Interop.WindowInteropHelper]::new($script:window).Handle if($hwnd -ne [IntPtr]::Zero) { [TaskbarIconKeeper]::WindowHandle = $hwnd [TaskbarIconKeeper]::TaskbarCreatedMsg = [TaskbarIconKeeper]::RegisterWindowMessage("TaskbarCreated") [TaskbarIconKeeper]::ApplyIcon() # Keep the delegate referenced in script scope - AddHook holds only a # weak-ish native registration and a GC'd delegate would crash the hook. $script:taskbarIconHook = [System.Delegate]::CreateDelegate([System.Windows.Interop.HwndSourceHook], [TaskbarIconKeeper], "WndProcHook") $src = [System.Windows.Interop.HwndSource]::FromHwnd($hwnd) if($src) { $src.AddHook($script:taskbarIconHook) } } } catch { Write-LogDebug "Taskbar icon setup failed: $($_.Exception.Message)" } }) } # Custom title bar buttons $btnMin = $script:window.FindName("btnTitleMinimize") $btnMax = $script:window.FindName("btnTitleMaximize") $btnCls = $script:window.FindName("btnTitleClose") if($btnMin) { $btnMin.Add_Click({ $script:window.WindowState = [System.Windows.WindowState]::Minimized }) } if($btnMax) { $btnMax.Add_Click({ if($script:window.WindowState -eq [System.Windows.WindowState]::Maximized) { $script:window.WindowState = [System.Windows.WindowState]::Normal } else { $script:window.WindowState = [System.Windows.WindowState]::Maximized } }) # Swap the glyph (Segoe MDL2 Assets) so it reads as "Maximize" (E922) when # normal and "Restore" (E923) when maximized — matches native Windows chrome. $script:window.Add_StateChanged({ $b = $script:window.FindName("btnTitleMaximize") if(-not $b) { return } if($script:window.WindowState -eq [System.Windows.WindowState]::Maximized) { $b.Content = [char]0xE923 $b.ToolTip = "Restore" } else { $b.Content = [char]0xE922 $b.ToolTip = "Maximize" } }) } if($btnCls) { $btnCls.Add_Click({ $script:window.Close() }) } $script:txtInfo = $script:window.FindName("txtInfo") $script:txtInfoDetail = $script:window.FindName("txtInfoDetail") $script:grdStatus = $script:window.FindName("grdStatus") # Wired once here rather than per Write-Status call: the handler is fixed, only # the armed action changes (Internal/StatusCancel.ps1 holds that). $script:btnStatusCancel = $script:window.FindName("btnStatusCancel") if($script:btnStatusCancel) { $script:btnStatusCancel.Add_Click({ Request-StatusCancel }) } $script:cvsPopup = $script:window.FindName("cvsPopup") $script:grdPopup = $script:window.FindName("grdPopup") # ToDo: Convert to a list for data binding $script:UIProvider.AddXamlEvent($script:window, "mnuSettings", "Add_Click", { Show-SettingsForm }) $script:UIProvider.AddXamlEvent($script:window, "mnuTenantSettings", "Add_Click", { Show-SettingsForm -Tenant }) $script:UIProvider.AddXamlEvent($script:window, "mnuUpdates", "Add_Click", { Show-UpdatesDialog }) $script:UIProvider.AddXamlEvent($script:window, "mnuAbout", "Add_Click", { Show-AboutDialog }) $script:UIProvider.AddXamlEvent($script:window, "mnuExit", "Add_Click", { if([System.Windows.MessageBox]::Show("Are you sure you want to exit?", "Exit?", "YesNo", "Question") -eq "Yes") { $script:window.Close() } }) $script:UIProvider.AddXamlEvent($script:window, "lstMenuItems", "Add_SelectionChanged", { param($S, $E) $script:ActiveView.OnItemChanged($S.SelectedItem) }) $script:UIProvider.AddXamlEvent($script:window, "mnuFile", "Add_SubmenuOpened", { param($S, $E) Update-AuthDependentMenuState }) # Re-evaluate menu state the moment auth changes so the user doesn't need to # close + reopen the File menu after signing in/out to see Tenant Settings # reflect the new state. Symmetric: token issued → enable; disconnect → disable. Add-AppEventHandler "AuthenticatedNewToken" "Update-AuthDependentMenuState" Add-AppEventHandler "AuthenticationUserDisconnected" "Update-AuthDependentMenuState" $script:UIProvider.AddXamlEvent($script:window, "grdPopup", "add_MouseLeftButtonDown", { $script:UIProvider.HidePopup() }) $script:window.Add_Closed({ $grdViewPanel = $script:window.FindName("grdViewPanel") $grdViewPanel.Children.Clear() }) $script:window.add_Loaded({ $script:SplashScreen.Hide() $script:window.Activate() [System.Windows.Forms.Application]::DoEvents() Set-WindowTitleBarTheme (Get-SettingValue 'AppTheme') if((Get-SettingStoreValue "" "FirstTimeRunning" "true") -eq "true") { $script:welcomeForm = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\Welcome.xaml")) $script:UIProvider.AddXamlEvent($script:welcomeForm, "gitHubLink", "Add_RequestNavigate", { Open-ExternalUri $_.Uri.AbsoluteUri; $_.Handled = $true }) $script:UIProvider.AddXamlEvent($script:welcomeForm, "licenseLink", "Add_RequestNavigate", { Open-ExternalUri $_.Uri.AbsoluteUri; $_.Handled = $true }) $script:UIProvider.AddXamlEvent($script:welcomeForm, "chkAcceptConditions", "add_click", { $script:UIProvider.SetXamlProperty($script:welcomeForm, "btnAcceptConditions", "IsEnabled", ($this.IsChecked -eq $true)) }) $script:UIProvider.AddXamlEvent($script:welcomeForm, "btnAcceptConditions", "add_click", { Save-SettingStoreValue "" "FirstTimeRunning" "False" Show-ModalObject }) $script:UIProvider.AddXamlEvent($script:welcomeForm, "btnCancel", "add_click", { if([System.Windows.MessageBox]::Show("Conditions not accepted`n`nDo you want to close the application?", "Close App?", "YesNo", "Warning") -eq "Yes") { $script:window.Close() } }) $script:UIProvider.ShowModalForm($script:window.Title, $script:welcomeForm, $true) } else { ###!!! ToDo: Force login here if configured #if($script:ActiveView.Authenticate()) #{ # # Skip for now...need additional code to skip previous login and force this based on setting. # #!!!& (Get-CurrentViewObject).Authenticate -Params (@{"Interactve"=$true}) #} } $script:MainUIStarted = $true # Always render the title-bar profile area when the window is ready. # Get-MSALUserInfo handles all three startup states: # * MSAL tokens cached pre-Show -> populates avatar via MSAL path # * Active non-MSAL provider has user -> populates via provider.GetUserInfo # (covers MgGraph silent resume from Azure.Identity disk cache at startup) # * No provider has a session -> clears state and Show-AuthenticationInfo # draws the login icon # It internally calls Show-AuthenticationInfo so no separate call is needed. Get-MSALUserInfo }) # Build the Views menu in a fixed order: # 1. IntuneManagement (always first) # 2. Any other registered views (sorted by Title) # 3. Separator # 4. CoreLog -> GraphCalls -> CoreCachedObjects (always last, in this order) $pinTopId = "IntuneManagement" $pinBottomIds = @("CoreLog","GraphCalls","CoreCachedObjects") $top = @($script:viewObjects | Where-Object { $_.Id -eq $pinTopId }) $middle = @($script:viewObjects | Where-Object { $_.Id -ne $pinTopId -and $_.Id -notin $pinBottomIds } | Sort-Object Title) $bottom = @( foreach($id in $pinBottomIds) { $script:viewObjects | Where-Object { $_.Id -eq $id } } ) | Where-Object { $_ } $addViewMenuItem = { param($view) $subItem = [System.Windows.Controls.MenuItem]::new() $subItem.Header = $view.Title $subItem.Tag = $view.Id $subItem.Add_Click({ if($this.Tag) { Show-View $this.Tag } }) # Views that opt in to ExpandInViewsMenu also surface their items # as child MenuItems here. Click of a child both activates the # parent view and selects the item in the left nav list — the # ListBox's existing SelectionChanged handler then runs the view's # OnItemChanged for us. if($view.ExpandInViewsMenu) { try { $childItems = @($view.GetViewItems()) if($childItems.Count -gt 0) { # Sort by Category (so "ADMX" / "Assignments" cluster) then # by Title; insert a Separator between distinct categories # for a quick visual scan. $sorted = $childItems | Sort-Object @{e='Category'}, @{e='Title'} $lastCat = $null foreach($child in $sorted) { $cat = $null if($child.PSObject.Properties['Category']) { $cat = [string]$child.Category } if($null -ne $lastCat -and $cat -ne $lastCat) { [void]$subItem.Items.Add([System.Windows.Controls.Separator]::new()) } $lastCat = $cat $childMenu = [System.Windows.Controls.MenuItem]::new() $childMenu.Header = $child.Title # Tag carries (ViewId, ItemId) so the click handler can # both switch views and target the right left-nav row. $childMenu.Tag = [PSCustomObject]@{ ViewId = $view.Id; ItemId = $child.Id } $childMenu.Add_Click({ $info = $this.Tag if(-not $info) { return } Show-View $info.ViewId if($script:lstMenuItems) { # ListBox.Items wraps the CollectionView (when grouping is # active) and exposes the flat item sequence; iterating # finds the target regardless of group membership. foreach($it in @($script:lstMenuItems.Items)) { if($it -and $it.PSObject.Properties['Id'] -and $it.Id -eq $info.ItemId) { $script:lstMenuItems.SelectedItem = $it break } } } }) [void]$subItem.Items.Add($childMenu) } } } catch { Write-LogDebug "Views menu sub-item build failed for $($view.Id): $($_.Exception.Message)" } } $mnuViews.AddChild($subItem) | Out-Null } foreach($view in $top) { & $addViewMenuItem $view } foreach($view in $middle) { & $addViewMenuItem $view } if($bottom.Count -gt 0) { $hasAbove = ($top.Count -gt 0) -or ($middle.Count -gt 0) if($hasAbove) { $mnuViews.AddChild([System.Windows.Controls.Separator]::new()) | Out-Null } foreach($view in $bottom) { & $addViewMenuItem $view } } $savedTheme = Get-SettingValue "AppTheme" if($savedTheme) { Set-AppTheme $savedTheme } } function Set-WindowTitleBarTheme { param([string]$ThemeName) if(-not $script:window) { return } try { # Load DWM P/Invoke helper once if(-not ([System.Management.Automation.PSTypeName]'DwmTitleBarHelper').Type) { Add-Type -TypeDefinition @" using System; using System.Runtime.InteropServices; public class DwmTitleBarHelper { [DllImport("dwmapi.dll")] public static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int attrValue, int attrSize); public const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20; public const int DWMWA_CAPTION_COLOR = 35; } "@ -ErrorAction Stop } $helper = [System.Windows.Interop.WindowInteropHelper]::new($script:window) $hwnd = $helper.Handle if($hwnd -eq [IntPtr]::Zero) { return } # Toggle dark/light title bar (Windows 10 1903+) $darkMode = if($ThemeName -eq 'Dark') { 1 } else { 0 } [DwmTitleBarHelper]::DwmSetWindowAttribute($hwnd, [DwmTitleBarHelper]::DWMWA_USE_IMMERSIVE_DARK_MODE, [ref]$darkMode, 4) | Out-Null # Custom caption color (Windows 11 22000+ only) $bgBrush = $script:window.TryFindResource('WindowBackgroundColor') if($bgBrush -and $bgBrush -is [System.Windows.Media.SolidColorBrush]) { $c = $bgBrush.Color $colorRef = [int](($c.B -shl 16) -bor ($c.G -shl 8) -bor [int]$c.R) [DwmTitleBarHelper]::DwmSetWindowAttribute($hwnd, [DwmTitleBarHelper]::DWMWA_CAPTION_COLOR, [ref]$colorRef, 4) | Out-Null } } catch { } # Silently ignore on unsupported OS versions } function Set-AppTheme { param([string]$ThemeName) if(-not $script:window) { return } # "Default" is not a file - it means "follow the Windows app theme", so # resolve it to Light or Dark first (UI/Classes/UIThemeCommon.ps1). $themeFile = "$script:AppUIRootFolder\Themes\$(Resolve-AppTheme $ThemeName).xaml" if(-not [IO.File]::Exists($themeFile)) { $themeFile = "$script:AppUIRootFolder\Themes\Light.xaml" } try { $newDict = [Windows.Markup.XamlReader]::Load([System.Xml.XmlReader]::Create($themeFile)) $merged = $script:window.Resources.MergedDictionaries # WPF's ResourceDictionaryCollection indexer (merged[0] = newDict) fires a Replace # notification, which doesn't reliably re-evaluate DynamicResource bindings when the # new dictionary uses the same keys as the old one (typical theme swap). Adding the # new dictionary first, then removing every previously-merged dictionary, fires # separate Add and Remove events that WPF DOES propagate -- and at no point do the # DynamicResource lookups see an empty MergedDictionaries. $previous = @($merged) [void]$merged.Add($newDict) foreach($d in $previous) { [void]$merged.Remove($d) } # Propagate SystemColors overrides to Application.Resources so popup windows # (separate HwndSource, e.g. menu dropdowns) also pick up the theme colors $appResources = if([System.Windows.Application]::Current) { [System.Windows.Application]::Current.Resources } else { $null } if($appResources) { $menuSystemKeys = @( [System.Windows.SystemColors]::MenuBrushKey, [System.Windows.SystemColors]::MenuBarBrushKey, [System.Windows.SystemColors]::MenuTextBrushKey, [System.Windows.SystemColors]::MenuHighlightBrushKey, [System.Windows.SystemColors]::HotTrackBrushKey, [System.Windows.SystemColors]::HotTrackColorKey ) foreach($key in $menuSystemKeys) { if($newDict.Contains($key)) { $appResources[$key] = $newDict[$key] } elseif($appResources.Contains($key)) { $appResources.Remove($key) } } } Set-EnvironmentInfo Set-WindowTitleBarTheme $ThemeName } catch { Write-LogError "Failed to apply theme '$ThemeName'" $_.Exception } } function Add-ThemeToWindow { param($Window) if(-not $Window) { return } $themeFile = "$script:AppUIRootFolder\Themes\$(Resolve-AppTheme).xaml" if(-not [IO.File]::Exists($themeFile)) { $themeFile = "$script:AppUIRootFolder\Themes\Light.xaml" } try { $themeDict = [Windows.Markup.XamlReader]::Load([System.Xml.XmlReader]::Create($themeFile)) $Window.Resources.MergedDictionaries.Add($themeDict) } catch { Write-LogError "Failed to apply theme to window" $_.Exception } } #endregion #region Status functions function Update-UIStatus { # Two-line status panel: # -Text only -> set primary, clear detail (a new "scope" starts) # -Detail only -> update detail line, leave primary alone (sub-progress # within the current scope, e.g. per-batch update) # -Text + -Detail -> set both # -Text $null -> hide the whole panel (legacy clear behavior) # $PSBoundParameters is checked so the caller can pass an empty -Detail "" to # explicitly clear the detail line without touching the primary text. param($Text, $Detail, [switch]$SkipLog, [switch]$Block, [switch]$Force, $CancelText) $hasText = $PSBoundParameters.ContainsKey('Text') $hasDetail = $PSBoundParameters.ContainsKey('Detail') if((Get-CacheObject "ShowUI") -ne $true) { if($SkipLog -ne $true) { if($hasText -and $Text) { Write-Log $Text } if($hasDetail -and $Detail) { Write-Log $Detail } } return } # An explicit "clear primary" (Text = $null/empty) resets the block guard so a # subsequent caller can replace the status again. Detail-only updates don't. if($hasText -and -not $Text) { $script:BlockStatusUpdates = $false } elseif($script:BlockStatusUpdates -eq $true -and $Force -ne $true) { return } elseif($Block -eq $true) { $script:BlockStatusUpdates = $true } if($hasText) { $script:txtInfo.Text = $Text # New scope: any stale detail from the previous scope must go unless the # same call provided a fresh -Detail. if(-not $hasDetail) { $script:txtInfoDetail.Text = "" $script:txtInfoDetail.Visibility = "Collapsed" } } if($hasDetail) { $script:txtInfoDetail.Text = $Detail if($Detail) { $script:txtInfoDetail.Visibility = "Visible" } else { $script:txtInfoDetail.Visibility = "Collapsed" } } # The cancel button is per-scope: a caller that passes -CancelText gets it, and # any later -Text without one takes it away, so it can never outlive the wait # it belonged to. if($script:btnStatusCancel -and ($PSBoundParameters.ContainsKey('CancelText') -or $hasText)) { if($CancelText) { $script:btnStatusCancel.Content = $CancelText $script:btnStatusCancel.Visibility = "Visible" } else { $script:btnStatusCancel.Visibility = "Collapsed" } } # Visibility of the whole overlay tracks the primary line. A detail-only # update never shows the panel by itself — there's no scope without a primary. if(($hasText -and $Text) -or (-not $hasText -and $script:txtInfo.Text)) { $script:grdStatus.Visibility = "Visible" if($SkipLog -ne $true) { if($hasText -and $Text) { Write-Log $Text } if($hasDetail -and $Detail) { Write-Log $Detail } } } elseif($hasText -and -not $Text) { $script:grdStatus.Visibility = "Collapsed" $script:txtInfoDetail.Text = "" $script:txtInfoDetail.Visibility = "Collapsed" if($script:btnStatusCancel) { $script:btnStatusCancel.Visibility = "Collapsed" } } [System.Windows.Forms.Application]::DoEvents() return $true } function Invoke-UIMessagePump { [System.Windows.Forms.Application]::DoEvents() } function Request-UIConfirmation { param([string]$Message, [string]$Caption = "Confirm") return (($script:UIProvider.ShowMessageBox($Message, $Caption, "YesNo", "Question")) -eq "Yes") } function Initialize-SplashScreen { if($script:SplashScreen) { return } try { $script:SplashScreen = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\SplashScreen.xaml")) $script:txtSplashTitle = $script:SplashScreen.FindName("txtSplashTitle") $script:txtSplashText = $script:SplashScreen.FindName("txtSplashText") $script:txtSplashTitle.Text = ("Initializing Cloud API PowerShell Management") $script:SplashScreen.Show() | Out-Null [System.Windows.Forms.Application]::DoEvents() } catch { } } function Set-SplashWindowText { param($Text) if(-not $script:txtSplashText) { return } $script:txtSplashText.Text = $Text [System.Windows.Forms.Application]::DoEvents() } #endregion #region Xaml functions # Rewrites every in an XmlDocument to use an explicit # ItemTemplate () so that WPF's SelectionBoxItemTemplate # is populated correctly when a custom ComboBox ControlTemplate is in use. function Convert-XamlComboBoxDisplayMemberPath { param([xml]$Xaml) $ns = "http://schemas.microsoft.com/winfx/2006/xaml/presentation" $nsm = [System.Xml.XmlNamespaceManager]::new($Xaml.NameTable) $nsm.AddNamespace("s", $ns) foreach($cb in @($Xaml.SelectNodes("//s:ComboBox[@DisplayMemberPath]", $nsm))) { $displayPath = $cb.GetAttribute("DisplayMemberPath") $cb.RemoveAttribute("DisplayMemberPath") $textBlock = $Xaml.CreateElement("TextBlock", $ns) $textBlock.SetAttribute("Text", "{Binding $displayPath}") $dataTemplate = $Xaml.CreateElement("DataTemplate", $ns) $dataTemplate.AppendChild($textBlock) | Out-Null $itemTemplate = $Xaml.CreateElement("ComboBox.ItemTemplate", $ns) $itemTemplate.AppendChild($dataTemplate) | Out-Null $cb.AppendChild($itemTemplate) | Out-Null } } function Initialize-Window { param($XamlFile, [switch]$AddVariables) try { [xml]$Xaml = Get-Content $XamlFile -Encoding UTF8 [xml]$styles = Get-Content ($script:AppUIRootFolder + "\Themes\Styles.xaml") -Encoding UTF8 ### Update relative path to full path for ResourceDictionary ### Also replace any theme XAML reference with the currently active theme $activeThemeFile = "$script:AppUIRootFolder\Themes\$(Resolve-AppTheme).xaml" if(-not [IO.File]::Exists($activeThemeFile)) { $activeThemeFile = "$script:AppUIRootFolder\Themes\Light.xaml" } [System.Xml.XmlNamespaceManager] $nsm = $Xaml.NameTable; $nsm.AddNamespace("s", 'http://schemas.microsoft.com/winfx/2006/xaml/presentation'); foreach($rsdNode in ($Xaml.SelectNodes("//s:ResourceDictionary[@Source]", $nsm))) { $absPath = (Join-Path $script:AppRootFolder ($rsdNode.Source)).ToString() if($absPath -match '\\Themes\\[^\\]+\.xaml$') { $rsdNode.Source = $activeThemeFile } else { $rsdNode.Source = $absPath } } # Add Styles # Only element children, and append whatever element is there instead of # reaching for .Style: Themes/Styles.xaml carries inline XML comments, and # for a comment node $tmpNode.Style is $null, so AppendChild($null) threw # NullReferenceException - reported only as "Failed to load Xaml file # MainWindow.xaml", with nothing pointing at the theme file. This also lets # the file hold resources that are not