mirror of
https://github.com/Micke-K/IntuneManagement.git
synced 2026-09-28 19:05:38 +02:00
3473 lines
129 KiB
PowerShell
3473 lines
129 KiB
PowerShell
|
|
[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 <ComboBox DisplayMemberPath="Foo"> in an XmlDocument to use an explicit
|
|
# ItemTemplate (<TextBlock Text="{Binding Foo}"/>) 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 <Style> (a ControlTemplate, a brush).
|
|
foreach($node in @($styles.DocumentElement.ChildNodes | Where-Object { $_.NodeType -eq 'Element' }))
|
|
{
|
|
$tmpNode = $Xaml.CreateElement("Temp")
|
|
$tmpNode.InnerXml = $node.OuterXml
|
|
$Xaml.Window.'Window.Resources'.ResourceDictionary.AppendChild($tmpNode.FirstChild) | Out-Null
|
|
}
|
|
Convert-XamlComboBoxDisplayMemberPath $Xaml
|
|
$XamlObj = ([Windows.Markup.XamlReader]::Load((New-Object System.Xml.XmlNodeReader $Xaml)))
|
|
if($XamlObj -and $AddVariables -eq $true)
|
|
{
|
|
Add-XamlVariables $Xaml $XamlObj
|
|
}
|
|
# Same per-control ContextMenu attachment as Get-XamlObject (loose-XAML
|
|
# workaround for the missing x:Shared support).
|
|
if($XamlObj) {
|
|
try { Add-ThemedTextBoxContextMenu $XamlObj }
|
|
catch { Write-LogDebug "Add-ThemedTextBoxContextMenu failed: $($_.Exception.Message)" }
|
|
}
|
|
Add-ThemeToWindow $XamlObj
|
|
return $XamlObj
|
|
}
|
|
catch
|
|
{
|
|
Write-LogError "Failed to initialize window" $_.Exception
|
|
return
|
|
}
|
|
}
|
|
|
|
function New-TextEditContextMenu
|
|
{
|
|
# Builds a fresh Cut/Copy/Paste/SelectAll ContextMenu instance. Built in code
|
|
# because loose XAML resource dictionaries don't honour x:Shared="False" — the
|
|
# styled-Setter approach yields "element is already the logical child of
|
|
# another element" at runtime. ApplicationCommands wire the click handlers and
|
|
# the enabled state automatically; Header is set explicitly (rather than letting
|
|
# WPF derive it from Command.Text) so we can use access-key underlines and
|
|
# control the exact label.
|
|
$cm = [System.Windows.Controls.ContextMenu]::new()
|
|
$cm.MinWidth = 140
|
|
|
|
foreach($entry in @(
|
|
@{ Cmd = [System.Windows.Input.ApplicationCommands]::Cut; Header = "Cu_t"; Gesture = "Ctrl+X" }
|
|
@{ Cmd = [System.Windows.Input.ApplicationCommands]::Copy; Header = "_Copy"; Gesture = "Ctrl+C" }
|
|
@{ Cmd = [System.Windows.Input.ApplicationCommands]::Paste; Header = "_Paste"; Gesture = "Ctrl+V" }
|
|
)) {
|
|
$mi = [System.Windows.Controls.MenuItem]::new()
|
|
$mi.Header = $entry.Header
|
|
$mi.Command = $entry.Cmd
|
|
$mi.InputGestureText = $entry.Gesture
|
|
[void]$cm.Items.Add($mi)
|
|
}
|
|
[void]$cm.Items.Add([System.Windows.Controls.Separator]::new())
|
|
$miSelectAll = [System.Windows.Controls.MenuItem]::new()
|
|
$miSelectAll.Header = "Select _All"
|
|
$miSelectAll.Command = [System.Windows.Input.ApplicationCommands]::SelectAll
|
|
$miSelectAll.InputGestureText = "Ctrl+A"
|
|
[void]$cm.Items.Add($miSelectAll)
|
|
return $cm
|
|
}
|
|
|
|
function Add-ThemedTextBoxContextMenu
|
|
{
|
|
# Walks the logical tree rooted at $Root and attaches a fresh ContextMenu to
|
|
# every TextBox / PasswordBox. The logical tree is populated immediately after
|
|
# XamlReader.Load; the visual tree is not (visuals materialise later, when the
|
|
# template applies), so logical traversal is the right choice here.
|
|
param($Root)
|
|
if(-not $Root) { return }
|
|
|
|
$stack = [System.Collections.Generic.Stack[object]]::new()
|
|
$stack.Push($Root)
|
|
while($stack.Count -gt 0) {
|
|
$el = $stack.Pop()
|
|
|
|
if($el -is [System.Windows.Controls.TextBox] -or
|
|
$el -is [System.Windows.Controls.PasswordBox]) {
|
|
# Only attach when the form hasn't supplied a custom ContextMenu — we
|
|
# don't want to clobber per-control menus (e.g. the policy-details
|
|
# JSON viewer might have its own).
|
|
if(-not $el.ContextMenu) {
|
|
$el.ContextMenu = New-TextEditContextMenu
|
|
}
|
|
}
|
|
|
|
if($el -is [System.Windows.DependencyObject]) {
|
|
try {
|
|
foreach($child in [System.Windows.LogicalTreeHelper]::GetChildren($el)) {
|
|
if($child -is [System.Windows.DependencyObject]) {
|
|
$stack.Push($child)
|
|
}
|
|
}
|
|
}
|
|
catch { }
|
|
}
|
|
}
|
|
}
|
|
|
|
function Get-XamlObject
|
|
{
|
|
param($FileName, [switch]$AddVariables, [switch]$AddStyles)
|
|
|
|
if(([IO.File]::Exists($FileName)))
|
|
{
|
|
try
|
|
{
|
|
[xml]$Xaml = Get-Content $FileName -Encoding UTF8
|
|
|
|
if($AddStyles -eq $true) {
|
|
[xml]$styles = Get-Content ($script:AppUIRootFolder + "\Themes\Styles.xaml") -Encoding UTF8
|
|
|
|
### Update relative path to full path for ResourceDictionary
|
|
### Theme references are additionally redirected to the ACTIVE theme,
|
|
### the same way Initialize-Window does it. The Source in the XAML is
|
|
### a design-time placeholder (Themes\Light.xaml); without this
|
|
### redirect every dialog loaded here would render light even in the
|
|
### dark theme, and would break outright if that file were renamed.
|
|
$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 (same comment/non-Style guard as Initialize-Window above)
|
|
foreach($node in @($styles.DocumentElement.ChildNodes | Where-Object { $_.NodeType -eq 'Element' }))
|
|
{
|
|
$tmpNode = $Xaml.CreateElement("Temp")
|
|
$tmpNode.InnerXml = $node.OuterXml
|
|
$Xaml.Window.'Window.Resources'.ResourceDictionary.AppendChild($tmpNode.FirstChild) | Out-Null
|
|
}
|
|
}
|
|
Convert-XamlComboBoxDisplayMemberPath $Xaml
|
|
$XamlObj = ([Windows.Markup.XamlReader]::Load((New-Object System.Xml.XmlNodeReader $Xaml)))
|
|
|
|
# Attach themed Cut/Copy/Paste menus to every TextBox/PasswordBox in the tree.
|
|
# WPF loose XAML doesn't support x:Shared, so the styled-Setter approach
|
|
# produces "element already has a logical parent" errors. Walking the tree
|
|
# after Load and assigning a fresh ContextMenu instance per control is the
|
|
# supported workaround.
|
|
if($XamlObj) {
|
|
try { Add-ThemedTextBoxContextMenu $XamlObj }
|
|
catch { Write-LogDebug "Add-ThemedTextBoxContextMenu failed: $($_.Exception.Message)" }
|
|
}
|
|
|
|
if($XamlObj -and $AddVariables -eq $true)
|
|
{
|
|
Add-XamlVariables $Xaml $XamlObj
|
|
}
|
|
return $XamlObj
|
|
}
|
|
catch
|
|
{
|
|
Write-LogError "Failed to load Xaml file $FileName. Error:" $_.Exception
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Write-Log "Failed to open Xaml file. File not found: $FileName" 3
|
|
}
|
|
}
|
|
|
|
function Add-XamlVariables
|
|
{
|
|
param($Xaml, $Obj)
|
|
|
|
# Generate a global variable for each object with Name property set
|
|
# Ref: https://learn-powershell.net/2014/08/10/powershell-and-wpf-radio-button/
|
|
$Xaml.SelectNodes("//*[@*[contains(translate(name(.),'n','N'),'Name')]]") | ForEach-Object {
|
|
Write-LogDebug "Add script variable $($_.Name)"
|
|
New-Variable -Name $_.Name -Value $Obj.FindName($_.Name) -Force -Scope Script
|
|
}
|
|
}
|
|
|
|
function Set-XamlProperty
|
|
{
|
|
param($XamlObj, $ControlName, $PropertyName, $Value)
|
|
|
|
try
|
|
{
|
|
$Obj = $XamlObj.FindName($ControlName)
|
|
if($Obj)
|
|
{
|
|
$Obj."$PropertyName" = $Value
|
|
}
|
|
else
|
|
{
|
|
Write-Log "Could not find object with name $ControlName" 3
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
Write-LogError "Failed to set Xaml property value. Control: $ControlName. Property: $PropertyName. Error:" $_.Exception
|
|
}
|
|
}
|
|
|
|
function Get-XamlProperty
|
|
{
|
|
param($XamlObj, $ControlName, $PropertyName, $DefaultValue = $null)
|
|
|
|
try
|
|
{
|
|
$Obj = $XamlObj.FindName($ControlName)
|
|
if($Obj)
|
|
{
|
|
return (?? $Obj."$PropertyName" $DefaultValue)
|
|
}
|
|
else
|
|
{
|
|
Write-Log "Could not find object with name $ControlName" 3
|
|
return $DefaultValue
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
Write-LogError "Failed to get Xaml property value. Control: $ControlName. Property: $PropertyName. Error:" $_.Exception
|
|
return $DefaultValue
|
|
}
|
|
}
|
|
|
|
function Add-XamlEvent
|
|
{
|
|
param($XamlObj, [string]$ControlName, [string]$EventName, [scriptblock]$ScriptBlock)
|
|
|
|
try {
|
|
$Obj = $XamlObj.FindName($ControlName)
|
|
if($Obj)
|
|
{
|
|
$Obj."$EventName"($ScriptBlock)
|
|
}
|
|
else
|
|
{
|
|
Write-Log "Failed to add Xaml event $EventName to $ControlName. Control not found" 3
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
Write-LogError "Failed to add Xaml event $EventName to $ControlName. Error:" $_.Exception
|
|
}
|
|
}
|
|
|
|
function Add-GridObject
|
|
{
|
|
param($Grid, $Obj)
|
|
|
|
$rd = [System.Windows.Controls.RowDefinition]::new()
|
|
$rd.Height = [double]::NaN
|
|
$Obj.SetValue([System.Windows.Controls.Grid]::RowProperty,$Grid.RowDefinitions.Count) | Out-Null
|
|
$Grid.RowDefinitions.Add($rd) | Out-Null
|
|
$Grid.Children.Add($Obj) | Out-Null
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Dialogs
|
|
function Show-AboutDialog
|
|
{
|
|
$script:dlgAbout = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\AboutDialog.xaml"))
|
|
if(-not $script:dlgAbout) { return }
|
|
|
|
$loadedItems = @()
|
|
$externalModules = @("MSAL.PS","Az.Account")
|
|
$externalAssemblies = @("Microsoft.Identity.Client.dll")
|
|
|
|
foreach($module in (((Get-Module | Where-Object { $_.ModuleBase -like "$($script:AppRootFolder)*" -or $_.Name -in $externalModules }))))
|
|
{
|
|
$ver = $module.Version
|
|
if($module.Version.Major -eq 0 -and $module.Version.Minor -eq 0)
|
|
{
|
|
$cmd = $module.ExportedFunctions["Get-ModuleVersion"]
|
|
if($cmd)
|
|
{
|
|
$tmpVer = Invoke-Command -ScriptBlock $cmd.ScriptBlock
|
|
$ver = ?? $tmpVer $ver
|
|
}
|
|
}
|
|
|
|
$loadedItems += (New-Object PSObject -Property @{
|
|
Name = $module.Name
|
|
Version = $ver
|
|
Type = "PSModule"
|
|
})
|
|
}
|
|
|
|
$assms = [System.AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GlobalAssemblyCache -eq $false -and [String]::IsNullOrEmpty($_.Location) -eq $false }
|
|
foreach($assmName in $externalAssemblies)
|
|
{
|
|
$assmObjs = $assms | Where-Object { $_.Location -like "*\$($assmName)" }
|
|
foreach($assmObj in $assmObjs)
|
|
{
|
|
try
|
|
{
|
|
$fi = [IO.FileInfo]"$($assmObj.Location)"
|
|
$loadedItems += (New-Object PSObject -Property @{
|
|
Name = $fi.Name
|
|
Version = $fi.VersionInfo.FileVersion
|
|
Type = "Assembly"
|
|
})
|
|
}
|
|
catch {}
|
|
}
|
|
}
|
|
|
|
$script:UIProvider.SetXamlProperty($script:dlgAbout, "txtTitle", "Text", "Intune Management")
|
|
$script:UIProvider.SetXamlProperty($script:dlgAbout, "txtViewTitle", "Text", ("Current view: " + $script:ActiveView.Title))
|
|
if($script:ActiveView.Description)
|
|
{
|
|
$script:UIProvider.SetXamlProperty($script:dlgAbout, "txtViewDescription", "Text", $script:ActiveView.Description)
|
|
}
|
|
|
|
$script:UIProvider.SetXamlProperty($script:dlgAbout, "lstModules", "ItemsSource", $loadedItems)
|
|
|
|
$script:UIProvider.AddXamlEvent($script:dlgAbout, "linkSource", "Add_RequestNavigate", { Open-ExternalUri $_.Uri.AbsoluteUri; $_.Handled = $true })
|
|
$script:UIProvider.AddXamlEvent($script:dlgAbout, "linkCoffee", "Add_RequestNavigate", { Open-ExternalUri $_.Uri.AbsoluteUri; $_.Handled = $true })
|
|
|
|
$script:UIProvider.ShowModalForm("About", $script:dlgAbout)
|
|
}
|
|
|
|
function Show-UpdatesDialog
|
|
{
|
|
$localReleaseNotes = ($script:AppRootFolder + "\ReleaseNotes.md")
|
|
$script:dlgUpdates = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\UpdatesDialog.xaml"))
|
|
if(-not $script:dlgUpdates) { return }
|
|
|
|
Write-Status "Getting Release Notes Information"
|
|
|
|
$script:UIProvider.AddXamlEvent($script:dlgUpdates, "btnClose", "add_click", {
|
|
$script:dlgUpdates = $null
|
|
Show-ModalObject
|
|
})
|
|
|
|
if([IO.File]::Exists($localReleaseNotes) -eq $false) {
|
|
Write-Log "Local release notes not found"
|
|
}
|
|
else {
|
|
$fileContent = [IO.File]::ReadAllText($localReleaseNotes)
|
|
try
|
|
{
|
|
$tmp = $fileContent.Replace("`r`n","`n")
|
|
$mystring = ("blob $($tmp.Length)`0" + $tmp)
|
|
$mystream = [IO.MemoryStream]::new([byte[]][char[]]$mystring)
|
|
$curHash = Get-FileHash -InputStream $mystream -Algorithm SHA1
|
|
}
|
|
finally
|
|
{
|
|
if($mystream) { $mystream.Dispose() }
|
|
}
|
|
}
|
|
|
|
# Fetch via Internal/AppUpdateCheck.ps1 so both backends share one
|
|
# implementation (R10). Sha is the blob hash used below to decide whether the
|
|
# published notes differ from the local copy.
|
|
$content = Get-AppRemoteReleaseNotes
|
|
if($content)
|
|
{
|
|
# Rendered rather than dumped as raw text: Set-WpfMarkdownText turns the
|
|
# headings, bullets and **bold** spans into TextBlock inlines. See
|
|
# UI/WPF/Extensions/MarkdownRenderWPF.ps1.
|
|
Set-WpfMarkdownText $script:dlgUpdates.FindName("txtReleaseNotes") $content.Text
|
|
|
|
if(-not $curHash)
|
|
{
|
|
# No local ReleaseNotes.md - there is nothing to compare, so neither
|
|
# the "matches" nor the "differs" statement applies and the Local tab
|
|
# has nothing to show. (Without this guard the sha comparison below
|
|
# always took the "differs" branch and rendered $null into the tab.)
|
|
$script:UIProvider.SetXamlProperty($script:dlgUpdates, "txtReleaseNotesMatch", "Visibility", "Collapsed")
|
|
$script:UIProvider.SetXamlProperty($script:dlgUpdates, "txtReleaseNotesNoMatch", "Visibility", "Collapsed")
|
|
$script:UIProvider.SetXamlProperty($script:dlgUpdates, "tabLocalReleaseNotes", "Visibility", "Collapsed")
|
|
}
|
|
elseif($content.sha -ne $curHash.Hash)
|
|
{
|
|
# ReleaseNotes.md not matching - show the local copy alongside.
|
|
# This used to write $fileContent into txtReleaseNotes, overwriting the
|
|
# GitHub notes with the local ones while the Local tab it had just made
|
|
# visible stayed empty. The local text belongs in txtReleaseNotesLocal.
|
|
$script:UIProvider.SetXamlProperty($script:dlgUpdates, "tabLocalReleaseNotes", "Visibility", "Visible")
|
|
Set-WpfMarkdownText $script:dlgUpdates.FindName("txtReleaseNotesLocal") $fileContent
|
|
$script:UIProvider.SetXamlProperty($script:dlgUpdates, "txtReleaseNotesMatch", "Visibility", "Collapsed")
|
|
}
|
|
else
|
|
{
|
|
$script:UIProvider.SetXamlProperty($script:dlgUpdates, "txtReleaseNotesNoMatch", "Visibility", "Collapsed")
|
|
$script:UIProvider.SetXamlProperty($script:dlgUpdates, "tabLocalReleaseNotes", "Visibility", "Collapsed")
|
|
}
|
|
}
|
|
|
|
Write-Status ""
|
|
|
|
$script:UIProvider.ShowModalForm("Release Notes", $script:dlgUpdates, $true)
|
|
}
|
|
|
|
function Get-IsLatestVersion
|
|
{
|
|
# Thin caller: the network + version comparison lives in
|
|
# Internal/AppUpdateCheck.ps1 so both UI backends share one implementation
|
|
# (R10). Only the notification is backend-specific.
|
|
Set-SplashWindowText "Check for updates"
|
|
|
|
$info = Get-AppUpdateInfo
|
|
if($info.IsOutdated)
|
|
{
|
|
[System.Windows.MessageBox]::Show("There is a new version available on GitHub $($info.RemoteVersion.ToString())`n`nCurrent version is $($info.LocalVersion.ToString())", "Old version!", "OK", "Warning")
|
|
}
|
|
}
|
|
|
|
function Get-ModuleDataTable
|
|
{
|
|
param($ModuleText)
|
|
|
|
$result = $null
|
|
|
|
if(-not $ModuleText) { return }
|
|
|
|
try
|
|
{
|
|
$Path = [IO.path]::ChangeExtension([IO.Path]::GetTempFileName(), "psd1")
|
|
$FI = [io.FileInfo]$Path
|
|
$Utf8NoBomEncoding = New-Object System.Text.UTF8Encoding $false
|
|
[System.IO.File]::WriteAllLines($FI.FullName, $ModuleText, $Utf8NoBomEncoding)
|
|
$Result = $null
|
|
Import-LocalizedData -BindingVariable Result -BaseDirectory $FI.DirectoryName -FileName $fi.Name
|
|
}
|
|
catch
|
|
{
|
|
|
|
}
|
|
finally
|
|
{
|
|
try { [IO.File]::Delete(([IO.path]::ChangeExtension($FI.FullName, "tmp"))) } catch {}
|
|
try { $FI.Delete() } catch{}
|
|
}
|
|
|
|
$result
|
|
}
|
|
|
|
function Show-InputDialog
|
|
{
|
|
param(
|
|
$FormTitle = "Input",
|
|
$FormText,
|
|
$DefaultValue)
|
|
|
|
$script:inputBox = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\InputDialog.xaml"))
|
|
|
|
if(-not $script:inputBox) { return }
|
|
|
|
$script:inputBox.Title = $FormTitle
|
|
|
|
$script:UIProvider.SetXamlProperty($script:inputBox, "txtLabel", "Content", $FormText)
|
|
$script:UIProvider.SetXamlProperty($script:inputBox, "txtValue", "Text", $DefaultValue)
|
|
|
|
$script:txtValue = $script:inputBox.FindName("txtValue")
|
|
|
|
$script:UIProvider.AddXamlEvent($script:inputBox, "btnOk", "Add_Click", { $script:inputBox.Close() })
|
|
$script:UIProvider.AddXamlEvent($script:inputBox, "btnCancel", "Add_Click", { $script:txtValue.Text =""; $script:inputBox.Close() })
|
|
|
|
$inputBox.Add_ContentRendered({
|
|
$script:txtValue.SelectAll();
|
|
$script:txtValue.Focus();
|
|
})
|
|
|
|
$inputBox.Owner = $script:Window
|
|
$inputBox.Icon = $script:Window.Icon
|
|
|
|
$inputBox.ShowDialog() | Out-null
|
|
|
|
return $script:txtValue.Text
|
|
}
|
|
|
|
function Show-ModalForm
|
|
{
|
|
param(
|
|
$FormTitle = "",
|
|
$FormObject,
|
|
[switch]$HideButtons)
|
|
|
|
$xamlStr = Get-Content ($script:AppUIRootFolder + "\Xaml\ModalForm.xaml") -Encoding UTF8
|
|
|
|
$modalForm = [Windows.Markup.XamlReader]::Parse($xamlStr)
|
|
|
|
if($HideButtons -eq $true)
|
|
{
|
|
$script:UIProvider.SetXamlProperty($modalForm, "spButtons", "Visibility", "Collapsed")
|
|
}
|
|
else
|
|
{
|
|
$closeButton = $modalForm.FindName("btnClose")
|
|
if($closeButton) { $closeButton.Tag = $FormObject }
|
|
$script:UIProvider.AddXamlEvent($modalForm, "btnClose", "Add_Click", {
|
|
$formObject = $this.Tag
|
|
if($formObject -and $formObject.PSObject.Methods['ConfirmClose']) {
|
|
try {
|
|
if($formObject.ConfirmClose() -ne $true) { return }
|
|
}
|
|
catch {
|
|
Write-LogError "Modal close confirmation failed" $_.Exception
|
|
return
|
|
}
|
|
}
|
|
Show-ModalObject
|
|
})
|
|
}
|
|
|
|
$script:UIProvider.SetXamlProperty($modalForm, "txtTitle", "Text", $FormTitle)
|
|
|
|
$grdModalContainer = $modalForm.FindName("grdModalContainer")
|
|
if($grdModalContainer -and $FormObject)
|
|
{
|
|
$FormObject.SetValue([System.Windows.Controls.Grid]::RowProperty,1)
|
|
$grdModalContainer.Children.Add($FormObject) | Out-Null
|
|
}
|
|
Show-ModalObject $modalForm
|
|
}
|
|
|
|
function Show-ModalObject
|
|
{
|
|
param( $Obj )
|
|
|
|
if($Obj)
|
|
{
|
|
$Obj.SetValue([System.Windows.Controls.Grid]::RowProperty,1)
|
|
$Obj.SetValue([System.Windows.Controls.Grid]::ColumnProperty,1)
|
|
$script:grdModal.Children.Add($Obj) | Out-Null
|
|
$script:grdModal.Visibility = "Visible"
|
|
}
|
|
else
|
|
{
|
|
$script:grdModal.Children.Clear()
|
|
$script:grdModal.Visibility = "Collapsed"
|
|
}
|
|
|
|
[System.Windows.Forms.Application]::DoEvents()
|
|
}
|
|
|
|
function Close-TopModalObject
|
|
{
|
|
if($script:grdModal -and $script:grdModal.Children.Count -gt 0)
|
|
{
|
|
$script:grdModal.Children.RemoveAt($script:grdModal.Children.Count - 1)
|
|
if($script:grdModal.Children.Count -eq 0) {
|
|
$script:grdModal.Visibility = "Collapsed"
|
|
}
|
|
else {
|
|
$script:grdModal.Visibility = "Visible"
|
|
}
|
|
}
|
|
|
|
[System.Windows.Forms.Application]::DoEvents()
|
|
}
|
|
|
|
function Show-MessageBox
|
|
{
|
|
param(
|
|
[string]$Text,
|
|
[string]$Caption = "",
|
|
[System.Windows.MessageBoxButton]$Button = [System.Windows.MessageBoxButton]::OK,
|
|
[System.Windows.MessageBoxImage]$Icon = [System.Windows.MessageBoxImage]::None
|
|
)
|
|
|
|
# Fall back to system MessageBox when no UI is available or the Default theme is active
|
|
$theme = Get-SettingValue "AppTheme"
|
|
if(-not $script:window -or -not $theme -or $theme -eq "Default")
|
|
{
|
|
return [System.Windows.MessageBox]::Show($Text, $Caption, $Button, $Icon)
|
|
}
|
|
|
|
try
|
|
{
|
|
$script:_msgBoxDialog = Initialize-Window ($script:AppUIRootFolder + "\Xaml\MessageBox.xaml")
|
|
if(-not $script:_msgBoxDialog)
|
|
{
|
|
return [System.Windows.MessageBox]::Show($Text, $Caption, $Button, $Icon)
|
|
}
|
|
|
|
$script:_msgBoxResult = [System.Windows.MessageBoxResult]::None
|
|
|
|
$script:_msgBoxDialog.Title = if($Caption) { $Caption } else { $script:window.Title }
|
|
$script:_msgBoxDialog.FindName("txtMessage").Text = $Text
|
|
|
|
# Icon
|
|
if($Icon -ne [System.Windows.MessageBoxImage]::None)
|
|
{
|
|
$iconBorder = $script:_msgBoxDialog.FindName("iconBorder")
|
|
$iconSymbol = $script:_msgBoxDialog.FindName("iconSymbol")
|
|
|
|
$iconColor = $null
|
|
$iconGlyph = $null
|
|
|
|
if($Icon -eq [System.Windows.MessageBoxImage]::Question)
|
|
{
|
|
$iconColor = "#FF0078D4"
|
|
$iconGlyph = "?"
|
|
}
|
|
elseif([int]$Icon -eq 48) # Warning / Exclamation
|
|
{
|
|
$iconColor = "#FFE8A000"
|
|
$iconGlyph = "!"
|
|
}
|
|
elseif([int]$Icon -eq 16) # Error / Hand / Stop
|
|
{
|
|
$iconColor = "#FFD92D20"
|
|
$iconGlyph = "X"
|
|
}
|
|
else # Information / Asterisk (64)
|
|
{
|
|
$iconColor = "#FF0078D4"
|
|
$iconGlyph = "i"
|
|
}
|
|
|
|
$iconBorder.Background = [System.Windows.Media.BrushConverter]::new().ConvertFromString($iconColor)
|
|
$iconSymbol.Text = $iconGlyph
|
|
$iconBorder.Visibility = "Visible"
|
|
}
|
|
|
|
# Buttons — show and wire up only the required subset
|
|
if($Button -eq [System.Windows.MessageBoxButton]::OK -or
|
|
$Button -eq [System.Windows.MessageBoxButton]::OKCancel)
|
|
{
|
|
$script:_msgBoxDialog.FindName("btnOK").Visibility = "Visible"
|
|
$script:_msgBoxDialog.FindName("btnOK").IsDefault = $true
|
|
$script:UIProvider.AddXamlEvent($script:_msgBoxDialog, "btnOK", "add_click", {
|
|
$script:_msgBoxResult = [System.Windows.MessageBoxResult]::OK
|
|
$script:_msgBoxDialog.DialogResult = $true
|
|
})
|
|
}
|
|
|
|
if($Button -eq [System.Windows.MessageBoxButton]::YesNo -or
|
|
$Button -eq [System.Windows.MessageBoxButton]::YesNoCancel)
|
|
{
|
|
$script:_msgBoxDialog.FindName("btnYes").Visibility = "Visible"
|
|
$script:_msgBoxDialog.FindName("btnYes").IsDefault = $true
|
|
$script:_msgBoxDialog.FindName("btnNo").Visibility = "Visible"
|
|
$script:UIProvider.AddXamlEvent($script:_msgBoxDialog, "btnYes", "add_click", {
|
|
$script:_msgBoxResult = [System.Windows.MessageBoxResult]::Yes
|
|
$script:_msgBoxDialog.DialogResult = $true
|
|
})
|
|
$script:UIProvider.AddXamlEvent($script:_msgBoxDialog, "btnNo", "add_click", {
|
|
$script:_msgBoxResult = [System.Windows.MessageBoxResult]::No
|
|
$script:_msgBoxDialog.DialogResult = $true
|
|
})
|
|
}
|
|
|
|
if($Button -eq [System.Windows.MessageBoxButton]::OKCancel -or
|
|
$Button -eq [System.Windows.MessageBoxButton]::YesNoCancel)
|
|
{
|
|
$script:_msgBoxDialog.FindName("btnCancel").Visibility = "Visible"
|
|
$script:UIProvider.AddXamlEvent($script:_msgBoxDialog, "btnCancel", "add_click", {
|
|
$script:_msgBoxResult = [System.Windows.MessageBoxResult]::Cancel
|
|
$script:_msgBoxDialog.DialogResult = $false
|
|
})
|
|
}
|
|
|
|
# Closing covers: X button, Escape key (via IsCancel on btnCancel)
|
|
$script:_msgBoxDialog.add_Closing({
|
|
if($script:_msgBoxResult -eq [System.Windows.MessageBoxResult]::None)
|
|
{
|
|
if($script:_msgBoxDialog.FindName("btnCancel").Visibility -eq "Visible") {
|
|
$script:_msgBoxResult = [System.Windows.MessageBoxResult]::Cancel
|
|
}
|
|
elseif($script:_msgBoxDialog.FindName("btnNo").Visibility -eq "Visible") {
|
|
$script:_msgBoxResult = [System.Windows.MessageBoxResult]::No
|
|
}
|
|
else {
|
|
$script:_msgBoxResult = [System.Windows.MessageBoxResult]::OK
|
|
}
|
|
}
|
|
})
|
|
|
|
$script:UIProvider.AddXamlEvent($script:_msgBoxDialog, "btnTitleClose", "add_click", {
|
|
$script:_msgBoxDialog.Close()
|
|
})
|
|
|
|
$script:_msgBoxDialog.Owner = $script:window
|
|
$script:_msgBoxDialog.Icon = $script:window.Icon
|
|
$script:_msgBoxDialog.ShowDialog() | Out-Null
|
|
|
|
return $script:_msgBoxResult
|
|
}
|
|
catch
|
|
{
|
|
Write-LogError "Failed to show themed message box" $_.Exception
|
|
return [System.Windows.MessageBox]::Show($Text, $Caption, $Button, $Icon)
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region XAML Controls
|
|
|
|
function Show-AuthenticationInfo
|
|
{
|
|
if($script:grdMenu)
|
|
{
|
|
Set-SplashWindowText "Get profile picture"
|
|
|
|
# Remove a previously-added profile picture if one is there.
|
|
# grdMenu can be empty (the title-bar layout no longer pre-populates it with the menu),
|
|
# so iterate the actual collection instead of indexing with [-1].
|
|
$existing = @()
|
|
foreach($child in $script:grdMenu.Children)
|
|
{
|
|
if($child.Tag -eq "ProfilePicture") { $existing += $child }
|
|
}
|
|
foreach($child in $existing) { [void]$script:grdMenu.Children.Remove($child) }
|
|
|
|
# ToDo: Should be using binding to a Authentication object
|
|
$profileObj = Get-MSALUserProfile -Size 24 -Fontsize 12 -Popup
|
|
if($profileObj)
|
|
{
|
|
$profileObj.Tag = "ProfilePicture"
|
|
$profileObj.SetValue([System.Windows.Controls.Grid]::ColumnProperty,2) | Out-Null
|
|
$script:grdMenu.Children.Add($profileObj) | Out-Null
|
|
}
|
|
|
|
[System.Windows.Forms.Application]::DoEvents()
|
|
}
|
|
}
|
|
|
|
function Set-EnvironmentInfo
|
|
{
|
|
param([string]$TenantName)
|
|
|
|
if([string]::IsNullOrWhiteSpace($TenantName))
|
|
{
|
|
$TenantName = $script:OrganizationName
|
|
}
|
|
|
|
if(-not $script:borderEnvBadge -or -not $script:txtEnvBadge) { return }
|
|
|
|
# Suppress the badge entirely when no one is signed in. The Environment* settings
|
|
# are global preferences (e.g. "Lab"), but showing them before sign-in is misleading
|
|
# since the user isn't actually working with that environment yet.
|
|
$signedIn = $false
|
|
try {
|
|
$activeProvider = Get-AuthProvider
|
|
if($activeProvider) {
|
|
$defId = Get-DefaultTokenId
|
|
if($defId) { $signedIn = ($null -ne $activeProvider.GetUserInfo($defId)) }
|
|
# Fallback for providers that don't use the MSAL TokenId registry (e.g. MgGraph
|
|
# has a single session, no registry entry): ask the provider with TokenId 0.
|
|
if(-not $signedIn) { $signedIn = ($null -ne $activeProvider.GetUserInfo(0)) }
|
|
}
|
|
}
|
|
catch { $signedIn = $false }
|
|
|
|
# An expired-but-still-registered token still returns GetUserInfo, so treat an
|
|
# expired default token as "not signed in" - keeps the badge in step with the
|
|
# avatar, which also reverts to the Sign-in icon on expiry.
|
|
if($signedIn -and (Get-Command Test-DefaultTokenExpired -ErrorAction SilentlyContinue)) {
|
|
try { if(Test-DefaultTokenExpired) { $signedIn = $false } } catch { }
|
|
}
|
|
|
|
if(-not $signedIn)
|
|
{
|
|
$script:borderEnvBadge.Visibility = "Collapsed"
|
|
$script:txtEnvBadge.Text = ""
|
|
[System.Windows.Forms.Application]::DoEvents()
|
|
return
|
|
}
|
|
|
|
$envText = Get-SettingValue "EnvironmentText"
|
|
$envColor = Get-SettingValue "EnvironmentColor"
|
|
$showOrg = (Get-SettingValue "MenuShowOrganizationName") -eq $true
|
|
|
|
# Build the merged label: "<envText> - <tenant>" / either part on its own / empty.
|
|
$parts = @()
|
|
if($envText) { $parts += $envText }
|
|
if($TenantName -and $showOrg) { $parts += $TenantName }
|
|
$badgeText = $parts -join " - "
|
|
|
|
if([string]::IsNullOrWhiteSpace($badgeText))
|
|
{
|
|
$script:borderEnvBadge.Visibility = "Collapsed"
|
|
$script:txtEnvBadge.Text = ""
|
|
[System.Windows.Forms.Application]::DoEvents()
|
|
return
|
|
}
|
|
|
|
$script:txtEnvBadge.Text = $badgeText
|
|
|
|
# Apply the user-chosen background color only if EnvironmentText was actually set --
|
|
# otherwise the box is just the tenant name and should blend with the title bar.
|
|
if($envText -and $envColor)
|
|
{
|
|
try
|
|
{
|
|
$bg = [System.Windows.Media.BrushConverter]::new().ConvertFromString($envColor)
|
|
$script:borderEnvBadge.Background = $bg
|
|
|
|
$c = [System.Windows.Media.ColorConverter]::ConvertFromString($envColor)
|
|
$luminance = 0.299 * $c.R + 0.587 * $c.G + 0.114 * $c.B
|
|
$fgHex = if($luminance -gt 128) { "#FF1A1A1A" } else { "#FFEEEEEE" }
|
|
$script:txtEnvBadge.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString($fgHex)
|
|
}
|
|
catch { }
|
|
}
|
|
else
|
|
{
|
|
$script:borderEnvBadge.Background = [System.Windows.Media.Brushes]::Transparent
|
|
$script:txtEnvBadge.ClearValue([System.Windows.Controls.TextBlock]::ForegroundProperty)
|
|
}
|
|
|
|
$script:borderEnvBadge.Visibility = "Visible"
|
|
|
|
[System.Windows.Forms.Application]::DoEvents()
|
|
}
|
|
|
|
function Get-NumericUpDownControl
|
|
{
|
|
param($Id, [decimal]$MinValue = 0, [decimal]$MaxValue = 9999, [int]$Step = 1)
|
|
|
|
try
|
|
{
|
|
$XamlObj = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\NumericUpDown.xaml"))
|
|
|
|
$XamlObj.Name = $Id
|
|
$XamlObj.Children[0].Name = $Id + "_TextBox"
|
|
$XamlObj.Children[1].Name = $Id + "_UpButton"
|
|
$XamlObj.Children[2].Name = $Id + "_DownButton"
|
|
|
|
$settings = [PSCustomObject]@{
|
|
MinValue = $MinValue
|
|
MaxValue = $MaxValue
|
|
Step = $Step
|
|
_lastKnownValue = $null
|
|
}
|
|
|
|
$XamlObj | Add-Member -MemberType NoteProperty -Name "Settings" -Value $settings
|
|
|
|
$XamlObj.Children[0].Add_TextChanged({
|
|
$val = $null
|
|
if([decimal]::TryParse($this.Parent.Children[0].Text, [ref]$val))
|
|
{
|
|
$this.Parent.Settings._lastKnownValue = $val;
|
|
}
|
|
})
|
|
|
|
$XamlObj.Children[0].Add_LostFocus({
|
|
$val = $null
|
|
if([decimal]::TryParse($this.Parent.Children[0].Text, [ref]$val))
|
|
{
|
|
;
|
|
}
|
|
elseif($this.Parent.Settings._lastKnownValue)
|
|
{
|
|
$val = $this.Parent.Settings._lastKnownValue
|
|
}
|
|
|
|
if($null -ne $val)
|
|
{
|
|
if($val -gt $this.Parent.Settings.MaxValue)
|
|
{
|
|
$val = $this.Parent.Settings.MaxValue
|
|
}
|
|
elseif($val -lt $this.Parent.Settings.MinValue)
|
|
{
|
|
$val = $this.Parent.Settings.MinValue
|
|
}
|
|
$this.Parent.Children[0].Text = $val.ToString()
|
|
}
|
|
})
|
|
|
|
$XamlObj.Children[1].Add_Click({
|
|
$val = $null
|
|
if([decimal]::TryParse($this.Parent.Children[0].Text, [ref]$val))
|
|
{
|
|
$val = $val + $this.Parent.Settings.Step
|
|
if($val -gt $this.Parent.Settings.MaxValue)
|
|
{
|
|
$val = $this.Parent.Settings.MaxValue
|
|
}
|
|
$this.Parent.Children[0].Text = $val.ToString()
|
|
}
|
|
})
|
|
|
|
$XamlObj.Children[2].Add_Click({
|
|
$val = $null
|
|
if([decimal]::TryParse($this.Parent.Children[0].Text, [ref]$val))
|
|
{
|
|
$val = $val - $this.Parent.Settings.Step
|
|
if($val -lt $this.Parent.Settings.MinValue)
|
|
{
|
|
$val = $this.Parent.Settings.MinValue
|
|
}
|
|
$this.Parent.Children[0].Text = $val.ToString()
|
|
}
|
|
})
|
|
|
|
return $XamlObj
|
|
|
|
}
|
|
catch
|
|
{
|
|
Write-LogError "Failed to create NumericUpDown control" $_.Exception
|
|
return $null
|
|
}
|
|
}
|
|
|
|
function Set-SplitButtonMenu {
|
|
param(
|
|
[System.Windows.Controls.ContextMenu]$ButtonMenu,
|
|
[Parameter(Mandatory)]
|
|
[PSCustomObject[]]$Items
|
|
)
|
|
|
|
$ButtonMenu.Items.Clear()
|
|
|
|
foreach ($entry in $Items) {
|
|
$menuItem = New-Object System.Windows.Controls.MenuItem
|
|
$menuItem.Header = $entry.DisplayName
|
|
|
|
if ($entry.Action -is [scriptblock]) {
|
|
$menuItem.Add_Click($entry.Action)
|
|
}
|
|
|
|
$ButtonMenu.Items.Add($menuItem) | Out-Null
|
|
}
|
|
}
|
|
|
|
function Update-SplitButtonMenu {
|
|
param(
|
|
[System.Windows.Controls.UserControl]$SplitButton,
|
|
[PSCustomObject[]]$Items
|
|
)
|
|
|
|
$menu = $SplitButton.Tag
|
|
if ($menu -is [System.Windows.Controls.ContextMenu]) {
|
|
Set-SplitButtonMenu -ButtonMenu $menu -Items $Items
|
|
}
|
|
}
|
|
|
|
function Get-SplitButtonControl {
|
|
param(
|
|
[int]$Width = 150,
|
|
[int]$Height = 30,
|
|
[string]$Label = "Split Button",
|
|
[scriptblock]$MainAction = { },
|
|
[PSCustomObject[]]$MenuItems
|
|
)
|
|
|
|
# Define SplitButton XAML
|
|
[xml]$Xaml = @"
|
|
<UserControl xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
Width="$Width" Height="$Height">
|
|
<Grid>
|
|
<Grid.ColumnDefinitions>
|
|
<ColumnDefinition Width="*"/>
|
|
<ColumnDefinition Width="Auto"/>
|
|
</Grid.ColumnDefinitions>
|
|
|
|
<!-- Main button -->
|
|
<Button x:Name="MainButton" Grid.Column="0" Content="$Label"/>
|
|
|
|
<!-- Separator + drop arrow -->
|
|
<Border Grid.Column="1" BorderBrush="{DynamicResource BorderColor}" BorderThickness="1,0,0,0">
|
|
<Button x:Name="DropDownArrow" Width="25" Padding="0">
|
|
<Path Data="M 0 0 L 4 4 L 8 0 Z" Fill="{DynamicResource TextColor}"
|
|
Stretch="Uniform" HorizontalAlignment="Center"
|
|
VerticalAlignment="Center" Width="8" Height="8"/>
|
|
</Button>
|
|
</Border>
|
|
</Grid>
|
|
</UserControl>
|
|
"@
|
|
|
|
$reader = (New-Object System.Xml.XmlNodeReader $Xaml)
|
|
$control = [Windows.Markup.XamlReader]::Load($reader)
|
|
|
|
$MainButton = $control.FindName("MainButton")
|
|
$DropDownArrow = $control.FindName("DropDownArrow")
|
|
|
|
# Create context menu dynamically
|
|
$DropDownMenu = New-Object System.Windows.Controls.ContextMenu
|
|
|
|
# Populate dropdown if provided
|
|
if ($MenuItems) {
|
|
Set-SplitButtonMenu -ButtonMenu $DropDownMenu -Items $MenuItems
|
|
}
|
|
|
|
# Main button action
|
|
if ($MainAction -is [ScriptBlock]) {
|
|
$MainButton.Add_Click($MainAction)
|
|
}
|
|
|
|
# Arrow opens menu
|
|
$DropDownArrow.Add_Click({
|
|
$DropDownMenu = $_.Source.Tag
|
|
$DropDownMenu.PlacementTarget = $_.Source
|
|
$DropDownMenu.Placement = [System.Windows.Controls.Primitives.PlacementMode]::Bottom
|
|
$DropDownMenu.IsOpen = $true
|
|
})
|
|
|
|
# Store context menu for later updates
|
|
$DropDownArrow.Tag = $DropDownMenu
|
|
|
|
return $control
|
|
}
|
|
#endregion
|
|
|
|
#region Settings UI functions
|
|
########################################################################
|
|
#
|
|
# Settings functions
|
|
#
|
|
########################################################################
|
|
function Add-SettingsItem
|
|
{
|
|
param($SettingItem, $SettingValue)
|
|
|
|
$rd = [System.Windows.Controls.RowDefinition]::new()
|
|
$rd.Height = [double]::NaN
|
|
$script:spSettings.RowDefinitions.Add($rd)
|
|
$SettingItem.SetValue([System.Windows.Controls.Grid]::RowProperty,$script:spSettings.RowDefinitions.Count-1)
|
|
|
|
if(-not $SettingValue)
|
|
{
|
|
$SettingItem.SetValue([System.Windows.Controls.Grid]::ColumnSpanProperty, 99)
|
|
}
|
|
else
|
|
{
|
|
# Title + Description are developer-supplied JSON strings, but still get
|
|
# parsed as XAML when interpolated — a stray "<", "&", or quote would
|
|
# crash the form. Build the stub with placeholders we control and assign
|
|
# the user-visible text programmatically. Same root cause as the value
|
|
# injection in Add-SettingTextBox / Add-SettingFolder.
|
|
$Xaml = @"
|
|
<StackPanel $script:WPFNS Orientation="Horizontal" Margin="5,5,5,0">
|
|
<TextBlock x:Name="settingTitleText" Foreground="{DynamicResource TitleForegroundColor}" VerticalAlignment="Center"/>
|
|
<Rectangle x:Name="settingDescIcon" Style="{DynamicResource InfoIcon}" Margin="5,0,0,0" Visibility="Collapsed">
|
|
<Rectangle.ToolTip>
|
|
<TextBlock x:Name="settingDescText" />
|
|
</Rectangle.ToolTip>
|
|
</Rectangle>
|
|
</StackPanel>
|
|
"@
|
|
|
|
if($script:tenantSettings -and $SettingValue)
|
|
{
|
|
#_IsChecked
|
|
$tenantConfig = [System.Windows.Controls.CheckBox]::new()
|
|
$tenantConfig.ToolTip = "Enable tenant specific setting"
|
|
$tenantConfig.SetValue([System.Windows.Controls.Grid]::RowProperty,$script:spSettings.RowDefinitions.Count-1)
|
|
$tenantConfig.SetValue([System.Windows.Controls.Grid]::ColumnProperty, 0)
|
|
$tenantConfig.Margin = "0,5,0,0"
|
|
$tenantConfig.Tag = $SettingValue
|
|
$tenantConfig.IsChecked = (Test-SettingValueConfigured -Key $SettingValue.Key -Tenant)
|
|
$SettingItem.IsEnabled = $tenantConfig.IsChecked
|
|
$tenantConfig.add_Click({
|
|
if($this.Tag.Control) { $this.Tag.Control.IsEnabled = $this.IsChecked }
|
|
}
|
|
)
|
|
$script:spSettings.AddChild($tenantConfig)
|
|
}
|
|
|
|
$settingsTitle = [Windows.Markup.XamlReader]::Parse($Xaml)
|
|
|
|
$titleText = $settingsTitle.FindName("settingTitleText")
|
|
if($titleText) { $titleText.Text = [string]$SettingValue.Title }
|
|
if($SettingValue.Description)
|
|
{
|
|
$descIcon = $settingsTitle.FindName("settingDescIcon")
|
|
$descText = $settingsTitle.FindName("settingDescText")
|
|
if($descText) { $descText.Text = [string]$SettingValue.Description }
|
|
if($descIcon) { $descIcon.Visibility = "Visible" }
|
|
}
|
|
|
|
$settingsTitle.SetValue([System.Windows.Controls.Grid]::RowProperty,$script:spSettings.RowDefinitions.Count-1)
|
|
$settingsTitle.SetValue([System.Windows.Controls.Grid]::ColumnProperty, 1)
|
|
|
|
$SettingItem.SetValue([System.Windows.Controls.Grid]::ColumnProperty, 2)
|
|
$script:spSettings.AddChild($settingsTitle)
|
|
$SettingItem.Margin = "0,5,0,0"
|
|
}
|
|
$script:spSettings.AddChild($SettingItem)
|
|
}
|
|
|
|
function Add-SettingTextBox
|
|
{
|
|
param($Id, $Value)
|
|
|
|
# Build a value-free XAML stub and assign Text programmatically. Interpolating
|
|
# $Value into the XAML string fails when the saved value contains markup the
|
|
# parser treats as a second Text source — e.g. "<TextBox.Text>..." or a stray
|
|
# "Text=..." pattern — producing "'Text' property has already been set on
|
|
# 'TextBox'". Setting .Text on the parsed object goes through WPF's property
|
|
# setter and bypasses the XAML parser entirely.
|
|
$Xaml = "<TextBox $script:WPFNS Name=`"$($Id)`" />"
|
|
$tb = [Windows.Markup.XamlReader]::Parse($Xaml)
|
|
if($null -ne $Value) { $tb.Text = [string]$Value }
|
|
return $tb
|
|
}
|
|
|
|
function Add-SettingCheckBox
|
|
{
|
|
param($Id, $Value)
|
|
|
|
$tmpValue = ($Value -eq $true -or $Value -eq "true").ToString().ToLower()
|
|
|
|
$Xaml = @"
|
|
<CheckBox $script:WPFNS Name="$($Id)" IsChecked="$($tmpValue)" />
|
|
"@
|
|
return [Windows.Markup.XamlReader]::Parse($Xaml)
|
|
}
|
|
|
|
function Add-SettingComboBox
|
|
{
|
|
param($Id, $Value, $SettingObj)
|
|
|
|
$nameProp = ?? $SettingObj.DisplayMemberPath "Name"
|
|
$valueProp = ?? $SettingObj.SelectedValuePath "Value"
|
|
|
|
$Xaml = @"
|
|
<ComboBox $script:WPFNS Name="$($Id)" SelectedValuePath="$($valueProp)" />
|
|
"@
|
|
$XamlObj = [Windows.Markup.XamlReader]::Parse($Xaml)
|
|
|
|
$itemTemplateXaml = @"
|
|
<DataTemplate xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
|
<TextBlock Text="{Binding $($nameProp)}"/>
|
|
</DataTemplate>
|
|
"@
|
|
$XamlObj.ItemTemplate = [Windows.Markup.XamlReader]::Parse($itemTemplateXaml)
|
|
|
|
$XamlObj.ItemsSource = $SettingObj.ItemsSource
|
|
if($Value)
|
|
{
|
|
$XamlObj.SelectedValue = $Value
|
|
}
|
|
|
|
$XamlObj
|
|
}
|
|
|
|
function Add-SettingFolder
|
|
{
|
|
param($Id, $Value)
|
|
# Same hazard as Add-SettingTextBox: inline $Value can collide with the Text
|
|
# property when it contains XAML markup. Build empty, set .Text after parse.
|
|
$Xaml = @"
|
|
<Grid $script:WPFNS HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Margin="0,5,0,0">
|
|
<Grid.ColumnDefinitions>
|
|
<ColumnDefinition Width="*" />
|
|
<ColumnDefinition Width="5" />
|
|
<ColumnDefinition Width="Auto" />
|
|
</Grid.ColumnDefinitions>
|
|
<TextBox Name="$($Id)" />
|
|
<Button Grid.Column="2" Name="browse_$($Id)" Padding="5,0,5,0" Width="50">...</Button>
|
|
</Grid>
|
|
|
|
"@
|
|
|
|
$Obj = [Windows.Markup.XamlReader]::Parse($Xaml)
|
|
|
|
$btnBrowse = $Obj.FindName("browse_$($Id)")
|
|
$txtObj = $Obj.FindName($Id)
|
|
if($txtObj -and $null -ne $Value) { $txtObj.Text = [string]$Value }
|
|
if($btnBrowse)
|
|
{
|
|
$btnBrowse.Tag = $txtObj
|
|
$btnBrowse.Add_Click({
|
|
$folder = Get-Folder $this.Tag.Text
|
|
if($folder) { $this.Tag.Text = $folder }
|
|
})
|
|
}
|
|
return $Obj
|
|
}
|
|
|
|
function Add-SettingValue
|
|
{
|
|
param($SettingValue)
|
|
|
|
$Id = "id_" + [Guid]::NewGuid().ToString('n')
|
|
|
|
if($SettingValue.TenantSettings -eq $false -and $script:tenantSettings)
|
|
{
|
|
return # Value not supported in Tenant Settings
|
|
}
|
|
elseif($SettingValue.GlobalSettings -eq $false -and $script:tenantSettings -ne $true)
|
|
{
|
|
return # Value not supported in Global Settings
|
|
}
|
|
|
|
$Value = Get-SettingValue $SettingValue.Key -GlobalOnly:($script:tenantSettings -ne $true)
|
|
|
|
if($SettingValue.Type -eq "folder")
|
|
{
|
|
$SettingObj = Add-SettingFolder $Id $Value
|
|
}
|
|
elseif($SettingValue.Type -eq "Boolean")
|
|
{
|
|
$SettingObj = Add-SettingCheckBox $Id $Value
|
|
}
|
|
elseif($SettingValue.Type -eq "List")
|
|
{
|
|
$SettingObj = Add-SettingComboBox $Id $Value $SettingValue
|
|
}
|
|
else
|
|
{
|
|
$SettingObj = Add-SettingTextBox $Id $Value
|
|
}
|
|
|
|
if($SettingObj)
|
|
{
|
|
Add-SettingsItem $SettingObj $SettingValue
|
|
# Find the control in the setting object that contains the actual value
|
|
# $SettingObj might be a grid that contains the TextBox with the settings value
|
|
$ctrl = $SettingObj.FindName($Id)
|
|
if(($SettingValue | Get-Member -MemberType NoteProperty -Name "Control"))
|
|
{
|
|
$SettingValue.Control = $ctrl
|
|
}
|
|
else
|
|
{
|
|
$SettingValue | Add-Member -MemberType NoteProperty -Name "Control" -Value $ctrl
|
|
}
|
|
}
|
|
}
|
|
|
|
function Add-SettingTitle
|
|
{
|
|
param($Title, $MarginTop = "0")
|
|
|
|
$Xaml = @"
|
|
<TextBlock $script:WPFNS Text="$Title" Background="{DynamicResource SettingsSectionBackgroundColor}" Foreground="{DynamicResource TitleForegroundColor}" FontWeight="Bold" Padding="5" Margin="0,$MarginTop,0,0" />
|
|
"@
|
|
|
|
Add-SettingsItem ([Windows.Markup.XamlReader]::Parse($Xaml)) | Out-Null
|
|
}
|
|
|
|
function Show-SettingsForm
|
|
{
|
|
param([switch]$Tenant)
|
|
|
|
$settingsStr = Get-Content ($script:AppUIRootFolder + "\Xaml\SettingsForm.xaml") -Encoding UTF8
|
|
|
|
$settingsForm = [Windows.Markup.XamlReader]::Parse($settingsStr)
|
|
$script:spSettings = $settingsForm.FindName("spSettings")
|
|
|
|
$script:tenantSettings = ($Tenant -eq $true)
|
|
$script:UIProvider.AddXamlEvent($settingsForm, "btnSave", "Add_Click", {
|
|
Save-AllSettings
|
|
})
|
|
|
|
$script:UIProvider.AddXamlEvent($settingsForm, "btnClose", "Add_Click", {
|
|
Remove-Variable "tenantSettings" -Scope Script -Force # $script:tenantSettings = $null
|
|
Show-ModalObject
|
|
})
|
|
|
|
if($JsonSettingsObj -or $script:tenantSettings -eq $true)
|
|
{
|
|
$script:UIProvider.SetXamlProperty($settingsForm, "btnExport", "Visibility", "Collapsed")
|
|
}
|
|
else
|
|
{
|
|
$script:UIProvider.AddXamlEvent($settingsForm, "btnExport", "Add_Click", {
|
|
$sf = [System.Windows.Forms.SaveFileDialog]::new()
|
|
$sf.FileName = $script:currentObjName
|
|
$sf.DefaultExt = "*.json"
|
|
$sf.Filter = "Json (*.json)|*.json|All files (*.*)|*.*"
|
|
if($sf.ShowDialog() -eq "OK")
|
|
{
|
|
Export-Settings $sf.FileName
|
|
}
|
|
})
|
|
}
|
|
|
|
$tmp = Get-SettingsSection "General"
|
|
if($tmp.Values.Count -gt 0)
|
|
{
|
|
Add-SettingTitle $tmp.Title
|
|
foreach($SettingObj in $tmp.Values)
|
|
{
|
|
Add-SettingValue $SettingObj
|
|
}
|
|
}
|
|
|
|
|
|
foreach($section in ((Get-SettingsSections) | Where-Object Id -ne "General" | Sort-Object -Property Order,Title))
|
|
{
|
|
if($section.Values.Count -eq 0) { continue }
|
|
Add-SettingTitle $section.Title 5
|
|
foreach($SettingObj in $section.Values)
|
|
{
|
|
Add-SettingValue $SettingObj
|
|
}
|
|
}
|
|
Show-ModalObject $settingsForm
|
|
}
|
|
|
|
function Save-AllSettings
|
|
{
|
|
Write-Status "Save settings"
|
|
$dt1 = Get-Date
|
|
$curHideNoAccess = Get-SettingValue "HideNoAccess"
|
|
|
|
foreach($section in (Get-SettingsSections))
|
|
{
|
|
foreach($SettingObj in $section.Values)
|
|
{
|
|
if(-not $SettingObj.Control) { continue }
|
|
if($SettingObj.Control.IsEnabled -eq $false -and $script:tenantSettings)
|
|
{
|
|
Remove-SettingValue -Key $SettingObj.Key -Tenant
|
|
continue
|
|
}
|
|
|
|
$valueFound = $false
|
|
if($SettingObj.Control.GetType().Name -eq "TextBox")
|
|
{
|
|
$Value = $SettingObj.Control.Text
|
|
if($SettingObj.Type -eq "Int")
|
|
{
|
|
try
|
|
{
|
|
$Value = [int]$Value
|
|
}
|
|
catch
|
|
{
|
|
# Log or set invalid
|
|
$Value = $SettingObj.Value
|
|
}
|
|
}
|
|
$valueFound = $true
|
|
}
|
|
elseif($SettingObj.Control.GetType().Name -eq "CheckBox")
|
|
{
|
|
$Value = $SettingObj.Control.IsChecked
|
|
$valueFound = $true
|
|
}
|
|
elseif($SettingObj.Control.GetType().Name -eq "ComboBox")
|
|
{
|
|
Write-LogDebug "$($SettingObj.Control.Text) | $($SettingObj.Control.SelectedIndex)"
|
|
if($SettingObj.Control.SelectedIndex -eq -1)
|
|
{
|
|
$Value = $SettingObj.Control.Text
|
|
}
|
|
else
|
|
{
|
|
$Value = $SettingObj.Control.SelectedValue
|
|
}
|
|
$valueFound = $true
|
|
}
|
|
|
|
if($valueFound)
|
|
{
|
|
# The path comes from the resolver, not from string concatenation here.
|
|
# The old code composed "$tenantId\$($SettingObj.SubPath)" and only
|
|
# assigned $subPath INSIDE `if($tenantId)`, with no else - so with the
|
|
# tenant settings form open and no resolvable tenant, this setting was
|
|
# written to the PREVIOUS setting's path. Resolve-SettingStorePath
|
|
# reports that failure instead, and it also handles the root-stored
|
|
# keys (every General entry), where the hand-composed path left a
|
|
# trailing separator. It resolves the tenant from $script:OrganizationId,
|
|
# which is what Get-SettingValue reads with, so the form's writer and
|
|
# reader cannot disagree - the old Get-AuthProvider.GetUserInfo(0)
|
|
# route could.
|
|
$isTenant = ($script:tenantSettings -eq $true)
|
|
$subPath = Resolve-SettingStorePath -Key $SettingObj.Key -Definition $SettingObj -Tenant:$isTenant
|
|
if($null -eq $subPath) { continue }
|
|
|
|
# Read the raw stored value at THIS scope, not the effective value: the
|
|
# only use is the change event below, and a tenant value that is not set
|
|
# has to look unset (Get-SettingValue would answer with the global one).
|
|
$currentValue = Get-SettingStoreValue -SubPath $subPath -Key $SettingObj.Key
|
|
|
|
Set-SettingValue -Key $SettingObj.Key -Value $Value -Tenant:$isTenant
|
|
|
|
if($null -ne $Value -and $Value -isnot [String]) {
|
|
$stringValue = $Value.ToString()
|
|
}
|
|
else {
|
|
$stringValue = $Value
|
|
}
|
|
|
|
if($stringValue -ne $currentValue -and -not [String]::IsNullOrEmpty($stringValue) -and -not [String]::IsNullOrEmpty($currentValue)) {
|
|
Invoke-AppEvent "SettingValueUpdated" $SettingObj $Value $currentValue
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Invoke-AppEvent "SettingsUpdated"
|
|
|
|
#Initialize-Settings -Updated
|
|
|
|
$newHideNoAccess = Get-SettingValue "HideNoAccess"
|
|
if($curHideNoAccess -ne $newHideNoAccess )
|
|
{
|
|
Show-ViewMenu
|
|
}
|
|
|
|
if($dt1.AddSeconds(1) -lt (Get-Date))
|
|
{
|
|
Start-Sleep -Seconds 1 # It goes to quick...ToDo: Do this in a better way
|
|
}
|
|
Write-Status ""
|
|
}
|
|
#endregion
|
|
|
|
#region Geneic functions
|
|
function Set-ObjectGrid
|
|
{
|
|
param($Grid, $Obj)
|
|
|
|
if($Obj)
|
|
{
|
|
$Grid.Children.Add($Obj) | Out-Null
|
|
$Grid.Visibility = "Visible"
|
|
}
|
|
else
|
|
{
|
|
$Grid.Children.Clear()
|
|
$Grid.Visibility = "Collapsed"
|
|
}
|
|
|
|
[System.Windows.Forms.Application]::DoEvents()
|
|
}
|
|
|
|
function Get-Folder
|
|
{
|
|
param($Path = $env:temp, $Title = "Select a directory")
|
|
|
|
$dlgCOFD = $null
|
|
|
|
if($script:useDefaultFolderDialog -ne $true)
|
|
{
|
|
try
|
|
{
|
|
if($script:WindowsAPICodePackLoaded -eq $false)
|
|
{
|
|
$apiCodec = Join-Path $script:AppRootFolder "UI\Bin\Microsoft.WindowsAPICodePack.Shell.dll"
|
|
if([IO.File]::Exists($apiCodec))
|
|
{
|
|
Add-Type -Path $apiCodec | Out-Null
|
|
$script:WindowsAPICodePackLoaded = $true
|
|
}
|
|
else
|
|
{
|
|
Write-Log "Could not find Microsoft.WindowsAPICodePack.Shell.dll" 2
|
|
}
|
|
}
|
|
$dlgCOFD = New-Object Microsoft.WindowsAPICodePack.Dialogs.CommonOpenFileDialog
|
|
}
|
|
catch
|
|
{
|
|
Write-LogError "Failed to load Microsoft.WindowsAPICodePack.Shell.dll. Verify that the .Net 3.5 feature is enabled" $_.Exception
|
|
}
|
|
}
|
|
|
|
if($dlgCOFD -and $script:useDefaultFolderDialog -ne $true)
|
|
{
|
|
$dlgCOFD.EnsureReadOnly = $true
|
|
$dlgCOFD.IsFolderPicker = $true
|
|
$dlgCOFD.AllowNonFileSystemItems = $false
|
|
$dlgCOFD.Multiselect = $false
|
|
$dlgCOFD.Title = $Title
|
|
|
|
if($Path -and (Test-Path $Path))
|
|
{
|
|
$dlgCOFD.InitialDirectory = $Path
|
|
}
|
|
if($dlgCOFD.ShowDialog($script:window) -eq [Microsoft.WindowsAPICodePack.Dialogs.CommonFileDialogResult]::Ok)
|
|
{
|
|
$dlgCofd.FileName
|
|
}
|
|
}
|
|
else
|
|
{
|
|
$script:useDefaultFolderDialog = $true
|
|
[Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") | Out-Null
|
|
[System.Windows.Forms.Application]::EnableVisualStyles()
|
|
$dlgFBD = New-Object System.Windows.Forms.FolderBrowserDialog
|
|
$dlgFBD.SelectedPath = "C:\"
|
|
$dlgFBD.ShowNewFolderButton = $false
|
|
$dlgFBD.Description = $Title
|
|
if($dlgFBD.ShowDialog() -eq "OK")
|
|
{
|
|
$dlgFBD.SelectedPath
|
|
}
|
|
$dlgFBD.Dispose()
|
|
}
|
|
}
|
|
|
|
function Get-GridCheckboxColumn
|
|
{
|
|
param($BindingProperty = "IsSelected", [scriptblock]$ScriptBlock)
|
|
|
|
$binding = [System.Windows.Data.Binding]::new($BindingProperty)
|
|
$binding.UpdateSourceTrigger = [System.Windows.Data.UpdateSourceTrigger]::PropertyChanged
|
|
$column = [System.Windows.Controls.DataGridTemplateColumn]::new()
|
|
$fef = [System.Windows.FrameworkElementFactory]::new([System.Windows.Controls.CheckBox])
|
|
$binding.Mode = [System.Windows.Data.BindingMode]::TwoWay
|
|
$fef.SetValue([System.Windows.Controls.CheckBox]::IsCheckedProperty, $binding)
|
|
$fef.SetValue([System.Windows.Controls.CheckBox]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Center)
|
|
$fef.SetValue([System.Windows.Controls.CheckBox]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
|
|
if($null -ne $ScriptBlock)
|
|
{
|
|
[System.Windows.RoutedEventHandler]$checkedEventHandler = $ScriptBlock
|
|
$fef.AddHandler([System.Windows.Controls.CheckBox]::CheckedEvent, $checkedEventHandler)
|
|
}
|
|
$dt = [System.Windows.DataTemplate]::new()
|
|
$dt.VisualTree = $fef
|
|
$column.CellTemplate = $dt
|
|
|
|
$header = [System.Windows.Controls.CheckBox]::new()
|
|
$header.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Center
|
|
$header.VerticalAlignment = [System.Windows.VerticalAlignment]::Center
|
|
$header.ToolTip = "Select/deselect all items"
|
|
$column.Header = $header
|
|
|
|
# HeaderStyle: carry theme colors via DynamicResource + zero padding + centering
|
|
$headerStyle = [System.Windows.Style]::new([System.Windows.Controls.Primitives.DataGridColumnHeader])
|
|
$headerStyle.Setters.Add([System.Windows.Setter]::new([System.Windows.Controls.Control]::PaddingProperty, [System.Windows.Thickness]::new(0)))
|
|
$headerStyle.Setters.Add([System.Windows.Setter]::new([System.Windows.Controls.Control]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Center))
|
|
$headerStyle.Setters.Add([System.Windows.Setter]::new([System.Windows.Controls.Control]::VerticalContentAlignmentProperty, [System.Windows.VerticalAlignment]::Center))
|
|
$bgSetter = [System.Windows.Setter]::new()
|
|
$bgSetter.Property = [System.Windows.Controls.Control]::BackgroundProperty
|
|
$bgSetter.Value = [System.Windows.DynamicResourceExtension]::new("PanelBackgroundColor")
|
|
$headerStyle.Setters.Add($bgSetter)
|
|
$fgSetter = [System.Windows.Setter]::new()
|
|
$fgSetter.Property = [System.Windows.Controls.Control]::ForegroundProperty
|
|
$fgSetter.Value = [System.Windows.DynamicResourceExtension]::new("TextColor")
|
|
$headerStyle.Setters.Add($fgSetter)
|
|
$borderSetter = [System.Windows.Setter]::new()
|
|
$borderSetter.Property = [System.Windows.Controls.Control]::BorderBrushProperty
|
|
$borderSetter.Value = [System.Windows.DynamicResourceExtension]::new("BorderColor")
|
|
$headerStyle.Setters.Add($borderSetter)
|
|
$column.HeaderStyle = $headerStyle
|
|
|
|
$column
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Popup
|
|
function Show-Popup
|
|
{
|
|
param($Popup)
|
|
|
|
if(-not $script:grdPopup -or -not $script:cvsPopup) { return }
|
|
|
|
$script:cvsPopup.AddChild($Popup) | Out-Null
|
|
$script:grdPopup.Visibility = "Visible"
|
|
|
|
# Install ESC-to-dismiss once per window. Pressing Escape while the popup is visible
|
|
# closes it — the standard transient-popup affordance users expect (the existing
|
|
# click-anywhere-outside behaviour stays via grdPopup's MouseLeftButtonDown).
|
|
if(-not $script:popupEscHandlerInstalled) {
|
|
$script:window.add_PreviewKeyDown({
|
|
param($S, $E)
|
|
if($E.Key -eq [System.Windows.Input.Key]::Escape -and
|
|
$script:grdPopup -and $script:grdPopup.Visibility -eq [System.Windows.Visibility]::Visible) {
|
|
$script:UIProvider.HidePopup()
|
|
$E.Handled = $true
|
|
}
|
|
})
|
|
$script:popupEscHandlerInstalled = $true
|
|
}
|
|
|
|
# Give the popup keyboard focus so the global PreviewKeyDown fires reliably.
|
|
try { [System.Windows.Input.Keyboard]::Focus($Popup) | Out-Null } catch { }
|
|
|
|
[System.Windows.Forms.Application]::DoEvents()
|
|
}
|
|
|
|
function Hide-Popup
|
|
{
|
|
if(-not $script:grdPopup -or -not $script:cvsPopup) { return }
|
|
$script:cvsPopup.Children.Clear()
|
|
$script:grdPopup.Visibility = "Collapsed"
|
|
[System.Windows.Forms.Application]::DoEvents()
|
|
}
|
|
#endregion
|
|
|
|
#region Settings
|
|
#endregion
|
|
|
|
#region Event functions
|
|
function Invoke-CoreUIEventSettingValueUpdated
|
|
{
|
|
param($SettingInfo, $NewValue, $OldValue)
|
|
|
|
if($SettingInfo.Key -eq "EnvironmentText" -or $SettingInfo.Key -eq "EnvironmentColor") {
|
|
Set-EnvironmentInfo
|
|
}
|
|
elseif($SettingInfo.Key -eq "AppTheme") {
|
|
Set-AppTheme $NewValue
|
|
}
|
|
}
|
|
#Endregion
|
|
|
|
#region Select Items - Dialog
|
|
function Show-ItemSelectionDialog {
|
|
param(
|
|
[Parameter(Mandatory)]
|
|
[string]$Title,
|
|
|
|
[Parameter(Mandatory)]
|
|
[System.Collections.IEnumerable]$Items,
|
|
|
|
[string[]]$Columns = @(), # property names to display as columns
|
|
[switch]$MultiSelect, # allow selecting more than one item
|
|
[switch]$CheckBoxMultiSelect, # use checkboxes instead of Ctrl+Click
|
|
[switch]$AutoSizeColumns, # auto-size columns to fit content
|
|
[switch]$Searchable, # auto-size columns to fit content
|
|
|
|
[int]$Width = 650,
|
|
[int]$Height = 500
|
|
)
|
|
|
|
# XAML for dialog
|
|
$Xaml = @"
|
|
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
Title="$Title" Height="$Height" Width="$Width"
|
|
WindowStartupLocation="CenterScreen"
|
|
ResizeMode="CanResizeWithGrip"
|
|
Background="{DynamicResource WindowBackgroundColor}">
|
|
<DockPanel Margin="10">
|
|
<TextBlock Text="$Title" DockPanel.Dock="Top" FontSize="16" FontWeight="Bold" Margin="0,0,0,10"/>
|
|
|
|
<!-- Search box -->
|
|
<TextBox Name="SearchBox" DockPanel.Dock="Top" Margin="0,0,0,10" Height="25" />
|
|
|
|
<!-- ListView with GridView -->
|
|
<ListView Name="ItemList" DockPanel.Dock="Top" MinHeight="320" SelectionMode="Extended">
|
|
<ListView.View>
|
|
<GridView />
|
|
</ListView.View>
|
|
</ListView>
|
|
|
|
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
|
|
<Button Name="OkBtn" Width="75" Margin="5">OK</Button>
|
|
<Button Name="CancelBtn" Width="75" Margin="5">Cancel</Button>
|
|
</StackPanel>
|
|
</DockPanel>
|
|
</Window>
|
|
"@
|
|
|
|
$reader = New-Object System.Xml.XmlNodeReader ([xml]$Xaml)
|
|
$dialog = [Windows.Markup.XamlReader]::Load($reader)
|
|
Add-ThemeToWindow $dialog
|
|
|
|
$listView = $dialog.FindName("ItemList")
|
|
$gridView = $listView.View
|
|
$okBtn = $dialog.FindName("OkBtn")
|
|
$cancelBtn = $dialog.FindName("CancelBtn")
|
|
$searchBox = $dialog.FindName("SearchBox")
|
|
|
|
if($Searchable -eq $false) {
|
|
$searchBox.Visibility = "Collapsed"
|
|
}
|
|
|
|
# Prepare items
|
|
$observable = [System.Collections.ObjectModel.ObservableCollection[object]]::new()
|
|
foreach ($item in $Items) {
|
|
$Obj = [pscustomobject]@{
|
|
Item = $item
|
|
IsSelected = $false
|
|
}
|
|
foreach ($c in $Columns) { $Obj | Add-Member -NotePropertyName $c -NotePropertyValue $item.$c }
|
|
$observable.Add($Obj)
|
|
}
|
|
|
|
if ($CheckBoxMultiSelect) {
|
|
|
|
# First column = checkbox
|
|
$col = New-Object System.Windows.Controls.GridViewColumn
|
|
$col.Header = "Select"
|
|
$col.CellTemplate = [Windows.Markup.XamlReader]::Parse(@"
|
|
<DataTemplate xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
|
<CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay}" HorizontalAlignment="Center"/>
|
|
</DataTemplate>
|
|
"@)
|
|
if ($AutoSizeColumns) { $col.Width = [double]::NaN }
|
|
$gridView.Columns.Add($col)
|
|
|
|
}
|
|
|
|
foreach ($c in $Columns) {
|
|
$col = New-Object System.Windows.Controls.GridViewColumn
|
|
$col.Header = $c
|
|
$col.DisplayMemberBinding = New-Object System.Windows.Data.Binding($c)
|
|
if ($AutoSizeColumns) { $col.Width = [double]::NaN }
|
|
$gridView.Columns.Add($col)
|
|
}
|
|
|
|
# CollectionView for filtering + sorting
|
|
$view = [System.Windows.Data.CollectionViewSource]::GetDefaultView($observable)
|
|
$listView.ItemsSource = $view
|
|
|
|
# Filtering
|
|
if($Searchable -eq $true) {
|
|
$searchBox.Add_TextChanged({
|
|
$Text = $searchBox.Text.ToLower()
|
|
if (-not $Text) {
|
|
$view.Filter = $null
|
|
} else {
|
|
$view.Filter = {
|
|
param($Obj)
|
|
if ($CheckBoxMultiSelect) {
|
|
foreach ($c in $Columns) {
|
|
$val = $Obj.$c
|
|
if ($val -and $val -like "*$Text*") { return $true }
|
|
}
|
|
return $false
|
|
}
|
|
elseif ($Columns.Count -gt 0) {
|
|
foreach ($c in $Columns) {
|
|
$val = $Obj.$c
|
|
if ($val -and $val -like "*$Text*") { return $true }
|
|
}
|
|
return $false
|
|
} else {
|
|
return $Obj.ToString() -like "*$Text*"
|
|
}
|
|
}
|
|
}
|
|
$view.Refresh()
|
|
})
|
|
}
|
|
|
|
# Sorting by clicking headers with arrow indicator
|
|
$script:lastHeaderClicked = $null
|
|
$script:lastDirection = "Ascending"
|
|
|
|
$headerClickHandler = [System.Windows.RoutedEventHandler]{
|
|
param($Sender, $E)
|
|
$header = $E.OriginalSource
|
|
if ($header -is [System.Windows.Controls.GridViewColumnHeader] -and $null -ne $header.Column) {
|
|
$binding = $header.Column.DisplayMemberBinding
|
|
if ($binding) {
|
|
$prop = $binding.Path.Path
|
|
$view.SortDescriptions.Clear()
|
|
if ($script:lastHeaderClicked -eq $header -and $script:lastDirection -eq "Ascending") {
|
|
$script:lastDirection = "Descending"
|
|
} else {
|
|
$script:lastDirection = "Ascending"
|
|
}
|
|
$view.SortDescriptions.Add((New-Object System.ComponentModel.SortDescription($prop,$script:lastDirection)))
|
|
|
|
# Reset all headers
|
|
foreach ($colHeader in ($gridView.Columns | ForEach-Object { $_.Header })) {
|
|
if ($colHeader -is [string]) {
|
|
$null = $colHeader
|
|
}
|
|
}
|
|
|
|
# Add arrow to current header
|
|
if ($script:lastDirection -eq "Ascending") {
|
|
$header.Content = "$prop ↑"
|
|
} else {
|
|
$header.Content = "$prop ↓"
|
|
}
|
|
|
|
if ($script:lastHeaderClicked -and $script:lastHeaderClicked -ne $header) {
|
|
$binding = $script:lastHeaderClicked.Column.DisplayMemberBinding
|
|
$prop = $binding.Path.Path
|
|
$script:lastHeaderClicked.Content = $prop
|
|
}
|
|
|
|
$script:lastHeaderClicked = $header
|
|
}
|
|
}
|
|
}
|
|
$listView.AddHandler([System.Windows.Controls.GridViewColumnHeader]::ClickEvent, $headerClickHandler)
|
|
|
|
$script:popupDialogItems = $null
|
|
|
|
$okBtn.Add_Click({
|
|
if ($CheckBoxMultiSelect) {
|
|
$script:popupDialogItems = @($observable | Where-Object { $_.IsSelected } | ForEach-Object { $_.Item })
|
|
}
|
|
elseif ($MultiSelect) {
|
|
$script:popupDialogItems = $listView.SelectedItems | ForEach-Object { $_.Item }
|
|
}
|
|
else {
|
|
$script:popupDialogItems = $listView.SelectedItem.Item
|
|
}
|
|
$dialog.DialogResult = $true
|
|
$dialog.Close()
|
|
})
|
|
|
|
$cancelBtn.Add_Click({
|
|
$dialog.DialogResult = $false
|
|
$dialog.Close()
|
|
})
|
|
|
|
$null = $dialog.ShowDialog()
|
|
return $script:popupDialogItems
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Picker Dialogs
|
|
|
|
# Show a one-line message in the picker's status area (hidden when empty).
|
|
function Set-PickerStatusText
|
|
{
|
|
param([string]$Text)
|
|
|
|
if(-not $script:txtPickerStatus -or -not $script:grpPickerStatus) { return }
|
|
|
|
$script:txtPickerStatus.Text = $Text
|
|
$script:grpPickerStatus.Visibility = if([String]::IsNullOrWhiteSpace($Text)) { "Collapsed" } else { "Visible" }
|
|
}
|
|
|
|
# The scope object behind the current "Search in" selection, or $null. WPF binds
|
|
# the raw scope objects straight onto the ComboBox; the Avalonia counterpart has
|
|
# to map back through a projection, which is why callers go through this.
|
|
function Get-PickerSelectedScope
|
|
{
|
|
if(-not $script:cbPickerScope) { return $null }
|
|
return $script:cbPickerScope.SelectedItem
|
|
}
|
|
|
|
# The tenant entry behind the current "Tenant" selection, or $null when the
|
|
# dialog is single-tenant.
|
|
function Get-PickerSelectedTenant
|
|
{
|
|
if(-not $script:cbPickerTenant) { return $null }
|
|
return $script:cbPickerTenant.SelectedItem
|
|
}
|
|
|
|
# Drop whatever is in the results grid. Called when the tenant changes - those
|
|
# rows belong to the previous tenant and picking one would compare the wrong
|
|
# object.
|
|
function Clear-PickerResults
|
|
{
|
|
if($script:_pickerState) { $script:_pickerState.ItemsBox.Items = @() }
|
|
if($script:dgPickerObjects) { $script:dgPickerObjects.ItemsSource = @() }
|
|
if($script:btnPickerOK) { $script:btnPickerOK.IsEnabled = $false }
|
|
}
|
|
|
|
function Show-PickerDialog
|
|
{
|
|
param($Title, $InitHandler, $ClickHandler, $OkHandler, [switch]$MultiSelect, $ClickHandlerArgs = $null, $LoadHandler = $null, [string]$LoadLabel = "Load all from Intune", $Scopes = $null, [string]$SelectedScopeKey = $null, $Tenants = $null, [string]$SelectedTenantKey = $null)
|
|
|
|
if(-not $script:PickerWindow)
|
|
{
|
|
$pickerPath = $script:AppUIRootFolder + "\Xaml\PickerDialog.xaml"
|
|
if([IO.File]::Exists($pickerPath))
|
|
{
|
|
[xml]$script:PickerWindow = Get-Content $pickerPath -Encoding UTF8
|
|
}
|
|
}
|
|
if(-not $script:PickerWindow) { Write-Log "PickerDialog.xaml not found" 3; return }
|
|
|
|
$script:pickerDialog = [Windows.Markup.XamlReader]::Load((New-Object System.XML.XMLNodeReader $script:PickerWindow))
|
|
$script:pickerDialog.Title = $Title
|
|
Add-ThemeToWindow $script:pickerDialog
|
|
|
|
$script:dgPickerObjects = $script:pickerDialog.FindName("dgPickerObjects")
|
|
$script:btnPickerOK = $script:pickerDialog.FindName("btnPickerOK")
|
|
$script:grpPickerStatus = $script:pickerDialog.FindName("grpPickerStatus")
|
|
$script:txtPickerStatus = $script:pickerDialog.FindName("txtPickerStatus")
|
|
$script:txtPickerSearch = $script:pickerDialog.FindName("txtPickerSearch")
|
|
$script:cbPickerScope = $script:pickerDialog.FindName("cbPickerScope")
|
|
$script:cbPickerTenant = $script:pickerDialog.FindName("cbPickerTenant")
|
|
|
|
if($MultiSelect -eq $true) {
|
|
$script:dgPickerObjects.SelectionMode = "Extended"
|
|
}
|
|
|
|
# Tenant selector - only shown when more than one tenant is signed in.
|
|
# Switching tenant clears the grid: the rows in it came from the previous
|
|
# tenant, and the search has to be re-run against the new one.
|
|
if($Tenants -and $script:cbPickerTenant) {
|
|
$script:cbPickerTenant.ItemsSource = Get-UIItemsArray $Tenants
|
|
if($SelectedTenantKey) { $script:cbPickerTenant.SelectedValue = $SelectedTenantKey }
|
|
if($null -eq $script:cbPickerTenant.SelectedItem) { $script:cbPickerTenant.SelectedIndex = 0 }
|
|
|
|
$grdPickerTenant = $script:pickerDialog.FindName("grdPickerTenant")
|
|
if($grdPickerTenant) { $grdPickerTenant.Visibility = "Visible" }
|
|
|
|
$script:cbPickerTenant.Add_SelectionChanged({
|
|
Clear-PickerResults
|
|
$tenant = Get-PickerSelectedTenant
|
|
if($tenant) { Set-PickerStatusText "Search $($tenant.Title) for an object." }
|
|
})
|
|
}
|
|
|
|
# Scope selector - only shown when the caller supplies scopes to search in.
|
|
if($Scopes -and $script:cbPickerScope) {
|
|
$script:cbPickerScope.ItemsSource = Get-UIItemsArray $Scopes
|
|
if($SelectedScopeKey) { $script:cbPickerScope.SelectedValue = $SelectedScopeKey }
|
|
if($null -eq $script:cbPickerScope.SelectedItem) { $script:cbPickerScope.SelectedIndex = 0 }
|
|
|
|
$grdPickerScope = $script:pickerDialog.FindName("grdPickerScope")
|
|
if($grdPickerScope) { $grdPickerScope.Visibility = "Visible" }
|
|
}
|
|
|
|
$script:UIProvider.AddXamlEvent($script:pickerDialog, "btnPickerCancel", "Add_Click", {
|
|
$script:pickerDialog.Close()
|
|
})
|
|
|
|
$script:UIProvider.AddXamlEvent($script:pickerDialog, "btnPickerOK", "Add_Click", $OkHandler)
|
|
|
|
$script:UIProvider.AddXamlEvent($script:pickerDialog, "btnPickerSearch", "Add_Click", $ClickHandler)
|
|
|
|
# Enter in the search box runs the search. Without this the key falls
|
|
# through to the dialog and does nothing, which reads as a broken box.
|
|
$script:pickerSearchHandler = $ClickHandler
|
|
$script:txtPickerSearch.Add_KeyDown({
|
|
param($S, $E)
|
|
|
|
if($E.Key -eq "Return" -and $script:pickerSearchHandler) {
|
|
$E.Handled = $true
|
|
Invoke-Command -ScriptBlock $script:pickerSearchHandler
|
|
}
|
|
})
|
|
|
|
$script:UIProvider.AddXamlEvent($script:pickerDialog, "dgPickerObjects", "Add_SelectionChanged", {
|
|
$script:btnPickerOK.IsEnabled = ($null -ne $this.SelectedItem)
|
|
})
|
|
|
|
if($ClickHandlerArgs) {
|
|
$btnPickerSearch = $script:pickerDialog.FindName("btnPickerSearch")
|
|
if($btnPickerSearch) {
|
|
$btnPickerSearch.Tag = $ClickHandlerArgs
|
|
}
|
|
}
|
|
|
|
$btnPickerLoadAll = $script:pickerDialog.FindName("btnPickerLoadAll")
|
|
if($btnPickerLoadAll -and $LoadHandler -is [scriptblock]) {
|
|
$btnPickerLoadAll.Content = $LoadLabel
|
|
$btnPickerLoadAll.Visibility = "Visible"
|
|
$script:pickerLoadHandler = $LoadHandler
|
|
$btnPickerLoadAll.Add_Click({
|
|
try {
|
|
# Disabled only for the duration - the scope can change, so a
|
|
# second load is legitimate.
|
|
$this.IsEnabled = $false
|
|
Invoke-Command -ScriptBlock $script:pickerLoadHandler
|
|
} catch {
|
|
Write-LogError "Picker LoadHandler failed" $_.Exception
|
|
} finally {
|
|
$this.IsEnabled = $true
|
|
}
|
|
})
|
|
}
|
|
|
|
$script:pickerOKHandler = $OkHandler
|
|
$script:dgPickerObjects.Add_MouseDoubleClick({
|
|
param($S, $E)
|
|
|
|
if($E.ChangedButton -eq "Left" -and $script:btnPickerOK.IsEnabled) {
|
|
Invoke-Command -ScriptBlock $script:pickerOKHandler
|
|
}
|
|
})
|
|
|
|
$script:dgPickerObjects.Add_PreviewKeyDown({
|
|
param($S, $E)
|
|
|
|
if($E.Key -eq 6 -and $script:btnPickerOK.IsEnabled) {
|
|
$E.Handled = $true
|
|
Invoke-Command -ScriptBlock $script:pickerOKHandler
|
|
}
|
|
})
|
|
|
|
$script:pickerDialog.Add_ContentRendered({
|
|
$script:txtPickerSearch.Focus();
|
|
})
|
|
|
|
if($InitHandler -is [scriptblock]) {
|
|
Invoke-Command -ScriptBlock $InitHandler
|
|
}
|
|
|
|
$script:pickerDialog.Owner = $script:window
|
|
|
|
[System.Windows.Forms.Application]::DoEvents()
|
|
$script:pickerDialog.ShowDialog() | Out-Null
|
|
}
|
|
|
|
#endregion
|
|
|
|
# Shared UI settings (AppTheme, HideNoAccess, environment badge, ...) are
|
|
# registered once for both backends in UI/Classes/UICommonSettings.ps1.
|
|
Add-AppEvent "SettingValueUpdated"
|
|
Add-AppEventHandler "SettingValueUpdated" "Invoke-CoreUIEventSettingValueUpdated"
|