# Avalonia counterpart of UI/WPF/Extensions/CoreUI.ps1. # # Threading model: Avalonia is set up on the PowerShell thread. That thread # IS the UI thread, so UI access is direct (no Dispatcher.Invoke marshaling) # and PS scriptblocks attached to events fire on the same thread that # subscribed them — same as WPF in PowerShell. $script:AvaloniaBinDir = Join-Path $script:AppRootFolder 'Bin/Avalonia' $script:AvaloniaInitialized = $false $script:AvaloniaHostType = $null $script:Window = $null # View registry: ports the WPF CoreUI's $script:viewObjects + $script:ActiveView # pair. Show-MainWindow populates the registry by discovering ViewObjectBase # subclasses; Show-View flips ActiveView and reflects the change in the chrome. $script:viewObjects = @() $script:ActiveView = $null function Resolve-NetCoreRefPack { # pwsh ships the reference assemblies for its own runtime in $PSHOME/ref (it # is what Add-Type compiles against by default), and they match the runtime # the script is executing on by construction - including on macOS, where the # main-thread hook runs this code inside the user's pwsh. Mixing them with # a different SDK's references causes Roslyn CS1705 during host compilation, # so this is the first and normal choice on every platform. $bundledRef = Join-Path $PSHOME 'ref' if (Test-Path (Join-Path $bundledRef 'System.Runtime.dll')) { return $bundledRef } # Locate the highest installed Microsoft.NETCore.App.Ref pack so Roslyn # has reference assemblies for compiling the C# host. $dotnet = Get-Command dotnet -ErrorAction SilentlyContinue if (-not $dotnet) { throw "No reference assemblies: $PSHOME/ref is missing and no dotnet SDK was found in PATH." } # Resolve the real dotnet root. On Linux the launcher is usually a symlink # (e.g. /usr/bin/dotnet -> /usr/lib/dotnet/dotnet), so Split-Path on the # symlink path points at /usr/bin, which has no packs/ folder. Build a list # of candidate roots (DOTNET_ROOT, the symlink target dir, the launcher dir) # and pick the first that actually contains the ref pack. $candidateRoots = @() if ($env:DOTNET_ROOT) { $candidateRoots += $env:DOTNET_ROOT } try { $resolved = (Get-Item -LiteralPath $dotnet.Path).ResolveLinkTarget($true) if ($resolved) { $candidateRoots += (Split-Path -Parent $resolved.FullName) } } catch { } $candidateRoots += (Split-Path -Parent $dotnet.Path) $refRoot = $null foreach ($root in ($candidateRoots | Where-Object { $_ } | Select-Object -Unique)) { $candidate = Join-Path $root 'packs/Microsoft.NETCore.App.Ref' if (Test-Path $candidate) { $refRoot = $candidate; break } } if (-not $refRoot) { throw "Microsoft.NETCore.App.Ref pack not found under any of: $($candidateRoots -join ', '). Install the .NET 8+ SDK." } $versionDirs = Get-ChildItem -Path $refRoot -Directory | Where-Object { $_.Name -match '^\d+\.\d+\.\d+' } | Sort-Object { [Version]($_.Name -replace '-.*$','') } -Descending foreach ($vd in $versionDirs) { $refDir = Join-Path $vd.FullName 'ref' if (-not (Test-Path $refDir)) { continue } $tfms = Get-ChildItem -Path $refDir -Directory | Where-Object { $_.Name -match '^net\d+\.\d+$' } | Sort-Object { [Version](($_.Name -replace '^net','')) } -Descending if ($tfms) { return $tfms[0].FullName } } throw "No usable .NET reference assemblies found under $refRoot." } function Initialize-AvaloniaRuntime { if ($script:AvaloniaInitialized) { return } if (-not (Test-Path $script:AvaloniaBinDir)) { throw "Avalonia binaries not found at $($script:AvaloniaBinDir). Run .\UI\Avalonia\Bootstrap/Restore-AvaloniaBinaries.ps1 first." } if ($PSVersionTable.PSEdition -ne 'Core') { throw "Avalonia backend requires PowerShell 7+ (PSEdition=Core). Current edition: $($PSVersionTable.PSEdition)." } $coreAssemblies = @( 'Avalonia.Base.dll', 'Avalonia.Controls.dll', 'Avalonia.Controls.DataGrid.dll', 'Avalonia.Desktop.dll', 'Avalonia.DesignerSupport.dll', 'Avalonia.Dialogs.dll', 'Avalonia.Markup.dll', 'Avalonia.Markup.Xaml.dll', 'Avalonia.Markup.Xaml.Loader.dll', 'Avalonia.Themes.Fluent.dll', 'Avalonia.Fonts.Inter.dll', 'Avalonia.Win32.dll', 'Avalonia.Skia.dll', 'Avalonia.dll' ) $loaded = @() foreach ($name in $coreAssemblies) { $path = Join-Path $script:AvaloniaBinDir $name if (Test-Path $path) { try { Add-Type -Path $path -ErrorAction Stop $loaded += $path } catch { Write-LogDebug "Skipping $name (already loaded or not needed): $($_.Exception.Message)" } } } if (-not $loaded) { throw "Failed to load any Avalonia assemblies from $($script:AvaloniaBinDir)." } $hostSource = [IO.File]::ReadAllText((Join-Path $script:AppUIRootFolder 'Bootstrap/AvaloniaHost.cs')) $bclRefDir = Resolve-NetCoreRefPack $bclRefs = Get-ChildItem -Path $bclRefDir -Filter '*.dll' -File | ForEach-Object FullName $avaloniaRefs = @( 'Avalonia.Base.dll', 'Avalonia.Controls.dll', 'Avalonia.Controls.DataGrid.dll', 'Avalonia.Desktop.dll', 'Avalonia.Markup.Xaml.dll', 'Avalonia.Markup.Xaml.Loader.dll', 'Avalonia.Themes.Fluent.dll', 'Avalonia.dll' ) | ForEach-Object { Join-Path $script:AvaloniaBinDir $_ } | Where-Object { Test-Path $_ } $referenced = ($bclRefs + $avaloniaRefs + $loaded) | Sort-Object -Unique # -WarningAction SilentlyContinue: when the installed .NET SDK ref-pack is # a newer major than what Avalonia.dll references (e.g. SDK 9 vs Avalonia # built against runtime 8), Roslyn emits CS1701 "assuming assembly reference # matches identity" for every Avalonia.* reference. Binding redirects handle # it at runtime; the noise just clutters startup. Add-Type -TypeDefinition $hostSource ` -ReferencedAssemblies $referenced ` -Language CSharp -IgnoreWarnings -ErrorAction Stop ` -WarningAction SilentlyContinue $script:AvaloniaHostType = [IntuneManagement.AvaloniaHost.Host] # IM_THEME_VARIANT env var lets you flip dark/light without editing the # settings JSON. Otherwise prefer the AppTheme setting (the key the # Settings dialog writes) so theme picks selected via the UI survive a # restart. ThemeVariant is the older Avalonia-specific key kept as a # fallback for users who set it directly before AppTheme existed. $themeVariant = if ($env:IM_THEME_VARIANT) { $env:IM_THEME_VARIANT } else { # Get-SettingValue (not raw Get-SettingStoreValue) so tenant-scoped overrides and the # registered default apply - matches how WPF reads AppTheme. $appTheme = Get-SettingValue "AppTheme" "" if ($appTheme) { $appTheme } else { Get-SettingStoreValue "" "ThemeVariant" "Default" } } # Resolve before handing it to the host: "Default" means "follow Windows", and # IntuneManagementApp.Initialize only understands concrete Light/Dark. $script:AvaloniaHostType::Initialize((Resolve-AppTheme $themeVariant)) # Initialize() drops assemblies that Avalonia's XAML type-system walk cannot # enumerate (see Host.SanitizeXamlTypeSystem). Surface what was dropped so a # future "type not found in XAML" report has the list in the log. $dropped = $script:AvaloniaHostType::SanitizeXamlTypeSystem() if ($dropped -and $dropped.Count -gt 0) { Write-LogDebug "XAML type system: skipped $($dropped.Count) unreadable assemblies ($($dropped -join ', '))" } # Theme load order: # 1. Brushes.axaml — ThemeDictionaries (Light/Dark) -> App.Resources.MergedDictionaries # 2. Default.axaml — Style selectors that DynamicResource against those keys # 3. Styles.axaml — Named/class styles # Variant selection is automatic via Application.RequestedThemeVariant; no # conditional Dark.axaml load needed. $themesDir = Join-Path $script:AppUIRootFolder 'Themes' foreach ($leaf in 'Brushes.axaml','Default.axaml','Styles.axaml') { $path = Join-Path $themesDir $leaf if (Test-Path $path) { $script:AvaloniaHostType::LoadStyles($path) } else { Write-LogDebug "Theme file not found: $path" } } $script:AvaloniaInitialized = $true } function Get-AvaloniaHost { if (-not $script:AvaloniaInitialized) { Initialize-AvaloniaRuntime } return $script:AvaloniaHostType } #region XAML helpers (Avalonia) function Get-XamlObject { param($FileName, [switch]$AddVariables, [switch]$AddStyles) $ui = $script:UIProvider if (-not [IO.File]::Exists($FileName)) { Write-Log "Failed to open Xaml file. File not found: $FileName" 3 return $null } $xamlText = [IO.File]::ReadAllText($FileName) $hostType = Get-AvaloniaHost try { $obj = $hostType::LoadXaml($xamlText, ([Uri]("file:///" + ($FileName -replace '\\','/'))).AbsoluteUri) } catch { $inner = $_.Exception while ($inner.InnerException) { $inner = $inner.InnerException } Write-LogError "Failed to load Avalonia XAML $FileName. Error:" $_.Exception throw "Avalonia XAML load failed for $FileName`: $($inner.GetType().FullName): $($inner.Message)" } if ($obj -and $AddVariables) { $ui.AddXamlVariables($xamlText, $obj) } return $obj } function Add-XamlVariables { param($XamlText, $Obj) try { [xml]$Xaml = $XamlText $Xaml.SelectNodes("//*[@*[contains(translate(name(.),'n','N'),'Name')]]") | ForEach-Object { $name = $_.Name if (-not $name) { return } $found = (Get-AvaloniaHost)::FindByName($Obj, $name) New-Variable -Name $name -Value $found -Force -Scope Script } } catch { Write-LogDebug "Add-XamlVariables (Avalonia) skipped: $($_.Exception.Message)" } } function Set-XamlProperty { param($XamlObj, $ControlName, $PropertyName, $Value) try { $Obj = (Get-AvaloniaHost)::FindByName($XamlObj, $ControlName) if ($Obj) { $Obj.$PropertyName = $Value } else { Write-Log "Could not find object with name $ControlName" 3 } } catch { Write-LogError "Failed to set Avalonia property. Control: $ControlName Property: $PropertyName Error:" $_.Exception } } function Get-XamlProperty { param($XamlObj, $ControlName, $PropertyName, $DefaultValue = $null) try { $Obj = (Get-AvaloniaHost)::FindByName($XamlObj, $ControlName) if (-not $Obj) { Write-Log "Could not find object with name $ControlName" 3 return $DefaultValue } $val = $Obj.$PropertyName if ([String]::IsNullOrEmpty($val)) { return $DefaultValue } return $val } catch { Write-LogError "Failed to read Avalonia property. Control: $ControlName Property: $PropertyName Error:" $_.Exception return $DefaultValue } } function Add-XamlEvent { param($XamlObj, [string]$ControlName, [string]$EventName, [scriptblock]$ScriptBlock) try { $Obj = (Get-AvaloniaHost)::FindByName($XamlObj, $ControlName) if (-not $Obj) { Write-Log "Failed to add event $EventName to $ControlName. Control not found" 3 return } # WPF tolerates `$Obj."Add_Click"($scriptBlock)` — the dynamic member # invoke binds the scriptblock as a RoutedEventHandler via PowerShell's # auto-conversion. In Avalonia the same path silently no-ops: the call # returns without subscribing, so click handlers attached this way # never fire. Resolve the accessor explicitly and cast the scriptblock # to the event delegate type before invoking so the subscription # actually lands. Direct add_X({...}) calls elsewhere keep working # untouched because they go through a different binder path. $accessorName = $EventName if ($accessorName -match '^Add_') { $accessorName = 'add_' + $accessorName.Substring(4) } $method = $Obj.GetType().GetMethod($accessorName, [Reflection.BindingFlags]'Public,Instance') if ($method) { # Bind the scriptblock to this module's session state before casting # to a delegate. The PowerShell -> delegate cast otherwise runs the # scriptblock against the runspace's CURRENT session state at click # time (not the module's), which makes $script:* lookups return $null # AND breaks bare module-private function calls like Save-SettingStoreValue, # Get-PoliciesFromFolder, Close-TopModalObject, Write-Log, etc. # NewBoundScriptBlock rebinds without disturbing local-variable # captures from GetNewClosure(). $ScriptBlock = ConvertTo-AvaloniaEventScriptBlock $ScriptBlock $delegateType = $method.GetParameters()[0].ParameterType $delegate = $ScriptBlock -as $delegateType if (-not $delegate) { Write-Log "Failed to bind $EventName scriptblock to $($delegateType.FullName) on $ControlName" 3 return } [void]$method.Invoke($Obj, @($delegate)) } else { $Obj."$EventName"($ScriptBlock) } } catch { Write-LogError "Failed to add Avalonia event $EventName to $ControlName. Error:" $_.Exception } } # Wraps a scriptblock so it runs against IntuneManagement's session state when # invoked as an Avalonia event delegate. Use at every Avalonia event-subscription # call site — Add-XamlEvent calls it automatically, direct .add_X({...}) sites # should wrap their scriptblock with this helper: # # $btn.add_Click( (ConvertTo-AvaloniaEventScriptBlock { # ... # }.GetNewClosure()) ) # # Without the wrap, $script:UIProvider, $script:Window, and bare module-private # function calls (Save-SettingStoreValue, Close-TopModalObject, Get-PoliciesFromFolder, # Get-MigrationTableInfo, ...) all fail at click time because the scriptblock # is invoked against the runspace's CURRENT session state, not the module's. function ConvertTo-AvaloniaEventScriptBlock { [CmdletBinding()] param([Parameter(Mandatory)][scriptblock]$ScriptBlock) $module = Get-Module IntuneManagement if (-not $module) { return $ScriptBlock } return $module.NewBoundScriptBlock($ScriptBlock) } #endregion #region App lifecycle (Avalonia) function Initialize-UI { Initialize-AvaloniaRuntime } function Show-MainWindow { param($View) $ui = $script:UIProvider Initialize-AvaloniaRuntime $mainXaml = Join-Path $script:AppUIRootFolder 'XAML/MainWindow.axaml' $script:Window = $ui.GetXamlObject($mainXaml, $true) if (-not $script:Window) { throw "Failed to load Avalonia MainWindow.axaml" } # The XAML is authored for the Windows chrome (extended client area + system # caption buttons). Linux/macOS need that adapted before the window is shown. Set-MainWindowChrome $script:Window # Maximized windows with ExtendClientAreaToDecorationsHint hang over the # screen edge by the resize-border thickness plus the invisible "padded # border" (standard Win32 behaviour - normally the OS chrome absorbs it, # but with extended client area the CONTENT gets clipped a few pixels all # around). Window.OffScreenMargin under-reports by the padded border, so # compute the real overhang from SM_CXSIZEFRAME(32) + SM_CXPADDEDBORDER(92) # at the window's DPI and use whichever is larger. if ($script:IsWindowsOS -and -not ("IMMaximizeMetrics" -as [type])) { Add-Type -TypeDefinition @' using System.Runtime.InteropServices; public static class IMMaximizeMetrics { [DllImport("user32.dll")] private static extern int GetSystemMetricsForDpi(int nIndex, uint dpi); [DllImport("user32.dll")] private static extern int GetSystemMetrics(int nIndex); public static int GetOverhangPixels(double scaling) { try { uint dpi = (uint)(96.0 * scaling); return GetSystemMetricsForDpi(32, dpi) + GetSystemMetricsForDpi(92, dpi); } catch { return GetSystemMetrics(32) + GetSystemMetrics(92); } } } '@ } $script:Window.add_PropertyChanged({ param($S, $E) # Win32-only: IMMaximizeMetrics P/Invokes user32, and off-Windows there is # no extended client area to correct for (Set-MainWindowChrome disabled it). if (-not $script:IsWindowsOS) { return } if ($E.Property.Name -notin @('WindowState', 'OffScreenMargin')) { return } try { if (-not $S.Content) { return } if ($S.WindowState -eq [Avalonia.Controls.WindowState]::Maximized) { $dip = [IMMaximizeMetrics]::GetOverhangPixels($S.RenderScaling) / $S.RenderScaling $dip = [Math]::Max($dip, $S.OffScreenMargin.Left) $S.Content.Margin = [Avalonia.Thickness]::new($dip) } else { $S.Content.Margin = $S.OffScreenMargin } } catch { } }) # Capture overlay refs that Update-UIStatus / Show-Popup poke at by # script-scoped name. Add-XamlVariables (the -AddVariables sweep) only # synthesizes script vars when PowerShell's XML adapter projects the # Name attribute as a property, which it doesn't on every node — keep # the explicit assignments to mirror the WPF Show-MainWindow flow. $hostType = Get-AvaloniaHost $script:grdStatus = $hostType::FindByName($script:Window, 'grdStatus') $script:txtInfo = $hostType::FindByName($script:Window, 'txtInfo') $script:txtInfoDetail = $hostType::FindByName($script:Window, 'txtInfoDetail') # Wired once here rather than per Write-Status call: the handler is fixed, only # the armed action changes (Internal/StatusCancel.ps1 holds that). Calling the # module function BY NAME is also what keeps this legal - a handler built by # ConvertTo-AvaloniaEventScriptBlock loses function-local captures. $script:btnStatusCancel = $hostType::FindByName($script:Window, 'btnStatusCancel') if($script:btnStatusCancel) { $script:btnStatusCancel.add_Click((ConvertTo-AvaloniaEventScriptBlock { param($S, $E) try { Write-Log "Avalonia status Cancel click dispatched" $S.IsEnabled = $false $S.Content = "Cancelling..." Request-StatusCancel } catch { Write-LogError "Avalonia status Cancel handler failed" $_.Exception } })) } Set-CacheObject "ShowUI" $true # Discover ViewObjectBase subclasses, instantiate as singletons, register # the ones that opt into the menu. Mirrors the WPF Show-MainWindow flow. $allViews = @(Get-SubClasses ([ViewObjectBase]) | ForEach-Object { try { Get-SingletonObject $_ } catch { Write-LogError "Failed to instantiate view $($_.Name):" $_.Exception } } | Where-Object { $null -ne $_ }) foreach ($vo in ($allViews | Where-Object { $_.AddToMenu })) { # The Avalonia Preview sandbox view is a porting aid, not a product # view - keep it out of the Views menu unless explicitly requested. if ($vo -is [PreviewViewObject] -and $env:IM_AVALONIA_PREVIEW -ne '1') { continue } Add-ViewObject $vo } Build-ViewsMenu # Wire chrome handlers that need no view context: Exit closes the window # and ends the dispatcher loop. Other File-menu items remain stubs until # their dialogs are ported. $ui.AddXamlEvent($script:Window, 'mnuExit', 'Add_Click', ({ # WPF confirms before exiting; Avalonia closed immediately. if ((Show-MessageBox -Text "Are you sure you want to exit?" -Caption "Exit?" -Button "YesNo" -Icon "Question") -eq "Yes") { $script:Window.Close() } })) $ui.AddXamlEvent($script:Window, 'mnuAbout', 'Add_Click', ({ try { $ui.ShowAboutDialog() } catch { Write-LogError "Show-AboutDialog failed" $_.Exception } })) $ui.AddXamlEvent($script:Window, 'mnuSettings', 'Add_Click', ({ try { Show-SettingsForm } catch { Write-LogError "Show-SettingsForm failed" $_.Exception } })) $ui.AddXamlEvent($script:Window, 'mnuTenantSettings', 'Add_Click', ({ try { Show-SettingsForm -Tenant } catch { Write-LogError "Show-SettingsForm -Tenant failed" $_.Exception } })) $ui.AddXamlEvent($script:Window, 'mnuUpdates', 'Add_Click', ({ try { Show-ReleaseNotes } catch { Write-LogError 'Show-ReleaseNotes failed' $_.Exception } })) # Tenant Settings depends on an active session — disable it when signed # out. Re-evaluate on File-menu open (covers the steady state) and on auth # events (so the user doesn't have to close + reopen File after sign-in # to see the state flip). Mirrors WPF Update-AuthDependentMenuState wire. $ui.AddXamlEvent($script:Window, 'mnuFile', 'Add_SubmenuOpened', ({ try { Update-AuthDependentMenuState } catch { Write-LogError 'Update-AuthDependentMenuState failed on submenu open' $_.Exception } })) Add-AppEventHandler 'AuthenticatedNewToken' 'Update-AuthDependentMenuState' Add-AppEventHandler 'AuthenticationUserDisconnected' 'Update-AuthDependentMenuState' # Title-bar + taskbar icon. Asset is copied locally (UI/Avalonia/Assets/) # rather than referenced from UI/WPF/ — keeps the port self-contained. Set-MainWindowIcon # Apply the immersive-dark-mode title bar to match the saved AppTheme. # Window.Opened fires after the HWND exists; calling Set-WindowTitleBarTheme # before that is a no-op (TryGetPlatformHandle returns null). Window.Opened # is also where the first-run welcome modal goes — before then the modal # host (grdModal) hasn't been laid out so the overlay can't display. $script:Window.add_Opened((ConvertTo-AvaloniaEventScriptBlock { param($S, $E) $theme = Get-SettingValue "AppTheme" "Default" Set-WindowTitleBarTheme $theme if ((Get-SettingStoreValue "" "FirstTimeRunning" "true") -eq "true") { try { Show-WelcomeDialog } catch { Write-LogError 'Show-WelcomeDialog failed' $_.Exception } } # Stacks on top of the welcome modal on a first run off Windows, so the # experimental warning is the first thing read and the first dismissed. try { Show-ExperimentalPlatformNotice } catch { Write-LogError 'Show-ExperimentalPlatformNotice failed' $_.Exception } })) # Tell Windows this process is the Intune Management app so the taskbar # button gets grouped under its own AUMID instead of being merged with # "Windows PowerShell". Must run before the window is shown. Set-TaskbarAppId $ui.AddXamlEvent($script:Window, 'borderTitleBar', 'Add_PointerPressed', ({ param($s, $e) # Skip drag/maximize when the click originated on an interactive control # inside the title bar. Avalonia's Menu/MenuItem don't mark # PointerPressed as handled when bubbling, so without this filter the # File-menu submenu would close immediately as soon as BeginMoveDrag # starts a window drag. $src = $e.Source while ($src -and $src -ne $s) { if ($src -is [Avalonia.Controls.Button] -or $src -is [Avalonia.Controls.MenuItem] -or $src -is [Avalonia.Controls.Menu]) { return } $src = $src.Parent } if ($e.ClickCount -eq 2) { $script:Window.WindowState = if ($script:Window.WindowState -eq [Avalonia.Controls.WindowState]::Maximized) { [Avalonia.Controls.WindowState]::Normal } else { [Avalonia.Controls.WindowState]::Maximized } } else { $script:Window.BeginMoveDrag($e) } })) # Selecting a left-nav item routes through the active view, mirroring the # WPF wire-up. Views that don't override OnItemChanged inherit the # ViewObjectBase no-op, so this is safe even before specific views are # ported. $ui.AddXamlEvent($script:Window, 'lstMenuItems', 'Add_SelectionChanged', ({ param($S, $E) # Suppressed while the Intune view's count-label refresh rebinds the # ItemsSource and restores the selection — without this guard, the # restore would retrigger a full Graph reload of the current type. if ($script:_suppressMenuSelectionEvents) { return } # Show-ViewMenu interleaves Category-header rows in the items list to # mimic WPF's . Skip them — the ListBoxItem style # already disables hit-test + focus on headers, but a programmatic # SelectedItem assignment (e.g., Add-ViewMenu's "select by Id" loop) # could still land on one. $sel = $S.SelectedItem if ($sel -and $sel.PSObject.Properties['IsHeader'] -and $sel.IsHeader) { return } if ($script:ActiveView) { try { $script:ActiveView.OnItemChanged($sel) | Out-Null } catch { Write-LogError "OnItemChanged threw on view $($script:ActiveView.Id):" $_.Exception } } })) Show-View $View # Startup update check, matching the WPF backend. The version comparison # itself lives in Internal/AppUpdateCheck.ps1 so both backends share one # implementation; only the notification differs. Wrapped because a failed # network call must never stop the app from starting. if ((Get-SettingValue 'CheckForUpdates') -eq $true) { try { $updateInfo = Get-AppUpdateInfo if ($updateInfo.IsOutdated) { Show-MessageBox ("There is a new version available on GitHub $($updateInfo.RemoteVersion.ToString())" + [Environment]::NewLine + [Environment]::NewLine + "Current version is $($updateInfo.LocalVersion.ToString())") 'Old version!' 'OK' 'Warning' | Out-Null } } catch { Write-LogError 'Update check failed' $_.Exception } } # Populate the title-bar profile slot before showing the window. WPF does # this in Window.Loaded; we call it pre-RunMainWindow so the avatar / # Sign-in button is already in grdMenu when the window appears (no flash # of empty title bar). Get-MSALUserInfo handles all three startup states # and internally calls Show-AuthenticationInfo + Set-EnvironmentInfo. if (Get-Command Get-MSALUserInfo -ErrorAction SilentlyContinue) { try { Get-MSALUserInfo } catch { Write-LogError 'Initial Get-MSALUserInfo failed' $_.Exception } } else { # Fallback in case the auth UI file didn't load — at least render the # Sign-in button so the user has *something* to click. try { $ui.ShowAuthenticationInfo() } catch { Write-LogError 'Initial Show-AuthenticationInfo failed' $_.Exception } } # Blocks running the dispatcher loop on this thread until the last # window closes. This is Avalonia's normal app entry point. (Get-AvaloniaHost)::RunMainWindow($script:Window) | Out-Null } #region View system (Avalonia) 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 Get-CurrentViewObject { $script:ActiveView } function Add-MenuItem { param($MenuItem, $Index) if (-not $script:mnuMain) { Write-Log "Add-MenuItem: mnuMain not available yet" 3 return } $script:mnuMain.Items.Insert($Index, $MenuItem) | Out-Null } function Show-View { param($ViewId = "IntuneManagement") $ui = $script:UIProvider if (($script:viewObjects | Measure-Object).Count -eq 0) { Write-Log "No View Objects loaded!" 3 return } if (-not $ViewId) { $ViewId = $script:viewObjects[0].Id } if ($script:ActiveView -and $script:ActiveView.ID -eq $ViewId) { return } $viewObject = Get-ViewObject $ViewId if (-not $viewObject) { # Common during the port: callers pass the WPF default # "IntuneManagement" which Avalonia hasn't ported yet. Prefer # IntuneTools (real Intune surface) over PreviewViewObject (sandbox # demo) so the first impression is the actual app, not the demo. $preferred = @('IntuneTools') foreach ($pref in $preferred) { $viewObject = $script:viewObjects | Where-Object { $_.Id -eq $pref } | Select-Object -First 1 if ($viewObject) { break } } if (-not $viewObject) { $viewObject = $script:viewObjects[0] } Write-Log "Falling back to view: $($viewObject.Id)" 2 } Write-Log "Change view to $($viewObject.Title)" if ($script:ActiveView) { Write-LogDebug "Deactivating View $($script:ActiveView.Title)" $script:ActiveView.OnDeactivating($viewObject) } $previousView = $script:ActiveView $script:ActiveView = $viewObject Show-ViewMenu if ($script:lblMenuTitle) { $script:lblMenuTitle.Content = $viewObject.Title } if ($script:grdViewPanel) { $script:grdViewPanel.Children.Clear() } Write-LogDebug "Activating View $($viewObject.Title)" $viewObject.OnActivating($previousView) $panel = $viewObject.ViewPanel if ($panel -and $script:grdViewPanel) { $script:grdViewPanel.Children.Add($panel) | Out-Null } $ui.SetMainTitle() # WPF Visibility -> Avalonia IsVisible (boolean). if ($script:grdViewItemMenu) { $script:grdViewItemMenu.IsVisible = -not $viewObject.HideMenu } $viewObject.OnActivated() } function Build-ViewsMenu { # Populates the title-bar Views submenu with one MenuItem per registered # view (clicking → Show-View). Views with ExpandInViewsMenu also surface # their items as nested children (click → activate view + select the row # in the left nav). Direct port of the WPF builder in # UI/WPF/Extensions/CoreUI.ps1 ~1011-1098. if (-not $script:mnuViews) { $script:mnuViews = (Get-AvaloniaHost)::FindByName($script:Window, 'mnuViews') } if (-not $script:mnuViews) { return } $script:mnuViews.Items.Clear() $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 = [Avalonia.Controls.MenuItem]::new() $subItem.Header = $view.Title $subItem.Tag = $view.Id $subItem.add_Click((ConvertTo-AvaloniaEventScriptBlock { param($S, $E) if ($S.Tag) { Show-View $S.Tag } })) if ($view.ExpandInViewsMenu) { try { $childItems = @($view.GetViewItems()) if ($childItems.Count -gt 0) { # Sort by Category then Title so the visual layout matches # the left-nav (which Show-ViewMenu also sorts the same way). $sorted = $childItems | Sort-Object @{Expression='Category'}, @{Expression='Title'} $lastCat = $null foreach ($child in $sorted) { $cat = if ($child.Category) { [string]$child.Category } else { $null } if ($null -ne $lastCat -and $cat -ne $lastCat) { [void]$subItem.Items.Add([Avalonia.Controls.Separator]::new()) } $lastCat = $cat $childMenu = [Avalonia.Controls.MenuItem]::new() $childMenu.Header = $child.Title $childMenu.Tag = [PSCustomObject]@{ ViewId = $view.Id; ItemId = $child.Id } $childMenu.add_Click((ConvertTo-AvaloniaEventScriptBlock { param($S, $E) $info = $S.Tag if (-not $info) { return } Show-View $info.ViewId if ($script:lstMenuItems) { foreach ($it in @($script:lstMenuItems.Items)) { if ($it -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)" } } $script:mnuViews.Items.Add($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) { $script:mnuViews.Items.Add([Avalonia.Controls.Separator]::new()) | Out-Null } foreach ($view in $bottom) { & $addViewMenuItem $view } } } function Show-ViewMenu { if (-not $script:lstMenuItems) { return } $items = @($script:ActiveView.GetViewItems()) # WPF used ListCollectionView + PropertyGroupDescription("Category") to # render an expandable-group tree under . Avalonia's # ListBox has no GroupStyle equivalent (Avalonia 11), so we emulate the # WPF appearance by interleaving non-selectable [ViewMenuItem] header # rows (IsHeader=$true) between the leaves of each category. The # MainWindow.axaml DataTemplate branches on IsHeader to render either # the SemiBold category title or the icon+MenuLabel leaf. We lose the # WPF expand/collapse affordance, but WPF defaulted to IsExpanded=True # so the visual at rest is the same. $hasCategory = $false foreach ($it in $items) { if ($it -and $it.Category) { $hasCategory = $true; break } } if ($hasCategory) { # Build header+leaf interleaved list. Stable category ordering: # categories appear in the order their first item was returned by # GetViewItems, then alphabetical within each. Empty Category items # land in a leading "(Other)" group so nothing disappears silently. $grouped = [ordered]@{} foreach ($it in $items) { if (-not $it) { continue } $cat = if ($it.Category) { [string]$it.Category } else { '(Other)' } if (-not $grouped.Contains($cat)) { $grouped[$cat] = New-Object 'System.Collections.Generic.List[object]' } $grouped[$cat].Add($it) } $flat = New-Object 'System.Collections.Generic.List[object]' # Snapshot keys so we don't iterate the live OrderedDictionary key # collection. Use ::new() + property assignment instead of the # [ViewMenuItem]@{...} hashtable cast — the cast resolves overloads # dynamically on every call and intermittently throws # "Argument types do not match" when a property has a default value # ([bool]$IsHeader = $false) but the hashtable supplies a different # value. Explicit assignment sidesteps that. foreach ($cat in @($grouped.Keys)) { $header = [ViewMenuItem]::new() $header.Title = [string]$cat $header.IsHeader = $true [void]$flat.Add($header) $sortedItems = $grouped[$cat] | Sort-Object -Property Title foreach ($it in $sortedItems) { [void]$flat.Add($it) } } $items = $flat.ToArray() } $script:lstMenuItems.ItemsSource = $items # Per-view title-config button. Default to hidden; 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. Mirrors CoreUIWPF's Show-ViewMenu hook. if ($script:btnMenuTitleConfig) { $script:btnMenuTitleConfig.IsVisible = $false } if ($script:ActiveView -and $script:ActiveView.ID -eq 'IntuneManagement') { try { Update-AvaloniaMenuTitleConfigForIntuneView } catch { Write-LogDebug "Update-AvaloniaMenuTitleConfigForIntuneView failed: $($_.Exception.Message)" } } } function Show-WelcomeDialog { # First-run license-accept gate. Loads Welcome.axaml as a modal with hidden # default Close button (the form supplies its own OK / Cancel pair). The # checkbox toggles the OK button; OK saves LicenseAccepted=True and # FirstTimeRunning=False then dismisses; Cancel prompts to close the app. $ui = $script:UIProvider $welcome = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/Welcome.axaml')) if (-not $welcome) { return } # Open-ExternalUri by name - a $launchUri closure here would be stripped # by ConvertTo-AvaloniaEventScriptBlock and resolve to nothing at click # time (the documented .GetNewClosure() trap). $ui.AddXamlEvent($welcome, 'gitHubLink', 'Add_Click', ((ConvertTo-AvaloniaEventScriptBlock { Open-ExternalUri 'https://github.com/Micke-K/IntuneManagement' }))) $ui.AddXamlEvent($welcome, 'licenseLink', 'Add_Click', ((ConvertTo-AvaloniaEventScriptBlock { Open-ExternalUri 'https://github.com/Micke-K/IntuneManagement/blob/master/LICENSE' }))) $chkAccept = (Get-AvaloniaHost)::FindByName($welcome, 'chkAcceptConditions') $btnOk = (Get-AvaloniaHost)::FindByName($welcome, 'btnAcceptConditions') if ($chkAccept -and $btnOk) { # Avalonia's CheckBox raises IsCheckedChanged (StyledProperty change); # Click also fires on toggle and is simpler from PS. # The OK button rides on the checkbox's Tag: a captured $btnOk local is # stripped by ConvertTo-AvaloniaEventScriptBlock, which left the accept # checkbox inert and the OK button permanently disabled - the first-run # dialog could never be completed. $chkAccept.Tag = $btnOk $chkAccept.add_Click((ConvertTo-AvaloniaEventScriptBlock { param($S, $E) if ($S.Tag) { $S.Tag.IsEnabled = [bool]$S.IsChecked } })) } $ui.AddXamlEvent($welcome, 'btnAcceptConditions', 'Add_Click', ({ Save-SettingStoreValue "" "FirstTimeRunning" "False" Show-ModalObject })) $ui.AddXamlEvent($welcome, 'btnCancel', 'Add_Click', ({ if (($ui.ShowMessageBox("Conditions not accepted`n`nDo you want to close the application?", 'Close App?', 'YesNo', 'Warning')) -eq 'Yes') { $script:Window.Close() } })) $ui.ShowModalForm($script:Window.Title, $welcome, $true) } function Show-ReleaseNotes { # Avalonia port of the WPF Show-UpdatesDialog. Prefers the published notes so # the dialog answers "what changed upstream", falling back to the local copy # when GitHub is unreachable. Fetch + version comparison live in # Internal/AppUpdateCheck.ps1, shared with WPF (R10). WPF renders the version # banner in a separate tab; here it is prepended as a header line, since the # Avalonia dialog is a single TextBox. $ui = $script:UIProvider $releaseNotesPath = Join-Path $script:AppRootFolder 'ReleaseNotes.md' $localNotes = if (Test-Path -LiteralPath $releaseNotesPath) { [IO.File]::ReadAllText($releaseNotesPath) } else { "ReleaseNotes.md not found at $releaseNotesPath." } $remote = $null $header = $null try { $remote = Get-AppRemoteReleaseNotes $info = Get-AppUpdateInfo if ($info.IsOutdated) { $header = "A newer version is available on GitHub: $($info.RemoteVersion.ToString()) (installed: $($info.LocalVersion.ToString()))" } elseif ($info.Resolved) { $header = "Running the latest version: $($info.LocalVersion.ToString())" } } catch { Write-LogError 'Failed to get release notes from GitHub' $_.Exception } $body = if ($remote -and $remote.Text) { $remote.Text } else { $localNotes } $content = if ($header) { $header + [Environment]::NewLine + ('-' * 72) + [Environment]::NewLine + [Environment]::NewLine + $body } else { $body } # Rendered rather than shown as raw text: Set-AvaloniaMarkdownText turns the # headings, bullets and **bold** spans into TextBlock inlines. See # UI/Avalonia/Extensions/MarkdownRenderAvalonia.ps1. A TextBlock (not TextBox) # is required - only TextBlock exposes Inlines - so it needs an explicit # ScrollViewer around it, unlike the TextBox this replaced. $tb = [Avalonia.Controls.TextBlock]::new() $tb.TextWrapping = [Avalonia.Media.TextWrapping]::Wrap $tb.MaxWidth = 900 Set-AvaloniaMarkdownText $tb $content $scroll = [Avalonia.Controls.ScrollViewer]::new() $scroll.Content = $tb $scroll.MinWidth = 700 $scroll.MinHeight = 500 $scroll.VerticalScrollBarVisibility = [Avalonia.Controls.Primitives.ScrollBarVisibility]::Auto $scroll.HorizontalScrollBarVisibility = [Avalonia.Controls.Primitives.ScrollBarVisibility]::Disabled $ui.ShowModalForm('Release Notes', $scroll) } function Set-MainWindowIcon { if (-not $script:Window) { return } $iconPath = Join-Path $script:AppUIRootFolder 'Assets/intune.png' if (-not (Test-Path -LiteralPath $iconPath)) { Write-LogDebug "Window icon not found at $iconPath" return } try { $bitmap = [Avalonia.Media.Imaging.Bitmap]::new($iconPath) # Window.Icon expects an Avalonia.Controls.WindowIcon (constructed from a Bitmap). $script:Window.Icon = [Avalonia.Controls.WindowIcon]::new($bitmap) if ($script:imgTitleIcon) { $script:imgTitleIcon.Source = $bitmap } } catch { Write-LogError 'Failed to load window icon' $_.Exception } } function Update-AuthDependentMenuState { # Toggles Tenant Settings IsEnabled based on whether any auth provider has # a user. Called from File-menu open and from AuthenticatedNewToken / # AuthenticationUserDisconnected so the state stays current. EventArg is # the AppEvent payload — unused, but the handler signature accepts it. [CmdletBinding()] param($EventArg) $ui = $script:UIProvider if (-not $script:Window) { return } $signedIn = $false try { $provider = Get-AuthProvider if ($provider -and $provider.GetUserInfo(0)) { $signedIn = $true } } catch { } try { $ui.SetXamlProperty($script:Window, 'mnuTenantSettings', 'IsEnabled', $signedIn) } catch { Write-LogDebug "Update-AuthDependentMenuState: $($_.Exception.Message)" } } function Set-TaskbarAppId { # Direct port of the WPF AUMID logic. Without this the taskbar groups the # window under powershell.exe and uses the PowerShell icon regardless of # WM_SETICON. SetCurrentProcessExplicitAppUserModelID must be called # before the window's HWND is created (i.e. before RunMainWindow). # shell32.dll is Windows-only; the X11/Cocoa backends group windows by their # own .desktop/app identity, so this is a no-op elsewhere. if (-not $script:IsWindowsOS) { return } if (-not ([System.Management.Automation.PSTypeName]'AppUserModelHelper').Type) { try { 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 } catch { Write-LogDebug "AUMID type compile failed: $($_.Exception.Message)" return } } try { [void][AppUserModelHelper]::SetCurrentProcessExplicitAppUserModelID('Intune.IntuneManagement') } catch { Write-LogDebug "AUMID set failed: $($_.Exception.Message)" } } function Set-AppTheme { # Runtime theme variant switch — called from the Settings dialog when the # user changes the AppTheme preference. Avalonia's theme system reacts to # Application.RequestedThemeVariant; setting it re-resolves every # {DynamicResource ...} that points at Brushes.axaml's ThemeDictionaries. # Also re-applies the Win32 immersive-dark-mode title bar so the OS-painted # caption buttons match (without this they stay light on the dark title strip). param([string]$ThemeName) # An empty name is NOT a no-op: fall through so Resolve-AppTheme reads the # stored AppTheme setting. Returning here used to swallow the call silently # whenever a caller lost the argument, applying nothing and logging nothing. if (-not $ThemeName) { Write-LogDebug "Set-AppTheme called with no theme name, falling back to the AppTheme setting" } $app = [Avalonia.Application]::Current if (-not $app) { Write-LogDebug "Set-AppTheme: no Avalonia Application (headless), nothing to theme" return } # Resolve "Default" to a concrete Light/Dark from the Windows app theme rather # than handing Avalonia ThemeVariant::Default. Brushes.axaml only defines Light # and Dark dictionaries, so ThemeVariant::Default depends on toolkit fallback # behaviour instead of the user's OS setting - and would disagree with WPF, # which resolves the same setting through Resolve-AppTheme. $resolved = Resolve-AppTheme $ThemeName $variant = if ($resolved -ieq 'Dark') { [Avalonia.Styling.ThemeVariant]::Dark } else { [Avalonia.Styling.ThemeVariant]::Light } try { $app.RequestedThemeVariant = $variant } catch { Write-LogError "Set-AppTheme failed for '$ThemeName' (resolved '$resolved')" $_.Exception } # The Win32 caption buttons follow the RESOLVED theme, not the setting name - # passing "Default" here would leave light caption buttons on a dark strip. Set-WindowTitleBarTheme $resolved } function Set-WindowTitleBarTheme { # Toggle the Win32 immersive-dark-mode flag on the window's title bar so the # OS-painted caption buttons (min/max/close — kept via PreferSystemChrome) # render in the right palette for our theme. Silent no-op on pre-1903 OS # builds (DwmSetWindowAttribute returns non-zero; we ignore it). param([string]$ThemeName) if (-not $script:Window) { return } # dwmapi.dll is Windows-only. On X11/Cocoa the caption buttons aren't # OS-painted via DWM, so there's nothing to toggle. if (-not $script:IsWindowsOS) { return } try { 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; } "@ -ErrorAction Stop } # HWND is only valid after Window.Opened. Caller wires the initial # invocation via the Opened event; subsequent Set-AppTheme calls land # here while the window is alive so the handle is always populated. $handle = $script:Window.TryGetPlatformHandle() if (-not $handle) { return } $hwnd = $handle.Handle if ($hwnd -eq [IntPtr]::Zero) { return } $darkMode = if ($ThemeName -ieq 'Dark') { 1 } else { 0 } [void][DwmTitleBarHelper]::DwmSetWindowAttribute( $hwnd, [DwmTitleBarHelper]::DWMWA_USE_IMMERSIVE_DARK_MODE, [ref]$darkMode, 4) } catch { Write-LogDebug "Set-WindowTitleBarTheme failed: $($_.Exception.Message)" } } function Set-MainTitle { param($Title) if (-not $Title -and $script:ActiveView) { $Title = $script:ActiveView.Title } if (-not $Title) { return } # Set both the centered title-bar label (visible) and the OS window title # (taskbar caption / Alt+Tab text). WPF Set-MainTitle keeps these in sync. if ($script:Window) { $script:Window.Title = [string]$Title } if ($script:txtTitleViewName) { $script:txtTitleViewName.Text = [string]$Title } } function Get-MainWindow { $script:Window } #endregion function Show-InputDialog { param( $FormTitle = "Input", $FormText, $DefaultValue ) $ui = $script:UIProvider Initialize-AvaloniaRuntime $dialogXaml = Join-Path $script:AppUIRootFolder 'XAML/InputDialog.axaml' $dialog = $ui.GetXamlObject($dialogXaml) if (-not $dialog) { return } $dialog.Title = $FormTitle $ui.SetXamlProperty($dialog, 'txtLabel', 'Content', $FormText) $ui.SetXamlProperty($dialog, 'txtValue', 'Text', $DefaultValue) $txtValue = (Get-AvaloniaHost)::FindByName($dialog, 'txtValue') # Module scope: handler captures are stripped, so OK/Cancel could not # close the dialog and Cancel could not blank the value. $script:_inputDialog = $dialog $script:_inputDialogTxt = $txtValue $ui.AddXamlEvent($dialog, 'btnOk', 'Add_Click', ({ if ($script:_inputDialog) { $script:_inputDialog.Close() } })) $ui.AddXamlEvent($dialog, 'btnCancel', 'Add_Click', ({ if ($script:_inputDialogTxt) { $script:_inputDialogTxt.Text = '' } if ($script:_inputDialog) { $script:_inputDialog.Close() } })) (Get-AvaloniaHost)::ShowDialog($dialog, $script:Window) $value = if ($txtValue) { $txtValue.Text } else { '' } $script:_inputDialog = $null $script:_inputDialogTxt = $null return $value } #endregion #region Modal host (Avalonia) # WPF used WindowsForms Application.DoEvents() to flush the message pump after # mounting/unmounting modal content. Avalonia's dispatcher loop runs # continuously on this thread (see Initialize-AvaloniaRuntime), so the visual # tree updates without an explicit pump call. function Show-ModalForm { param( $FormTitle = "", $FormObject, [switch]$HideButtons) $ui = $script:UIProvider $modalForm = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/ModalForm.axaml')) if (-not $modalForm) { return } if ($HideButtons) { $ui.SetXamlProperty($modalForm, 'spButtons', 'IsVisible', $false) } else { $closeButton = (Get-AvaloniaHost)::FindByName($modalForm, 'btnClose') if ($closeButton) { $closeButton.Tag = $FormObject } $ui.AddXamlEvent($modalForm, 'btnClose', 'Add_Click', ({ param($S, $E) $formObject = $S.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 })) } $ui.SetXamlProperty($modalForm, 'txtTitle', 'Text', $FormTitle) $grdModalContainer = (Get-AvaloniaHost)::FindByName($modalForm, 'grdModalContainer') if ($grdModalContainer -and $FormObject) { [Avalonia.Controls.Grid]::SetRow($FormObject, 1) $grdModalContainer.Children.Add($FormObject) | Out-Null } $ui.ShowModalObject($modalForm) } function Show-ModalObject { param( $Obj ) if (-not $script:grdModal) { return } if ($Obj) { [Avalonia.Controls.Grid]::SetRow($Obj, 1) [Avalonia.Controls.Grid]::SetColumn($Obj, 1) $script:grdModal.Children.Add($Obj) | Out-Null $script:grdModal.IsVisible = $true } else { $script:grdModal.Children.Clear() $script:grdModal.IsVisible = $false } } function Close-TopModalObject { if ($script:grdModal -and $script:grdModal.Children.Count -gt 0) { $script:grdModal.Children.RemoveAt($script:grdModal.Children.Count - 1) $script:grdModal.IsVisible = ($script:grdModal.Children.Count -gt 0) } } #endregion #region Status functions function Update-UIStatus { param($Text, $Detail, [switch]$SkipLog, [switch]$Block, [switch]$Force, $CancelText) $ui = $script:UIProvider $hasText = $PSBoundParameters.ContainsKey('Text') $hasDetail = $PSBoundParameters.ContainsKey('Detail') if($hasText -and -not $Text) { $script:BlockStatusUpdates = $false } elseif($script:BlockStatusUpdates -eq $true -and $Force -ne $true) { return $true } elseif($Block -eq $true) { $script:BlockStatusUpdates = $true } if($hasText -and $script:txtInfo) { $script:txtInfo.Text = $Text if(-not $hasDetail -and $script:txtInfoDetail) { $script:txtInfoDetail.Text = "" $script:txtInfoDetail.IsVisible = $false } } if($hasDetail -and $script:txtInfoDetail) { $script:txtInfoDetail.Text = $Detail $script:txtInfoDetail.IsVisible = [bool]$Detail } # 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.IsEnabled = $true $script:btnStatusCancel.IsVisible = $true } else { $script:btnStatusCancel.IsVisible = $false } } if($script:grdStatus) { if(($hasText -and $Text) -or (-not $hasText -and $script:txtInfo -and $script:txtInfo.Text)) { $script:grdStatus.IsVisible = $true if($SkipLog -ne $true) { if($hasText -and $Text) { Write-Log $Text } if($hasDetail -and $Detail) { Write-Log $Detail } } # Avalonia coalesces invalidations and only paints when the # PowerShell UI thread returns to the dispatcher. Long-running # synchronous work (Graph PATCH, file I/O) blocks that return, # and any later Write-Status "" toggles IsVisible back off # before a single paint happens — net effect: dimming overlay # never appears. RunJobs drains queued layout/render passes on # this thread (Avalonia's nested-pump equivalent of WPF's # DoEvents) so the overlay actually paints before the caller # resumes its blocking work. $ui.InvokeUIMessagePump() } elseif($hasText -and -not $Text) { $script:grdStatus.IsVisible = $false if($script:txtInfoDetail) { $script:txtInfoDetail.Text = "" $script:txtInfoDetail.IsVisible = $false } if($script:btnStatusCancel) { $script:btnStatusCancel.IsVisible = $false } $ui.InvokeUIMessagePump() } return $true } return $false } function Invoke-UIMessagePump { # Enter a short nested platform/dispatcher loop. RunJobs() was sufficient to # repaint but did not reliably ingest native pointer input while synchronous # PowerShell owned the UI thread, leaving the visible status Cancel button dead. try { (Get-AvaloniaHost)::PumpEventsOnce(10) } catch { # Pre-init or shutdown — pump unavailable. Keep status updates non-fatal, # but leave evidence for any unexpected runtime/API failure. Write-LogDebug "Avalonia event pump unavailable: $($_.Exception.Message)" } } function Request-UIConfirmation { param([string]$Message, [string]$Caption = "Confirm") $ui = $script:UIProvider return (($ui.ShowMessageBox($Message, $Caption, "YesNo", "Question")) -eq "Yes") } #endregion #region About dialog (Avalonia) function Show-AboutDialog { $ui = $script:UIProvider $dlgAbout = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/AboutDialog.axaml')) if (-not $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 += [AboutModuleEntry]@{ Name = $module.Name Version = "$ver" Type = 'PSModule' } } $assms = [System.AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GlobalAssemblyCache -eq $false -and -not [String]::IsNullOrEmpty($_.Location) } foreach ($assmName in $externalAssemblies) { $assmObjs = $assms | Where-Object { $_.Location -like "*\$assmName" } foreach ($assmObj in $assmObjs) { try { $fi = [IO.FileInfo]"$($assmObj.Location)" $loadedItems += [AboutModuleEntry]@{ Name = $fi.Name Version = $fi.VersionInfo.FileVersion Type = 'Assembly' } } catch {} } } $ui.SetXamlProperty($dlgAbout, 'txtTitle', 'Text', 'Intune Management') if ($script:ActiveView) { $ui.SetXamlProperty($dlgAbout, 'txtViewTitle', 'Text', ("Current view: " + $script:ActiveView.Title)) if ($script:ActiveView.Description) { $ui.SetXamlProperty($dlgAbout, 'txtViewDescription', 'Text', $script:ActiveView.Description) } } $ui.SetXamlProperty($dlgAbout, 'lstModules', 'ItemsSource', $loadedItems) $ui.AddXamlEvent($dlgAbout, 'linkSource', 'Add_Click', ({ Open-ExternalUri 'https://github.com/Micke-K/IntuneManagement' })) $ui.AddXamlEvent($dlgAbout, 'linkCoffee', 'Add_Click', ({ Open-ExternalUri 'https://buymeacoffee.com/MickeK' })) $ui.ShowModalForm('About', $dlgAbout) } #endregion #region Message box (Avalonia) function Show-MessageBox { # Avalonia counterpart of UI/WPF/Extensions/CoreUI.ps1 Show-MessageBox. # Differences vs WPF: # - Params/return are strings (no [System.Windows.MessageBoxButton] enums) # because no caller in this repo actually passes enum literals; they all # pass strings like "OK", "YesNo", "Error". # - Escape key handled in code (Avalonia has no IsCancel on Button). # - Closed event derives a default result when the user dismisses via X / # Alt-F4 with no button click, matching WPF's Closing-event logic. param( [Parameter(Mandatory)] [string]$Text, [string]$Caption = "", [ValidateSet("OK","OKCancel","YesNo","YesNoCancel")] [string]$Button = "OK", [ValidateSet("None","Information","Question","Warning","Error","Asterisk","Exclamation","Hand","Stop")] [string]$Icon = "None" ) $ui = $script:UIProvider Initialize-AvaloniaRuntime $dialog = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/MessageBox.axaml')) if (-not $dialog) { return "None" } $captionText = if ($Caption) { $Caption } elseif ($script:Window) { $script:Window.Title } else { "" } $dialog.Title = $captionText $ui.SetXamlProperty($dialog, 'txtCaption', 'Text', $captionText) $ui.SetXamlProperty($dialog, 'txtMessage', 'Text', $Text) if ($Icon -ne 'None') { $iconBorder = (Get-AvaloniaHost)::FindByName($dialog, 'iconBorder') $iconSymbol = (Get-AvaloniaHost)::FindByName($dialog, 'iconSymbol') switch ($Icon) { 'Question' { $iconColor = '#FF0078D4'; $iconGlyph = '?' } { $_ -in 'Warning','Exclamation' } { $iconColor = '#FFE8A000'; $iconGlyph = '!' } { $_ -in 'Error','Hand','Stop' } { $iconColor = '#FFD92D20'; $iconGlyph = 'X' } default { $iconColor = '#FF0078D4'; $iconGlyph = 'i' } } $iconBorder.Background = [Avalonia.Media.SolidColorBrush]::new([Avalonia.Media.Color]::Parse($iconColor)) $iconSymbol.Text = $iconGlyph $iconBorder.IsVisible = $true } $showOK = $Button -in 'OK','OKCancel' $showYesNo = $Button -in 'YesNo','YesNoCancel' $showCancel = $Button -in 'OKCancel','YesNoCancel' # MODULE scope, not a captured local: handler captures are stripped by # ConvertTo-AvaloniaEventScriptBlock, which left every button unable to # record its answer or close the dialog. Only one message box runs at a # time (ShowDialog blocks), so a single module slot is safe. $script:_msgBoxDialog = $dialog $script:_msgBoxResult = 'None' $script:_msgBoxShowCancel = $showCancel $script:_msgBoxShowYesNo = $showYesNo if ($showOK) { $btnOK = (Get-AvaloniaHost)::FindByName($dialog, 'btnOK') $btnOK.IsVisible = $true $btnOK.IsDefault = $true $ui.AddXamlEvent($dialog, 'btnOK', 'Add_Click', ({ $script:_msgBoxResult = 'OK' if ($script:_msgBoxDialog) { $script:_msgBoxDialog.Close() } })) } if ($showYesNo) { $btnYes = (Get-AvaloniaHost)::FindByName($dialog, 'btnYes') $btnNo = (Get-AvaloniaHost)::FindByName($dialog, 'btnNo') $btnYes.IsVisible = $true $btnYes.IsDefault = $true $btnNo.IsVisible = $true $ui.AddXamlEvent($dialog, 'btnYes', 'Add_Click', ({ $script:_msgBoxResult = 'Yes' if ($script:_msgBoxDialog) { $script:_msgBoxDialog.Close() } })) $ui.AddXamlEvent($dialog, 'btnNo', 'Add_Click', ({ $script:_msgBoxResult = 'No' if ($script:_msgBoxDialog) { $script:_msgBoxDialog.Close() } })) } if ($showCancel) { $btnCancel = (Get-AvaloniaHost)::FindByName($dialog, 'btnCancel') $btnCancel.IsVisible = $true $ui.AddXamlEvent($dialog, 'btnCancel', 'Add_Click', ({ $script:_msgBoxResult = 'Cancel' if ($script:_msgBoxDialog) { $script:_msgBoxDialog.Close() } })) } $ui.AddXamlEvent($dialog, 'btnTitleClose', 'Add_Click', ({ if ($script:_msgBoxDialog) { $script:_msgBoxDialog.Close() } })) $dialog.Add_KeyDown((ConvertTo-AvaloniaEventScriptBlock { param($S, $E) if ($E.Key -eq [Avalonia.Input.Key]::Escape) { if ($script:_msgBoxDialog) { $script:_msgBoxDialog.Close() } } })) $dialog.Add_Closed((ConvertTo-AvaloniaEventScriptBlock { if ($script:_msgBoxResult -eq 'None') { if ($script:_msgBoxShowCancel) { $script:_msgBoxResult = 'Cancel' } elseif ($script:_msgBoxShowYesNo) { $script:_msgBoxResult = 'No' } else { $script:_msgBoxResult = 'OK' } } })) # WindowStartupLocation=CenterOwner positions BEFORE SizeToContent has # produced the final size (and misbehaves with custom decorations), which # is why the box landed in a corner. Center manually once the real size is # known; reads only the sender + $script:Window so no captures are needed. $dialog.add_Opened({ param($S, $E) try { $owner = $script:Window if (-not $owner) { return } $ownerSize = if ($owner.FrameSize.HasValue) { $owner.FrameSize.Value } else { $owner.ClientSize } $dlgSize = if ($S.FrameSize.HasValue) { $S.FrameSize.Value } else { $S.ClientSize } $x = $owner.Position.X + [int](($ownerSize.Width * $owner.RenderScaling - $dlgSize.Width * $S.RenderScaling) / 2) $y = $owner.Position.Y + [int](($ownerSize.Height * $owner.RenderScaling - $dlgSize.Height * $S.RenderScaling) / 2) $S.Position = [Avalonia.PixelPoint]::new($x, $y) } catch { Write-LogDebug "MessageBox centering failed: $($_.Exception.Message)" } }) (Get-AvaloniaHost)::ShowDialog($dialog, $script:Window) $answer = $script:_msgBoxResult $script:_msgBoxDialog = $null return $answer } #endregion #region Settings dialog (Avalonia) # Port of UI/WPF/Extensions/CoreUI.ps1 Settings section. Differences vs WPF: # - Setting helpers (Add-SettingTextBox/CheckBox/ComboBox/Folder) build the # controls programmatically and return a PSCustomObject @{ Visual; Control }. # WPF parsed XAML strings and used FindName($Id) to get the inner control; # Avalonia doesn't auto-register names for programmatically-built trees, # so we hand the inner control back explicitly. # - ComboBox ItemsSource items are converted from PSCustomObject (which # Avalonia's binder can't traverse) to [SettingsListItem] CLR instances. # See [[avalonia-binding-needs-clr-types]]. # - Folder picker uses (Get-AvaloniaHost)::OpenFolderPicker instead of # WindowsAPICodePack CommonOpenFileDialog. # - DynamicResource Foreground / InfoIcon style come from inline XAML snippets # parsed via Get-AvaloniaHost::LoadXaml so theme variant swap still works. function Add-SettingTextBox { param($Value) $tb = [Avalonia.Controls.TextBox]::new() if ($null -ne $Value) { $tb.Text = [string]$Value } [PSCustomObject]@{ Visual = $tb; Control = $tb } } function Add-SettingCheckBox { param($Value) $cb = [Avalonia.Controls.CheckBox]::new() $cb.IsChecked = ($Value -eq $true -or $Value -eq 'true') [PSCustomObject]@{ Visual = $cb; Control = $cb } } function Add-SettingComboBox { param($Value, $SettingObj) $nameProp = if ($SettingObj.DisplayMemberPath) { $SettingObj.DisplayMemberPath } else { 'Name' } $valueProp = if ($SettingObj.SelectedValuePath) { $SettingObj.SelectedValuePath } else { 'Value' } $items = New-Object 'System.Collections.Generic.List[SettingsListItem]' foreach ($src in @($SettingObj.ItemsSource)) { if (-not $src) { continue } $items.Add([SettingsListItem]@{ Name = [string]($src.$nameProp) Value = $src.$valueProp }) } $combo = [Avalonia.Controls.ComboBox]::new() $combo.ItemsSource = $items # Built in code, so the display field is set here rather than in .axaml - # same mechanism, just assigned imperatively. $combo.DisplayMemberBinding = [Avalonia.Data.Binding]::new('Name') # Avalonia's ComboBox defaults to HorizontalAlignment=Left, unlike TextBox # and friends - without this it collapses to its content width instead of # filling the settings column. $combo.HorizontalAlignment = [Avalonia.Layout.HorizontalAlignment]::Stretch if ($null -ne $Value) { $selected = $items | Where-Object { "$($_.Value)" -eq "$Value" } | Select-Object -First 1 if ($selected) { $combo.SelectedItem = $selected } } [PSCustomObject]@{ Visual = $combo; Control = $combo } } function Add-SettingFolder { param($Value) $ui = $script:UIProvider $grid = [Avalonia.Controls.Grid]::new() $grid.HorizontalAlignment = [Avalonia.Layout.HorizontalAlignment]::Stretch $grid.Margin = [Avalonia.Thickness]::new(0, 5, 0, 0) $grid.ColumnDefinitions.Add([Avalonia.Controls.ColumnDefinition]::new([Avalonia.Controls.GridLength]::new(1, [Avalonia.Controls.GridUnitType]::Star))) $grid.ColumnDefinitions.Add([Avalonia.Controls.ColumnDefinition]::new([Avalonia.Controls.GridLength]::new(5))) $grid.ColumnDefinitions.Add([Avalonia.Controls.ColumnDefinition]::new([Avalonia.Controls.GridLength]::Auto)) $tb = [Avalonia.Controls.TextBox]::new() if ($null -ne $Value) { $tb.Text = [string]$Value } $grid.Children.Add($tb) $btn = [Avalonia.Controls.Button]::new() $btn.Content = '...' $btn.Width = 50 $btn.Padding = [Avalonia.Thickness]::new(5, 0, 5, 0) [Avalonia.Controls.Grid]::SetColumn($btn, 2) $btn.Tag = $tb $btn.add_Click((ConvertTo-AvaloniaEventScriptBlock { param($S, $E) $textBox = $S.Tag $picked = $ui.ShowFolderPicker('Select folder') if ($picked) { $textBox.Text = $picked } })) $grid.Children.Add($btn) [PSCustomObject]@{ Visual = $grid; Control = $tb } } function Add-SettingsItem { param($SettingItem, $SettingValue) $rd = [Avalonia.Controls.RowDefinition]::new([Avalonia.Controls.GridLength]::Auto) $script:spSettings.RowDefinitions.Add($rd) $rowIndex = $script:spSettings.RowDefinitions.Count - 1 [Avalonia.Controls.Grid]::SetRow($SettingItem, $rowIndex) if (-not $SettingValue) { [Avalonia.Controls.Grid]::SetColumnSpan($SettingItem, 99) $script:spSettings.Children.Add($SettingItem) return } # Title strip: developer-supplied $Title / $Description are assigned via # property setters after the parse so a stray <, &, " in the JSON config # can't break the XAML parser. Same approach the WPF version takes. $titleXaml = @' '@ $titleStack = (Get-AvaloniaHost)::LoadXaml($titleXaml) $titleText = (Get-AvaloniaHost)::FindByName($titleStack, 'settingTitleText') $descIcon = (Get-AvaloniaHost)::FindByName($titleStack, 'settingDescIcon') if ($titleText) { $titleText.Text = [string]$SettingValue.Title } if ($SettingValue.Description -and $descIcon) { [Avalonia.Controls.ToolTip]::SetTip($descIcon, [string]$SettingValue.Description) $descIcon.IsVisible = $true } if ($script:tenantSettings) { $tenantConfig = [Avalonia.Controls.CheckBox]::new() [Avalonia.Controls.ToolTip]::SetTip($tenantConfig, 'Enable tenant specific setting') [Avalonia.Controls.Grid]::SetRow($tenantConfig, $rowIndex) [Avalonia.Controls.Grid]::SetColumn($tenantConfig, 0) $tenantConfig.Margin = [Avalonia.Thickness]::new(0, 5, 0, 0) $tenantConfig.Tag = $SettingValue $tenantConfig.IsChecked = (Test-SettingValueConfigured -Key $SettingValue.Key -Tenant) $SettingItem.IsEnabled = [bool]$tenantConfig.IsChecked $tenantConfig.add_Click((ConvertTo-AvaloniaEventScriptBlock { param($S, $E) if ($S.Tag -and $S.Tag.Control) { $S.Tag.Control.IsEnabled = [bool]$S.IsChecked } })) $script:spSettings.Children.Add($tenantConfig) } [Avalonia.Controls.Grid]::SetRow($titleStack, $rowIndex) [Avalonia.Controls.Grid]::SetColumn($titleStack, 1) $script:spSettings.Children.Add($titleStack) [Avalonia.Controls.Grid]::SetColumn($SettingItem, 2) $SettingItem.Margin = [Avalonia.Thickness]::new(0, 5, 0, 0) $script:spSettings.Children.Add($SettingItem) } function Add-SettingTitle { param($Title, $MarginTop = 0) # Parse from inline XAML so the {DynamicResource ...} brush refs survive # theme-variant switches. The previous version tried # Application.Current.TryFindResource — that's an extension method on # IResourceHost in Avalonia 11, not an instance method, so it throws # "does not contain a method named TryFindResource" when invoked from # PowerShell. Title text + margin are set after parse to keep developer- # supplied $Title out of the XAML literal (safe against stray <, &, "). $xaml = @' '@ $tb = (Get-AvaloniaHost)::LoadXaml($xaml) $tb.Text = [string]$Title $tb.Margin = [Avalonia.Thickness]::new(0, [double]$MarginTop, 0, 0) Add-SettingsItem $tb } function Add-SettingValue { param($SettingValue) if ($SettingValue.TenantSettings -eq $false -and $script:tenantSettings) { return } if ($SettingValue.GlobalSettings -eq $false -and -not $script:tenantSettings) { return } $Value = Get-SettingValue $SettingValue.Key -GlobalOnly:(-not $script:tenantSettings) $built = switch -Regex ("$($SettingValue.Type)") { '^(?i)folder$' { Add-SettingFolder $Value; break } '^(?i)boolean$' { Add-SettingCheckBox $Value; break } '^(?i)list$' { Add-SettingComboBox $Value $SettingValue; break } default { Add-SettingTextBox $Value } } if ($built) { Add-SettingsItem $built.Visual $SettingValue if ($SettingValue | Get-Member -MemberType NoteProperty -Name 'Control') { $SettingValue.Control = $built.Control } else { $SettingValue | Add-Member -MemberType NoteProperty -Name 'Control' -Value $built.Control -Force } } } function Show-SettingsForm { param([switch]$Tenant) $ui = $script:UIProvider Initialize-AvaloniaRuntime $settingsForm = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/SettingsForm.axaml')) if (-not $settingsForm) { return } $script:spSettings = (Get-AvaloniaHost)::FindByName($settingsForm, 'spSettings') $script:tenantSettings = [bool]$Tenant $ui.AddXamlEvent($settingsForm, 'btnSave', 'Add_Click', ({ Save-AllSettings })) $ui.AddXamlEvent($settingsForm, 'btnClose', 'Add_Click', ({ $script:tenantSettings = $false $script:spSettings = $null Show-ModalObject })) if ($JsonSettingsObj -or $script:tenantSettings) { $ui.SetXamlProperty($settingsForm, 'btnExport', 'IsVisible', $false) } else { $ui.AddXamlEvent($settingsForm, 'btnExport', 'Add_Click', ({ $picked = (Get-AvaloniaHost)::SaveFilePicker( $script:Window, 'Export settings', 'IntuneManagementSettings', 'json', 'Json files', '*.json') if ($picked) { Export-Settings $picked } })) } $tmp = Get-SettingsSection 'General' if ($tmp -and $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 } } $ui.ShowModalForm('Settings', $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 $Value = $null $typeName = $SettingObj.Control.GetType().Name if ($typeName -eq 'TextBox') { $Value = $SettingObj.Control.Text if ($SettingObj.Type -eq 'Int') { try { $Value = [int]$Value } catch { $Value = $SettingObj.Value } } $valueFound = $true } elseif ($typeName -eq 'CheckBox') { $Value = [bool]$SettingObj.Control.IsChecked $valueFound = $true } elseif ($typeName -eq 'ComboBox') { $selected = $SettingObj.Control.SelectedItem if ($selected) { $Value = $selected.Value } $valueFound = $true } if (-not $valueFound) { continue } # 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. $isTenant = ($script:tenantSettings -eq $true) $subPath = Resolve-SettingStorePath -Key $SettingObj.Key -Definition $SettingObj -Tenant:$isTenant if ($null -eq $subPath) { continue } # The raw stored value at THIS scope, not the effective one: it only feeds # the change event below, and an unset tenant value has to look unset. $currentValue = Get-SettingStoreValue -SubPath $subPath -Key $SettingObj.Key Set-SettingValue -Key $SettingObj.Key -Value $Value -Tenant:$isTenant $stringValue = if ($null -ne $Value -and $Value -isnot [string]) { $Value.ToString() } else { $Value } if ($stringValue -cne $currentValue) { # Fire on EVERY change, including from/to empty - the old # non-empty guards suppressed the live-apply (theme, env badge) # for a setting's very first save. Write-Log "Setting saved: $($SettingObj.Key) = '$stringValue' (was: '$currentValue')" # Invoke-AppEvent takes ONE argument payload and splats it into the # handler, so a multi-argument event has to pass a hashtable. Three # positional args bound only $SettingObj (as $EventArguments) and # dropped the rest into $args, so handlers saw $NewValue = $null - # which is why saving a new theme persisted it but never applied it. Invoke-AppEvent 'SettingValueUpdated' @{ SettingInfo = $SettingObj NewValue = $Value OldValue = $currentValue } } } } Invoke-AppEvent 'SettingsUpdated' $newHideNoAccess = Get-SettingValue 'HideNoAccess' if ($curHideNoAccess -ne $newHideNoAccess) { Show-ViewMenu } if ($dt1.AddSeconds(1) -lt (Get-Date)) { Start-Sleep -Seconds 1 } Write-Status '' } #endregion #region Popup overlay (Avalonia) # Convert a point in an anchor visual's local coords to window-client coords. # Uses PointToScreen -> PointToClient (rather than TranslatePoint) because the # latter returned null in practice when both visuals were attached but Avalonia # hadn't run a full layout pass on the popup-host yet. Returns $null on # failure so callers can fall back to a sensible default. function Get-WindowRelativePoint { param($AnchorVisual, [Avalonia.Point]$AnchorPoint) if (-not $AnchorVisual -or -not $script:Window) { return $null } # Visual-tree transform first. It stays entirely inside the window's own # coordinate space, so window decorations cannot affect it. The # PointToScreen/PointToClient round-trip below leaves that space and needs # the platform to report a consistent client origin - which it does not on # X11 once the WM owns the title bar, so it failed there and callers # silently fell back to a fixed guess. # TranslatePoint is an EXTENSION method (Avalonia.VisualExtensions), not an # instance method, so PowerShell cannot reach it with dot syntax - # $visual.TranslatePoint(...) throws "does not contain a method named". # It has to be invoked statically with the visual as the first argument. try { $translated = [Avalonia.VisualExtensions]::TranslatePoint($AnchorVisual, $AnchorPoint, $script:Window) if ($null -ne $translated) { # PowerShell usually unwraps Nullable[Point]; handle both shapes. if ($translated -is [Avalonia.Point]) { return $translated } if ($translated.HasValue) { return $translated.Value } } } catch { Write-LogDebug "Get-WindowRelativePoint: TranslatePoint failed: $($_.Exception.Message)" } try { $screenPt = $AnchorVisual.PointToScreen($AnchorPoint) return $script:Window.PointToClient($screenPt) } catch { Write-LogDebug "Get-WindowRelativePoint failed: $($_.Exception.Message)" return $null } } # Avalonia port of UI/WPF/Extensions/CoreUI.ps1 Show-Popup / Hide-Popup. The grdPopup # overlay + cvsPopup Canvas are already in MainWindow.axaml; helpers mount / # unmount the popup content and toggle visibility. Click-outside (on the dim # layer) and Escape both dismiss. function Show-Popup { param($Popup) $ui = $script:UIProvider if (-not $script:grdPopup -or -not $script:cvsPopup) { return } $script:cvsPopup.Children.Add($Popup) | Out-Null $script:grdPopup.IsVisible = $true if (-not $script:popupHandlersInstalled) { # Escape dismisses. KeyDown on the window catches it regardless of focus. $script:Window.add_KeyDown((ConvertTo-AvaloniaEventScriptBlock { param($S, $E) if ($E.Key -eq [Avalonia.Input.Key]::Escape -and $script:grdPopup -and $script:grdPopup.IsVisible) { $ui.HidePopup() $E.Handled = $true } })) # Click on the dim layer (i.e. anywhere outside the popup content) # dismisses. Children of cvsPopup mark their own PointerPressed handled, # so a click on the popup body doesn't bubble back here. $script:grdPopup.add_PointerPressed((ConvertTo-AvaloniaEventScriptBlock { param($S, $E) if ($E.Source -eq $S -or $E.Source -eq $script:cvsPopup) { $ui.HidePopup() } })) $script:popupHandlersInstalled = $true } } function Hide-Popup { if (-not $script:grdPopup -or -not $script:cvsPopup) { return } $script:cvsPopup.Children.Clear() $script:grdPopup.IsVisible = $false } # Position a popup below an anchor visual, right-aligned with the anchor's # right edge. Fallback: if PointToScreen / PointToClient fail (anchor not yet # rendered, or other reason), position near top-right of the window so the # popup is at least visible and approximately where the user expects. function Set-PopupPosition { param($Popup, $Anchor, [double]$PopupWidth = 320) if (-not $Popup) { return } if ($Popup.Width -gt 0) { $PopupWidth = $Popup.Width } $placed = $false if ($Anchor -and $script:Window) { $clientPt = Get-WindowRelativePoint $Anchor ([Avalonia.Point]::new(0, $Anchor.Bounds.Height)) if ($clientPt) { [Avalonia.Controls.Canvas]::SetLeft($Popup, $clientPt.X + $Anchor.Bounds.Width - $PopupWidth) [Avalonia.Controls.Canvas]::SetTop($Popup, $clientPt.Y + 4) $placed = $true } } if (-not $placed -and $script:Window) { # Fallback: anchor to the right edge of the window client area, just # below the title strip (32px tall). # # Only Windows paints caption buttons over our client area # (ExtendClientAreaChromeHints=PreferSystemChrome), and only there does # grdTitleBarContent keep its reserve - Set-MainWindowChrome zeroes it # everywhere else. Subtracting it unconditionally put the popup a full # caption-width left of the avatar it is meant to hang below, which is # exactly what showed on Linux. $reserve = 0 if ($script:IsWindowsOS) { $reserve = if ($script:IMWindowsCaptionWidth) { $script:IMWindowsCaptionWidth } else { 140 } } $clientWidth = $script:Window.Bounds.Width if ($clientWidth -le 0) { $clientWidth = $script:Window.Width } [Avalonia.Controls.Canvas]::SetLeft($Popup, [Math]::Max(0, $clientWidth - $reserve - $PopupWidth)) [Avalonia.Controls.Canvas]::SetTop($Popup, 36) } } #endregion #region Authentication chrome (Avalonia) # WPF Show-AuthenticationInfo defers to Get-MSALUserProfile (~400 lines: photo # ellipse, sign-in button, cached-account popup, "Sign in to a different cloud" # button). The Avalonia port currently implements only the two terminal states: # - Signed out: a "Sign in" Button that invokes Invoke-AuthProviderInteractiveLogin. # - Signed in: a circular Border with the user's photo or initials and a # tooltip showing display name + UPN. Click opens the profile # popup. Cached-account picker shown when cached rows exist. # When more of Get-MSALUserProfile is ported (session info, token viewers, # cloud picker, etc.) consider refactoring the WPF version to share. # Fills a Border with the user's avatar: ImageBrush background from # $script:CurrentProfilePhoto if a non-empty file exists, otherwise solid blue # with the user's initials. Used both for the small title-bar avatar (28x28, # FontSize 11) and the larger popup avatar (60x60, FontSize 24). function Set-AvatarVisual { param( [Avalonia.Controls.Border]$Border, [int]$FontSize = 11 ) # App-login detection is provider-agnostic (GetUserInfo().AuthType), so the 'APP' # initial works for any provider, not just MSAL. $isAppLogin = $false try { $p = Get-AuthProvider if ($p) { $u = $p.GetUserInfo((Get-DefaultAuthTokenId)); $isAppLogin = [bool]($u -and $u.AuthType -in @('Confidential','ClientCredential','ManagedIdentity','WorkloadFederation')) } } catch { } $initials = if ($script:CurrentUser.givenName -and $script:CurrentUser.surname) { "$($script:CurrentUser.givenName[0])$($script:CurrentUser.surname[0])".ToUpper() } elseif ($script:CurrentUser.userPrincipalName) { "$($script:CurrentUser.userPrincipalName[0])".ToUpper() } elseif ($isAppLogin) { 'APP' } else { '?' } $photoLoaded = $false if ($script:CurrentProfilePhoto -and (Test-Path -LiteralPath $script:CurrentProfilePhoto)) { try { $fi = Get-Item -LiteralPath $script:CurrentProfilePhoto if ($fi.Length -gt 0) { $bitmap = [Avalonia.Media.Imaging.Bitmap]::new($script:CurrentProfilePhoto) $brush = [Avalonia.Media.ImageBrush]::new($bitmap) $brush.Stretch = [Avalonia.Media.Stretch]::UniformToFill $Border.Background = $brush $Border.Child = $null $photoLoaded = $true } } catch { Write-LogDebug "Profile photo load failed, falling back to initials: $($_.Exception.Message)" } } if (-not $photoLoaded) { $Border.Background = [Avalonia.Media.SolidColorBrush]::new([Avalonia.Media.Color]::Parse('#FF0078D4')) $tb = [Avalonia.Controls.TextBlock]::new() $tb.Text = $initials $tb.Foreground = [Avalonia.Media.Brushes]::White $tb.FontWeight = [Avalonia.Media.FontWeight]::Bold $tb.HorizontalAlignment = [Avalonia.Layout.HorizontalAlignment]::Center $tb.VerticalAlignment = [Avalonia.Layout.VerticalAlignment]::Center $tb.FontSize = $FontSize $Border.Child = $tb } } function Get-AvaloniaUserProfile { # An expired default token counts as "not signed in" so the title-bar reverts to # the Sign-in button (which prompts interactive login on click). Provider-agnostic; # returns $false when expiry is unknown/SDK-managed. $tokenExpired = $false if(Get-Command Test-DefaultTokenExpired -ErrorAction SilentlyContinue) { try { $tokenExpired = Test-DefaultTokenExpired } catch { } } # Signed-in state is provider-agnostic: $script:CurrentUser is set by # Sync-AuthContextFromProvider for every provider, while $script:MSALDefaultToken # is MSAL-only (null for an OAuth/MgGraph session). Gating on it kept OAuth logins # showing the Sign-in button. if (-not $script:CurrentUser -or $tokenExpired) { $btn = [Avalonia.Controls.Button]::new() $btn.Content = 'Sign in' $btn.Padding = [Avalonia.Thickness]::new(8, 2, 8, 2) $btn.add_Click((ConvertTo-AvaloniaEventScriptBlock { param($S, $E) try { # If the active provider exposes cached accounts, show the # picker popup so the user can re-auth as a previously-signed-in # account without re-typing their UPN. Empty list (or provider # doesn't support caching, e.g. MgGraph today) falls through to # the interactive flow. $authProvider = $null try { $authProvider = Get-AuthProvider } catch { Write-LogError 'Get-AuthProvider failed in login-click handler' $_.Exception } # Bootstrap an MSAL public-client app + refresh $script:MSALAccounts # from the on-disk cache before reading cached accounts. On a fresh # session (or pure BYO/CC session) $script:MSALApps is empty, so # AuthenticationMSAL.GetCachedAccounts() would return [] even when # the persistent cache has interactive accounts from earlier # sessions. Mirrors WPF Get-MSALUserProfile (~line 117). No network # call — GetAccountsAsync just deserialises the cache file. try { $publicClientApp = $script:MSALApps | Select-Object -First 1 if (-not $publicClientApp -and (Get-Command New-MSALApp -ErrorAction SilentlyContinue)) { $publicClientApp = New-MSALApp } if ($publicClientApp) { $script:MSALAccounts = $publicClientApp.GetAccountsAsync().GetAwaiter().GetResult() } } catch { Write-LogDebug "Failed to refresh cached account list: $($_.Exception.Message)" } $cachedRows = @() if ($authProvider -and $authProvider.SupportsCachedUsers) { try { $cachedRows = @($authProvider.GetCachedAccounts()) } catch { $cachedRows = @() } } if ($cachedRows.Count -gt 0) { Show-CachedAccountPicker $S $cachedRows return } # Do not start a synchronous wait before this Click event returns: # Avalonia still owns pointer capture for the Sign-in button until # then, which makes the first later click on Cancel disappear. Invoke-AvaloniaDeferredAction -Action { param($unused) Invoke-AvaloniaInteractiveSignIn } } catch { Write-LogError 'Sign-in click handler failed' $_.Exception Write-Status '' } })) return $btn } $border = [Avalonia.Controls.Border]::new() $border.Width = 28 $border.Height = 28 $border.CornerRadius = [Avalonia.CornerRadius]::new(14) $border.Cursor = [Avalonia.Input.Cursor]::new([Avalonia.Input.StandardCursorType]::Hand) $tipText = if ($script:CurrentUser.userPrincipalName) { "$($script:CurrentUser.displayName)`n$($script:CurrentUser.userPrincipalName)" } else { [string]$script:CurrentUser.displayName } [Avalonia.Controls.ToolTip]::SetTip($border, $tipText) Set-AvatarVisual -Border $border -FontSize 11 $border.add_PointerPressed((ConvertTo-AvaloniaEventScriptBlock { param($S, $E) # Mark handled so the title bar's PointerPressed does not also start a # window drag - the drag filter only skips Button/MenuItem/Menu, and # this avatar is a Border, so clicking it dragged the window too. $E.Handled = $true try { Show-ProfilePopup $S } catch { Write-LogError 'Show-ProfilePopup failed' $_.Exception } })) return $border } function Show-ProfilePopup { param($Anchor) $ui = $script:UIProvider if (-not $script:CurrentUser) { return } $popup = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/ProfileInfo.axaml')) if (-not $popup) { return } # Provider-agnostic user view drives org / auth / expiry / app / app-login below. # Identity name + photo still come from $script:CurrentUser / $script:CurrentProfilePhoto. $provider = $null try { $provider = Get-AuthProvider } catch { } $userInfo = $null if ($provider) { try { $userInfo = $provider.GetUserInfo((Get-DefaultAuthTokenId)) } catch { } } $isAppLogin = [bool]($userInfo -and $userInfo.AuthType -in @('Confidential','ClientCredential','ManagedIdentity','WorkloadFederation')) $orgName = if ($userInfo -and $userInfo.TenantName) { $userInfo.TenantName } else { $script:OrganizationName } $ui.SetXamlProperty($popup, 'txtOrganization', 'Text', ([string]$orgName)) # An app-only login has no human account, so the provider fills DisplayName / # UPN from the token's account username - an identifier (the client id, or the # service principal oid on a BYO token), never a name. Lead with the resolved # app name instead; the identifier is still shown by pnlAppInfo below. WPF # prints the fixed string "App Login" here, which is the fallback when the # app name could not be resolved. $displayName = if ($isAppLogin) { if ($userInfo.AppName) { [string]$userInfo.AppName } else { 'App Login' } } elseif ($script:CurrentUser.displayName) { [string]$script:CurrentUser.displayName } elseif ($script:CurrentUser.userPrincipalName) { [string]$script:CurrentUser.userPrincipalName } else { '' } $ui.SetXamlProperty($popup, 'txtUsername', 'Text', $displayName) $ui.SetXamlProperty($popup, 'txtLogonName', 'Text', ([string]$script:CurrentUser.userPrincipalName)) # Auth method + token expiry from the provider-agnostic user view. $authLabel = $null $expiryLabel = $null if ($userInfo) { $authTypeRaw = if ($userInfo.AuthType) { $userInfo.AuthType } else { 'Interactive' } $modeName = switch ($authTypeRaw) { 'BYO' { 'Bring-your-own token' } 'Confidential' { 'Client credentials' } 'ClientCredential' { 'Client credentials' } 'ManagedIdentity' { 'Managed identity' } default { 'Interactive' } } $providerDisplay = if ($provider) { $provider.DisplayName } else { '' } $authLabel = "$providerDisplay - $modeName" if ($userInfo.ExpiresOn) { $expiryLabel = "Token expires $($userInfo.ExpiresOn.ToString('g'))" } } if ($authLabel) { $ui.SetXamlProperty($popup, 'txtAuthMethod', 'Text', $authLabel) $ui.SetXamlProperty($popup, 'txtAuthMethod', 'IsVisible', $true) } if ($expiryLabel) { $ui.SetXamlProperty($popup, 'txtAuthExpiry', 'Text', $expiryLabel) $ui.SetXamlProperty($popup, 'txtAuthExpiry', 'IsVisible', $true) } $bigAvatar = (Get-AvaloniaHost)::FindByName($popup, 'bigAvatar') if ($bigAvatar) { Set-AvatarVisual -Border $bigAvatar -FontSize 24 } # App info from the provider-agnostic user view (GetUserInfo resolves it per provider). $appName = if ($userInfo) { $userInfo.AppName } else { $null } $appId = if ($userInfo) { $userInfo.AppId } else { $null } if ($appName -or $appId) { $ui.SetXamlProperty($popup, 'txtAppName', 'Text', ([string]$appName)) $ui.SetXamlProperty($popup, 'txtAppId', 'Text', ([string]$appId)) $ui.SetXamlProperty($popup, 'pnlAppInfo', 'IsVisible', $true) } # Request Consent - only providers that support it, skip on app-token logins # (the consent flow targets a signed-in user, N/A for client-cred), and - # matching WPF - only when permissions are actually missing. Avalonia showed # it on every MSAL session. $hasMissingPermissions = (($script:missingPermissions | Measure-Object).Count -gt 0) if ($provider -and $provider.SupportsConsentPrompt -and -not $isAppLogin -and $hasMissingPermissions) { $ui.SetXamlProperty($popup, 'lnkRequestConsent', 'IsVisible', $true) $ui.AddXamlEvent($popup, 'lnkRequestConsent', 'Add_Click', ({ $ui.HidePopup() Invoke-AvaloniaDeferredAction -Action { param($unused) Invoke-AvaloniaConsentPrompt } })) } $ui.AddXamlEvent($popup, 'btnProfileClose', 'Add_Click', ({ $ui.HidePopup() })) # Refresh: same logic as WPF lnkForceRefresh — MSAL fast-path via # Connect-EntraEnvironment -ForceRefresh, generic providers via Refresh(0). # Gating: providers that can't refresh (e.g. MgGraph today) and BYO tokens # disable the button entirely. $lnkRefresh = (Get-AvaloniaHost)::FindByName($popup, 'lnkRefresh') if ($lnkRefresh) { $activeProvider = $null try { $activeProvider = Get-AuthProvider } catch { } if ($activeProvider -and -not $activeProvider.SupportsRefresh) { $lnkRefresh.IsEnabled = $false [Avalonia.Controls.ToolTip]::SetTip($lnkRefresh, "$($activeProvider.DisplayName) does not support manual refresh.") } elseif ($userInfo -and $userInfo.AuthType -eq 'BYO') { $lnkRefresh.IsEnabled = $false [Avalonia.Controls.ToolTip]::SetTip($lnkRefresh, 'BYO tokens cannot be refreshed. Re-run Connect-IntuneManagement with a new token.') } $ui.AddXamlEvent($popup, 'lnkRefresh', 'Add_Click', ({ Invoke-AvaloniaDeferredAction -Action { param($unused) Invoke-AvaloniaProfileRefresh } })) } # Session Info — provider-agnostic. MSAL: AuthenticationResult fields. # MgGraph: Get-MgContext properties. Other providers: empty. $ui.AddXamlEvent($popup, 'lnkTokeninfo', 'Add_Click', ({ $tokenArr = @() $providerNow = $null try { $providerNow = Get-AuthProvider } catch { } # Provider-agnostic: each provider returns its own native session rows (MSAL # AuthenticationResult fields, MgGraph Get-MgContext, ...) as Name/Value pairs; # wrap into the CLR row type the Avalonia DataGrid binds to. if ($providerNow) { foreach ($r in $providerNow.GetSessionInfoRows()) { $tokenArr += [TokenInfoRow]@{ Name = $r.Name; Value = $r.Value } } } $dg = [Avalonia.Controls.DataGrid]::new() $dg.AutoGenerateColumns = $true $dg.IsReadOnly = $true $dg.CanUserSortColumns = $true $dg.MinWidth = 600 $dg.MinHeight = 400 $dg.ItemsSource = $tokenArr $ui.ShowModalForm('Session Info', $dg) })) $ui.AddXamlEvent($popup, 'lnkAccessTokenInfo', 'Add_Click', ({ # Decode the active provider's access token on demand (works for any provider # whose access token is a JWT - MSAL, OAuth). $providerNow = $null try { $providerNow = Get-AuthProvider } catch { } if ($providerNow) { $raw = $providerNow.GetAccessToken(0, "https://$(Get-GraphDomain)") if ($raw) { $jwt = Get-JWTtoken $raw Show-MSALDecodedToken $jwt 'Access Token Info' } else { $ui.ShowMessageBox("No access token available from provider '$($providerNow.Id)'.", 'Access Token Info', 'OK', 'Information') | Out-Null } } })) $ui.AddXamlEvent($popup, 'lnkIdTokenInfo', 'Add_Click', ({ # ID token decode via the provider (only providers that surface one return it). $providerNow = $null try { $providerNow = Get-AuthProvider } catch { } $idJwt = if ($providerNow) { $providerNow.GetIdTokenJwt((Get-DefaultAuthTokenId)) } else { $null } if ($idJwt) { Show-MSALDecodedToken $idJwt 'Id Token Info' } else { $ui.ShowMessageBox('ID token is not available from the active provider.', 'Id Token Info', 'OK', 'Information') | Out-Null } })) # Permissions: token scopes x Intune role, per policy type (EffectivePermissionsUIAvalonia.ps1). $ui.AddXamlEvent($popup, 'lnkEffectivePermissions', 'Add_Click', ({ Show-EffectivePermissionsDialog })) # Inspector button visibility, self-describing per provider (no MSAL knowledge): # Session Info - only when the provider returns session rows (OAuth has none). # Id Token - only when the provider surfaces an id token (MSAL). # Access Token - always (decoded on demand from GetAccessToken). $lnkSession = (Get-AvaloniaHost)::FindByName($popup, 'lnkTokeninfo') if ($lnkSession -and (-not $provider -or @($provider.GetSessionInfoRows()).Count -eq 0)) { $lnkSession.IsVisible = $false } $lnkIdToken = (Get-AvaloniaHost)::FindByName($popup, 'lnkIdTokenInfo') if ($lnkIdToken -and (-not $provider -or $null -eq $provider.GetIdTokenJwt((Get-DefaultAuthTokenId)))) { $lnkIdToken.IsVisible = $false } $ui.AddXamlEvent($popup, 'lnkLogout', 'Add_Click', ({ $ui.HidePopup() try { if (Disconnect-EntraEnvironment) { # AuthenticationUserDisconnected event triggers Get-MSALUserInfo # → Show-AuthenticationInfo, which swaps the avatar back to a # Sign-in button. Nothing to do here. } } catch { Write-LogError 'Sign-out failed' $_.Exception } })) # Cached accounts — other MSAL identities the user has signed in with. # Skip the current user and personal MSA accounts. Mirrors the WPF block # in MSGraphAuthenticationUIWPF.ps1 (Add-CachedUser per row). $grdCached = (Get-AvaloniaHost)::FindByName($popup, 'grdCachedAccounts') if ($grdCached) { $activeProviderForCached = $null try { $activeProviderForCached = Get-AuthProvider } catch { } $cachedRows = @() if ($activeProviderForCached -and $activeProviderForCached.SupportsCachedUsers) { try { $cachedRows = @($activeProviderForCached.GetCachedAccounts()) } catch { $cachedRows = @() } } if ((Get-SettingValue 'SortAccountList') -eq $true) { $cachedRows = @($cachedRows | Sort-Object -Property Username) } foreach ($row in $cachedRows) { if (-not $row.Native) { continue } $acct = $row.Native # Skip the currently signed-in user (matched on UPN / user id from the # provider-agnostic user view). if ($script:CurrentUser.userPrincipalName -eq $acct.Username -or ($userInfo -and $userInfo.UserId -eq $acct.HomeAccountId.ObjectId)) { continue } if (Test-IsPersonalMSAAccount $acct) { continue } Add-CachedUser $acct $grdCached } } # "Sign in with a different account" + optional cloud picker — appended to # grdLoginAccount footer. Mirrors WPF rows after grdCachedAccounts. $grdLoginAccount = (Get-AvaloniaHost)::FindByName($popup, 'grdLoginAccount') if ($grdLoginAccount) { $altBtn = [Avalonia.Controls.Button]::new() $altBtn.Content = 'Sign in with a different account' $altBtn.Margin = [Avalonia.Thickness]::new(0, 5, 0, 0) $altBtn.HorizontalAlignment = [Avalonia.Layout.HorizontalAlignment]::Stretch $altBtn.HorizontalContentAlignment = [Avalonia.Layout.HorizontalAlignment]::Left $altBtn.Cursor = [Avalonia.Input.Cursor]::new([Avalonia.Input.StandardCursorType]::Hand) $altBtn.add_Click((ConvertTo-AvaloniaEventScriptBlock { $ui.HidePopup() Invoke-AvaloniaDeferredAction -Action { param($unused) Invoke-AvaloniaInteractiveSignIn } })) $grdLoginAccount.Children.Add($altBtn) | Out-Null if (@($script:Clouds).Count -gt 1) { $cloudBtn = [Avalonia.Controls.Button]::new() $cloudBtn.Content = 'Sign in to a different cloud...' $cloudBtn.Margin = [Avalonia.Thickness]::new(0, 5, 0, 0) $cloudBtn.HorizontalAlignment = [Avalonia.Layout.HorizontalAlignment]::Stretch $cloudBtn.HorizontalContentAlignment = [Avalonia.Layout.HorizontalAlignment]::Left $cloudBtn.Cursor = [Avalonia.Input.Cursor]::new([Avalonia.Input.StandardCursorType]::Hand) [Avalonia.Controls.ToolTip]::SetTip($cloudBtn, 'Sign in to US Government (GCC High / DoD) or China cloud. To make a choice permanent, change Default cloud in Settings.') $cloudBtn.add_Click((ConvertTo-AvaloniaEventScriptBlock { $ui.HidePopup() try { $picked = Show-CloudPickerMenu if ($picked) { Invoke-AvaloniaDeferredAction -Argument $picked -Action { param($cloud) Invoke-AvaloniaInteractiveSignIn -Cloud $cloud } } } catch { Write-LogError 'Cloud picker sign-in failed' $_.Exception Write-Status '' } })) $grdLoginAccount.Children.Add($cloudBtn) | Out-Null } } # Tenant accounts grid — visible only when $script:AccessibleTenants has # >1 entry (MSAL populates this from /organization?$select=... when the # GetTenantList setting is on). Each non-current tenant is a button that # re-acquires via Connect-EntraEnvironment -TenantId; the current tenant # is shown as a highlighted text block. $grdTenants = (Get-AvaloniaHost)::FindByName($popup, 'grdTenantAccounts') if ($grdTenants -and @($script:AccessibleTenants).Count -gt 1) { $header = [Avalonia.Controls.TextBlock]::new() $header.Text = 'Tenants:' $header.FontWeight = [Avalonia.Media.FontWeight]::Bold $header.Margin = [Avalonia.Thickness]::new(0, 5, 0, 0) $grdTenants.Children.Add($header) | Out-Null $tenants = if ((Get-SettingValue 'SortTenantList') -eq $true) { $script:AccessibleTenants | Sort-Object -Property DisplayName } else { $script:AccessibleTenants } $currentTenantId = if ($userInfo) { $userInfo.TenantId } else { $null } foreach ($tenant in $tenants) { try { # Build a TextBlock with three Runs (name bold, then domain, then id). $tb = [Avalonia.Controls.TextBlock]::new() $tb.HorizontalAlignment = [Avalonia.Layout.HorizontalAlignment]::Stretch $nameRun = [Avalonia.Controls.Documents.Run]::new([string]$tenant.DisplayName) $nameRun.FontWeight = [Avalonia.Media.FontWeight]::Bold $tb.Inlines.Add($nameRun) | Out-Null $tb.Inlines.Add([Avalonia.Controls.Documents.LineBreak]::new()) | Out-Null $tb.Inlines.Add([Avalonia.Controls.Documents.Run]::new([string]$tenant.defaultDomain)) | Out-Null $tb.Inlines.Add([Avalonia.Controls.Documents.LineBreak]::new()) | Out-Null $tb.Inlines.Add([Avalonia.Controls.Documents.Run]::new([string]$tenant.tenantId)) | Out-Null if ($tenant.tenantId -ne $currentTenantId) { # Other tenant — clickable Button switches Entra context. $btn = [Avalonia.Controls.Button]::new() $btn.Content = $tb $btn.HorizontalAlignment = [Avalonia.Layout.HorizontalAlignment]::Stretch $btn.HorizontalContentAlignment = [Avalonia.Layout.HorizontalAlignment]::Left $btn.Margin = [Avalonia.Thickness]::new(0, 5, 0, 0) $btn.Cursor = [Avalonia.Input.Cursor]::new([Avalonia.Input.StandardCursorType]::Hand) $btn.Tag = $tenant $btn.add_Click((ConvertTo-AvaloniaEventScriptBlock { param($S, $E) $t = $S.Tag $ui.HidePopup() Invoke-AvaloniaDeferredAction -Argument $t -Action { param($selectedTenant) Invoke-AvaloniaTenantSwitch $selectedTenant } })) $grdTenants.Children.Add($btn) | Out-Null } else { # Current tenant — non-clickable highlighted row. $border = [Avalonia.Controls.Border]::new() $border.Padding = [Avalonia.Thickness]::new(5, 2, 5, 2) $border.Margin = [Avalonia.Thickness]::new(0, 5, 0, 0) $border.HorizontalAlignment = [Avalonia.Layout.HorizontalAlignment]::Stretch # Try the theme's selected-row brush; fall back to a subtle white tint. $bg = $null [void][Avalonia.Application]::Current.Resources.TryGetResource('SelectedRowBackgroundColor', $null, [ref]$bg) if ($bg) { $border.Background = $bg } else { $border.Background = [Avalonia.Media.SolidColorBrush]::new([Avalonia.Media.Color]::Parse('#22FFFFFF')) } $border.Child = $tb $grdTenants.Children.Add($border) | Out-Null } } catch { Write-LogDebug "Tenant row build failed for $($tenant.DisplayName): $($_.Exception.Message)" } } } Set-PopupPosition $popup $Anchor 320 # Stop dim-layer click-outside dismissal from firing when the user clicks # inside the popup body. Show-Popup checks $e.Source against grdPopup / # cvsPopup; marking handled at the popup root keeps it from reaching either. $popup.add_PointerPressed((ConvertTo-AvaloniaEventScriptBlock { param($S, $E) $E.Handled = $true })) $ui.ShowPopup($popup) } function Show-CachedAccountPicker { param($Anchor, $CachedRows) $ui = $script:UIProvider $popup = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/LoginPanel.axaml')) if (-not $popup) { return } $grdAccounts = (Get-AvaloniaHost)::FindByName($popup, 'grdAccounts') if (-not $grdAccounts) { return } foreach ($row in $CachedRows) { if ($row.Native -and (Test-IsPersonalMSAAccount $row.Native)) { continue } Add-CachedUser $row.Native $grdAccounts } # Footer: "Sign in with a different account" — closes the picker and goes # straight to the active provider's interactive flow. $altBtn = [Avalonia.Controls.Button]::new() $altBtn.Content = 'Sign in with a different account' $altBtn.Margin = [Avalonia.Thickness]::new(0, 10, 0, 0) $altBtn.HorizontalAlignment = [Avalonia.Layout.HorizontalAlignment]::Stretch $altBtn.Cursor = [Avalonia.Input.Cursor]::new([Avalonia.Input.StandardCursorType]::Hand) $altBtn.add_Click((ConvertTo-AvaloniaEventScriptBlock { $ui.HidePopup() Invoke-AvaloniaDeferredAction -Action { param($unused) Invoke-AvaloniaInteractiveSignIn } })) $grdAccounts.Children.Add($altBtn) | Out-Null # Cloud picker: pick a non-default cloud (GCC High, DoD, China) before # sign-in. Only useful when $script:Clouds has >1 entry; otherwise hide. if (@($script:Clouds).Count -gt 1) { $cloudBtn = [Avalonia.Controls.Button]::new() $cloudBtn.Content = 'Sign in to a different cloud...' $cloudBtn.Margin = [Avalonia.Thickness]::new(0, 5, 0, 0) $cloudBtn.HorizontalAlignment = [Avalonia.Layout.HorizontalAlignment]::Stretch $cloudBtn.Cursor = [Avalonia.Input.Cursor]::new([Avalonia.Input.StandardCursorType]::Hand) $cloudBtn.add_Click((ConvertTo-AvaloniaEventScriptBlock { $ui.HidePopup() try { $picked = Show-CloudPickerMenu if ($picked) { Invoke-AvaloniaDeferredAction -Argument $picked -Action { param($cloud) Invoke-AvaloniaInteractiveSignIn -Cloud $cloud } } } catch { Write-LogError 'Cloud picker sign-in failed' $_.Exception Write-Status '' } })) $grdAccounts.Children.Add($cloudBtn) | Out-Null } Set-PopupPosition $popup $Anchor 340 $popup.add_PointerPressed((ConvertTo-AvaloniaEventScriptBlock { param($S, $E) $E.Handled = $true })) $ui.ShowPopup($popup) } function Show-AuthenticationInfo { if (-not $script:grdMenu) { return } # Snapshot then remove — Avalonia Children is a live collection and removing # while iterating misses elements. $existing = @() foreach ($child in $script:grdMenu.Children) { if ($child.Tag -eq 'ProfilePicture') { $existing += $child } } foreach ($child in $existing) { [void]$script:grdMenu.Children.Remove($child) } $profileObj = Get-AvaloniaUserProfile if ($profileObj) { $profileObj.Tag = 'ProfilePicture' [Avalonia.Controls.Grid]::SetColumn($profileObj, 2) $script:grdMenu.Children.Add($profileObj) | Out-Null } } function Set-EnvironmentInfo { param([string]$TenantName) if ([string]::IsNullOrWhiteSpace($TenantName)) { $TenantName = $script:OrganizationName } if (-not $script:borderEnvBadge -or -not $script:txtEnvBadge) { return } $signedIn = $false try { $activeProvider = Get-AuthProvider if ($activeProvider) { $defId = Get-DefaultTokenId if ($defId) { $signedIn = ($null -ne $activeProvider.GetUserInfo($defId)) } 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 button on expiry. if ($signedIn -and (Get-Command Test-DefaultTokenExpired -ErrorAction SilentlyContinue)) { try { if (Test-DefaultTokenExpired) { $signedIn = $false } } catch { } } if (-not $signedIn) { $script:borderEnvBadge.IsVisible = $false $script:txtEnvBadge.Text = '' return } $envText = Get-SettingValue 'EnvironmentText' $envColor = Get-SettingValue 'EnvironmentColor' $showOrg = (Get-SettingValue 'MenuShowOrganizationName') -eq $true $parts = @() if ($envText) { $parts += $envText } if ($TenantName -and $showOrg) { $parts += $TenantName } $badgeText = $parts -join ' - ' if ([string]::IsNullOrWhiteSpace($badgeText)) { $script:borderEnvBadge.IsVisible = $false $script:txtEnvBadge.Text = '' return } $script:txtEnvBadge.Text = $badgeText if ($envText -and $envColor) { try { $c = [Avalonia.Media.Color]::Parse($envColor) $script:borderEnvBadge.Background = [Avalonia.Media.SolidColorBrush]::new($c) $luminance = 0.299 * $c.R + 0.587 * $c.G + 0.114 * $c.B $fgHex = if ($luminance -gt 128) { '#FF1A1A1A' } else { '#FFEEEEEE' } $script:txtEnvBadge.Foreground = [Avalonia.Media.SolidColorBrush]::new([Avalonia.Media.Color]::Parse($fgHex)) } catch { } } else { $script:borderEnvBadge.Background = [Avalonia.Media.Brushes]::Transparent $script:txtEnvBadge.ClearValue([Avalonia.Controls.TextBlock]::ForegroundProperty) } $script:borderEnvBadge.IsVisible = $true } #endregion #region Debug views — Log / Cached Objects / Graph Calls # Avalonia counterparts of Get-LogViewPanel / Get-CachedObjectsViewPanel / # Get-GraphCallsViewPanel from UI/WPF/Extensions/CoreUIWPF.ps1. Hosted by the # three ViewObject subclasses in Classes/CoreUIBaseClassesAvalonia.ps1; the # binder needs CLR row classes (LogRowItem / CacheRowItem / GraphCallRowItem / # GraphBatchRequestRowItem in Classes/) because $script:LogItems / # $script:cacheObjects / $script:AllGraphCalls hold PSCustomObjects that the # Avalonia DataTemplates can't reach (see [[avalonia-binding-needs-clr-types]]). function Get-LogViewPanel { $ui = $script:UIProvider $viewPanel = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/LogInfo.axaml')) if (-not $viewPanel) { return $null } Update-LogView -ViewPanel $viewPanel return $viewPanel } function Update-LogView { param($ViewPanel) if (-not $ViewPanel) { return } # Row-color brushes match the WPF DataTrigger palette: Type 2 -> Orange # (warning), Type 3 -> Red (error). Computed once per refresh and shared # across every projected row so the binder only sees a couple of brush # instances. Foreground=$null falls back to the inherited theme color. $orange = [Avalonia.Media.SolidColorBrush]::new([Avalonia.Media.Color]::FromRgb(255, 165, 0)) $red = [Avalonia.Media.SolidColorBrush]::new([Avalonia.Media.Colors]::Red) $rows = @() if ($script:LogItems) { $rows = @($script:LogItems | ForEach-Object { $row = [LogRowItem]::new() $row.ID = $_.ID $row.DateTime = $_.DateTime $row.Type = $_.Type $row.TypeText = $_.TypeText $row.Text = $_.Text $row.RowForeground = switch ($_.Type) { 2 { $orange } 3 { $red } default { $null } } $row }) } $dg = (Get-AvaloniaHost)::FindByName($ViewPanel, 'dgLogInfo') if ($dg) { $dg.ItemsSource = $null $dg.ItemsSource = [System.Collections.IEnumerable]$rows } } 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 Get-CachedObjectsViewPanel { $ui = $script:UIProvider $viewPanel = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/CachedObjects.axaml')) if (-not $viewPanel) { return $null } Update-CachedObjectsView -ViewPanel $viewPanel # SelectionChanged on dgCachedObjects updates the detail TextBox. The # detail Grid binds DataContext to SelectedItem so most fields refresh # automatically; the value pane gets special-case formatting for # Hashtable values (one entry per line, "key - count objects"). $dgCachedObjects = (Get-AvaloniaHost)::FindByName($viewPanel, 'dgCachedObjects') $txtCacheObjectInfo = (Get-AvaloniaHost)::FindByName($viewPanel, 'txtCacheObjectInfo') if ($dgCachedObjects -and $txtCacheObjectInfo) { # Module-bound handlers lose function-local captures, so the target # TextBox travels on the grid's Tag (same pattern as btnClearCachedObject). $dgCachedObjects.Tag = $txtCacheObjectInfo $dgCachedObjects.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock { param($s, $e) $txt = $s.Tag if (-not $txt) { return } $sel = $s.SelectedItem if (-not $sel) { $txt.Text = ''; return } if ($sel.Value -is [Hashtable]) { $txt.Text = ($sel.Value.Keys | ForEach-Object { "$_ - $($sel.Value[$_].Count) objects" }) -join "`n" } else { $txt.Text = "$($sel.Value)" } })) } $btnClear = (Get-AvaloniaHost)::FindByName($viewPanel, 'btnClearCachedObject') if ($btnClear) { $btnClear.Tag = $viewPanel $btnClear.add_Click((ConvertTo-AvaloniaEventScriptBlock { param($s, $e) $rootPanel = $s.Tag $dg = (Get-AvaloniaHost)::FindByName($rootPanel, 'dgCachedObjects') $sel = if ($dg) { $dg.SelectedItem } else { $null } if ($sel -and $sel.Persistent -ne $true) { $answer = Show-MessageBox -Text "Are you sure you want to remove $($sel.Name)?" -Caption 'Clear object from cache?' -Button 'YesNo' -Icon 'Question' if ($answer -eq 'Yes') { Clear-CacheObject -Name $sel.Name Update-CachedObjectsView -ViewPanel $rootPanel } } })) } $btnRefresh = (Get-AvaloniaHost)::FindByName($viewPanel, 'btnRefreshCachedObjects') if ($btnRefresh) { $btnRefresh.Tag = $viewPanel $btnRefresh.add_Click((ConvertTo-AvaloniaEventScriptBlock { param($s, $e) Update-CachedObjectsView -ViewPanel $s.Tag })) } return $viewPanel } function Update-CachedObjectsView { param($ViewPanel) $ui = $script:UIProvider if (-not $ViewPanel) { return } $totalBytes = [long]0 $rows = @() if ($script:cacheObjects -and $script:cacheObjects.Values) { $rows = @($script:cacheObjects.Values | ForEach-Object { $sz = Get-CacheObjectSize $_.Value $totalBytes += $sz $tagsText = if ($_.Tags) { ($_.Tags -join ', ') } else { '' } $valueText = if ($null -eq $_.Value) { '' } elseif ($_.Value -is [Hashtable]) { "Hashtable ($($_.Value.Count) keys)" } else { "$($_.Value)" } $row = [CacheRowItem]::new() $row.Name = $_.Name $row.Tags = $_.Tags $row.TagsText = $tagsText $row.Value = $_.Value $row.ValueText = $valueText $row.Persistent = [bool]$_.Persistent if ($_.TimeOut) { $row.TimeOut = $_.TimeOut } $row.Size = $sz $row.SizeText = (Format-CacheByteSize $sz) $row }) } $dg = (Get-AvaloniaHost)::FindByName($ViewPanel, 'dgCachedObjects') if ($dg) { $dg.ItemsSource = $null $dg.ItemsSource = [System.Collections.IEnumerable]$rows } $stats = Get-CacheStats $ui.SetXamlProperty($ViewPanel, 'txtCacheEntries', 'Text', ('{0:N0}' -f $stats.Entries)) $ui.SetXamlProperty($ViewPanel, 'txtCacheHits', 'Text', ('{0:N0}' -f $stats.Hits)) $ui.SetXamlProperty($ViewPanel, 'txtCacheMisses', 'Text', ('{0:N0}' -f $stats.Misses)) $ui.SetXamlProperty($ViewPanel, 'txtCacheBytes', 'Text', (Format-CacheByteSize $totalBytes)) } 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) # Verbatim port from UI/WPF/Extensions/CoreUIWPF.ps1 — descriptions # oriented toward what each code means specifically for Microsoft Graph. 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 Build-GraphStatusCodePanel { # Populates a WrapPanel with one clickable Button per status code. Click # sets $script:_graphCallsFilter and re-runs Update-GraphCallsView so the # grid + indicator both refresh in one pass. WPF used a LinkButton style; # Avalonia uses Themes/Styles.axaml's Button.link class for the same look. param( $ViewPanel, [string]$PanelName, [System.Collections.Generic.Dictionary[string,int]]$Counts, [string]$ActiveCode ) $panel = (Get-AvaloniaHost)::FindByName($ViewPanel, $PanelName) if (-not $panel) { return } $panel.Children.Clear() if (-not $Counts -or $Counts.Count -eq 0) { $tb = [Avalonia.Controls.TextBlock]::new() $tb.Text = '-' $tb.Margin = [Avalonia.Thickness]::new(0, 0, 4, 0) [void]$panel.Children.Add($tb) return } foreach ($key in ($Counts.Keys | Sort-Object)) { $btn = [Avalonia.Controls.Button]::new() $btn.Content = ('{0}: {1:N0}' -f $key, $Counts[$key]) $btn.Margin = [Avalonia.Thickness]::new(0, 0, 12, 0) $btn.Padding = [Avalonia.Thickness]::new(0) [void]$btn.Classes.Add('link') $tt = (Get-HttpStatusDescription $key) + "`n`nClick to filter the grid by this status code (includes batch jobs containing a matching sub-request)." [Avalonia.Controls.ToolTip]::SetTip($btn, $tt) if ($ActiveCode -and $key -eq $ActiveCode) { $btn.FontWeight = [Avalonia.Media.FontWeight]::Bold } $btn.Tag = [PSCustomObject]@{ Code = $key; ViewPanel = $ViewPanel } $btn.add_Click((ConvertTo-AvaloniaEventScriptBlock { param($s, $e) $script:_graphCallsFilter = $s.Tag.Code Update-GraphCallsView -ViewPanel $s.Tag.ViewPanel })) [void]$panel.Children.Add($btn) } } function Get-GraphCallsViewPanel { $ui = $script:UIProvider $xamlPath = Join-Path $script:AppUIRootFolder 'XAML/GraphCallsPanel.axaml' $viewPanel = $ui.GetXamlObject($xamlPath) if (-not $viewPanel) { Write-LogError "Get-GraphCallsViewPanel: XAML failed to load from '$xamlPath'." $null return $null } try { Update-GraphCallsView -ViewPanel $viewPanel } catch { Write-LogError 'Get-GraphCallsViewPanel: Update-GraphCallsView threw' $_.Exception } $btnRefresh = (Get-AvaloniaHost)::FindByName($viewPanel, 'btnRefreshGraphCalls') if ($btnRefresh) { $btnRefresh.Tag = $viewPanel $btnRefresh.add_Click((ConvertTo-AvaloniaEventScriptBlock { param($s, $e) $script:_graphCallsFilter = $null Update-GraphCallsView -ViewPanel $s.Tag })) } $btnClearFilter = (Get-AvaloniaHost)::FindByName($viewPanel, 'btnClearGraphCallsFilter') if ($btnClearFilter) { $btnClearFilter.Tag = $viewPanel $btnClearFilter.add_Click((ConvertTo-AvaloniaEventScriptBlock { param($s, $e) $script:_graphCallsFilter = $null Update-GraphCallsView -ViewPanel $s.Tag })) } return $viewPanel } function Update-GraphCallsView { param($ViewPanel) $ui = $script:UIProvider 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 so the breakdown always shows the full # distribution — that's how the user discovers what to filter on. $filterCode = $script:_graphCallsFilter $callsForGrid = $calls if ($filterCode) { $callsForGrid = @($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 }) } $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() # Single pass: aggregate totals + per-code counts across the FULL call # list, project filtered calls into GraphCallRowItem instances for the # grid. Two collections so totals reflect the unfiltered population # while the grid honors the active filter. $rowsForGrid = [System.Collections.Generic.List[object]]::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]++ $errSummary = '' $batchRows = [System.Collections.Generic.List[GraphBatchRequestRowItem]]::new() 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]++ $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]++ } # Project each batch sub-request into its CLR row class so the # nested DataGrid (bound via SelectedItem.BatchRequests) can # resolve column paths. The WPF version walked through PSObject # via {Binding Response.StatusCode}; Avalonia's binder doesn't. $br = [GraphBatchRequestRowItem]::new() $br.Id = "$($item.Id)" if ($item.Response -and $null -ne $item.Response.KB) { $br.KB = [double]$item.Response.KB } if ($item.Response -and $null -ne $item.Response.ObjectCount) { $br.ObjectCount = [int]$item.Response.ObjectCount } if ($item.Response -and $null -ne $item.Response.PageCount) { $br.PageCount = [int]$item.Response.PageCount } $br.StatusCode = if ($item.Response) { "$($item.Response.StatusCode)" } else { '' } $br.Method = "$($item.Method)" $br.URL = "$($item.URL)" [void]$batchRows.Add($br) } if ($errorCounts.Count -gt 0) { $errSummary = (($errorCounts.Keys | Sort-Object | ForEach-Object { "$($_):$($errorCounts[$_])" }) -join ', ') } } # Stash on the source PSCustomObject so external WPF callers (and any # legacy code that still reads the Note) keep seeing the same shape. $call | Add-Member -MemberType NoteProperty -Name 'BatchErrorSummary' -Value $errSummary -Force # Filter happens here, after totals are aggregated. $passes = $true if ($filterCode) { $passes = $false if ($code -eq $filterCode) { $passes = $true } elseif ($call.IsBatch -and $call.BatchRequests) { foreach ($br2 in $call.BatchRequests) { if ("$($br2.Response.StatusCode)" -eq $filterCode) { $passes = $true; break } } } } if (-not $passes) { continue } $row = [GraphCallRowItem]::new() $row.Provider = "$($call.Provider)" if ($call.Time) { $row.Time = $call.Time } if ($null -ne $call.Duration) { $row.Duration = [double]$call.Duration } if ($null -ne $call.KB) { $row.KB = [double]$call.KB } if ($null -ne $call.ObjectCount) { $row.ObjectCount = [int]$call.ObjectCount } if ($null -ne $call.PageCount) { $row.PageCount = [int]$call.PageCount } $row.StatusCode = $code $row.BatchErrorSummary = $errSummary $row.Method = "$($call.Method)" $row.URL = "$($call.URL)" $row.IsBatch = [bool]$call.IsBatch foreach ($br in $batchRows) { [void]$row.BatchRequests.Add($br) } [void]$rowsForGrid.Add($row) } $dgGraphCalls = (Get-AvaloniaHost)::FindByName($ViewPanel, 'dgGraphCalls') if ($dgGraphCalls) { $dgGraphCalls.ItemsSource = $null $dgGraphCalls.ItemsSource = [System.Collections.IEnumerable]$rowsForGrid } $ui.SetXamlProperty($ViewPanel, 'txtTotalCalls', 'Text', ('{0:N0}' -f $calls.Count)) $ui.SetXamlProperty($ViewPanel, 'txtTotalMB', 'Text', ('{0:N2} MB' -f ($totalBytes / 1MB))) $ui.SetXamlProperty($ViewPanel, 'txtTotalObjects', 'Text', ('{0:N0}' -f $totalObjects)) $ui.SetXamlProperty($ViewPanel, 'txtTotalTime', 'Text', (Format-GraphTotalDuration $totalDurationMs)) Build-GraphStatusCodePanel -ViewPanel $ViewPanel -PanelName 'pnlHttpStatuses' -Counts $httpStatuses -ActiveCode $filterCode Build-GraphStatusCodePanel -ViewPanel $ViewPanel -PanelName 'pnlBatchStatuses' -Counts $batchStatuses -ActiveCode $filterCode $indicator = (Get-AvaloniaHost)::FindByName($ViewPanel, 'pnlFilterIndicator') if ($indicator) { if ($filterCode) { $shown = $rowsForGrid.Count $ui.SetXamlProperty($ViewPanel, 'txtActiveFilter', 'Text', ('status code {0} - showing {1:N0} of {2:N0} call(s)' -f $filterCode, $shown, $calls.Count)) $indicator.IsVisible = $true } else { $ui.SetXamlProperty($ViewPanel, 'txtActiveFilter', 'Text', '') $indicator.IsVisible = $false } } } #endregion #region DataGrid select-all header # # WPF parity for the select/deselect-all column header. Avalonia's # DataGridCheckBoxColumn doesn't expose a bindable header CheckBox, so the # bulk forms used to carry a separate `chkSelectAll` on the toolbar — the user # called this out as a regression vs. the WPF UX. The pattern is: # 1. AXAML declares a DataGridTemplateColumn whose Header is a CheckBox # (Header object can be any Avalonia Control) and whose CellTemplate is # a CheckBox bound to with TwoWay mode. # 2. ps1 calls Initialize-AvaloniaGridSelectAllHeader after the form's # DataGrid is in the visual tree; the helper locates the header CheckBox # and wires IsCheckedChanged -> Invoke-AvaloniaGridSelectAllToggle. # # Per-grid state lives in $script:_avaloniaGridSelectAllMap so the click # handler can re-resolve everything by reference. Closures in # .GetNewClosure() scriptblocks get scrubbed by # ConvertTo-AvaloniaEventScriptBlock (see feedback_avalonia_closure_dynamic_module), # so we route through a module-scope function + state instead. function Initialize-AvaloniaGridSelectAllHeader { param( [Parameter(Mandatory)]$Grid, [string]$BindingProperty = 'Selected', [Nullable[bool]]$InitiallyChecked = $true ) if (-not $Grid) { return $null } # AXAML carries a DataGridTemplateColumn whose Header is a CheckBox. Pull # it out without depending on names — Avalonia DataGrid header names live # in a separate scope and aren't reliably findable from the form's root. $headerCb = $null foreach ($col in $Grid.Columns) { if ($col.Header -is [Avalonia.Controls.CheckBox]) { $headerCb = $col.Header; break } } if (-not $headerCb) { return $null } if ($null -ne $InitiallyChecked) { $headerCb.IsChecked = [bool]$InitiallyChecked } if (-not $script:_avaloniaGridSelectAllMap) { $script:_avaloniaGridSelectAllMap = @{} } # Hashtable keys for reference-typed objects use ReferenceEqualityComparer # by default, so the same CheckBox always hits the same entry. $script:_avaloniaGridSelectAllMap[$headerCb] = [PSCustomObject]@{ Grid = $Grid BindingProperty = $BindingProperty } $headerCb.add_IsCheckedChanged((ConvertTo-AvaloniaEventScriptBlock { # $this is the sender control (the header CheckBox) inside an Avalonia # event handler — see CoreUIAvalonia's ConvertTo-AvaloniaEventScriptBlock. Invoke-AvaloniaGridSelectAllToggle -HeaderCheckBox $this })) return $headerCb } function Invoke-AvaloniaGridSelectAllToggle { param([Parameter(Mandatory)]$HeaderCheckBox) if (-not $script:_avaloniaGridSelectAllMap -or -not $script:_avaloniaGridSelectAllMap.ContainsKey($HeaderCheckBox)) { return } $entry = $script:_avaloniaGridSelectAllMap[$HeaderCheckBox] $grid = $entry.Grid $prop = $entry.BindingProperty if (-not $grid -or -not $grid.ItemsSource) { return } $newVal = [bool]$HeaderCheckBox.IsChecked $touched = $false foreach ($row in @($grid.ItemsSource)) { if (-not $row) { continue } if ($row.PSObject.Properties[$prop]) { $row.$prop = $newVal $touched = $true } } if (-not $touched) { return } # PSCustomObject rows don't raise PropertyChanged, so the cell CheckBoxes # don't repaint automatically. Re-binding the ItemsSource forces the grid # to re-realise its cells — same pattern as the per-form chkSelectAll # workarounds we're replacing. $current = @($grid.ItemsSource) $grid.ItemsSource = $null $grid.ItemsSource = $current } #endregion #region UI settings registration function Invoke-CoreUIEventSettingValueUpdated { # Apply UI-affecting setting changes immediately when the user saves them # instead of waiting for an app restart. Matches WPF dispatch. param($SettingInfo, $NewValue, $OldValue) $ui = $script:UIProvider if ($SettingInfo.Key -eq 'EnvironmentText' -or $SettingInfo.Key -eq 'EnvironmentColor') { try { $ui.SetEnvironmentInfo() } catch { Write-LogError 'Set-EnvironmentInfo failed in SettingValueUpdated' $_.Exception } } elseif ($SettingInfo.Key -eq 'AppTheme') { try { $ui.SetAppTheme($NewValue) } catch { Write-LogError 'Set-AppTheme failed in SettingValueUpdated' $_.Exception } } } #endregion # Top-level registration — runs when the module loads the file. Settings have # to be registered before the Settings dialog is opened; the event + handler # pairing is what makes "Save" auto-apply theme / env-badge changes. # 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"