Files
IntuneManagement/UI/Avalonia/Extensions/IntuneToolsUIAvalonia.ps1
T

2444 lines
111 KiB
PowerShell

# UI/Avalonia counterpart of UI/WPF/Extensions/IntuneToolsUI.ps1 — minimum-viable
# port of the IntuneTools view so its left-nav items render and clicking them
# swaps placeholder content into grdToolsMain. The four real tool bodies
# (Show-IntuneAssignmentsTool, Show-IntuneFilterUsageTool, Show-ADMXImportTool,
# Show-ADMXRegValuesTool — ~2500 lines in the WPF file) are NOT ported yet;
# each is currently a placeholder TextBlock saying "not yet ported".
Add-AppEventHandler "AppInitialized" "Initialize-IntuneAssignmentsModule"
function Get-IntuneToolsViewItems
{
# Items must be CLR [ViewMenuItem] instances, not [PSCustomObject], so
# Avalonia's binder can resolve MenuLabel / IconImage on the lstMenuItems
# DataTemplate. See [[avalonia-binding-needs-clr-types]].
$items = @()
$items += [ViewMenuItem]@{
Id = 'IntuneAssignments'
Title = 'Intune Assignments'
MenuLabel = 'Intune Assignments'
Category = 'Assignments'
Description = 'List assignments for Intune objects, from an exported folder or directly from Intune.'
IconImage = Get-IntuneToolsItemIcon 'DeviceConfiguration'
}
$items += [ViewMenuItem]@{
Id = 'IntuneFilterUsage'
Title = 'Intune Filter Usage'
MenuLabel = 'Intune Filter Usage'
Category = 'Assignments'
Description = 'Show every assignment filter and the policies / apps that reference it.'
IconImage = Get-IntuneToolsItemIcon 'DeviceConfiguration'
}
$items += [ViewMenuItem]@{
Id = 'ADMXImport'
Title = 'ADMX Import'
MenuLabel = 'ADMX Import'
Category = 'ADMX'
Description = 'Load an .admx (+ .adml) file, edit policy settings, and ingest as a Custom OMA-URI device-configuration profile.'
IconImage = Get-IntuneToolsItemIcon 'DeviceConfiguration'
}
$items += [ViewMenuItem]@{
Id = 'ADMXRegValues'
Title = 'Reg Values'
MenuLabel = 'Reg Values'
Category = 'ADMX'
Description = 'Build a Custom OMA-URI policy from manually-added registry keys/values (HKLM + HKCU).'
IconImage = Get-IntuneToolsItemIcon 'DeviceConfiguration'
}
return $items
}
function Get-IntuneToolsItemIcon
{
param([string]$IconName)
$ui = $script:UIProvider
# Icons live under UI/Avalonia/XAML/Icons/*.axaml (Avalonia-flavoured
# ports of the WPF UI/WPF/XAML/Icons/*.xaml shape XAML, generated by
# XAML/Icons/_Convert-Icons.ps1). Each is a Viewbox/Canvas/Path tree the
# lstMenuItems ContentControl can host directly.
if (-not $IconName) { return $null }
$iconFile = Join-Path $script:AppUIRootFolder "XAML/Icons/$IconName.axaml"
if (-not [IO.File]::Exists($iconFile)) { return $null }
return ($ui.GetXamlObject($iconFile))
}
function Get-IntuneToolsViewPanel
{
$ui = $script:UIProvider
$panel = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/IntuneTools.axaml'))
if (-not $panel) { return $null }
$script:grdToolsMain = (Get-AvaloniaHost)::FindByName($panel, 'grdToolsMain')
return $panel
}
function Invoke-IntuneToolsActivateItem
{
param($SelectedItem)
if (-not $SelectedItem -or -not $script:grdToolsMain) { return $null }
# WPF returned a CollectionViewGroup sentinel for group headers; the
# Avalonia flat-list path doesn't surface those (yet).
if (-not $SelectedItem.Id) {
Write-Log "Unable to activate Intune tool. Selected item did not include a tool Id." 2
return $null
}
Write-Log "Activate tool: $($SelectedItem.Title)"
switch ($SelectedItem.Id)
{
'IntuneAssignments' { Show-IntuneAssignmentsTool }
'IntuneFilterUsage' { Show-IntuneFilterUsageTool }
'ADMXImport' { Show-ADMXImportTool }
'ADMXRegValues' { Show-ADMXRegValuesTool }
default
{
Write-Log "Unknown tool selected: $($SelectedItem.Id)" 2
$script:grdToolsMain.Children.Clear()
}
}
return $null
}
# Placeholder tool bodies — each clears grdToolsMain and writes a "not yet
# ported" notice. Real bodies are large (DataGrids, ADMX editors, filter
# resolution) and live in UI/WPF/Extensions/IntuneToolsUI.ps1; port them
# incrementally when their flows are needed in Avalonia.
function Set-IntuneToolPlaceholder
{
param([string]$ToolName)
if (-not $script:grdToolsMain) { return }
$script:grdToolsMain.Children.Clear()
$tb = [Avalonia.Controls.TextBlock]::new()
$tb.Text = "$ToolName - not yet ported to Avalonia."
$tb.HorizontalAlignment = [Avalonia.Layout.HorizontalAlignment]::Center
$tb.VerticalAlignment = [Avalonia.Layout.VerticalAlignment]::Center
$tb.FontStyle = [Avalonia.Media.FontStyle]::Italic
$tb.Foreground = [Avalonia.Media.Brushes]::Gray
$script:grdToolsMain.Children.Add($tb) | Out-Null
}
# ─── Intune Assignments tool ──────────────────────────────────────────────────
#
# Avalonia port of UI/WPF/Extensions/IntuneToolsUI.ps1 Show-IntuneAssignmentsTool.
# Notable differences vs WPF:
# - PSCustomObject rows from Get-IntuneAssignmentRow are converted to
# [IntuneAssignmentRowItem] CLR instances before the DataGrid sees them
# (Avalonia's binder can't read PSObject NoteProperty).
# - Provider ComboBox uses ItemTemplate {Binding Name} instead of WPF
# DisplayMemberPath; SelectedItem.Value substitutes for SelectedValuePath.
# - Filter rebuilds an ItemsSource subset rather than using WPF's
# ICollectionView.Items.Filter (Avalonia DataGrid doesn't expose that).
# - Folder-options panel doesn't use DataContext binding (provider classes
# don't implement INPC); Register-IntuneAssignmentsProviderEvents sets
# TextBox.Text imperatively and wires TextChanged + Browse to push back.
# - Save button goes through (Get-AvaloniaHost)::SaveFilePicker instead of
# WinForms SaveFileDialog (R13 — WindowsAPICodePack lives only in UI/).
function Show-IntuneAssignmentsTool
{
$ui = $script:UIProvider
if (-not $script:grdToolsMain) { return }
$script:grdToolsMain.Children.Clear()
$panel = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/IntuneToolsAssignments.axaml'))
if (-not $panel) {
Write-Log "Failed to load IntuneToolsAssignments.axaml" 3
Set-IntuneToolPlaceholder 'Intune Assignments'
return
}
$script:intuneAssignmentsPanel = $panel
$script:intuneAssignmentsProviderPanelCache = @{}
# Keep last-loaded rows across panel rebuilds so the user doesn't have to
# re-run the load every time they navigate away from and back to the tool.
if (-not $script:intuneAssignmentsRows) { $script:intuneAssignmentsRows = @() }
$script:cbIntuneAssignmentsProvider = (Get-AvaloniaHost)::FindByName($panel, 'cbIntuneAssignmentsProvider')
$script:ccIntuneAssignmentsProviderOptions = (Get-AvaloniaHost)::FindByName($panel, 'ccIntuneAssignmentsProviderOptions')
$script:dgIntuneAssignments = (Get-AvaloniaHost)::FindByName($panel, 'dgIntuneAssignments')
$script:txtIntuneAssignmentsFilter = (Get-AvaloniaHost)::FindByName($panel, 'txtIntuneAssignmentsFilter')
$script:txtIntuneAssignmentsCount = (Get-AvaloniaHost)::FindByName($panel, 'txtIntuneAssignmentsCount')
$script:cbIntuneAssignmentsView = (Get-AvaloniaHost)::FindByName($panel, 'cbIntuneAssignmentsView')
if ($script:cbIntuneAssignmentsProvider) {
$script:cbIntuneAssignmentsProvider.ItemsSource = @($script:intuneAssignmentsProviders)
# DisplayMemberPath -> ItemTemplate binding (Avalonia ComboBox has no
# DisplayMemberPath property).
# Restore previously-selected provider. SelectedValuePath doesn't exist
# in Avalonia ComboBox; match by .Value on the source list.
$savedValue = Get-SettingStoreValue 'IntuneAssignments' 'Provider' 'folder'
$selected = $script:intuneAssignmentsProviders | Where-Object { $_.Value -eq $savedValue } | Select-Object -First 1
if (-not $selected -and $script:intuneAssignmentsProviders.Count -gt 0) {
$selected = $script:intuneAssignmentsProviders[0]
}
if ($selected) { $script:cbIntuneAssignmentsProvider.SelectedItem = $selected }
# Run the options-panel switch synchronously once before wiring the
# event handler, so the initial Set fires exactly once.
Set-IntuneAssignmentsProviderOptions $script:cbIntuneAssignmentsProvider
$script:cbIntuneAssignmentsProvider.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock {
param($S, $E)
Set-IntuneAssignmentsProviderOptions $S
}))
}
$ui.AddXamlEvent($panel, 'btnGetIntuneAssignments', 'Add_Click', ({
Start-IntuneAssignmentsLoad
}))
$ui.AddXamlEvent($panel, 'btnIntuneAssignmentsCopy', 'Add_Click', ({
$items = @(Get-IntuneToolsDataGridVisibleItems $script:dgIntuneAssignments)
if ($items.Count -eq 0) { return }
($items | Select-Object Name, Type, AssignmentCount, HasFilters, IncludedString, ExcludedString, IncludedFilterString, ExcludedFilterString | ConvertTo-Csv -NoTypeInformation) | Set-Clipboard
}))
$ui.AddXamlEvent($panel, 'btnIntuneAssignmentsSave', 'Add_Click', ({
$items = @(Get-IntuneToolsDataGridVisibleItems $script:dgIntuneAssignments)
if ($items.Count -eq 0) { return }
$initialName = "IntuneAssignments_$((Get-Date).ToString('yyyyMMdd-HHmm'))"
# Reopen at the folder used last (WPF sets InitialDirectory); the
# setting was written on every save but never read back.
$lastDir = Get-SettingStoreValue 'IntuneAssignments' 'LastSaveDirectory'
if (-not $lastDir) { $lastDir = Get-SettingValue 'RootFolder' }
$picked = (Get-AvaloniaHost)::SaveFilePicker(
$script:Window,
'Save assignments',
$initialName,
'csv',
'CSV files',
'*.csv',
[string]$lastDir)
if ($picked) {
Save-SettingStoreValue 'IntuneAssignments' 'LastSaveDirectory' ([IO.FileInfo]$picked).DirectoryName
($items | Select-Object Name, Type, AssignmentCount, HasFilters, IncludedString, ExcludedString, IncludedFilterString, ExcludedFilterString | ConvertTo-Csv -NoTypeInformation) | Out-File -LiteralPath $picked -Force -Encoding UTF8
}
}))
$ui.AddXamlEvent($panel, 'txtIntuneAssignmentsFilter', 'Add_TextChanged', ({
Update-IntuneAssignmentsFilter
}))
if ($script:cbIntuneAssignmentsView) {
# Wrap in the CLR combo type - a PSCustomObject would bind blank here.
$viewItems = [System.Collections.Generic.List[NameValueComboItem]]::new()
foreach ($option in (Get-IntuneAssignmentViewOptions)) {
[void]$viewItems.Add([NameValueComboItem]@{ Name = $option.Name; Value = $option.Value; Source = $option })
}
$script:cbIntuneAssignmentsView.ItemsSource = $viewItems
# First option (Show all) is the default.
$script:cbIntuneAssignmentsView.SelectedIndex = 0
$script:cbIntuneAssignmentsView.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock {
param($S, $E)
Update-IntuneAssignmentsFilter
}))
}
if (@($script:intuneAssignmentsRows).Count -gt 0 -and $script:dgIntuneAssignments) {
$script:dgIntuneAssignments.ItemsSource = $script:intuneAssignmentsRows
Update-IntuneAssignmentsFilter
}
$script:grdToolsMain.Children.Add($panel) | Out-Null
}
function Set-IntuneAssignmentsProviderOptions
{
param($Control)
$ui = $script:UIProvider
if (-not $Control) { return }
$Provider = $Control.SelectedItem
if (-not $Provider) { return }
Save-SettingStoreValue 'IntuneAssignments' 'Provider' $Provider.Value
$providerPanel = $null
if ($Provider.OptionsXaml) {
if ($script:intuneAssignmentsProviderPanelCache.ContainsKey($Provider.Value)) {
$providerPanel = $script:intuneAssignmentsProviderPanelCache[$Provider.Value]
} else {
$panelPath = Join-Path $script:AppUIRootFolder "XAML/$($Provider.OptionsXaml).axaml"
if (Test-Path -LiteralPath $panelPath) {
$providerPanel = $ui.GetXamlObject($panelPath)
if ($providerPanel) {
$script:intuneAssignmentsProviderPanelCache[$Provider.Value] = $providerPanel
Register-IntuneAssignmentsProviderEvents $providerPanel $Provider
}
} else {
Write-Log "Provider options panel not found: $panelPath" 3
}
}
}
if ($script:ccIntuneAssignmentsProviderOptions) {
$script:ccIntuneAssignmentsProviderOptions.Content = $providerPanel
$script:ccIntuneAssignmentsProviderOptions.IsVisible = [bool]$providerPanel
}
}
function Register-IntuneAssignmentsProviderEvents
{
param($Panel, $Provider)
$ui = $script:UIProvider
if ($Provider -is [IntuneAssignmentsFolderProvider]) {
$Provider.ExportPath = Get-SettingStoreValue 'IntuneAssignments' 'ExportPath'
$txtPath = (Get-AvaloniaHost)::FindByName($Panel, 'txtIntuneAssignmentsExportPath')
if ($txtPath) {
$txtPath.Text = [string]$Provider.ExportPath
# Two-way sync: typing pushes into Provider.ExportPath. No INPC needed.
$txtPath.Tag = $Provider
$txtPath.add_TextChanged((ConvertTo-AvaloniaEventScriptBlock {
param($S, $E)
if ($S.Tag) { $S.Tag.ExportPath = [string]$S.Text }
}))
}
$browse = (Get-AvaloniaHost)::FindByName($Panel, 'browseIntuneAssignmentsExportPath')
if ($browse -and $txtPath) {
$browse.Tag = @{ Provider = $Provider; TextBox = $txtPath }
$browse.add_Click((ConvertTo-AvaloniaEventScriptBlock {
param($S, $E)
$tag = $S.Tag
# $script:UIProvider by name - a captured function-local $ui is
# stripped by ConvertTo-AvaloniaEventScriptBlock (closure law).
# Start the picker at the current path like WPF does.
$folder = $script:UIProvider.ShowFolderPicker([string]$tag.Provider.ExportPath, 'Select root folder for exported objects')
if ($folder) {
$tag.Provider.ExportPath = $folder
$tag.TextBox.Text = $folder # TextChanged will refresh Provider again — harmless
}
}))
}
}
}
function Start-IntuneAssignmentsLoad
{
$ui = $script:UIProvider
if (-not $script:cbIntuneAssignmentsProvider) { return }
$provider = $script:cbIntuneAssignmentsProvider.SelectedItem
if (-not $provider) {
$ui.ShowMessageBox('Select an input source first', 'Intune Assignments', 'OK', 'Error')
return
}
try {
if (-not $provider.Validate()) { return }
$provider.SaveSettings()
Write-Status 'Get Intune assignments'
$script:dgIntuneAssignments.ItemsSource = $null
$rawRows = @($provider.GetAssignments())
# Coerce each PSCustomObject row into a CLR class instance so Avalonia
# bindings (Name / Type / HasFilters / ...) resolve.
$rows = @($rawRows | ForEach-Object {
if (-not $_) { return }
[IntuneAssignmentRowItem]@{
Name = [string]$_.Name
Type = [string]$_.Type
AssignmentCount = [int]$_.AssignmentCount
HasFilters = [bool]$_.HasFilters
IncludedString = [string]$_.IncludedString
ExcludedString = [string]$_.ExcludedString
IncludedFilterString = [string]$_.IncludedFilterString
ExcludedFilterString = [string]$_.ExcludedFilterString
Id = [string]$_.Id
Object = $_.Object
Included = @($_.Included)
Excluded = @($_.Excluded)
IncludedFilters = @($_.IncludedFilters)
ExcludedFilters = @($_.ExcludedFilters)
}
})
$script:intuneAssignmentsRows = $rows
Update-IntuneAssignmentsFilter # sets ItemsSource + summary
} catch {
$ui.ShowMessageBox($_.Exception.Message, 'Intune Assignments', 'OK', 'Error')
} finally {
Write-Status ''
}
}
function Get-IntuneAssignmentsSelectedView
{
# Value of the View combo, defaulting to All before the panel is built.
if (-not $script:cbIntuneAssignmentsView) { return 'All' }
$value = "$($script:cbIntuneAssignmentsView.SelectedItem.Value)"
if ([string]::IsNullOrEmpty($value)) { return 'All' }
return $value
}
function Update-IntuneAssignmentsFilter
{
if (-not $script:dgIntuneAssignments) { return }
if (-not $script:txtIntuneAssignmentsFilter) { return }
$text = "$($script:txtIntuneAssignmentsFilter.Text)".Trim()
$view = Get-IntuneAssignmentsSelectedView
$script:dgIntuneAssignments.ItemsSource = $null
if ([string]::IsNullOrEmpty($text) -and $view -eq 'All') {
$script:dgIntuneAssignments.ItemsSource = $script:intuneAssignmentsRows
} else {
$pattern = [regex]::Escape($text)
# Where-Object pipes items as PSObject wrappers; Avalonia's DataGrid
# text-column binder reflects on the runtime type and silently fails
# against the wrapper. Collect into a typed List so rows stay as
# bare IntuneAssignmentRowItem instances.
$filtered = [System.Collections.Generic.List[IntuneAssignmentRowItem]]::new()
foreach ($row in $script:intuneAssignmentsRows) {
if (-not (Test-IntuneAssignmentViewMatch $row $view)) { continue }
if ([string]::IsNullOrEmpty($text)) { [void]$filtered.Add($row); continue }
if (
($row.Name -and $row.Name -match $pattern) -or
($row.Type -and $row.Type -match $pattern) -or
($row.IncludedString -and $row.IncludedString -match $pattern) -or
($row.ExcludedString -and $row.ExcludedString -match $pattern) -or
($row.IncludedFilterString -and $row.IncludedFilterString -match $pattern) -or
($row.ExcludedFilterString -and $row.ExcludedFilterString -match $pattern) -or
($row.HasFilters -and 'Has filters' -match $pattern)
) {
[void]$filtered.Add($row)
}
}
$script:dgIntuneAssignments.ItemsSource = $filtered
}
Update-IntuneAssignmentsSummary
}
function Update-IntuneAssignmentsSummary
{
if (-not $script:txtIntuneAssignmentsCount) { return }
$total = @($script:intuneAssignmentsRows).Count
$visible = @($script:dgIntuneAssignments.ItemsSource).Count
$script:txtIntuneAssignmentsCount.Text =
if ($total -eq $visible) { "$total objects" } else { "$visible of $total objects" }
}
function Get-IntuneToolsDataGridVisibleItems
{
param($DataGrid)
if (-not $DataGrid -or -not $DataGrid.ItemsSource) { return @() }
return @($DataGrid.ItemsSource)
}
# ─── Intune Filter Usage tool ────────────────────────────────────────────────
#
# Avalonia port of UI/WPF/Extensions/IntuneToolsUI.ps1 Show-IntuneFilterUsageTool.
# Same translation pattern as Intune Assignments — DataGrid items converted
# from PSCustomObject to [IntuneFilterUsageRowItem], filter via ItemsSource
# rebuild, Save via Avalonia SaveFilePicker.
#
# Data layer (Get-IntuneFilterUsageData + Get-IntuneFilterPayloadDispatch +
# Get-IntuneEnrollmentConfiguration*) is duplicated verbatim from WPF. The
# functions don't touch any WPF API — they're pure Graph data layer that
# cleanup.md (R2) flags as misplaced in a UI file. Move to Internal/ in a
# future cleanup and both UI trees can share one copy.
function Show-IntuneFilterUsageTool
{
$ui = $script:UIProvider
if (-not $script:grdToolsMain) { return }
$script:grdToolsMain.Children.Clear()
$panel = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/IntuneToolsFilterUsage.axaml'))
if (-not $panel) {
Write-Log "Failed to load IntuneToolsFilterUsage.axaml" 3
Set-IntuneToolPlaceholder 'Intune Filter Usage'
return
}
$script:intuneFilterUsagePanel = $panel
if (-not $script:intuneFilterUsageRows) { $script:intuneFilterUsageRows = @() }
$script:dgIntuneFilterUsage = (Get-AvaloniaHost)::FindByName($panel, 'dgIntuneFilterUsage')
$script:txtIntuneFilterUsageFilter = (Get-AvaloniaHost)::FindByName($panel, 'txtIntuneFilterUsageFilter')
$script:txtIntuneFilterUsageCount = (Get-AvaloniaHost)::FindByName($panel, 'txtIntuneFilterUsageCount')
$script:btnGetIntuneFilterUsage = (Get-AvaloniaHost)::FindByName($panel, 'btnGetIntuneFilterUsage')
if (-not $script:dgIntuneFilterUsage -or -not $script:btnGetIntuneFilterUsage) {
Write-Log "Intune Filter Usage XAML loaded, but required controls were not found" 3
return
}
$ui.AddXamlEvent($panel, 'btnGetIntuneFilterUsage', 'Add_Click', ({
Start-IntuneFilterUsageLoad
}))
$ui.AddXamlEvent($panel, 'btnIntuneFilterUsageCopy', 'Add_Click', ({
$items = @(Get-IntuneToolsDataGridVisibleItems $script:dgIntuneFilterUsage)
if ($items.Count -eq 0) { return }
($items | Select-Object FilterName, PolicyName, PayloadType, Mode, GroupName | ConvertTo-Csv -NoTypeInformation) | Set-Clipboard
}))
$ui.AddXamlEvent($panel, 'btnIntuneFilterUsageSave', 'Add_Click', ({
$items = @(Get-IntuneToolsDataGridVisibleItems $script:dgIntuneFilterUsage)
if ($items.Count -eq 0) { return }
$initialName = "IntuneFilterUsage_$((Get-Date).ToString('yyyyMMdd-HHmm'))"
# Reopen at the folder used last (WPF sets InitialDirectory); the
# setting was written on every save but never read back.
$lastDir = Get-SettingStoreValue 'IntuneFilterUsage' 'LastSaveDirectory'
if (-not $lastDir) { $lastDir = Get-SettingValue 'RootFolder' }
$picked = (Get-AvaloniaHost)::SaveFilePicker(
$script:Window,
'Save filter usage',
$initialName,
'csv',
'CSV files',
'*.csv',
[string]$lastDir)
if ($picked) {
Save-SettingStoreValue 'IntuneFilterUsage' 'LastSaveDirectory' ([IO.FileInfo]$picked).DirectoryName
($items | Select-Object FilterName, PolicyName, PayloadType, Mode, GroupName | ConvertTo-Csv -NoTypeInformation) | Out-File -LiteralPath $picked -Force -Encoding UTF8
}
}))
$ui.AddXamlEvent($panel, 'txtIntuneFilterUsageFilter', 'Add_TextChanged', ({
Update-IntuneFilterUsageFilter
}))
if (@($script:intuneFilterUsageRows).Count -gt 0 -and $script:dgIntuneFilterUsage) {
$script:dgIntuneFilterUsage.ItemsSource = $script:intuneFilterUsageRows
Update-IntuneFilterUsageFilter
}
$script:grdToolsMain.Children.Add($panel) | Out-Null
}
function Start-IntuneFilterUsageLoad
{
$ui = $script:UIProvider
if (-not $script:dgIntuneFilterUsage) { return }
if ($script:btnGetIntuneFilterUsage) { $script:btnGetIntuneFilterUsage.IsEnabled = $false }
try {
# Reset per-load lazy caches so a re-run picks up fresh server state.
$script:_intuneEnrollmentConfigCache = $null
$script:_intuneManagedAppPolicyCache = $null
Write-Status 'Get Intune filter usage'
$script:dgIntuneFilterUsage.ItemsSource = $null
try {
$rawRows = @(Get-IntuneFilterUsageData)
} catch {
Write-LogError 'Intune Filter Usage: load failed' $_.Exception
Write-Status ''
$ui.ShowMessageBox("Failed to load filter usage:`n`n$($_.Exception.Message)", 'Intune Filter Usage', 'OK', 'Error')
return
}
# PSCustomObject -> CLR class for Avalonia binding.
$rows = @($rawRows | ForEach-Object {
if (-not $_) { return }
[IntuneFilterUsageRowItem]@{
FilterName = [string]$_.FilterName
Platform = [string]$_.Platform
FilterType = [string]$_.FilterType
PolicyName = [string]$_.PolicyName
PayloadType = [string]$_.PayloadType
Mode = [string]$_.Mode
GroupId = [string]$_.GroupId
GroupName = [string]$_.GroupName
}
})
$script:intuneFilterUsageRows = $rows
Update-IntuneFilterUsageFilter # sets ItemsSource + summary
Write-Status ''
} finally {
if ($script:btnGetIntuneFilterUsage) { $script:btnGetIntuneFilterUsage.IsEnabled = $true }
}
}
function Update-IntuneFilterUsageFilter
{
if (-not $script:dgIntuneFilterUsage) { return }
if (-not $script:txtIntuneFilterUsageFilter) { return }
$text = "$($script:txtIntuneFilterUsageFilter.Text)".Trim()
$script:dgIntuneFilterUsage.ItemsSource = $null
if ([string]::IsNullOrEmpty($text)) {
$script:dgIntuneFilterUsage.ItemsSource = $script:intuneFilterUsageRows
} else {
$pattern = [regex]::Escape($text)
# Same wrapping issue as Update-IntuneAssignmentsFilter — collect
# into a typed List so DataGrid text columns reflect the row class.
$filtered = [System.Collections.Generic.List[IntuneFilterUsageRowItem]]::new()
foreach ($row in $script:intuneFilterUsageRows) {
if (
($row.FilterName -and $row.FilterName -match $pattern) -or
($row.PolicyName -and $row.PolicyName -match $pattern) -or
($row.PayloadType -and $row.PayloadType -match $pattern) -or
($row.Mode -and $row.Mode -match $pattern) -or
($row.GroupName -and $row.GroupName -match $pattern)
) {
[void]$filtered.Add($row)
}
}
$script:dgIntuneFilterUsage.ItemsSource = $filtered
}
Update-IntuneFilterUsageSummary
}
function Update-IntuneFilterUsageSummary
{
if (-not $script:txtIntuneFilterUsageCount) { return }
$total = @($script:intuneFilterUsageRows).Count
$visible = @($script:dgIntuneFilterUsage.ItemsSource).Count
$script:txtIntuneFilterUsageCount.Text =
if ($total -eq $visible) { "$total rows" } else { "$visible of $total rows" }
}
# ─── Filter-usage data layer (verbatim copy from WPF; see cleanup.md R2) ─────
# ─── ADMX Import tool — Slices 5a1+5a2+5a3+5a4 wired ─────────────────────────
#
# Avalonia port of UI/WPF/Extensions/IntuneToolsUI.ps1 Show-ADMXImportTool.
# Loads IntuneToolsADMX.axaml, caches every named control the WPF version
# uses, and wires every handler (Load ADMX / Load ADML / Import + Import-tab
# TextBox / CheckBox / Add-Random + DataGrid DoubleTap / context-menu Edit).
#
# Differences from WPF:
# - WinForms OpenFileDialog -> (Get-AvaloniaHost)::OpenFilePicker (R13: this
# project doesn't carry a WinForms / WindowsAPICodePack dependency in
# UI/Avalonia/).
# - The picker returns a string (or $null on cancel) rather than a DialogResult
# + FileName pair, so the click handlers branch on truthiness.
function Show-ADMXImportTool
{
$ui = $script:UIProvider
if (-not $script:grdToolsMain) { return }
$script:grdToolsMain.Children.Clear()
$panel = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/IntuneToolsADMX.axaml'))
if (-not $panel) {
Write-Log "Failed to load IntuneToolsADMX.axaml" 3
Set-IntuneToolPlaceholder 'ADMX Import'
return
}
# Cache panel + controls used by event handlers. FindByName once at panel-init
# so click/select handlers don't re-probe the visual tree.
$script:admxImportPanel = $panel
$script:btnADMXLoadADMX = (Get-AvaloniaHost)::FindByName($panel, 'btnADMXLoadADMX')
$script:btnADMXLoadADML = (Get-AvaloniaHost)::FindByName($panel, 'btnADMXLoadADML')
$script:btnADMXImport = (Get-AvaloniaHost)::FindByName($panel, 'btnADMXImport')
$script:btnADMXPolicyNameRandom = (Get-AvaloniaHost)::FindByName($panel, 'btnADMXPolicyNameRandom')
$script:tvADMXCategories = (Get-AvaloniaHost)::FindByName($panel, 'tvADMXCategories')
$script:dgADMXCategoryPolicies = (Get-AvaloniaHost)::FindByName($panel, 'dgADMXCategoryPolicies')
# The ADMX rows are PSCustomObjects, and Avalonia's binder cannot read
# NoteProperties ({Binding Name} renders empty cells - see
# [[avalonia-binding-needs-clr-types]]). Rebind each column through
# IntunePolicyPathConverter with a self-binding (empty path = the row),
# the same trick the main grid uses for ObjectColumns overrides.
if ($script:dgADMXCategoryPolicies) {
$admxPathConverter = (Get-AvaloniaHost)::CreatePathConverter()
$admxColPaths = @('Name', 'SettingStatusText', 'Category')
for ($i = 0; $i -lt [Math]::Min($admxColPaths.Count, $script:dgADMXCategoryPolicies.Columns.Count); $i++) {
$b = New-Object Avalonia.Data.Binding ''
$b.Mode = [Avalonia.Data.BindingMode]::OneWay
$b.Converter = $admxPathConverter
$b.ConverterParameter = $admxColPaths[$i]
$script:dgADMXCategoryPolicies.Columns[$i].Binding = $b
}
}
$script:txtADMXProfileName = (Get-AvaloniaHost)::FindByName($panel, 'txtADMXProfileName')
$script:txtADMXProfileDescription = (Get-AvaloniaHost)::FindByName($panel, 'txtADMXProfileDescription')
$script:txtADMXPolicyFileName = (Get-AvaloniaHost)::FindByName($panel, 'txtADMXPolicyFileName')
$script:txtADMXPolicyAppName = (Get-AvaloniaHost)::FindByName($panel, 'txtADMXPolicyAppName')
$script:txtADMXPolicyIngestName = (Get-AvaloniaHost)::FindByName($panel, 'txtADMXPolicyIngestName')
$script:chkADMXPolicyIngest = (Get-AvaloniaHost)::FindByName($panel, 'chkADMXPolicyIngest')
# Per-tool state — initialised here so file-dialog handlers can write to it
# without a null-check inside the click body.
$script:currentADMXFile = $null
# Idempotency for tree/grid event registration. Show-ADMXImportTool can be
# called multiple times across nav switches; the cached panel persists in
# memory for the lifetime of the IntuneTools view, so events shouldn't
# double-register.
$script:_admxImportEventsRegistered = $false
$ui.AddXamlEvent($panel, 'btnADMXLoadADMX', 'add_Click', ({
$startPath = Get-SettingStoreValue 'Tools' 'ADMXLastADMXFile'
$picked = (Get-AvaloniaHost)::OpenFilePicker(
$script:Window,
'Select ADMX file',
$startPath,
'ADMX files',
'*.admx')
if ($picked) {
$script:currentADMXFile = [IO.FileInfo]$picked
Save-SettingStoreValue 'Tools' 'ADMXLastADMXFile' $picked
Write-Status "Loading policy settings from $($script:currentADMXFile.Name)"
Start-ADMXLoadFile $picked
Write-Status ''
}
}))
$ui.AddXamlEvent($panel, 'btnADMXLoadADML', 'add_Click', ({
$startPath = Get-SettingStoreValue 'Tools' 'ADMXLastADMLFile'
$picked = (Get-AvaloniaHost)::OpenFilePicker(
$script:Window,
'Select ADML file',
$startPath,
'ADML files',
'*.adml')
if ($picked) {
Save-SettingStoreValue 'Tools' 'ADMXLastADMLFile' $picked
Write-Status "Loading ADML policy $picked"
Invoke-ADMXLoadSettings $picked
Write-Status ''
}
}))
$ui.AddXamlEvent($panel, 'btnADMXImport', 'add_Click', ({
Write-Status 'Import ADMX policy'
Import-ADMXPolicyToIntune
Write-Status ''
}))
# Append-or-replace GUID suffix on the policy file name. The trailing
# segment after "_" is the unique-id portion; if it's already a 32-char
# GUID we leave the existing prefix alone and replace only the suffix.
$ui.AddXamlEvent($panel, 'btnADMXPolicyNameRandom', 'add_Click', ({
$guid = [Guid]::NewGuid().Guid
if ($script:txtADMXPolicyFileName.Text) {
$parts = $script:txtADMXPolicyFileName.Text.Split('_')
if ($parts[-1].Length -eq $guid.Length) {
$script:txtADMXPolicyFileName.Text = (($parts[0..($parts.Count - 2)] -join '_') + '_' + $guid)
}
else {
$script:txtADMXPolicyFileName.Text = ($script:txtADMXPolicyFileName.Text + '_' + $guid)
}
}
else {
$script:txtADMXPolicyFileName.Text = $guid
}
}))
$script:grdToolsMain.Children.Add($panel) | Out-Null
}
# ─── ADMX Import — helpers (data layer, pure functions) ───────────────────────
#
# Slice 5a2 — ports the ADMX/ADML parser + category-tree builder. Pure functions
# read $script:_admx* state set up by Start-ADMXLoadFile / Invoke-ADMXLoadSettings;
# none of them touch UI controls (the TreeViewItem renderer is the lone
# exception and is clearly named).
#
# Differences from WPF (UI/WPF/Extensions/IntuneToolsUI.ps1):
# - Add-ADMXCategoryTreeNodeRecursive instantiates [Avalonia.Controls.TreeViewItem]
# instead of [System.Windows.Controls.TreeViewItem]; the API surface used
# (Header, Tag, Items.Add, Add-Member NoteProperty AllPolicies) carries over.
function Add-ADMXCategoriesRecursive
{
param($Categories, $Parent, $SettingClass)
foreach ($cat in $Categories.ref) {
$catPath = Get-ADMXCategoryIdPath $cat
$tvObj = $Parent
foreach ($catName in $catPath.Split('/')) {
$curParent = $tvObj.Children | Where-Object { $_.CategoryNode.name -eq $catName }
if (-not $curParent) {
$catNode = $script:_admxXML.policyDefinitions.categories.SelectSingleNode("$($script:_admxNSPrefix)category[@name='$catName']", $script:_admxNS)
$curParent = [PSCustomObject]@{
Name = Get-ADMXADMLString $catNode
CategoryName = $catName
CategoryNode = $catNode
SettingClass = $SettingClass
Children = @()
}
$tvObj.Children += $curParent
}
$tvObj = $curParent
}
}
}
function Add-ADMXCategoryTreeNodeRecursive
{
param($Obj, $Parent)
$tvItem = [Avalonia.Controls.TreeViewItem]::new()
$tvItem.Header = $Obj.Name
$tvItem.Tag = $Obj
[void]$Parent.Items.Add($tvItem)
$Obj.Children | Sort-Object -Property Name | ForEach-Object {
Add-ADMXCategoryTreeNodeRecursive $_ $tvItem
}
}
# ─── ADMX Import — loaders ────────────────────────────────────────────────────
function Start-ADMXLoadFile
{
param($FileName)
$ui = $script:UIProvider
# Hard reset — partial state from a previous file would cross-contaminate
# the tree-building below.
$script:_admxNS = $null
$script:_admxNSPrefix = $null
$script:_admxXML = $null
$script:_admxPolicies = @()
$script:_admxPoliciesHT = @{}
$script:_admxSupportedOn = @()
$script:_admxCategoryPaths = @{}
$script:_admxStringTable = @{}
$script:_admxlngADML = $null
if ($script:txtADMXProfileName) { $script:txtADMXProfileName.Text = '' }
if ($script:txtADMXProfileDescription) { $script:txtADMXProfileDescription.Text = '' }
if ($script:txtADMXPolicyFileName) { $script:txtADMXPolicyFileName.Text = '' }
if ($script:txtADMXPolicyIngestName) { $script:txtADMXPolicyIngestName.Text = '' }
if ($script:txtADMXPolicyAppName) { $script:txtADMXPolicyAppName.Text = '' }
if ($script:btnADMXLoadADML) { $script:btnADMXLoadADML.IsEnabled = $false }
$admxFI = [IO.FileInfo]$FileName
if (-not $admxFI.Exists) {
$ui.ShowMessageBox("ADMX file not found: $FileName", 'ADMX Import', 'OK', 'Error')
return
}
# Convention: paired .adml lives in en-US\ next to the .admx, or sometimes
# in the same folder. Missing ADML is a soft warning — Get-ADMXADMLString
# falls back to raw attributes if so.
$admlFile = [IO.Path]::Combine($admxFI.DirectoryName, "en-US", "$($admxFI.BaseName).adml")
if (-not [IO.File]::Exists($admlFile)) {
$admlFile = [IO.Path]::Combine($admxFI.DirectoryName, "$($admxFI.BaseName).adml")
}
if (-not [IO.File]::Exists($admlFile)) {
Write-Log "Could not find an ADML file alongside $FileName" 2
$admlFile = $null
}
try {
Write-Log "Load ADMX file $FileName"
[xml]$script:_admxXML = [IO.File]::ReadAllText($FileName)
$namespace = $script:_admxXML.DocumentElement.NamespaceURI
if ($namespace) {
$script:_admxNS = New-Object System.Xml.XmlNamespaceManager($script:_admxXML.NameTable)
$script:_admxNS.AddNamespace('ns', $namespace)
$script:_admxNSPrefix = 'ns:'
}
else {
$script:_admxNS = $null
$script:_admxNSPrefix = ''
}
# Default the App Id from the ADMX namespace prefix; fall back to the
# filename if that's missing.
$prefix = $script:_admxXML.policyDefinitions.policyNamespaces.SelectSingleNode("$($script:_admxNSPrefix)target[@prefix]", $script:_admxNS)
$policyId = $null
if ($prefix) {
if ($prefix.namespace) { $policyId = $prefix.namespace.Split('.')[-1] }
else { $policyId = $prefix.prefix }
}
if (-not $policyId) {
$policyId = $admxFI.BaseName -replace ' ', ''
Write-Log 'Failed to get policy id from ADMX namespace; using file base name' 2
}
if ($script:txtADMXPolicyAppName) { $script:txtADMXPolicyAppName.Text = $policyId }
if ($script:txtADMXPolicyFileName) { $script:txtADMXPolicyFileName.Text = $policyId }
}
catch {
Write-LogError 'Failed to load ADMX file' $_.Exception
return
}
if ($script:btnADMXLoadADML) { $script:btnADMXLoadADML.IsEnabled = $true }
Invoke-ADMXLoadSettings $admlFile
}
function Invoke-ADMXLoadSettings
{
param($AdmlFile)
if ($AdmlFile -and [IO.File]::Exists($AdmlFile)) {
try {
Write-Log "Load ADML file $AdmlFile"
[xml]$script:_admxlngADML = [IO.File]::ReadAllText($AdmlFile)
}
catch {
Write-LogError "Failed to load ADML file $AdmlFile" $_.Exception
}
}
$script:_admxStringTable = @{}
if ($script:_admxlngADML) {
foreach ($strNode in $script:_admxlngADML.policyDefinitionResources.resources.stringTable.string) {
if (-not $script:_admxStringTable.ContainsKey($strNode.id)) {
$script:_admxStringTable.Add($strNode.id, $strNode.'#text')
}
}
}
# supportedOn table — augmented with Windows.adml's SUPPORTED_* entries so
# policies that reference common Windows constants still resolve in the
# SettingProperties dialog (slice 5a3).
$script:_admxSupportedOn = @()
foreach ($polObj in $script:_admxXML.policyDefinitions.supportedOn.definitions.definition) {
$script:_admxSupportedOn += [PSCustomObject]@{
Id = $polObj.Name
DisplayName = (Get-ADMXADMLString $polObj)
}
}
if ($script:_admxWindowsADML) {
foreach ($winString in ($script:_admxWindowsADML.policyDefinitionResources.resources.stringTable.string | Where-Object Id -like 'SUPPORTED_*')) {
$script:_admxSupportedOn += [PSCustomObject]@{
Id = $winString.id
DisplayName = $winString.'#text'
}
}
}
$devicePolicies = @()
$userPolicies = @()
foreach ($polObj in $script:_admxXML.policyDefinitions.policies.policy) {
$category = if ($polObj.parentCategory.ref) { Get-ADMXCategoryNamePath $polObj.parentCategory.ref '/' } else { $null }
$displayName = Get-ADMXADMLString $polObj
$description = Get-ADMXADMLString $polObj 'explainText'
# Class can be Both / Machine / User. "Both" expands into two virtual
# entries so it shows up in both Device + User configuration trees.
$classArr = switch ($polObj.Class) {
'Both' { @('Device','User') }
'Machine' { @('Device') }
default { @($polObj.Class) }
}
foreach ($class in $classArr) {
$key = "$($polObj.Name)_$class"
if ($script:_admxPoliciesHT.ContainsKey($key)) { continue }
$newSetting = [PSCustomObject]@{
Name = $displayName
Description = $description
OMAURIName = $null
OMAURIDescription = $null
Category = $category
CategoryId = $polObj.parentCategory.ref
Id = $polObj.Name
Definition = $polObj
SettingStatus = $null
SettingStatusText = $null
PolicySettings = $null
PolicyDefinition = $null
ElementsPanel = $null
ManualConfig = $false
SettingClass = $class
SupportedOn = $null
}
$script:_admxPoliciesHT.Add($key, $newSetting)
$script:_admxPolicies += $newSetting
if ($class -eq 'User') { $userPolicies += $newSetting }
else { $devicePolicies += $newSetting }
}
}
$script:_admxPolicies | ForEach-Object { Set-ADMXSettingStatusText $_ }
$script:_admxPolicies = $script:_admxPolicies | Sort-Object -Property Name
if ($script:tvADMXCategories) { $script:tvADMXCategories.Items.Clear() }
$treeItems = @()
# Device Configuration root
$tvItem = [PSCustomObject]@{ Name = 'Computer Configuration'; Children = @() }
if ($script:_admxXML.policyDefinitions.policies) {
$policies = $script:_admxXML.policyDefinitions.policies.SelectNodes("$($script:_admxNSPrefix)policy[@class = 'Both' or @class = 'Machine']", $script:_admxNS)
if ($policies) {
$categories = $policies.parentCategory | Select-Object ref -Unique
Add-ADMXCategoriesRecursive $categories $tvItem 'Device'
}
}
$treeItems += $tvItem
# User Configuration root
$tvItem = [PSCustomObject]@{ Name = 'User Configuration'; Children = @() }
if ($script:_admxXML.policyDefinitions.policies) {
$policies = $script:_admxXML.policyDefinitions.policies.SelectNodes("$($script:_admxNSPrefix)policy[@class = 'Both' or @class = 'User']", $script:_admxNS)
if ($policies) {
$categories = $policies.parentCategory | Select-Object ref -Unique
Add-ADMXCategoriesRecursive $categories $tvItem 'User'
}
}
$treeItems += $tvItem
$treeItems | ForEach-Object { Add-ADMXCategoryTreeNodeRecursive $_ $script:tvADMXCategories }
# "All Policies" leaf under each root — quick way to see everything.
if ($devicePolicies.Count -gt 0 -and $script:tvADMXCategories.Items.Count -ge 1) {
$tv = [Avalonia.Controls.TreeViewItem]::new()
$tv.Header = 'All Policies'
$tv.Tag = $devicePolicies
$tv | Add-Member -MemberType NoteProperty -Name 'AllPolicies' -Value $true
[void]$script:tvADMXCategories.Items[0].Items.Add($tv)
}
if ($userPolicies.Count -gt 0 -and $script:tvADMXCategories.Items.Count -ge 2) {
$tv = [Avalonia.Controls.TreeViewItem]::new()
$tv.Header = 'All Policies'
$tv.Tag = $userPolicies
$tv | Add-Member -MemberType NoteProperty -Name 'AllPolicies' -Value $true
try { [void]$script:tvADMXCategories.Items[1].Items.Add($tv) } catch { }
}
Register-ADMXImportTreeEvents
}
function Register-ADMXImportTreeEvents
{
# Idempotent — only registers once per panel lifetime. ScriptBlocks read
# $script: state, so they work the same across reloads of ADMX files.
if ($script:_admxImportEventsRegistered) { return }
$script:_admxImportEventsRegistered = $true
if ($script:tvADMXCategories) {
# WPF SelectedItemChanged -> Avalonia SelectionChanged on TreeView
# (TreeView extends SelectingItemsControl; raises SelectionChanged when
# the selected node changes).
$script:tvADMXCategories.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock {
$sel = $script:tvADMXCategories.SelectedItem
if (-not $sel) { return }
# When Items were populated with TreeViewItem instances directly,
# SelectedItem is the TreeViewItem itself; .Tag carries our model.
$hasAllPolicies = ($sel.PSObject.Properties['AllPolicies']) -and $sel.AllPolicies -eq $true
if ($hasAllPolicies) {
$script:dgADMXCategoryPolicies.Columns[0].Width = [Avalonia.Controls.DataGridLength]::Auto
$script:dgADMXCategoryPolicies.Columns[1].Width = [Avalonia.Controls.DataGridLength]::Auto
$script:dgADMXCategoryPolicies.Columns[2].Width = [Avalonia.Controls.DataGridLength]::Auto
$script:dgADMXCategoryPolicies.Columns[2].IsVisible = $true
$list = $sel.Tag
}
else {
$script:dgADMXCategoryPolicies.Columns[0].Width = [Avalonia.Controls.DataGridLength]::new(1, [Avalonia.Controls.DataGridLengthUnitType]::Star)
$script:dgADMXCategoryPolicies.Columns[1].Width = [Avalonia.Controls.DataGridLength]::Auto
$script:dgADMXCategoryPolicies.Columns[2].IsVisible = $false
$list = @($script:_admxPolicies | Where-Object {
$_.CategoryId -eq $sel.Tag.CategoryName -and
$_.SettingClass -eq $sel.Tag.SettingClass
})
}
$script:dgADMXCategoryPolicies.ItemsSource = [System.Collections.ObjectModel.ObservableCollection[object]]::new(@($list))
}))
}
if ($script:dgADMXCategoryPolicies) {
# WPF MouseDoubleClick -> Avalonia DoubleTapped (per established pattern).
$script:dgADMXCategoryPolicies.add_DoubleTapped((ConvertTo-AvaloniaEventScriptBlock {
if (-not $script:dgADMXCategoryPolicies.SelectedItem) { return }
Show-ADMXSettingPropertiesDialog $script:dgADMXCategoryPolicies.SelectedItem
}))
$mnu = $script:dgADMXCategoryPolicies.ContextMenu
if ($mnu) {
# Avalonia ContextMenu has Opening (cancellable) but the WPF Opened-
# to-toggle-IsEnabled pattern is fragile here anyway; the click
# handler already returns early if there's no SelectedItem, so no
# menu-open hook is needed.
$edit = $mnu.Items | Where-Object Name -eq 'mnuADMXSettingEdit' | Select-Object -First 1
if ($edit) {
$edit.add_Click((ConvertTo-AvaloniaEventScriptBlock {
if ($script:dgADMXCategoryPolicies.SelectedItem) {
Show-ADMXSettingPropertiesDialog $script:dgADMXCategoryPolicies.SelectedItem
}
}))
}
}
}
# Pre-load Windows.adml for SUPPORTED_* string resolution (best-effort).
if (-not $script:_admxWindowsADML) {
$winADML = "$($env:WinDir)\PolicyDefinitions\en-US\Windows.adml"
if ([IO.File]::Exists($winADML)) {
try { [xml]$script:_admxWindowsADML = [IO.File]::ReadAllText($winADML) }
catch { Write-LogDebug "Failed to load Windows.adml: $($_.Exception.Message)" }
}
}
}
# ─── ADMX Import — SettingProperties dialog (Slice 5a3) ──────────────────────
#
# Avalonia port of Show-ADMXSettingPropertiesDialog + Set-ADMXSettingProperties +
# Set-ADMXElementsPanel + Save-ADMXSettings + Get-ADMXSettingsString from
# UI/WPF/Extensions/IntuneToolsUI.ps1. Differences from WPF:
# - {Binding ...} on PSCustomObject doesn't render in Avalonia. Form fields
# that bound to SupportedOn / Description / PolicyDefinition are now named
# TextBoxes; Set-ADMXSettingProperties pushes values imperatively.
# - WPF NumericUpDown.xaml replaced with [Avalonia.Controls.NumericUpDown];
# Get-ADMXSettingsString reads .Value directly (no .Children[0].Text dance).
# - WPF [System.Windows.LogicalTreeHelper]::FindLogicalNode replaced with a
# flat scan of $Item.ElementsPanel.Children by .Name (the elements grid is
# only one level deep so iteration is fine).
# - WPF DataGrid.CanUserAddRows is missing in Avalonia 11; listBox elements
# get inline Add / Remove buttons after the DataGrid so users can build
# the rows themselves.
# - WPF CollectionViewSource.GetDefaultView(...).Refresh() has no Avalonia
# equivalent; rebuild the ItemsSource ObservableCollection instead so the
# single edited row's status text updates.
function Show-ADMXSettingPropertiesDialog
{
param($SettingObj)
$ui = $script:UIProvider
if (-not $SettingObj) { return }
$form = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/IntuneToolsADMXSettingProperties.axaml'))
if (-not $form) { return }
$script:_admxSettingsForm = $form
$script:_admxLblSettingName = (Get-AvaloniaHost)::FindByName($form, 'lblADMXSettingName')
$script:_admxGrdElements = (Get-AvaloniaHost)::FindByName($form, 'grdADMXElements')
$script:_admxRbEnabled = (Get-AvaloniaHost)::FindByName($form, 'rbADMXSettingEnabled')
$script:_admxRbDisabled = (Get-AvaloniaHost)::FindByName($form, 'rbADMXSettingDisabled')
$script:_admxRbNotConfigured = (Get-AvaloniaHost)::FindByName($form, 'rbADMXSettingNotConfigured')
$script:_admxTxtSettings = (Get-AvaloniaHost)::FindByName($form, 'txtADMXSettings')
$script:_admxTxtSettingName = (Get-AvaloniaHost)::FindByName($form, 'txtADMXSettingName')
$script:_admxTxtSettingComment = (Get-AvaloniaHost)::FindByName($form, 'txtADMXSettingComment')
$script:_admxTxtSupportedOn = (Get-AvaloniaHost)::FindByName($form, 'txtADMXSupportedOn')
$script:_admxTxtDescription = (Get-AvaloniaHost)::FindByName($form, 'txtADMXSettingDescription')
$script:_admxTxtPolicyDefinition = (Get-AvaloniaHost)::FindByName($form, 'txtADMXPolicyDefinition')
$script:_admxChkManualConfig = (Get-AvaloniaHost)::FindByName($form, 'chkADMXManualConfig')
$script:_admxBtnPrev = (Get-AvaloniaHost)::FindByName($form, 'btnADMXPreviousSetting')
$script:_admxBtnNext = (Get-AvaloniaHost)::FindByName($form, 'btnADMXNextSetting')
$script:_admxTcPolicyConfig = (Get-AvaloniaHost)::FindByName($form, 'tcADMXPolicyConfig')
$script:_admxTabSettings = (Get-AvaloniaHost)::FindByName($form, 'tabADMXSettings')
Set-ADMXSettingProperties $SettingObj
Set-ADMXSettingButtonsStatus
$ui.AddXamlEvent($form, 'btnADMXSettingsOK', 'Add_Click', ({
Save-ADMXSettings
Show-ModalObject
}))
# Capture the index BEFORE Save-ADMXSettings: it rebuilds the grid's
# ItemsSource (its refresh mechanism), which resets SelectedIndex to -1 -
# reading the index afterwards navigated to an invalid row and the dialog
# silently stayed put.
$ui.AddXamlEvent($form, 'btnADMXPreviousSetting', 'Add_Click', ({
$idx = $script:dgADMXCategoryPolicies.SelectedIndex
if ($idx -le 0) { return }
Save-ADMXSettings
$script:dgADMXCategoryPolicies.SelectedIndex = $idx - 1
Set-ADMXSettingButtonsStatus
Set-ADMXSettingProperties $script:dgADMXCategoryPolicies.SelectedItem
}))
$ui.AddXamlEvent($form, 'btnADMXNextSetting', 'Add_Click', ({
$idx = $script:dgADMXCategoryPolicies.SelectedIndex
$count = @($script:dgADMXCategoryPolicies.ItemsSource).Count
if ($idx -lt 0 -or $idx -ge ($count - 1)) { return }
Save-ADMXSettings
$script:dgADMXCategoryPolicies.SelectedIndex = $idx + 1
Set-ADMXSettingButtonsStatus
Set-ADMXSettingProperties $script:dgADMXCategoryPolicies.SelectedItem
}))
$ui.AddXamlEvent($form, 'btnADMXSettingsCancel', 'Add_Click', ({
if ($script:_admxGrdElements) { $script:_admxGrdElements.Children.Clear() }
$script:_admxSettingsForm = $null
Show-ModalObject
}))
# WPF used Add_Checked on each RadioButton; Avalonia ToggleButton fires
# IsCheckedChanged regardless of direction, so we gate on IsChecked inside.
$script:_admxRbEnabled.add_IsCheckedChanged((ConvertTo-AvaloniaEventScriptBlock {
if ($script:_admxRbEnabled.IsChecked -eq $true) {
$script:_admxCurItemStatus = 1
Set-ADMXControlStatus
}
}))
$script:_admxRbDisabled.add_IsCheckedChanged((ConvertTo-AvaloniaEventScriptBlock {
if ($script:_admxRbDisabled.IsChecked -eq $true) {
$script:_admxCurItemStatus = 0
Set-ADMXControlStatus
}
}))
$script:_admxRbNotConfigured.add_IsCheckedChanged((ConvertTo-AvaloniaEventScriptBlock {
if ($script:_admxRbNotConfigured.IsChecked -eq $true) {
$script:_admxCurItemStatus = $null
Set-ADMXControlStatus
}
}))
$script:_admxChkManualConfig.add_Click((ConvertTo-AvaloniaEventScriptBlock { Set-ADMXControlStatus }))
$script:_admxTcPolicyConfig.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock {
param($S, $E)
if ($E.AddedItems.Count -eq 0) { return }
if ($E.AddedItems[0] -eq $script:_admxTabSettings -and
$script:dgADMXCategoryPolicies.SelectedItem -and
$script:dgADMXCategoryPolicies.SelectedItem.ManualConfig -ne 1) {
$script:_admxTxtSettings.Text = Get-ADMXSettingsString $script:dgADMXCategoryPolicies.SelectedItem
}
}))
$ui.ShowModalForm($SettingObj.Name, $form, $true)
}
function Set-ADMXSettingButtonsStatus
{
if (-not $script:_admxBtnPrev -or -not $script:_admxBtnNext) { return }
$count = @($script:dgADMXCategoryPolicies.ItemsSource).Count
$script:_admxBtnPrev.IsEnabled = $script:dgADMXCategoryPolicies.SelectedIndex -gt 0
$script:_admxBtnNext.IsEnabled = $script:dgADMXCategoryPolicies.SelectedIndex -lt ($count - 1)
}
function Set-ADMXSettingProperties
{
param($SettingObj)
if (-not $SettingObj) { return }
$script:_admxGrdElements.Children.Clear()
$script:_admxCurItemStatus = $SettingObj.SettingStatus
switch ($SettingObj.SettingStatus) {
0 { $script:_admxRbDisabled.IsChecked = $true }
1 { $script:_admxRbEnabled.IsChecked = $true }
default { $script:_admxRbNotConfigured.IsChecked = $true }
}
$script:_admxLblSettingName.Content = "Setting: $($SettingObj.Name)"
$script:_admxTxtSettings.Text = [string]$SettingObj.PolicySettings
$script:_admxChkManualConfig.IsChecked = [bool]$SettingObj.ManualConfig
$script:_admxTxtSettingName.Text = [string]$SettingObj.OMAURIName
$script:_admxTxtSettingComment.Text = [string]$SettingObj.Description
if (-not $SettingObj.PolicyDefinition) {
$SettingObj.PolicyDefinition = Format-XML $SettingObj.Definition.OuterXml
}
$script:_admxTxtPolicyDefinition.Text = [string]$SettingObj.PolicyDefinition
if (-not $SettingObj.SupportedOn -and $SettingObj.Definition.supportedOn.ref) {
$supRef = $SettingObj.Definition.supportedOn.ref.Split(':')[-1]
$supObj = $script:_admxSupportedOn | Where-Object Id -eq $supRef
if ($supObj) { $SettingObj.SupportedOn = $supObj.DisplayName }
}
$script:_admxTxtSupportedOn.Text = [string]$SettingObj.SupportedOn
$script:_admxTxtDescription.Text = [string]$SettingObj.Description
Set-ADMXElementsPanel $SettingObj
Set-ADMXControlStatus
}
function Set-ADMXControlStatus
{
# Element grid editable only when policy is Enabled AND not in manual mode.
# Manual-mode TextBox is editable only when Enabled + manual mode.
$manual = ($script:_admxChkManualConfig.IsChecked -eq $true)
$script:_admxGrdElements.IsEnabled = ($script:_admxCurItemStatus -eq 1 -and -not $manual)
$script:_admxTxtSettings.IsReadOnly = (-not ($script:_admxCurItemStatus -eq 1 -and $manual))
}
function Save-ADMXSettings
{
$item = $script:dgADMXCategoryPolicies.SelectedItem
if (-not $item) { return }
if ($script:_admxChkManualConfig.IsChecked -eq $true) {
$item.PolicySettings = $script:_admxTxtSettings.Text
} else {
$item.PolicySettings = Get-ADMXSettingsString $item
}
$item.ManualConfig = if ($script:_admxChkManualConfig.IsChecked -eq $true) { 1 } else { 0 }
$item.SettingStatus = $script:_admxCurItemStatus
$item.OMAURIName = $script:_admxTxtSettingName.Text
Set-ADMXSettingStatusText $item
# Avalonia has no CollectionViewSource.Refresh; rebuild the ObservableCollection
# to push the new SettingStatusText / PolicySettings into the grid cells.
$current = @($script:dgADMXCategoryPolicies.ItemsSource)
$script:dgADMXCategoryPolicies.ItemsSource = [System.Collections.ObjectModel.ObservableCollection[object]]::new($current)
$script:_admxGrdElements.Children.Clear()
}
function Add-ADMXGridObject
{
# Avalonia equivalent of WPF Add-GridObject — appends an Auto-height row to
# the target Grid, sets Grid.Row on the control, and adds it.
param($Grid, $Obj)
$rd = [Avalonia.Controls.RowDefinition]::new()
$rd.Height = [Avalonia.Controls.GridLength]::Auto
[Avalonia.Controls.Grid]::SetRow($Obj, $Grid.RowDefinitions.Count)
[void]$Grid.RowDefinitions.Add($rd)
[void]$Grid.Children.Add($Obj)
}
function Add-ADMXListEditorButtons
{
# Avalonia DataGrid lacks CanUserAddRows / CanUserDeleteRows — mount an
# Add / Remove button bar under the DataGrid so listBox elements stay
# usable. Buttons close over $DataGrid via .Tag.
param($Grid, $DataGrid)
$sp = [Avalonia.Controls.StackPanel]::new()
$sp.Orientation = [Avalonia.Layout.Orientation]::Horizontal
$sp.HorizontalAlignment = [Avalonia.Layout.HorizontalAlignment]::Right
$sp.Margin = [Avalonia.Thickness]::new(0, 5, 0, 0)
$btnAdd = [Avalonia.Controls.Button]::new()
$btnAdd.Content = 'Add row'
$btnAdd.MinWidth = 80
$btnAdd.Tag = $DataGrid
$btnAdd.add_Click((ConvertTo-AvaloniaEventScriptBlock {
param($S, $E)
$dg = $S.Tag
try { [void]$dg.CommitEdit([Avalonia.Controls.DataGridEditingUnit]::Cell, $true) } catch { }
try { [void]$dg.CommitEdit([Avalonia.Controls.DataGridEditingUnit]::Row, $true) } catch { }
$items = $dg.ItemsSource
if (-not ($items -is [System.Collections.ObjectModel.ObservableCollection[ADMXListValueItem]])) {
$items = New-ADMXListItemsCollection $items
$dg.ItemsSource = $items
}
$items.Add([ADMXListValueItem]::new())
$dg.SelectedIndex = $items.Count - 1
}))
$btnRemove = [Avalonia.Controls.Button]::new()
$btnRemove.Content = 'Remove'
$btnRemove.MinWidth = 80
$btnRemove.Margin = [Avalonia.Thickness]::new(5, 0, 0, 0)
$btnRemove.Tag = $DataGrid
$btnRemove.add_Click((ConvertTo-AvaloniaEventScriptBlock {
param($S, $E)
$dg = $S.Tag
if ($dg.SelectedIndex -lt 0) { return }
try { [void]$dg.CommitEdit([Avalonia.Controls.DataGridEditingUnit]::Cell, $true) } catch { }
try { [void]$dg.CommitEdit([Avalonia.Controls.DataGridEditingUnit]::Row, $true) } catch { }
$items = $dg.ItemsSource
if (-not ($items -is [System.Collections.ObjectModel.ObservableCollection[ADMXListValueItem]])) {
$items = New-ADMXListItemsCollection $items
$dg.ItemsSource = $items
}
$items.RemoveAt($dg.SelectedIndex)
}))
[void]$sp.Children.Add($btnAdd)
[void]$sp.Children.Add($btnRemove)
Add-ADMXGridObject $Grid $sp
}
function Set-ADMXComboDisplayMember
{
# ADMX presentation combos are built in code, so they get their display
# field here instead of in .axaml. Items are [SettingsListItem].
param($ComboBox)
if (-not $ComboBox) { return }
$ComboBox.DisplayMemberBinding = [Avalonia.Data.Binding]::new('Name')
# Avalonia's ComboBox defaults to HorizontalAlignment=Left (TextBox and
# most other controls default to Stretch), so a combo dropped into the
# elements grid collapses to ~64px in a 424px column instead of filling
# it like the WPF original. Measured 2026-08-27.
$ComboBox.HorizontalAlignment = [Avalonia.Layout.HorizontalAlignment]::Stretch
}
function New-ADMXListItemsCollection
{
param($Items)
$collection = [System.Collections.ObjectModel.ObservableCollection[ADMXListValueItem]]::new()
foreach ($item in @($Items)) {
if ($null -eq $item) { continue }
if ($item -is [ADMXListValueItem]) {
$collection.Add($item)
}
else {
$row = [ADMXListValueItem]::new()
if ($null -ne $item.Key) { $row.Key = [string]$item.Key }
if ($null -ne $item.Value) { $row.Value = [string]$item.Value }
$collection.Add($row)
}
}
return ,$collection
}
function Set-ADMXListGridDefaults
{
param($DataGrid)
if (-not $DataGrid) { return }
$DataGrid.CanUserSortColumns = $false
$DataGrid.CanUserResizeColumns = $true
$DataGrid.AutoGenerateColumns = $false
$DataGrid.IsReadOnly = $false
$DataGrid.HorizontalAlignment = [Avalonia.Layout.HorizontalAlignment]::Stretch
$DataGrid.ColumnWidth = [Avalonia.Controls.DataGridLength]::new(1, [Avalonia.Controls.DataGridLengthUnitType]::Star)
$DataGrid.MinHeight = 72
$DataGrid.MaxHeight = 120
$DataGrid.RowHeight = 28
$DataGrid.ColumnHeaderHeight = 28
$DataGrid.VerticalScrollBarVisibility = [Avalonia.Controls.Primitives.ScrollBarVisibility]::Auto
$DataGrid.HorizontalScrollBarVisibility = [Avalonia.Controls.Primitives.ScrollBarVisibility]::Disabled
}
function Set-ADMXElementsPanel
{
# Build per-element editor controls for a policy. Layout matches the WPF
# version 1:1; only the control types and grid attached props change.
param($Item)
if (-not $Item.Definition.elements) { return }
if (-not $Item.ElementsPanel) {
$grd = [Avalonia.Controls.Grid]::new()
$grd.HorizontalAlignment = [Avalonia.Layout.HorizontalAlignment]::Stretch
$presentation = Get-ADMXPresentationNode $Item
if ($presentation) {
foreach ($presentationNode in $presentation.ChildNodes) {
$ctrl = $null
$elementNode = $Item.Definition.elements.ChildNodes | Where-Object id -eq $presentationNode.refId
if ($presentationNode.Label.'#text') { $stringLabel = $presentationNode.Label.'#text' }
elseif ($presentationNode.Label) { $stringLabel = $presentationNode.Label }
else { $stringLabel = $presentationNode.'#text' }
if ($stringLabel -and $presentationNode.LocalName -ne 'CheckBox') {
$ctrl = [Avalonia.Controls.TextBlock]::new()
$ctrl.Text = [string]$stringLabel
Add-ADMXGridObject $grd $ctrl
}
if ($presentationNode.LocalName -eq 'text') { continue }
if ($ctrl) { $ctrl.Margin = [Avalonia.Thickness]::new(0, 5, 0, 0) }
switch ($presentationNode.LocalName)
{
'textbox' {
$ctrl = [Avalonia.Controls.TextBox]::new()
if ($null -ne $presentationNode.defaultValue) { $ctrl.Text = [string]$presentationNode.defaultValue }
}
{ $_ -in @('DecimalTextBox','LongDecimalTextBox') } {
$ctrl = [Avalonia.Controls.NumericUpDown]::new()
$ctrl.Minimum = [decimal](?? $elementNode.minValue 0)
$ctrl.Maximum = [decimal](?? $elementNode.maxValue 9999)
$ctrl.Increment = [decimal](?? $elementNode.SpinStep 1)
$ctrl.FormatString = '0'
if ($null -ne $presentationNode.defaultValue) {
try { $ctrl.Value = [decimal]$presentationNode.defaultValue } catch { }
}
}
'multiText' {
$ctrl = [Avalonia.Controls.TextBox]::new()
$ctrl.Height = 100
$ctrl.AcceptsReturn = $true
}
'CheckBox' {
$ctrl = [Avalonia.Controls.CheckBox]::new()
$ctrl.Content = [string]$stringLabel
if ($presentationNode.defaultChecked -eq $true) { $ctrl.IsChecked = $true }
}
{ $_ -in @('ComboBox','DropdownList') } {
$ctrl = [Avalonia.Controls.ComboBox]::new()
Set-ADMXComboDisplayMember $ctrl
$valItems = @()
foreach ($valItem in $elementNode.ChildNodes) {
$displayName = Get-ADMXADMLString $valItem
$value = $null
if ($valItem.value.decimal.value) { $value = $valItem.value.decimal.value }
elseif ($valItem.value.longDecimal) { $value = (?? $valItem.value.longDecimal.'#text' $valItem.value.longDecimal) }
elseif ($valItem.value.string) { $value = (?? $valItem.value.string.'#text' $valItem.value.string) }
else {
Write-Log "Unsupported value type for $($elementNode.Id): $($valItem.value.InnerXml)" 2
$value = '<SET MANUALLY!!!>'
}
$valItems += [SettingsListItem]@{ Name = [string]$displayName; Value = $value }
}
# Cast back to a typed array, don't just pipe. Sort-Object
# collapses a single item to a scalar (ItemsSource then
# throws) and re-wraps every item in a PSObject, which the
# binder cannot see through - DisplayMemberBinding would
# render blank rows. The [SettingsListItem[]] cast fixes both.
if ($presentationNode.NoSort -ne 'true') {
$valItems = [SettingsListItem[]]@($valItems | Sort-Object -Property Name)
}
$ctrl.ItemsSource = $valItems
if ($null -ne $presentationNode.defaultItem) {
try { $ctrl.SelectedIndex = [int]$presentationNode.defaultItem } catch { }
}
}
'listBox' {
$ctrl = [Avalonia.Controls.DataGrid]::new()
Set-ADMXListGridDefaults $ctrl
$col = [Avalonia.Controls.DataGridTextColumn]::new()
$col.Header = 'Value Name'
$col.Width = [Avalonia.Controls.DataGridLength]::new(1, [Avalonia.Controls.DataGridLengthUnitType]::Star)
$col.Binding = [Avalonia.Data.Binding]::new('Key')
$col.Binding.Mode = [Avalonia.Data.BindingMode]::TwoWay
$col.Binding.UpdateSourceTrigger = [Avalonia.Data.UpdateSourceTrigger]::PropertyChanged
if ($elementNode.explicitValue -ne 'true') { $col.IsVisible = $false }
[void]$ctrl.Columns.Add($col)
$col = [Avalonia.Controls.DataGridTextColumn]::new()
$col.Header = 'Value'
$col.Width = [Avalonia.Controls.DataGridLength]::new(1, [Avalonia.Controls.DataGridLengthUnitType]::Star)
$col.Binding = [Avalonia.Data.Binding]::new('Value')
$col.Binding.Mode = [Avalonia.Data.BindingMode]::TwoWay
$col.Binding.UpdateSourceTrigger = [Avalonia.Data.UpdateSourceTrigger]::PropertyChanged
[void]$ctrl.Columns.Add($col)
$ctrl.ItemsSource = New-ADMXListItemsCollection
}
default {
Write-Log "Unsupported presentation control: $($presentationNode.LocalName) (refId: $($presentationNode.refId))" 2
continue
}
}
if ($ctrl) {
Add-ADMXGridObject $grd $ctrl
if ($presentationNode.refId) {
$ctrl.Tag = $elementNode
$ctrl.Name = $presentationNode.refId
}
if ($presentationNode.LocalName -eq 'listBox') {
Add-ADMXListEditorButtons $grd $ctrl
}
}
}
}
else {
# Fallback: no presentation. Best-effort controls straight from <elements>.
Write-Log "No presentation found for $($Item.Definition.Name); building fallback editor" 2
foreach ($elementNode in $Item.Definition.elements.ChildNodes) {
try {
$ctrl = $null
switch ($elementNode.LocalName) {
'text' { $ctrl = [Avalonia.Controls.TextBox]::new() }
'multiText' {
$ctrl = [Avalonia.Controls.TextBox]::new()
$ctrl.Height = 100
$ctrl.AcceptsReturn = $true
}
'enum' {
$ctrl = [Avalonia.Controls.ComboBox]::new()
Set-ADMXComboDisplayMember $ctrl
$valItems = @()
foreach ($valItem in $elementNode.ChildNodes) {
$value = $null
if ($valItem.value.decimal.value) { $value = $valItem.value.decimal.value }
elseif ($valItem.value.string) { $value = (?? $valItem.value.string.'#text' $valItem.value.string) }
else { $value = '<SET MANUALLY!!!>' }
$valItems += [SettingsListItem]@{ Name = [string](Get-ADMXADMLString $valItem); Value = $value }
}
$ctrl.ItemsSource = $valItems
}
'list' {
$ctrl = [Avalonia.Controls.DataGrid]::new()
Set-ADMXListGridDefaults $ctrl
$col = [Avalonia.Controls.DataGridTextColumn]::new()
$col.Header = 'Value Name'
$col.Width = [Avalonia.Controls.DataGridLength]::new(1, [Avalonia.Controls.DataGridLengthUnitType]::Star)
$col.Binding = [Avalonia.Data.Binding]::new('Key')
$col.Binding.Mode = [Avalonia.Data.BindingMode]::TwoWay
$col.Binding.UpdateSourceTrigger = [Avalonia.Data.UpdateSourceTrigger]::PropertyChanged
if ($elementNode.explicitValue -ne 'true') { $col.IsVisible = $false }
[void]$ctrl.Columns.Add($col)
$col = [Avalonia.Controls.DataGridTextColumn]::new()
$col.Header = 'Value'
$col.Width = [Avalonia.Controls.DataGridLength]::new(1, [Avalonia.Controls.DataGridLengthUnitType]::Star)
$col.Binding = [Avalonia.Data.Binding]::new('Value')
$col.Binding.Mode = [Avalonia.Data.BindingMode]::TwoWay
$col.Binding.UpdateSourceTrigger = [Avalonia.Data.UpdateSourceTrigger]::PropertyChanged
[void]$ctrl.Columns.Add($col)
$ctrl.ItemsSource = New-ADMXListItemsCollection
}
'decimal' {
$ctrl = [Avalonia.Controls.NumericUpDown]::new()
$ctrl.FormatString = '0'
}
'boolean' {
$ctrl = [Avalonia.Controls.CheckBox]::new()
}
default {
Write-Log "Element type not supported in fallback: $($elementNode.LocalName)" 2
continue
}
}
if ($null -eq $ctrl) { continue }
$displayName = Get-ADMXADMLPresentationString $presentation $elementNode
if ($displayName) {
$tb = [Avalonia.Controls.TextBlock]::new()
if ($grd.RowDefinitions.Count -gt 0) {
$tb.Margin = [Avalonia.Thickness]::new(0, 5, 0, 0)
}
$tb.Text = [string]$displayName
Add-ADMXGridObject $grd $tb
}
$ctrl.Tag = $elementNode
$ctrl.Name = $elementNode.Id
Add-ADMXGridObject $grd $ctrl
if ($elementNode.LocalName -eq 'list') {
Add-ADMXListEditorButtons $grd $ctrl
}
}
catch {
Write-LogError "Failed to add ADMX element $($elementNode.LocalName) with id $($elementNode.id)" $_.Exception
}
}
}
# Trailing row so the grid stretches; harmless if empty.
[void]$grd.RowDefinitions.Add([Avalonia.Controls.RowDefinition]::new())
$Item.ElementsPanel = $grd
}
if ($Item.ElementsPanel) {
# Detach from previous parent — Avalonia controls can only have one
# logical parent, and re-opening the dialog re-mounts the same panel.
if ($Item.ElementsPanel.Parent) {
$oldParent = $Item.ElementsPanel.Parent
if ($oldParent.Children -and $oldParent.Children.Contains($Item.ElementsPanel)) {
[void]$oldParent.Children.Remove($Item.ElementsPanel)
}
}
[void]$script:_admxGrdElements.Children.Add($Item.ElementsPanel)
}
}
function Get-ADMXSettingsString
{
# Build the OMA-URI <data id=... value=.../> XML from live element controls.
# Empty values are skipped (with a log line for required ones). Returns the
# joined string or $null if the policy isn't Enabled.
param($Item)
if (-not $Item -or -not $Item.Definition.elements) { return }
if ($script:_admxCurItemStatus -ne 1) {
$Item.PolicySettings = $null
return
}
if (-not $Item.ElementsPanel) { return }
$policySettings = @()
foreach ($elementNode in $Item.Definition.elements.ChildNodes) {
$ctrl = $Item.ElementsPanel.Children | Where-Object { $_.Name -eq $elementNode.Id } | Select-Object -First 1
if (-not $ctrl) {
Write-Log "Could not find a control with id $($elementNode.Id)" 3
continue
}
$ctrlValue = $null
switch ($elementNode.LocalName) {
'text' { $ctrlValue = $ctrl.Text }
'multiText' { $ctrlValue = ($ctrl.Text -replace [Environment]::NewLine, '&#xF000;') }
'enum' {
if ($ctrl.SelectedItem) { $ctrlValue = $ctrl.SelectedItem.Value }
}
'list' {
try { [void]$ctrl.CommitEdit([Avalonia.Controls.DataGridEditingUnit]::Cell, $true) } catch { }
try { [void]$ctrl.CommitEdit([Avalonia.Controls.DataGridEditingUnit]::Row, $true) } catch { }
$i = 1
$arr = @()
foreach ($kv in $ctrl.ItemsSource) {
if (-not $kv.Value) { continue }
$arr += "$((?? $kv.Key $i))&#xF000;$($kv.Value)"
$i++
}
$ctrlValue = $arr -join '&#xF000;'
}
default {
if ($elementNode.LocalName -eq 'decimal' -or $ctrl.Tag.LocalName -eq 'longDecimal') {
if ($ctrl -is [Avalonia.Controls.NumericUpDown]) {
if ($null -ne $ctrl.Value) { $ctrlValue = $ctrl.Value.ToString() }
} else {
$ctrlValue = $ctrl.Text
}
}
elseif ($elementNode.LocalName -eq 'boolean') {
if ($ctrl -is [Avalonia.Controls.CheckBox]) {
$ctrlValue = if ($ctrl.IsChecked -eq $true) { '1' } else { '0' }
}
elseif ($ctrl -is [Avalonia.Controls.ComboBox]) {
if ($ctrl.SelectedItem) { $ctrlValue = $ctrl.SelectedItem.Value }
}
else {
Write-Log "Boolean element type not supported: $($elementNode.LocalName)" 2
continue
}
}
else {
Write-Log "Element type not supported: $($elementNode.LocalName)" 2
continue
}
}
}
if (-not $ctrlValue) {
if ($elementNode.required -eq $true) {
Write-Log "Required value is missing for $($elementNode.Id)" 3
} else {
Write-Log "Value not set for $($elementNode.Id) - value will not be added"
}
continue
}
$policySettings += "<data id=`"$($elementNode.Id)`" value=`"$ctrlValue`"/>"
}
return ($policySettings -join [Environment]::NewLine)
}
# ─── ADMX Import — Intune ingestion (Slice 5a4) ───────────────────────────────
#
# Direct port of UI/WPF/Extensions/IntuneToolsUI.ps1's Import-ADMXPolicyToIntune.
# No UI sub-dialog (the WPF tree's IntuneToolsADMXDriveMapping.xaml is dead
# code — never referenced from any handler), so the Avalonia version is a
# pure data-flow port: validate inputs → walk configured policies → POST a
# windows10CustomConfiguration to deviceManagement/deviceConfigurations.
function Import-ADMXPolicyToIntune
{
$ui = $script:UIProvider
if (-not $script:txtADMXProfileName.Text.Trim()) {
$ui.ShowMessageBox('Profile Name must be specified', 'ADMX Import', 'OK', 'Error')
return
}
if (-not $script:txtADMXPolicyFileName.Text.Trim()) {
$ui.ShowMessageBox('ADMX Policy Name must be specified', 'ADMX Import', 'OK', 'Error')
return
}
$configured = @()
foreach ($admxPolicy in @($script:_admxPolicies | Where-Object { $_.SettingStatus -in @(0,1) })) {
if ($admxPolicy.SettingStatus -eq 0) {
$policyValue = '<disabled/>'
}
else {
$policyValue = '<enabled/>'
if ($admxPolicy.PolicySettings) { $policyValue += "`n$($admxPolicy.PolicySettings)" }
}
if ((Get-SettingValue 'FormatOMAURI') -eq $true) {
$policyValue = Update-XmlFormatting $policyValue
}
$catPath = Get-ADMXCategoryOMAURIPath $admxPolicy.Definition.parentCategory.ref
$omaUri = "./$($admxPolicy.SettingClass)/Vendor/MSFT/Policy/Config/$($script:txtADMXPolicyAppName.Text)~Policy~$catPath/$($admxPolicy.Definition.name)"
$desc =
if ($admxPolicy.OMAURIDescription) { $admxPolicy.OMAURIDescription }
elseif ($admxPolicy.Description -and $admxPolicy.Description.Length -gt 1000) { $admxPolicy.Description.Substring(0, 1000) }
else { $admxPolicy.Description }
$configured += [PSCustomObject]@{
'@odata.type' = '#microsoft.graph.omaSettingString'
displayName = (?? $admxPolicy.OMAURIName $admxPolicy.Name)
description = $desc
omaUri = $omaUri
value = $policyValue
}
}
$intuneObj = [PSCustomObject]@{
'@odata.type' = '#microsoft.graph.windows10CustomConfiguration'
displayName = $script:txtADMXProfileName.Text
omaSettings = @()
roleScopeTagIds = @()
assignments = @()
}
if ($script:txtADMXProfileDescription.Text) {
$intuneObj | Add-Member -MemberType NoteProperty -Name 'description' -Value $script:txtADMXProfileDescription.Text
}
if ($script:chkADMXPolicyIngest.IsChecked) {
$xmlString = Format-XML $script:_admxXML
if ((Get-SettingValue 'FormatOMAURI') -eq $true) {
$xmlString = Update-XmlFormatting $xmlString
}
$ingestName = if ($script:txtADMXPolicyIngestName.Text) { $script:txtADMXPolicyIngestName.Text }
else { "$($script:currentADMXFile.Name) Ingestion" }
$intuneObj.omaSettings += [PSCustomObject]@{
'@odata.type' = '#microsoft.graph.omaSettingString'
displayName = $ingestName
description = $null
omaUri = "./Device/Vendor/MSFT/Policy/ConfigOperations/ADMXInstall/$($script:txtADMXPolicyAppName.Text)/Policy/$($script:txtADMXPolicyFileName.Text)"
value = $xmlString
}
}
$intuneObj.omaSettings += $configured
$json = $intuneObj | ConvertTo-Json -Depth 20
$tokenId = Get-DefaultTokenId
$resp = Invoke-MSGraphAPI -Url 'deviceManagement/deviceConfigurations' -Content $json -HttpMethod 'POST' -TokenId $tokenId -FullResponseObject
if ($resp -and $resp.Success) {
Write-Log "Device configuration profile '$($intuneObj.displayName)' created with id $($resp.Content.Id)"
$ui.ShowMessageBox("Profile '$($intuneObj.displayName)' created successfully.", 'ADMX Import', 'OK', 'Information')
}
else {
$errInfo = if ($resp) { "$($resp.StatusCode) $($resp.StatusDescription)" } else { 'no response' }
Write-Log "Failed to create device configuration profile '$($intuneObj.displayName)' - $errInfo" 3
$ui.ShowMessageBox("Failed to create device configuration profile '$($intuneObj.displayName)'.`n`n$errInfo`n`nCheck the log for details.", 'ADMX Import', 'OK', 'Error')
}
}
# ─── Reg Values tool — Slice 5b1 (main panel + classes wired) ────────────────
#
# Avalonia port of UI/WPF/Extensions/IntuneToolsUI.ps1 Show-ADMXRegValuesTool.
# The C# class definitions (ADMXRegPolicyElement / ADMXRegPolicy /
# ADMXRegProfile) are kept verbatim — they're shared CLR types backing the
# DataContext bindings. ADMXRegProfile.ADMXPolicies is an
# ObservableCollection<ADMXRegPolicy>, which Avalonia's DataGrid binds to
# without the PSCustomObject quirk (see [[avalonia-binding-needs-clr-types]]).
#
# Differences from WPF:
# - $panel.FindName -> (Get-AvaloniaHost)::FindByName.
# - cbADMXRegPolicyType has no DisplayMemberPath / SelectedValuePath; it gets
# [SettingsListItem] items + an ItemTemplate via LoadXaml, and a
# SelectionChanged handler pushes the chosen Value onto DataContext.PolicyType.
# - WPF MouseDoubleClick -> Avalonia DoubleTapped.
# - WPF Add_Opened on the ContextMenu (used to gate Edit's IsEnabled) is
# dropped — the click handler already returns early when SelectedItem is
# $null, so no menu-open hook is needed.
# - Show-ADMXRegSettingsDialog / Import-ADMXRegProfileToIntune are stubbed
# pending slice 5b2.
function Show-ADMXRegValuesTool
{
$ui = $script:UIProvider
if (-not $script:grdToolsMain) { return }
$script:grdToolsMain.Children.Clear()
Add-ADMXRegClasses
$panel = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/IntuneToolsADMXRegValues.axaml'))
if (-not $panel) {
Write-Log "Failed to load IntuneToolsADMXRegValues.axaml" 3
Set-IntuneToolPlaceholder 'Reg Values'
return
}
$script:_admxRegValuesPanel = $panel
$script:cbADMXRegPolicyType = (Get-AvaloniaHost)::FindByName($panel, 'cbADMXRegPolicyType')
$script:dgADMXRegAddedPolicies = (Get-AvaloniaHost)::FindByName($panel, 'dgADMXRegAddedPolicies')
$script:mnuADMXRegPoliciesCtx = (Get-AvaloniaHost)::FindByName($panel, 'mnuADMXRegPoliciesContextMenu')
$script:mnuADMXRegPolicyEdit = (Get-AvaloniaHost)::FindByName($panel, 'mnuADMXRegPolicyEdit')
$panel.DataContext = [ADMXRegProfile]::new()
if ($script:cbADMXRegPolicyType) {
$script:cbADMXRegPolicyType.ItemsSource = @(
[SettingsListItem]@{ Name = 'Policy'; Value = 'Policy' }
[SettingsListItem]@{ Name = 'Preference'; Value = 'Preference' }
)
# Initial selection mirrors the DataContext default (PolicyType = "Policy").
$current = [string]$panel.DataContext.PolicyType
$sel = $script:cbADMXRegPolicyType.ItemsSource | Where-Object { $_.Value -eq $current } | Select-Object -First 1
if (-not $sel) { $sel = $script:cbADMXRegPolicyType.ItemsSource[0] }
if ($sel) { $script:cbADMXRegPolicyType.SelectedItem = $sel }
$script:cbADMXRegPolicyType.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock {
param($S, $E)
if ($S.SelectedItem -and $script:_admxRegValuesPanel.DataContext) {
$script:_admxRegValuesPanel.DataContext.PolicyType = [string]$S.SelectedItem.Value
}
}))
}
$ui.AddXamlEvent($panel, 'btnADMXRegClear', 'add_Click', ({
$script:_admxRegValuesPanel.DataContext = [ADMXRegProfile]::new()
if ($script:cbADMXRegPolicyType) {
$sel = $script:cbADMXRegPolicyType.ItemsSource | Where-Object { $_.Value -eq 'Policy' } | Select-Object -First 1
if ($sel) { $script:cbADMXRegPolicyType.SelectedItem = $sel }
}
}))
$ui.AddXamlEvent($panel, 'btnADMXAddRegValue', 'add_Click', ({
Show-ADMXRegSettingsDialog
}))
$ui.AddXamlEvent($panel, 'btnADMXRegImport', 'add_Click', ({
Write-Status 'Import Reg Settings policy'
Import-ADMXRegProfileToIntune
Write-Status ''
}))
if ($script:dgADMXRegAddedPolicies) {
$script:dgADMXRegAddedPolicies.add_DoubleTapped((ConvertTo-AvaloniaEventScriptBlock {
if (-not $script:dgADMXRegAddedPolicies.SelectedItem) { return }
Show-ADMXRegSettingsDialog $script:dgADMXRegAddedPolicies.SelectedItem
}))
}
if ($script:mnuADMXRegPolicyEdit) {
$script:mnuADMXRegPolicyEdit.add_Click((ConvertTo-AvaloniaEventScriptBlock {
if (-not $script:dgADMXRegAddedPolicies.SelectedItem) { return }
Show-ADMXRegSettingsDialog $script:dgADMXRegAddedPolicies.SelectedItem
}))
}
$script:grdToolsMain.Children.Add($panel) | Out-Null
}
# ─── Reg Values — C# classes (INotifyPropertyChanged for live DataGrid binding) ─
# ─── Reg Values — registry-path safety list ───────────────────────────────────
#
# The MDM ADMX-ingest path can't write to certain Microsoft-owned roots. The
# original lists came from:
# https://docs.microsoft.com/en-us/windows/client-management/mdm/win32-and-centennial-app-policy-configuration
# Carried over verbatim so behaviour matches the WPF tool.
$script:_admxRegUnsupportedLocations = @(
'System'
'Software\Microsoft'
'Software\Policies\Microsoft'
)
$script:_admxRegUnsupportedOverride = @(
'Software\Policies\Microsoft\Office'
'Software\Microsoft\Office'
'Software\Microsoft\Windows\CurrentVersion\Explorer'
'Software\Microsoft\Internet Explorer'
'software\policies\microsoft\shared tools\proofing tools'
'software\policies\microsoft\imejp'
'software\policies\microsoft\ime\shared'
'software\policies\microsoft\shared tools\graphics filters'
'software\policies\microsoft\windows\currentversion\explorer'
'software\policies\microsoft\softwareprotectionplatform'
'software\policies\microsoft\officesoftwareprotectionplatform'
'software\policies\microsoft\windows\windows search\preferences'
'software\policies\microsoft\exchange'
'software\microsoft\shared tools\proofing tools'
'software\microsoft\shared tools\graphics filters'
'software\microsoft\windows\windows search\preferences'
'software\microsoft\exchange'
'software\policies\microsoft\vba\security'
'software\microsoft\onedrive'
'software\Microsoft\Edge'
'Software\Microsoft\EdgeUpdate'
)
# Template used to synthesize the ADMX XML payload for the ingest profile. One
# child <policy> is cloned per UI-defined ADMXRegPolicy; the seed entry is
# removed before the XML is serialised.
$script:_admxRegTemplate = @"
<policyDefinitions revision="1.0" schemaVersion="1.0">
<categories>
<category name="RegImport" />
</categories>
<policies>
<policy name="" class="" displayName="" explainText="" presentation="" key="" valueName="">
<parentCategory ref="RegImport" />
<supportedOn ref="windows:SUPPORTED_Windows7" />
<enabledValue>
<decimal value="1" />
</enabledValue>
<disabledValue>
<decimal value="0" />
</disabledValue>
<elements>
</elements>
</policy>
</policies>
</policyDefinitions>
"@
function Get-ADMXRegIsKeySupported
{
param($RegKey)
$ui = $script:UIProvider
if (-not $RegKey) { return $true }
$tmpPath = $RegKey.Trim('\') + '\'
$blockedSource = ''
foreach ($blockedPath in $script:_admxRegUnsupportedLocations) {
if ($tmpPath -like "$blockedPath\*") { $blockedSource = $blockedPath; break }
}
if ($blockedSource) {
foreach ($exemptPath in $script:_admxRegUnsupportedOverride) {
if ($tmpPath -like "$exemptPath\*") { $blockedSource = ''; break }
}
}
if ($blockedSource) {
$ui.ShowMessageBox("The registry key '$RegKey' is not supported.`n`nBlocked by root key: $blockedSource", 'Unsupported reg key', 'OK', 'Error')
return $false
}
return $true
}
# ─── Reg Values — settings dialog (Slice 5b2) ────────────────────────────────
#
# Avalonia port of UI/WPF/Extensions/IntuneToolsUI.ps1 Show-ADMXRegSettingsDialog.
# Differences from WPF:
# - $form.FindName -> (Get-AvaloniaHost)::FindByName.
# - The three ComboBoxes (Hive / PolicyStatus / DataType) use [SettingsListItem]
# + LoadXaml ItemTemplate (no DisplayMemberPath / SelectedValuePath); a
# SelectionChanged handler pushes the chosen Value onto
# $script:_admxRegCurrentPolicy / $script:_admxRegCurrentElement so the C#
# class properties stay in sync. Initial selection matches the live object.
# - DataContext is SECTIONED: grdADMXRegPolicy binds the ADMXRegPolicy and
# grdADMXRegPolicyElement binds the ADMXRegPolicyElement, because the
# Avalonia binder cannot traverse a PSCustomObject wrapper's NoteProperties
# (a form-level wrapper left every control in this dialog unbound).
# - WPF MouseDoubleClick -> Avalonia DoubleTapped on dgADMXRegAddedElements.
# - WPF Visibility = "Visible" / "Collapsed" -> IsVisible = $true / $false.
# - Two-state buttons (btnADMXRegElementAdd / btnADMXRegElementNew) toggle via
# IsVisible rather than the WPF Visibility enum.
function Show-ADMXRegSettingsDialog
{
param($RegPolicy)
$ui = $script:UIProvider
$form = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/IntuneToolsADMXAddRegPolicy.axaml'))
if (-not $form) { return }
$script:_admxRegPoliciesForm = $form
# New vs edit: when invoked from the "Add" button we get $null; double-click
# on a row passes the existing ADMXRegPolicy.
$newPolicy = -not $RegPolicy
if ($newPolicy) { $RegPolicy = [ADMXRegPolicy]::new() }
$script:_admxRegIsNewPolicy = $newPolicy
$newElement = [ADMXRegPolicyElement]::new()
# SECTIONED DataContexts, not a PSCustomObject wrapper: the Avalonia binder
# cannot traverse PSCustomObject NoteProperties, so a form-level
# {Binding RegPolicy.X} context left EVERY control in this dialog unbound
# (typed values never reached the object). Each grid gets the real CLR
# object it edits; both classes are C# with INPC.
$script:_admxRegCurrentPolicy = $RegPolicy
$script:_admxRegCurrentElement = $newElement
$grdPolicy = (Get-AvaloniaHost)::FindByName($form, 'grdADMXRegPolicy')
$grdElement = (Get-AvaloniaHost)::FindByName($form, 'grdADMXRegPolicyElement')
if ($grdPolicy) { $grdPolicy.DataContext = $RegPolicy }
if ($grdElement) { $grdElement.DataContext = $newElement }
$script:_admxRegGrdPolicy = $grdPolicy
$script:_admxRegGrdElement = $grdElement
# The elements grid binds the policy's real ObservableCollection directly
# (its {Binding PolicyElements} now resolves against no context of its own).
$dgElementsEarly = (Get-AvaloniaHost)::FindByName($form, 'dgADMXRegAddedElements')
if ($dgElementsEarly) { $dgElementsEarly.ItemsSource = $RegPolicy.PolicyElements }
# Cache child controls used by event handlers and the show/hide helper.
$script:_admxRegCbHive = (Get-AvaloniaHost)::FindByName($form, 'cbADMXRegHive')
$script:_admxRegCbPolicyStatus = (Get-AvaloniaHost)::FindByName($form, 'cbADMXRegPolicyStatus')
$script:_admxRegCbDataType = (Get-AvaloniaHost)::FindByName($form, 'cbADMXRegElementDataType')
$script:_admxRegTxtElementKey = (Get-AvaloniaHost)::FindByName($form, 'txtADMXRegElementKey')
$script:_admxRegTxtElementValName = (Get-AvaloniaHost)::FindByName($form, 'txtADMXRegElementValueName')
$script:_admxRegTxtSeparator = (Get-AvaloniaHost)::FindByName($form, 'txtADMXRegAttributeValueSeparator')
$script:_admxRegSpSeparator = (Get-AvaloniaHost)::FindByName($form, 'spADMXRegAttributeValueSeparator')
$script:_admxRegSpValuePrefix = (Get-AvaloniaHost)::FindByName($form, 'spADMXRegAttributeValuePrefix')
$script:_admxRegTxtValuePrefix = (Get-AvaloniaHost)::FindByName($form, 'txtADMXRegAttributeValuePrefix')
$script:_admxRegSpExpandable = (Get-AvaloniaHost)::FindByName($form, 'spADMXRegAttributeExpandable')
$script:_admxRegChkExpandable = (Get-AvaloniaHost)::FindByName($form, 'chkADMXRegAttributeExpandable')
$script:_admxRegSpAdditive = (Get-AvaloniaHost)::FindByName($form, 'spADMXRegAttributeAdditive')
$script:_admxRegChkAdditive = (Get-AvaloniaHost)::FindByName($form, 'chkADMXRegAttributeAdditive')
$script:_admxRegBtnElementAdd = (Get-AvaloniaHost)::FindByName($form, 'btnADMXRegElementAdd')
$script:_admxRegBtnElementUpdate = (Get-AvaloniaHost)::FindByName($form, 'btnADMXRegElementNew')
$script:_admxRegTxtKey = (Get-AvaloniaHost)::FindByName($form, 'txtADMXRegKey')
$script:_admxRegDgElements = (Get-AvaloniaHost)::FindByName($form, 'dgADMXRegAddedElements')
# ComboBox ItemTemplate is shared across all three combos.
$script:_admxRegCbHive.ItemsSource = @(
[SettingsListItem]@{ Name = 'HKEY_LOCAL_MACHINE'; Value = 'HKLM' }
[SettingsListItem]@{ Name = 'HKEY_CURRENT_USER'; Value = 'HKCU' }
)
$hiveCurrent = [string]$RegPolicy.Hive
$hiveSel = $script:_admxRegCbHive.ItemsSource | Where-Object { $_.Value -eq $hiveCurrent } | Select-Object -First 1
if (-not $hiveSel) { $hiveSel = $script:_admxRegCbHive.ItemsSource[0] }
if ($hiveSel) { $script:_admxRegCbHive.SelectedItem = $hiveSel }
$script:_admxRegCbHive.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock {
param($S, $E)
if ($S.SelectedItem -and $script:_admxRegCurrentPolicy) {
$script:_admxRegCurrentPolicy.Hive = [string]$S.SelectedItem.Value
}
}))
$script:_admxRegCbPolicyStatus.ItemsSource = @(
[SettingsListItem]@{ Name = 'Enabled'; Value = 'Enabled' }
[SettingsListItem]@{ Name = 'Disabled'; Value = 'Disabled' }
)
$statusCurrent = [string]$RegPolicy.PolicyStatus
$statusSel = $script:_admxRegCbPolicyStatus.ItemsSource | Where-Object { $_.Value -eq $statusCurrent } | Select-Object -First 1
if (-not $statusSel) { $statusSel = $script:_admxRegCbPolicyStatus.ItemsSource[0] }
if ($statusSel) { $script:_admxRegCbPolicyStatus.SelectedItem = $statusSel }
$script:_admxRegCbPolicyStatus.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock {
param($S, $E)
if ($S.SelectedItem -and $script:_admxRegCurrentPolicy) {
$script:_admxRegCurrentPolicy.PolicyStatus = [string]$S.SelectedItem.Value
}
}))
# longDecimal omitted intentionally — Intune's ADMX-ingest path doesn't
# currently accept QWORD. Note carried over from the original tool.
$script:_admxRegCbDataType.ItemsSource = @(
[SettingsListItem]@{ Name = 'String'; Value = 'text' }
[SettingsListItem]@{ Name = 'Multi-string'; Value = 'multiText' }
[SettingsListItem]@{ Name = 'List'; Value = 'list' }
[SettingsListItem]@{ Name = 'DWORD (32-bit)'; Value = 'decimal' }
)
$dtCurrent = [string]$newElement.DataType
$dtSel = $script:_admxRegCbDataType.ItemsSource | Where-Object { $_.Value -eq $dtCurrent } | Select-Object -First 1
if ($dtSel) { $script:_admxRegCbDataType.SelectedItem = $dtSel }
# When the user picks a different data type, attribute control visibility
# follows. Also push the picked Value onto the live element object.
$script:_admxRegCbDataType.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock {
param($S, $E)
if ($S.SelectedItem -and $script:_admxRegCurrentElement) {
$script:_admxRegCurrentElement.DataType = [string]$S.SelectedItem.Value
}
Set-ADMXRegAttributeControls
}))
# Apply the visibility rules for whatever DataType is currently selected.
Set-ADMXRegAttributeControls
# Double-click on an element row swaps the current PolicyElement for the
# selected one, switching the form from add-mode to update-mode.
$script:_admxRegDgElements.add_DoubleTapped((ConvertTo-AvaloniaEventScriptBlock {
if (-not $script:_admxRegDgElements.SelectedItem) { return }
$script:_admxRegBtnElementAdd.IsVisible = $false
$script:_admxRegBtnElementUpdate.IsVisible = $true
$selected = $script:_admxRegDgElements.SelectedItem
$script:_admxRegCurrentElement = $selected
if ($script:_admxRegGrdElement) { $script:_admxRegGrdElement.DataContext = $selected }
# Re-sync the DataType ComboBox with the swapped-in element.
$dtCurrent = [string]$selected.DataType
$dtSel = $script:_admxRegCbDataType.ItemsSource | Where-Object { $_.Value -eq $dtCurrent } | Select-Object -First 1
if ($dtSel) { $script:_admxRegCbDataType.SelectedItem = $dtSel }
Set-ADMXRegAttributeControls
}))
$ui.AddXamlEvent($form, 'btnADMXRegElementAdd', 'add_Click', ({
if ($script:_admxRegTxtElementKey.Text -and (Get-ADMXRegIsKeySupported $script:_admxRegTxtElementKey.Text) -eq $false) { return }
if (-not $script:_admxRegCurrentPolicy.Key) {
$ui.ShowMessageBox('The Key value must be specified for the policy', 'Reg Values', 'OK', 'Error')
return
}
if ($script:_admxRegTxtSeparator) {
$script:_admxRegCurrentElement.AttributeSeparator = $script:_admxRegTxtSeparator.Text
}
$script:_admxRegCurrentPolicy.PolicyElements.Add($script:_admxRegCurrentElement)
$script:_admxRegCurrentElement = [ADMXRegPolicyElement]::new()
if ($script:_admxRegGrdElement) { $script:_admxRegGrdElement.DataContext = $script:_admxRegCurrentElement }
# Reset the data-type ComboBox to no selection so the next row starts
# clean. Visibility helpers re-run via SelectionChanged.
$script:_admxRegCbDataType.SelectedItem = $null
}))
$ui.AddXamlEvent($form, 'btnADMXRegElementNew', 'add_Click', ({
$script:_admxRegCurrentElement = [ADMXRegPolicyElement]::new()
if ($script:_admxRegGrdElement) { $script:_admxRegGrdElement.DataContext = $script:_admxRegCurrentElement }
$script:_admxRegBtnElementAdd.IsVisible = $true
$script:_admxRegBtnElementUpdate.IsVisible = $false
$script:_admxRegCbDataType.SelectedItem = $null
}))
$ui.AddXamlEvent($form, 'btnADMXRegAddNew', 'add_Click', ({
if ((Get-ADMXRegIsKeySupported $script:_admxRegTxtKey.Text) -eq $false) { return }
if ($script:_admxRegIsNewPolicy) {
$script:_admxRegValuesPanel.DataContext.ADMXPolicies.Add($script:_admxRegCurrentPolicy)
}
Show-ModalObject
}))
$ui.AddXamlEvent($form, 'btnADMXRegCancel', 'add_Click', ({
$script:_admxRegPoliciesForm = $null
Show-ModalObject
}))
$ui.ShowModalForm('Add new reg policy', $form, $true)
}
function Set-ADMXRegAttributeControls
{
# Show or hide the per-data-type attribute controls so users only see the
# toggles that are relevant for the picked DataType. WPF Visibility =
# "Collapsed" -> Avalonia IsVisible = $false.
if (-not $script:_admxRegCbDataType -or -not $script:_admxRegCbDataType.SelectedItem) {
# No selection yet — hide all conditional controls.
if ($script:_admxRegSpSeparator) { $script:_admxRegSpSeparator.IsVisible = $false }
if ($script:_admxRegTxtSeparator) { $script:_admxRegTxtSeparator.IsVisible = $false }
if ($script:_admxRegSpValuePrefix) { $script:_admxRegSpValuePrefix.IsVisible = $false }
if ($script:_admxRegTxtValuePrefix){ $script:_admxRegTxtValuePrefix.IsVisible= $false }
if ($script:_admxRegSpExpandable) { $script:_admxRegSpExpandable.IsVisible = $false }
if ($script:_admxRegChkExpandable) { $script:_admxRegChkExpandable.IsVisible = $false }
if ($script:_admxRegSpAdditive) { $script:_admxRegSpAdditive.IsVisible = $false }
if ($script:_admxRegChkAdditive) { $script:_admxRegChkAdditive.IsVisible = $false }
if ($script:_admxRegTxtElementValName) { $script:_admxRegTxtElementValName.IsReadOnly = $false }
return
}
$dt = [string]$script:_admxRegCbDataType.SelectedItem.Value
$showSep = ($dt -eq 'list' -or $dt -eq 'multiText')
$script:_admxRegSpSeparator.IsVisible = $showSep
$script:_admxRegTxtSeparator.IsVisible = $showSep
if ($showSep -and [string]::IsNullOrEmpty($script:_admxRegTxtSeparator.Text)) {
$script:_admxRegTxtSeparator.Text = ';'
}
$showPrefix = ($dt -eq 'list')
$script:_admxRegSpValuePrefix.IsVisible = $showPrefix
$script:_admxRegTxtValuePrefix.IsVisible = $showPrefix
$script:_admxRegTxtElementValName.IsReadOnly = ($dt -eq 'list')
$showExpandable = ($dt -eq 'text' -or $dt -eq 'list')
$script:_admxRegSpExpandable.IsVisible = $showExpandable
$script:_admxRegChkExpandable.IsVisible = $showExpandable
$showAdditive = ($dt -eq 'list')
$script:_admxRegSpAdditive.IsVisible = $showAdditive
$script:_admxRegChkAdditive.IsVisible = $showAdditive
}
# ─── Reg Values — Intune ingestion (Slice 5b2) ────────────────────────────────
#
# Verbatim port of UI/WPF/Extensions/IntuneToolsUI.ps1 Import-ADMXRegProfileToIntune.
# No UI deps beyond $script:_admxRegValuesPanel.DataContext; everything else is
# data-shape (XML clone + OMA-URI assembly) and Graph POST.
function Import-ADMXRegProfileToIntune
{
$ui = $script:UIProvider
$regProfile = $script:_admxRegValuesPanel.DataContext
if (-not $regProfile -or -not $regProfile.ProfileName) {
$ui.ShowMessageBox('Custom Profile Name must be specified', 'Reg Values', 'OK', 'Error')
return
}
if ($regProfile.ADMXPolicies.Count -eq 0) {
$ui.ShowMessageBox('Add at least one registry policy before importing', 'Reg Values', 'OK', 'Information')
return
}
[xml]$xml = $script:_admxRegTemplate
# The synthesized category needs a unique name so re-imports under the same
# ingest namespace don't collide.
$guidId = [Guid]::NewGuid().Guid
$regPolicyFileName = "RegPolicy_$guidId"
$xml.policyDefinitions.categories.category.name = ($xml.policyDefinitions.categories.category.name + '_' + $guidId)
$intuneObj = [PSCustomObject]@{
'@odata.type' = '#microsoft.graph.windows10CustomConfiguration'
displayName = $regProfile.ProfileName
omaSettings = @()
roleScopeTagIds = @()
assignments = @()
}
if ($regProfile.ProfileDescription) {
$intuneObj | Add-Member -MemberType NoteProperty -Name description -Value $regProfile.ProfileDescription
}
$admxRegSettings = @()
foreach ($regPolicy in $regProfile.ADMXPolicies)
{
$omaUriString = if ($regPolicy.PolicyStatus -eq 'Enabled') { '<enabled/>' } else { '<disabled/>' }
if ($regPolicy.PolicyName) {
$policyName = $regPolicy.PolicyName -replace ' ', '_'
}
else {
$policyName = [Guid]::NewGuid().Guid
}
# Clone the seed <policy> for each user-defined policy. We mutate the
# clone, then append; the original is removed once the loop completes.
$newNode = $xml.policyDefinitions.policies.ChildNodes[0].CloneNode($true)
$newNode.name = $policyName
$newNode.class = if ($regPolicy.Hive -eq 'HKLM') { 'Machine' } else { 'User' }
$newNode.displayName = "`$(string.$policyName)"
$newNode.presentation = "`$(presentation.$policyName)"
$newNode.key = $regPolicy.Key.Trim('\')
$newNode.parentCategory.ref = $xml.policyDefinitions.categories.category.name
if (-not $regPolicy.StatusValueName) {
[void]$newNode.RemoveChild($newNode.SelectSingleNode('enabledValue'))
[void]$newNode.RemoveChild($newNode.SelectSingleNode('disabledValue'))
}
else {
$newNode.valueName = $regPolicy.StatusValueName
}
$omaUriItems = @()
if ($null -eq $regPolicy.PolicyElements -or $regPolicy.PolicyElements.Count -eq 0) {
[void]$newNode.RemoveChild($newNode.SelectSingleNode('elements'))
}
else
{
foreach ($element in $regPolicy.PolicyElements)
{
$child = $xml.CreateElement($element.DataType)
$elementId = $null
if ($element.DataType -in @('multiText','list')) {
# User-set separator is stored per element; fall back to
# ';' for legacy in-memory objects.
$splitter = ?? $element.AttributeSeparator ';'
$value = $element.Value -replace $splitter, '&#xF000;'
}
else {
$value = $element.Value
}
# valueName goes on every element type except "list" (which is
# keyed by an Id derived from the policy/element key path).
if ($element.DataType -ne 'list') {
Add-ADMXRegXmlAttribute $child 'valueName' $element.ValueName
}
else {
if ($element.AttributePrefix) {
Add-ADMXRegXmlAttribute $child 'valuePrefix' $element.AttributePrefix
}
}
if (($element.DataType -eq 'text' -or $element.DataType -eq 'list') -and $element.AttributeExpandable) {
Add-ADMXRegXmlAttribute $child 'expandable' 'true'
}
if ($element.DataType -eq 'list' -and $element.AttributeAdditive) {
Add-ADMXRegXmlAttribute $child 'additive' 'true'
}
if ($element.AttributeSoft) {
Add-ADMXRegXmlAttribute $child 'soft' 'true'
}
if ($element.DataType -eq 'list') {
$keyStr = ?? $element.Key $regPolicy.Key
if ($keyStr) { $idStr = $keyStr.Trim('\').Split('\')[-1] }
else { $idStr = [Guid]::NewGuid().Guid }
$elementId = $idStr + '_Id'
Add-ADMXRegXmlAttribute $child 'id' $elementId
}
else {
$elementId = $element.ValueName + '_Id'
Add-ADMXRegXmlAttribute $child 'id' $elementId
}
if ($element.Key) {
Add-ADMXRegXmlAttribute $child 'key' $element.Key.Trim('\')
}
$escapedValue = [System.Security.SecurityElement]::Escape($value) -replace '&amp;#xF000;', '&#xF000;'
$omaUriItems += "<data id=`"$elementId`" value=`"$escapedValue`"/>"
[void]$newNode.SelectSingleNode('elements').AppendChild($child)
}
}
if ($omaUriItems.Count -gt 0) {
$omaUriString = $omaUriString + [Environment]::NewLine + [Environment]::NewLine + ($omaUriItems -join [Environment]::NewLine)
}
if ((Get-SettingValue 'FormatOMAURI') -eq $true) {
$omaUriString = Update-XmlFormatting $omaUriString
}
[void]$xml.policyDefinitions.SelectSingleNode('policies').AppendChild($newNode)
$direction = if ($regPolicy.Hive -eq 'HKLM') { 'Device' } else { 'User' }
$admxRegSettings += [PSCustomObject]@{
'@odata.type' = '#microsoft.graph.omaSettingString'
displayName = "Set $policyName"
omaUri = "./$direction/Vendor/MSFT/Policy/Config/IntuneManagementReg~$($regProfile.PolicyType)~$($newNode.parentCategory.ref)/$policyName"
value = $omaUriString
}
}
# Remove the seed/template policy before serialisation.
[void]$xml.policyDefinitions.SelectSingleNode('policies').RemoveChild(
$xml.policyDefinitions.policies.SelectSingleNode('policy'))
$xmlString = Format-XML $xml
if ((Get-SettingValue 'FormatOMAURI') -eq $true) {
$xmlString = Update-XmlFormatting $xmlString
}
$intuneObj.omaSettings += [PSCustomObject]@{
'@odata.type' = '#microsoft.graph.omaSettingString'
displayName = 'Reg ADMX Ingestion'
description = 'This XML is generated by Intune Management tool'
omaUri = "./Device/Vendor/MSFT/Policy/ConfigOperations/ADMXInstall/IntuneManagementReg/$($regProfile.PolicyType)/$regPolicyFileName"
value = $xmlString
}
$intuneObj.omaSettings += $admxRegSettings
$json = $intuneObj | ConvertTo-Json -Depth 20
$tokenId = Get-DefaultTokenId
$resp = Invoke-MSGraphAPI -Url 'deviceManagement/deviceConfigurations' -Content $json -HttpMethod 'POST' -TokenId $tokenId -FullResponseObject
if ($resp -and $resp.Success) {
Write-Log "Custom profile '$($intuneObj.displayName)' created with id $($resp.Content.Id)"
$ui.ShowMessageBox("Profile '$($intuneObj.displayName)' created successfully.", 'Reg Values', 'OK', 'Information')
}
else {
$errInfo = if ($resp) { "$($resp.StatusCode) $($resp.StatusDescription)" } else { 'no response' }
Write-Log "Failed to create device configuration profile '$($intuneObj.displayName)' - $errInfo" 3
$ui.ShowMessageBox("Failed to create device configuration profile '$($intuneObj.displayName)'.`n`n$errInfo`n`nCheck the log for details.", 'Reg Values', 'OK', 'Error')
}
}