# 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 = '' } $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 . 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 = '' } $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 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, '') } '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))$($kv.Value)" $i++ } $ctrlValue = $arr -join '' } 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 += "" } 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 = '' } else { $policyValue = '' 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, 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 is cloned per UI-defined ADMXRegPolicy; the seed entry is # removed before the XML is serialised. $script:_admxRegTemplate = @" "@ 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') { '' } else { '' } if ($regPolicy.PolicyName) { $policyName = $regPolicy.PolicyName -replace ' ', '_' } else { $policyName = [Guid]::NewGuid().Guid } # Clone the seed 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, '' } 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 '', '' $omaUriItems += "" [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') } }