mirror of
https://github.com/Micke-K/IntuneManagement.git
synced 2026-09-28 10:55:38 +02:00
3311 lines
140 KiB
PowerShell
3311 lines
140 KiB
PowerShell
# 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 <ListBox.GroupStyle>. 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 <ListBox.GroupStyle>. 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 = @'
|
|
<StackPanel xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
Orientation="Horizontal" Margin="5,5,5,0">
|
|
<TextBlock x:Name="settingTitleText" Foreground="{DynamicResource TitleForegroundColor}" VerticalAlignment="Center"/>
|
|
<TextBlock x:Name="settingDescIcon" Text="ⓘ" Margin="5,0,0,0" IsVisible="False"
|
|
Foreground="{DynamicResource TitleForegroundColor}" VerticalAlignment="Center" FontSize="12"/>
|
|
</StackPanel>
|
|
'@
|
|
$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 = @'
|
|
<TextBlock xmlns="https://github.com/avaloniaui"
|
|
FontWeight="Bold" Padding="5"
|
|
Background="{DynamicResource SettingsSectionBackgroundColor}"
|
|
Foreground="{DynamicResource TitleForegroundColor}"/>
|
|
'@
|
|
$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 <BindingProperty> 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"
|