IntuneManagement 4.0.0-beta1

This commit is contained in:
Mikael Karlsson
2026-09-23 19:13:09 +10:00
commit 7869619510
892 changed files with 577109 additions and 0 deletions
@@ -0,0 +1,127 @@
# Avalonia authentication actions that must start after their initiating control
# event returns. Authentication itself remains synchronous and on the UI thread;
# only its start is deferred by one dispatcher turn so Avalonia can release the
# initiating button's pointer capture before a cancellable wait begins.
$script:AvaloniaDeferredActions = [System.Collections.Generic.Queue[object]]::new()
# Schedule module code for the next dispatcher turn. Arguments are stored
# explicitly because NewBoundScriptBlock intentionally does not retain
# function-local captures.
function Invoke-AvaloniaDeferredAction {
[CmdletBinding()]
param(
[Parameter(Mandatory)][scriptblock]$Action,
$Argument
)
$boundAction = ConvertTo-AvaloniaEventScriptBlock $Action
$runner = (ConvertTo-AvaloniaEventScriptBlock {
Invoke-NextAvaloniaDeferredAction
}) -as [Action]
if(-not $runner) { throw 'Could not create deferred Avalonia dispatcher action.' }
$script:AvaloniaDeferredActions.Enqueue([PSCustomObject]@{
Action = $boundAction
Argument = $Argument
})
[Avalonia.Threading.Dispatcher]::UIThread.Post($runner)
}
function Invoke-NextAvaloniaDeferredAction {
if(-not $script:AvaloniaDeferredActions -or $script:AvaloniaDeferredActions.Count -eq 0) { return }
$work = $script:AvaloniaDeferredActions.Dequeue()
try { & $work.Action $work.Argument }
catch { Write-LogError 'Deferred Avalonia action failed' $_.Exception }
}
function Invoke-AvaloniaInteractiveSignIn {
param([string]$Cloud)
if($Cloud) { Write-Status "Signing in to $Cloud cloud..." }
else { Write-Status 'Signing in...' }
try {
$tokenInfo = if($Cloud) {
Invoke-AuthProviderInteractiveLogin -Cloud $Cloud
} else {
Invoke-AuthProviderInteractiveLogin
}
# Successful providers register the token and synchronously fire
# AuthenticatedNewToken. Avalonia's handler owns profile enrichment and the
# auth-info redraw; repeating Get-MSALUserInfo here would issue /me and photo
# requests twice for every MSAL sign-in.
if (-not $tokenInfo) {
Write-Log 'Sign-in returned nothing (user cancelled or auth failed)' 2
}
}
catch {
Write-LogError 'Deferred sign-in failed' $_.Exception
}
finally {
Write-Status ''
}
}
function Invoke-AvaloniaConsentPrompt {
try {
if (Get-Command Start-MSALConsentPrompt -ErrorAction SilentlyContinue) {
Start-MSALConsentPrompt
}
}
catch {
Write-LogError 'Start-MSALConsentPrompt failed' $_.Exception
}
}
function Invoke-AvaloniaProfileRefresh {
Write-Status 'Refreshing the token'
try {
$providerNow = $null
try { $providerNow = Get-AuthProvider } catch { }
$refreshed = $false
if ($providerNow -and $providerNow.UsesBuiltInConnectPath) {
$refreshed = [bool](Connect-EntraEnvironment -ForceRefresh -TokenId (Get-DefaultAuthTokenId))
} elseif ($providerNow) {
# The real default token id, not a literal 0 - see the same call in
# UI/WPF/Extensions/MSGraphAuthenticationUIWPF.ps1: ids start at 1, so 0
# matched nothing in OAuth's token table and refresh was a silent no-op.
$refreshed = [bool]$providerNow.Refresh((Get-DefaultAuthTokenId))
}
if (-not $refreshed) {
$script:UIProvider.HidePopup()
} else {
try { Get-MSALUserInfo } catch { Write-LogError 'Get-MSALUserInfo failed after refresh' $_.Exception }
if (Get-Command Show-ViewMenu -ErrorAction SilentlyContinue) { Show-ViewMenu }
}
}
catch {
Write-LogError 'Token refresh failed' $_.Exception
}
finally {
Write-Status ''
}
}
function Invoke-AvaloniaTenantSwitch {
param($Tenant)
if(-not $Tenant) { return }
Write-Status "Logging in to $($Tenant.DisplayName)"
try {
$userName = $script:CurrentUser.userPrincipalName
if ($userName) {
Connect-EntraEnvironment -User $userName -TenantId $Tenant.tenantId -DefaultToken | Out-Null
} else {
Connect-EntraEnvironment -TenantId $Tenant.tenantId -DefaultToken | Out-Null
}
}
catch {
Write-LogError 'Tenant switch failed' $_.Exception
}
finally {
Write-Status ''
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,417 @@
# Object picker sub-modal (Avalonia).
#
# Split out of CoreUIAvalonia.ps1 so the shell file stays inside its R9 line
# budget. Avalonia port of the WPF Show-PickerDialog / Show-ObjectPickerDialog
# pair: a Window-shaped dialog shown through Host::ShowDialog for synchronous
# semantics, so callers can write
# $selected = Show-ObjectPickerDialog ...; if ($selected) { ... }
# A grdModal overlay would have to push a DispatcherFrame from PowerShell to
# block, which the C# side already wraps.
# Show a one-line message in the picker's status area (hidden when empty).
function Set-PickerStatusText
{
param([string]$Text)
if (-not $script:txtPickerStatus -or -not $script:grpPickerStatus) { return }
$script:txtPickerStatus.Text = $Text
$script:grpPickerStatus.IsVisible = (-not [String]::IsNullOrWhiteSpace($Text))
}
# The scope object behind the current "Search in" selection, or $null. The
# ComboBox holds [SettingsListItem] projections (Avalonia cannot bind
# NoteProperties), so the real scope comes back through $script:pickerScopeMap.
function Get-PickerSelectedScope
{
if (-not $script:cbPickerScope -or -not $script:pickerScopeMap) { return $null }
$selected = $script:cbPickerScope.SelectedItem
if (-not $selected) { return $null }
return $script:pickerScopeMap[[string]$selected.Value]
}
# The tenant entry behind the current "Tenant" selection, or $null when the
# dialog is single-tenant. Same projection-and-map trick as the scope combo.
function Get-PickerSelectedTenant
{
if (-not $script:cbPickerTenant -or -not $script:pickerTenantMap) { return $null }
$selected = $script:cbPickerTenant.SelectedItem
if (-not $selected) { return $null }
return $script:pickerTenantMap[[string]$selected.Value]
}
# Drop whatever is in the results grid. Called when the tenant changes - those
# rows belong to the previous tenant and picking one would compare the wrong
# object.
function Clear-PickerResults
{
$empty = [System.Collections.Generic.List[PickerObjectRow]]::new()
if ($script:_pickerState) { $script:_pickerState.Rows = $empty }
if ($script:dgPickerObjects) { $script:dgPickerObjects.ItemsSource = $empty }
if ($script:btnPickerOK) { $script:btnPickerOK.IsEnabled = $false }
}
# Wired to the tenant combo's SelectionChanged. A module function rather than an
# inline handler so it can be reached by name from the rebound scriptblock.
function Invoke-PickerTenantChanged
{
Clear-PickerResults
$tenant = Get-PickerSelectedTenant
if ($tenant) { Set-PickerStatusText "Search $($tenant.Title) for an object." }
}
function Show-PickerDialog
{
param(
$Title,
$InitHandler,
$ClickHandler,
$OkHandler,
[switch] $MultiSelect,
$ClickHandlerArgs = $null,
$LoadHandler = $null,
[string] $LoadLabel = "Load all from Intune",
# Entries from Get-PolicySearchScopes. When supplied, the dialog shows
# a "Search in" dropdown above the search box.
$Scopes = $null,
[string] $SelectedScopeKey = $null,
# Entries from Get-PolicySearchTenants. When supplied, the dialog shows
# a "Tenant" dropdown above the scope dropdown.
$Tenants = $null,
[string] $SelectedTenantKey = $null
)
$ui = $script:UIProvider
$dialogXaml = Join-Path $script:AppUIRootFolder 'XAML/PickerDialog.axaml'
$dialog = $ui.GetXamlObject($dialogXaml)
if (-not $dialog) { return }
$dialog.Title = $Title
$hostType = (Get-AvaloniaHost)
$script:pickerDialog = $dialog
$script:dgPickerObjects = $hostType::FindByName($dialog, 'dgPickerObjects')
$script:btnPickerOK = $hostType::FindByName($dialog, 'btnPickerOK')
$script:grpPickerStatus = $hostType::FindByName($dialog, 'grpPickerStatus')
$script:txtPickerStatus = $hostType::FindByName($dialog, 'txtPickerStatus')
$script:txtPickerSearch = $hostType::FindByName($dialog, 'txtPickerSearch')
$btnPickerSearch = $hostType::FindByName($dialog, 'btnPickerSearch')
if ($MultiSelect) {
$script:dgPickerObjects.SelectionMode = [Avalonia.Controls.DataGridSelectionMode]::Extended
}
# Tenant selector - only shown when more than one tenant is signed in.
$script:cbPickerTenant = $hostType::FindByName($dialog, 'cbPickerTenant')
$script:pickerTenantMap = $null
if ($Tenants -and $script:cbPickerTenant) {
$script:pickerTenantMap = @{}
$tenantItems = [System.Collections.Generic.List[SettingsListItem]]::new()
foreach ($tenant in @($Tenants)) {
$script:pickerTenantMap[[string]$tenant.Key] = $tenant
[void]$tenantItems.Add([SettingsListItem]@{ Name = $tenant.Title; Value = $tenant.Key })
}
$script:cbPickerTenant.ItemsSource = $tenantItems
$tenantIndex = 0
if ($SelectedTenantKey) {
for ($i = 0; $i -lt $tenantItems.Count; $i++) {
if ($tenantItems[$i].Value -eq $SelectedTenantKey) { $tenantIndex = $i; break }
}
}
if ($tenantItems.Count -gt 0) { $script:cbPickerTenant.SelectedIndex = $tenantIndex }
$grdPickerTenant = $hostType::FindByName($dialog, 'grdPickerTenant')
if ($grdPickerTenant) { $grdPickerTenant.IsVisible = $true }
# Switching tenant clears the grid: the rows in it came from the
# previous tenant and the search has to be re-run against the new one.
$script:cbPickerTenant.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock {
Invoke-PickerTenantChanged
}))
}
$script:cbPickerScope = $hostType::FindByName($dialog, 'cbPickerScope')
$script:pickerScopeMap = $null
if ($Scopes -and $script:cbPickerScope) {
$script:pickerScopeMap = @{}
$scopeItems = [System.Collections.Generic.List[SettingsListItem]]::new()
foreach ($scope in @($Scopes)) {
$script:pickerScopeMap[[string]$scope.Key] = $scope
[void]$scopeItems.Add([SettingsListItem]@{ Name = $scope.Title; Value = $scope.Key })
}
$script:cbPickerScope.ItemsSource = $scopeItems
$selectedIndex = 0
if ($SelectedScopeKey) {
for ($i = 0; $i -lt $scopeItems.Count; $i++) {
if ($scopeItems[$i].Value -eq $SelectedScopeKey) { $selectedIndex = $i; break }
}
}
if ($scopeItems.Count -gt 0) { $script:cbPickerScope.SelectedIndex = $selectedIndex }
$grdPickerScope = $hostType::FindByName($dialog, 'grdPickerScope')
if ($grdPickerScope) { $grdPickerScope.IsVisible = $true }
}
$ui.AddXamlEvent($dialog, 'btnPickerCancel', 'add_Click', ((ConvertTo-AvaloniaEventScriptBlock {
$script:pickerDialog.Close()
}.GetNewClosure())))
$script:pickerOKHandler = $OkHandler
$ui.AddXamlEvent($dialog, 'btnPickerOK', 'add_Click', $OkHandler)
$script:pickerSearchHandler = $ClickHandler
if ($ClickHandler) {
$ui.AddXamlEvent($dialog, 'btnPickerSearch', 'add_Click', $ClickHandler)
}
if ($ClickHandlerArgs -and $btnPickerSearch) {
$btnPickerSearch.Tag = $ClickHandlerArgs
}
$btnPickerLoadAll = $hostType::FindByName($dialog, 'btnPickerLoadAll')
if ($btnPickerLoadAll -and $LoadHandler -is [scriptblock]) {
$btnPickerLoadAll.Content = $LoadLabel
$btnPickerLoadAll.IsVisible = $true
$script:pickerLoadHandler = $LoadHandler
$btnPickerLoadAll.add_Click((ConvertTo-AvaloniaEventScriptBlock {
param($S, $E)
try {
# Disabled only for the duration - the scope can change, so a
# second load is legitimate.
$S.IsEnabled = $false
& $script:pickerLoadHandler
} catch {
Write-LogError "Picker LoadHandler failed" $_.Exception
} finally {
$S.IsEnabled = $true
}
}.GetNewClosure()))
}
$script:dgPickerObjects.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock {
$script:btnPickerOK.IsEnabled = ($null -ne $script:dgPickerObjects.SelectedItem)
}))
$script:dgPickerObjects.add_DoubleTapped((ConvertTo-AvaloniaEventScriptBlock {
if ($script:btnPickerOK.IsEnabled -and $script:pickerOKHandler) {
& $script:pickerOKHandler
}
}))
# Enter in the search box runs the search (matches WPF). Handled is set so
# it does not fall through to btnPickerOK, which is IsDefault.
$script:txtPickerSearch.add_KeyDown((ConvertTo-AvaloniaEventScriptBlock {
param($S, $E)
if ($E.Key -eq [Avalonia.Input.Key]::Enter -and $script:pickerSearchHandler) {
$E.Handled = $true
& $script:pickerSearchHandler
}
}))
if ($InitHandler -is [scriptblock]) {
& $InitHandler
}
$hostType::ShowDialog($script:pickerDialog, $script:Window)
}
# Project a policy object into the CLR row shape the picker DataGrid binds
# against (Avalonia ignores PSObject NoteProperties - see
# [[avalonia-binding-needs-clr-types]]). Module functions rather than
# scriptblocks because the handlers below cannot capture one - rebinding a
# scriptblock to the module strips its closure.
function ConvertTo-PickerObjectRow
{
param($Source)
if (-not $Source) { return $null }
return [PickerObjectRow]@{
Name = [string]$Source.Name
PolicyTypeTitle = if ($Source.PolicyType) { [string]$Source.PolicyType.Title } else { $null }
Description = [string]$Source.Description
Source = $Source
}
}
function ConvertTo-PickerObjectRowList
{
param($Items)
$rows = [System.Collections.Generic.List[PickerObjectRow]]::new()
foreach ($item in @($Items)) {
$row = ConvertTo-PickerObjectRow $item
if ($row) { [void]$rows.Add($row) }
}
return ,$rows
}
function Show-ObjectPickerDialog
{
param(
[string] $Title,
[object[]] $Items,
[object[]] $DisplayColumns,
[scriptblock] $LoadHandler,
[string] $LoadLabel = "Load all from Intune",
# Entries from Get-PolicySearchScopes. When supplied, the dialog shows
# a "Search in" dropdown above the search box.
$Scopes = $null,
[string] $SelectedScopeKey = $null,
# Entries from Get-PolicySearchTenants. When supplied, the dialog shows
# a "Tenant" dropdown above the scope dropdown.
$Tenants = $null,
[string] $SelectedTenantKey = $null,
# Called as & $SearchHandler $searchText $scope and expected to return
# the objects to display. When absent the Search button filters the
# supplied -Items client-side (the original behaviour).
[scriptblock] $SearchHandler = $null
)
# Everything the handlers need lives on $script:_pickerState, NOT in
# captured locals: ConvertTo-AvaloniaEventScriptBlock rebinds each block to
# the module, which strips the closure (proven 2026-08-27 with a real
# function local raised through a real Avalonia Click). Module-scope state,
# module functions by name, $S/$E and sender.Tag are what survive.
#
# DisplayColumns Binding paths are mapped to PickerObjectRow property names;
# only the paths used by current callers (Name, PolicyType.Title,
# Description) are recognised - extend PickerObjectRow and BindingMap
# together if a new column is needed.
$script:_pickerState = @{
Columns = $DisplayColumns
BindingMap = @{
'Name' = 'Name'
'PolicyType.Title' = 'PolicyTypeTitle'
'Description' = 'Description'
}
Rows = (ConvertTo-PickerObjectRowList $Items)
Result = @{ Value = $null }
SearchHandler = $SearchHandler
LoadHandler = $LoadHandler
}
$initHandler = (ConvertTo-AvaloniaEventScriptBlock {
$ps = $script:_pickerState
foreach ($colDef in $ps.Columns) {
$col = [Avalonia.Controls.DataGridTextColumn]::new()
$col.Header = $colDef.Header
$col.IsReadOnly = $true
$bindingPath = $ps.BindingMap[[string]$colDef.Binding]
if (-not $bindingPath) {
Write-LogError "Show-ObjectPickerDialog: unsupported column binding '$($colDef.Binding)' - extend PickerObjectRow + BindingMap together"
continue
}
$col.Binding = [Avalonia.Data.Binding]::new($bindingPath)
$col.Width = [Avalonia.Controls.DataGridLength]::new(1, [Avalonia.Controls.DataGridLengthUnitType]::Star)
$script:dgPickerObjects.Columns.Add($col) | Out-Null
}
$script:dgPickerObjects.ItemsSource = $ps.Rows
})
$clickHandler = (ConvertTo-AvaloniaEventScriptBlock {
$ps = $script:_pickerState
$filter = [string]$script:txtPickerSearch.Text
if ($ps.SearchHandler) {
# Server-side search: ask the caller for the objects matching the
# text in the selected scope and show exactly what comes back. The
# status line belongs to the search handler - it knows whether an
# empty result means "no matches" or "keep typing".
$scope = Get-PickerSelectedScope
try {
$found = @(& $ps.SearchHandler $filter $scope)
}
catch {
Write-LogError "Picker SearchHandler failed" $_.Exception
$found = @()
}
$ps.Rows = (ConvertTo-PickerObjectRowList $found)
$script:dgPickerObjects.ItemsSource = $ps.Rows
return
}
if ([string]::IsNullOrEmpty($filter)) {
$script:dgPickerObjects.ItemsSource = $ps.Rows
} else {
# Where-Object stamps PSObject ETS wrappers; Avalonia DataGrid's
# text-column binder reflects on the runtime type and renders blank
# cells against the wrapper (see [[avalonia-binding-needs-clr-types]]).
$filtered = [System.Collections.Generic.List[PickerObjectRow]]::new()
foreach ($r in $ps.Rows) {
if ($r.Name -ilike "*$filter*") { [void]$filtered.Add($r) }
}
$script:dgPickerObjects.ItemsSource = $filtered
}
})
$okHandler = (ConvertTo-AvaloniaEventScriptBlock {
$row = $script:dgPickerObjects.SelectedItem
if ($row) {
$script:_pickerState.Result.Value = $row.Source
}
$script:pickerDialog.Close()
})
$wrappedLoadHandler = $null
if ($LoadHandler) {
$wrappedLoadHandler = (ConvertTo-AvaloniaEventScriptBlock {
$ps = $script:_pickerState
$newItems = & $ps.LoadHandler
if ($ps.SearchHandler) {
# Scoped loads replace rather than merge - the returned set IS
# everything in the newly selected scope, and merging would
# leave stale rows from a previous scope in the grid.
$ps.Rows = (ConvertTo-PickerObjectRowList $newItems)
$script:dgPickerObjects.ItemsSource = $ps.Rows
return
}
if ($newItems) {
# Dedupe by Id; new entries appended so cached items stay first.
$existingIds = @{}
foreach ($r in $ps.Rows) {
if ($r.Source -and $r.Source.Id) { $existingIds[$r.Source.Id] = $true }
}
$merged = [System.Collections.Generic.List[PickerObjectRow]]::new()
foreach ($r in $ps.Rows) { [void]$merged.Add($r) }
foreach ($n in $newItems) {
if (-not $n) { continue }
if ($n.Id -and $existingIds.ContainsKey($n.Id)) { continue }
$row = ConvertTo-PickerObjectRow $n
if ($row) { [void]$merged.Add($row) }
}
$ps.Rows = $merged
}
Invoke-PickerSearchHandler
})
}
try {
Show-PickerDialog -Title $Title -InitHandler $initHandler -ClickHandler $clickHandler -OkHandler $okHandler -LoadHandler $wrappedLoadHandler -LoadLabel $LoadLabel -Scopes $Scopes -SelectedScopeKey $SelectedScopeKey -Tenants $Tenants -SelectedTenantKey $SelectedTenantKey
return $script:_pickerState.Result.Value
}
finally {
# Do not keep the handlers / rows alive between dialog invocations.
$script:_pickerState = $null
}
}
# Re-run the picker's current search/filter. A module function so the load
# handler can trigger it without capturing the click handler.
function Invoke-PickerSearchHandler
{
if ($script:pickerSearchHandler) { & $script:pickerSearchHandler }
}
@@ -0,0 +1,89 @@
# Avalonia port of UI/WPF/Extensions/EffectivePermissionsUIWPF.ps1: the
# "Permissions" button in the Profile popup. Thin caller of the public
# Get-GraphEffectivePermissions (R10); rows go through the CLR class
# Classes/EffectivePermissionRowItem.ps1 because the Avalonia binder cannot
# read PSCustomObject note properties. Wired from Show-ProfilePopup
# (CoreUIAvalonia.ps1) with a single AddXamlEvent line.
function Show-EffectivePermissionsDialog {
param()
$ui = $script:UIProvider
$rows = @()
$raw = $null
try {
$rows = @(Get-GraphEffectivePermissions)
$raw = Get-GraphEffectivePermissions -Raw
}
catch { Write-LogError "Get-GraphEffectivePermissions failed" $_.Exception }
if ($rows.Count -eq 0) {
$ui.ShowMessageBox('No access token available. Sign in first.', 'Permissions', 'OK', 'Information') | Out-Null
return
}
$items = @()
foreach ($r in ($rows | Sort-Object EffectiveLevel, Title)) {
$items += [EffectivePermissionRowItem]@{
Type = [string]$r.Title
Category = [string]$r.ResourceCategory
Required = [string]$r.Required
Token = [string]$r.TokenAccess
IntuneRole = [string]$r.RoleAccess
Effective = [string]$r.EffectiveAccess
Result = [string]$r.Result
Reason = [string]$r.Reason
}
}
$grid = [Avalonia.Controls.Grid]::new()
$rdHeader = [Avalonia.Controls.RowDefinition]::new(); $rdHeader.Height = [Avalonia.Controls.GridLength]::Auto
$rdGrid = [Avalonia.Controls.RowDefinition]::new(); $rdGrid.Height = [Avalonia.Controls.GridLength]::new(1, [Avalonia.Controls.GridUnitType]::Star)
$grid.RowDefinitions.Add($rdHeader)
$grid.RowDefinitions.Add($rdGrid)
$txt = [Avalonia.Controls.TextBlock]::new()
$txt.Text = Get-EffectivePermissionsHeaderText $raw
$txt.TextWrapping = [Avalonia.Media.TextWrapping]::Wrap
$txt.MaxWidth = 900
$txt.Margin = [Avalonia.Thickness]::new(5, 5, 5, 10)
[Avalonia.Controls.Grid]::SetRow($txt, 0)
$grid.Children.Add($txt)
$dg = [Avalonia.Controls.DataGrid]::new()
$dg.AutoGenerateColumns = $true
$dg.IsReadOnly = $true
$dg.CanUserSortColumns = $true
$dg.CanUserResizeColumns = $true
$dg.GridLinesVisibility = [Avalonia.Controls.DataGridGridLinesVisibility]::Horizontal
$dg.MinWidth = 900
$dg.MinHeight = 450
$dg.ItemsSource = $items
[Avalonia.Controls.Grid]::SetRow($dg, 1)
$grid.Children.Add($dg)
$ui.ShowModalForm('Permissions', $grid)
}
# Same wording as the WPF dialog; each backend keeps its own copy (R12).
function Get-EffectivePermissionsHeaderText {
param($Context)
$refresh = "Changed roles (PIM, a new Intune role)? Click Refresh in the Profile popup - the app keeps using the token it signed in with until then."
if (-not $Context) {
return ("Token scopes only. The Intune role check does not apply to this token (app-only sign-in) or " +
"could not be read; see the log. Scope tags are not evaluated. $refresh")
}
$asOf = if ($Context.AsOf) { " Token issued $($Context.AsOf.ToString('yyyy-MM-dd HH:mm'))." } else { "" }
if ($Context.AllAllowed) {
return ("Token scopes combined with the Intune Administrator / Global Administrator directory role " +
"(full Intune access, no per-action lookup).$asOf $refresh")
}
# "N of M" only when the action catalogue was readable (see the WPF copy).
$counts = "$($Context.Allowed.Count) resource actions allowed"
if ($Context.Catalog) { $counts = "$($Context.Allowed.Count) of $($Context.Catalog.Count) resource actions allowed" }
return ("Token scopes combined with the signed-in user's Intune role permissions " +
"($counts).$asOf " +
"Scope tags are not evaluated: a user limited to some tags can still be refused on individual objects. $refresh")
}
@@ -0,0 +1,62 @@
# Experimental-platform notice - the RENDERING half. The decision lives in
# UI/Classes/ExperimentalPlatformNotice.ps1 (loads under both backends, so the gate
# is unit-testable on Windows); this file only draws it.
#
# Avalonia only, by design: the notice never shows on Windows, and WPF is the only
# backend there - so there is no WPF twin to keep in sync.
#
# Closure law: click handlers run through ConvertTo-AvaloniaEventScriptBlock, which
# strips function-local captures but keeps module scope. The handler below therefore
# reads the checkbox off $S.Tag and calls module functions by name only - never a
# local, never $script:UIProvider.X(). See UI/Avalonia/CLAUDE.md.
function Get-ExperimentalPlatformName
{
if($IsMacOS) { return "macOS" }
if($IsLinux) { return "Linux" }
return "this platform"
}
function Show-ExperimentalPlatformNotice
{
if(-not (Test-ShouldShowExperimentalPlatformNotice)) { return }
$ui = $script:UIProvider
$panel = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/ExperimentalPlatformNotice.axaml'))
if(-not $panel) { return }
$platform = Get-ExperimentalPlatformName
$body = "IntuneManagement runs on $platform through the Avalonia UI backend, " +
"but this build has not been verified there. Policy export, import, " +
"compare and documentation all use the same engine as the Windows " +
"build, so they are expected to work - the parts most likely to " +
"misbehave are the window itself, file dialogs and the sign-in " +
"browser flow." + [Environment]::NewLine + [Environment]::NewLine +
"Word documentation output and MSI property extraction need Windows " +
"and are unavailable here." + [Environment]::NewLine + [Environment]::NewLine +
"Please report anything that breaks."
$ui.SetXamlProperty($panel, 'txtNoticeBody', 'Text', $body)
# Carry the checkbox on the button's Tag: the handler cannot capture it as a
# function local (see the closure note above).
$hostType = Get-AvaloniaHost
$okButton = $hostType::FindByName($panel, 'btnNoticeOk')
$checkBox = $hostType::FindByName($panel, 'chkHideNoticeAgain')
if($okButton) { $okButton.Tag = $checkBox }
$ui.AddXamlEvent($panel, 'btnNoticeOk', 'Add_Click', ({
param($S, $E)
try {
$chk = $S.Tag
if($chk -and $chk.IsChecked -eq $true) {
Save-SettingStoreValue "" $script:ExperimentalPlatformNoticeKey $true
}
}
catch { Write-LogError 'Failed to save the experimental platform notice preference' $_.Exception }
Close-TopModalObject
}))
Show-ModalForm -FormTitle "Experimental platform" -FormObject $panel -HideButtons
}
@@ -0,0 +1,917 @@
# Avalonia port of the Bulk Assignments dialog from
# UI/WPF/Extensions/IntuneManagerUI.ps1 (Show-GraphBulkAssignmentsForm + helpers).
# New file because Bulk Assignments is a self-contained subsystem and the WPF
# original sits in the giant IntuneManagerUI file we're trying not to grow
# further (architecture rule R9).
#
# Slice 4e: Bulk Assignments. Combines a Group/API DataGrid (Slice 4a-style)
# with an assignment-list DataGrid + row-level group picker sub-modal +
# 13-tab schema-driven settings sub-modal. Driver Set-GraphBulkAssignments
# lives in Public/, so the click handler just preflight-validates, prompts,
# and forwards to the cmdlet.
#
# Test-BulkAssignmentSupported lives in Internal/BulkAssignments.ps1
# (loaded in Avalonia mode), so no duplication is needed here.
function Update-BulkAssignObjectList
{
if (-not $script:dgBulkAssignObjects) { return }
$rows = [System.Collections.Generic.List[BulkAssignRowItem]]::new()
if ($script:bulkAssignMode -eq "Type") {
$sortedTypes = $script:bulkAssignEligibleTypes | Sort-Object Title
foreach ($pt in $sortedTypes) {
$rows.Add([BulkAssignRowItem]@{
Title = [string]$pt.Title
Selected = $true
ObjectGroup = $null
ObjectType = $pt
})
}
} else {
$sortedGroups = $script:bulkAssignEligibleGroups | Sort-Object Title
foreach ($grp in $sortedGroups) {
$rows.Add([BulkAssignRowItem]@{
Title = [string]$grp.Title
Selected = $true
ObjectGroup = $grp
ObjectType = $null
})
}
}
$script:bulkAssignObjects = @($rows)
$script:dgBulkAssignObjects.ItemsSource = $script:bulkAssignObjects
}
function Get-BulkAssignSelectedObjectIds
{
if ($null -eq $script:bulkAssignObjects) { return @() }
if ($script:bulkAssignMode -eq "Type") {
return @($script:bulkAssignObjects |
Where-Object { $_.Selected -and $_.ObjectType -and $_.ObjectType.Id } |
ForEach-Object { $_.ObjectType.Id })
}
return @($script:bulkAssignObjects |
Where-Object { $_.Selected -and $_.ObjectGroup -and $_.ObjectGroup.Id } |
ForEach-Object { $_.ObjectGroup.Id })
}
# Append a row to the assignments DataGrid. Used by All Devices / All Users
# buttons and by the group picker sub-modal after the user clicks Add.
function Add-BulkAssignmentRow
{
param(
[Parameter(Mandatory)][string]$TargetType,
[string]$GroupId,
[string]$GroupName,
[string]$FilterId,
[string]$FilterName,
[string]$FilterType = "include",
[string]$Intent = "required"
)
$ui = $script:UIProvider
$typeDisplay = switch ($TargetType) {
"groupAssignmentTarget" { "Include group" }
"exclusionGroupAssignmentTarget" { "Exclude group" }
"allDevicesAssignmentTarget" { "All devices" }
"allLicensedUsersAssignmentTarget" { "All users" }
default { $TargetType }
}
$filterDisplay = if ($FilterId) { "$FilterName ($FilterType)" } else { "" }
$row = [BulkAssignmentRowItem]@{
TargetType = $TargetType
TargetTypeDisplay = $typeDisplay
GroupId = $GroupId
GroupName = if ($GroupName) { $GroupName } else { $GroupId }
FilterId = $FilterId
FilterName = $FilterName
FilterType = $FilterType
FilterDisplay = $filterDisplay
Intent = $Intent
Settings = @{}
SettingsDisplay = "(none)"
}
$existing = @($script:colBulkAssignList | Where-Object {
$_.TargetType -eq $row.TargetType -and
[string]$_.GroupId -eq [string]$row.GroupId -and
[string]$_.FilterId -eq [string]$row.FilterId -and
([string]::IsNullOrEmpty($row.FilterId) -or $_.FilterType -eq $row.FilterType) -and
[string]$_.Intent -eq [string]$row.Intent
})
if ($existing.Count -gt 0) {
$ui.ShowMessageBox("That target is already in the list (with the same intent).", "Bulk Assignments", "OK", "Information") | Out-Null
return
}
[void]$script:colBulkAssignList.Add($row)
}
# ─── Bulk Assignments — per-platform settings ────────────────────────────────
#
# Schema for each settings type — one entry per TabItem in
# BulkAssignmentsSettings.axaml. Drives both load (Row.Settings → controls)
# and save (controls → Row.Settings) passes from one piece of code per
# direction. Same shape as the WPF original.
$script:_bulkAssignSettingsSchema = @(
@{ Apply = "chkApplyIosLob"; Type = "iosLobAppAssignmentSettings";
Fields = @(
@{ Control="chkIosLob_IsRemovable"; Key="isRemovable"; Kind="bool" }
@{ Control="chkIosLob_PreventBackup"; Key="preventManagedAppBackup"; Kind="bool" }
@{ Control="chkIosLob_Uninstall"; Key="uninstallOnDeviceRemoval"; Kind="bool" }
@{ Control="txtIosLob_VpnId"; Key="vpnConfigurationId"; Kind="string" }
)}
@{ Apply = "chkApplyIosStore"; Type = "iosStoreAppAssignmentSettings";
Fields = @(
@{ Control="chkIosStore_IsRemovable"; Key="isRemovable"; Kind="bool" }
@{ Control="chkIosStore_PreventBackup"; Key="preventManagedAppBackup"; Kind="bool" }
@{ Control="chkIosStore_Uninstall"; Key="uninstallOnDeviceRemoval"; Kind="bool" }
@{ Control="txtIosStore_VpnId"; Key="vpnConfigurationId"; Kind="string" }
)}
@{ Apply = "chkApplyIosVpp"; Type = "iosVppAppAssignmentSettings";
Fields = @(
@{ Control="chkIosVpp_DeviceLicensing"; Key="useDeviceLicensing"; Kind="bool" }
@{ Control="chkIosVpp_IsRemovable"; Key="isRemovable"; Kind="bool" }
@{ Control="chkIosVpp_PreventBackup"; Key="preventManagedAppBackup"; Kind="bool" }
@{ Control="chkIosVpp_PreventAutoUpdate"; Key="preventAutoAppUpdate"; Kind="bool" }
@{ Control="chkIosVpp_Uninstall"; Key="uninstallOnDeviceRemoval"; Kind="bool" }
@{ Control="txtIosVpp_VpnId"; Key="vpnConfigurationId"; Kind="string" }
)}
@{ Apply = "chkApplyIosDdm"; Type = "iosDdmLobAppAssignmentSettings";
Fields = @(
@{ Control="txtIosDdm_Domains"; Key="associatedDomains"; Kind="stringList" }
@{ Control="chkIosDdm_DirectDownload"; Key="associatedDomainsDirectDownloadAllowed"; Kind="bool" }
@{ Control="chkIosDdm_PreventBackup"; Key="preventManagedAppBackup"; Kind="bool" }
@{ Control="chkIosDdm_TapToPay"; Key="tapToPayScreenLockEnabled"; Kind="bool" }
@{ Control="txtIosDdm_VpnId"; Key="vpnConfigurationId"; Kind="string" }
)}
@{ Apply = "chkApplyAndroidStore"; Type = "androidManagedStoreAppAssignmentSettings";
Fields = @(
@{ Control="cbAndroidStore_AutoUpdate"; Key="autoUpdateMode"; Kind="enum" }
@{ Control="txtAndroidStore_Tracks"; Key="androidManagedStoreAppTrackIds"; Kind="stringList" }
)}
@{ Apply = "chkApplyMacLob"; Type = "macOsLobAppAssignmentSettings";
Fields = @(
@{ Control="chkMacLob_Uninstall"; Key="uninstallOnDeviceRemoval"; Kind="bool" }
)}
@{ Apply = "chkApplyMacVpp"; Type = "macOsVppAppAssignmentSettings";
Fields = @(
@{ Control="chkMacVpp_DeviceLicensing"; Key="useDeviceLicensing"; Kind="bool" }
@{ Control="chkMacVpp_PreventBackup"; Key="preventManagedAppBackup"; Kind="bool" }
@{ Control="chkMacVpp_PreventAutoUpdate"; Key="preventAutoAppUpdate"; Kind="bool" }
@{ Control="chkMacVpp_Uninstall"; Key="uninstallOnDeviceRemoval"; Kind="bool" }
)}
@{ Apply = "chkApplyMsStore"; Type = "microsoftStoreForBusinessAppAssignmentSettings";
Fields = @(
@{ Control="chkMsStore_DeviceContext"; Key="useDeviceContext"; Kind="bool" }
)}
@{ Apply = "chkApplyWinAppx"; Type = "windowsAppXAppAssignmentSettings";
Fields = @(
@{ Control="chkWinAppx_DeviceContext"; Key="useDeviceContext"; Kind="bool" }
)}
@{ Apply = "chkApplyWinUap"; Type = "windowsUniversalAppXAppAssignmentSettings";
Fields = @(
@{ Control="chkWinUap_DeviceContext"; Key="useDeviceContext"; Kind="bool" }
)}
@{ Apply = "chkApplyWin32"; Type = "win32LobAppAssignmentSettings";
Fields = @(
@{ Control="cbWin32_DeliveryOpt"; Key="deliveryOptimizationPriority"; Kind="enum" }
@{ Control="cbWin32_Notifications"; Key="notifications"; Kind="enum" }
)
Nested = @(
@{ Key="autoUpdateSettings"; Type="win32LobAppAutoUpdateSettings";
Fields=@(
@{ Control="cbWin32_Superseded"; Key="autoUpdateSupersededAppsState"; Kind="enum" }
)}
@{ Key="installTimeSettings"; Type="mobileAppInstallTimeSettings";
Fields=@(
@{ Control="chkWin32_UseLocalTime"; Key="useLocalTime"; Kind="bool" }
@{ Control="txtWin32_StartTime"; Key="startDateTime"; Kind="datetime" }
@{ Control="txtWin32_DeadlineTime"; Key="deadlineDateTime"; Kind="datetime" }
)}
@{ Key="restartSettings"; Type="win32LobAppRestartSettings";
Fields=@(
@{ Control="txtWin32_GracePeriod"; Key="gracePeriodInMinutes"; Kind="int" }
@{ Control="txtWin32_Countdown"; Key="countdownDisplayBeforeRestartInMinutes"; Kind="int" }
)}
)}
@{ Apply = "chkApplyWinGet"; Type = "winGetAppAssignmentSettings";
Fields = @(
@{ Control="cbWinGet_Notifications"; Key="notifications"; Kind="enum" }
)
Nested = @(
@{ Key="installTimeSettings"; Type="winGetAppInstallTimeSettings";
Fields=@(
@{ Control="chkWinGet_UseLocalTime"; Key="useLocalTime"; Kind="bool" }
@{ Control="txtWinGet_DeadlineTime"; Key="deadlineDateTime"; Kind="datetime" }
)}
@{ Key="restartSettings"; Type="winGetAppRestartSettings";
Fields=@(
@{ Control="txtWinGet_GracePeriod"; Key="gracePeriodInMinutes"; Kind="int" }
@{ Control="txtWinGet_Countdown"; Key="countdownDisplayBeforeRestartInMinutes"; Kind="int" }
)}
)}
@{ Apply = "chkApplyWinAutoUpdate"; Type = "windowsAutoUpdateCatalogAppAssignmentSettings";
Fields = @(
@{ Control="cbWinAutoUpdate_DeliveryOpt"; Key="deliveryOptimizationPriority"; Kind="enum" }
@{ Control="cbWinAutoUpdate_Notifications"; Key="notificationType"; Kind="enum" }
)
Nested = @(
@{ Key="installTimeSettings"; Type="windowsAutoUpdateCatalogAppInstallTimeSettings";
Fields=@(
@{ Control="chkWinAutoUpdate_UseLocalTime"; Key="useLocalTime"; Kind="bool" }
@{ Control="txtWinAutoUpdate_StartTime"; Key="startDateTime"; Kind="datetime" }
@{ Control="txtWinAutoUpdate_DeadlineTime"; Key="deadlineDateTime"; Kind="datetime" }
)}
@{ Key="restartSettings"; Type="windowsAutoUpdateCatalogAppRestartSettings";
Fields=@(
@{ Control="txtWinAutoUpdate_GracePeriod"; Key="gracePeriodInMinutes"; Kind="int" }
)}
)}
)
function Get-BulkAssignmentSettingValue
{
param($Form, $Field)
$hostType = Get-AvaloniaHost
$ctl = $hostType::FindByName($Form, $Field.Control)
if (-not $ctl) { return $null }
switch ($Field.Kind) {
"bool" { return [bool]$ctl.IsChecked }
"string" {
$v = [string]$ctl.Text
if ([string]::IsNullOrWhiteSpace($v)) { return $null }
return $v
}
"int" {
$v = [string]$ctl.Text
if ([string]::IsNullOrWhiteSpace($v)) { return $null }
$parsed = 0
if ([int]::TryParse($v, [ref]$parsed)) { return $parsed }
return $null
}
"enum" {
$v = [string]$ctl.SelectedItem
if ([string]::IsNullOrWhiteSpace($v)) { return $null }
return $v
}
"datetime" {
$v = [string]$ctl.Text
if ([string]::IsNullOrWhiteSpace($v)) { return $null }
return $v
}
"stringList" {
$v = [string]$ctl.Text
if ([string]::IsNullOrWhiteSpace($v)) { return $null }
$items = @($v -split "[`r`n;,]+" | ForEach-Object { $_.Trim() } | Where-Object { $_ })
if ($items.Count -eq 0) { return $null }
return ,$items
}
}
return $null
}
function Set-BulkAssignmentSettingValue
{
param($Form, $Field, $Value)
$hostType = Get-AvaloniaHost
$ctl = $hostType::FindByName($Form, $Field.Control)
if (-not $ctl) { return }
switch ($Field.Kind) {
"bool" { if ($null -ne $Value) { $ctl.IsChecked = [bool]$Value } }
"string" { if ($null -ne $Value) { $ctl.Text = [string]$Value } }
"int" { if ($null -ne $Value) { $ctl.Text = [string]$Value } }
"enum" { if ($null -ne $Value) { $ctl.SelectedItem = [string]$Value } }
"datetime" { if ($null -ne $Value) { $ctl.Text = [string]$Value } }
"stringList" {
if ($null -ne $Value) {
$ctl.Text = (@($Value) -join [Environment]::NewLine)
}
}
}
}
function Show-BulkAssignmentSettingsDialog
{
param([Parameter(Mandatory)]$Row)
$ui = $script:UIProvider
$form = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/BulkAssignmentsSettings.axaml'))
if (-not $form) { return }
$script:_bulkAssignSettingsForm = $form
$script:_bulkAssignSettingsRow = $Row
$hostType = Get-AvaloniaHost
# Populate enum combos once. Lists come from the Graph CSDL.
$enumLists = @{
cbAndroidStore_AutoUpdate = @("default","postponed","priority")
cbWin32_DeliveryOpt = @("notConfigured","foreground")
cbWin32_Notifications = @("showAll","showReboot","hideAll")
cbWin32_Superseded = @("notConfigured","enabled")
cbWinGet_Notifications = @("showAll","showReboot","hideAll")
cbWinAutoUpdate_DeliveryOpt = @("notConfigured","foreground")
cbWinAutoUpdate_Notifications = @("showAll","showReboot","hideAll")
cbScript_ScheduleType = @("Hourly","Daily","Once")
}
foreach ($name in $enumLists.Keys) {
$ctl = $hostType::FindByName($form, $name)
if ($ctl) { $ctl.ItemsSource = $enumLists[$name] }
}
# Custom load: Health Script tab uses a polymorphic runSchedule whose
# @odata.type depends on ScheduleType. Stored under the synthetic
# "deviceHealthScriptAssignment" key in Row.Settings.
if ($Row.Settings -and $Row.Settings.ContainsKey('deviceHealthScriptAssignment')) {
$hs = $Row.Settings['deviceHealthScriptAssignment']
if ($hs) {
($hostType::FindByName($form, 'chkApplyHealthScript')).IsChecked = $true
if ($hs.ContainsKey('runRemediationScript')) {
($hostType::FindByName($form, 'chkScript_RunRemediation')).IsChecked = [bool]$hs['runRemediationScript']
}
if ($hs.ContainsKey('scheduleType')) { ($hostType::FindByName($form, 'cbScript_ScheduleType')).SelectedItem = [string]$hs['scheduleType'] }
if ($hs.ContainsKey('interval')) { ($hostType::FindByName($form, 'txtScript_Interval')).Text = [string]$hs['interval'] }
if ($hs.ContainsKey('time')) { ($hostType::FindByName($form, 'txtScript_Time')).Text = [string]$hs['time'] }
if ($hs.ContainsKey('useUtc')) { ($hostType::FindByName($form, 'chkScript_UseUtc')).IsChecked = [bool]$hs['useUtc'] }
if ($hs.ContainsKey('date')) { ($hostType::FindByName($form, 'txtScript_Date')).Text = [string]$hs['date'] }
}
}
# Load existing values from Row.Settings into the controls.
foreach ($tab in $script:_bulkAssignSettingsSchema) {
$existing = $null
if ($Row.Settings -and $Row.Settings.ContainsKey($tab.Type)) {
$existing = $Row.Settings[$tab.Type]
}
if (-not $existing) { continue }
$applyCtl = $hostType::FindByName($form, $tab.Apply)
if ($applyCtl) { $applyCtl.IsChecked = $true }
foreach ($field in $tab.Fields) {
if ($existing.ContainsKey($field.Key)) {
Set-BulkAssignmentSettingValue $form $field $existing[$field.Key]
}
}
if ($tab.Nested) {
foreach ($nested in $tab.Nested) {
if (-not $existing.ContainsKey($nested.Key)) { continue }
$nestedHash = $existing[$nested.Key]
if (-not $nestedHash) { continue }
foreach ($field in $nested.Fields) {
if ($nestedHash.ContainsKey($field.Key)) {
Set-BulkAssignmentSettingValue $form $field $nestedHash[$field.Key]
}
}
}
}
}
$btnOK = $hostType::FindByName($form, 'btnBulkAssignSettingsOK')
$btnCancel = $hostType::FindByName($form, 'btnBulkAssignSettingsCancel')
if ($btnOK) {
$btnOK.add_Click({
$r = $script:_bulkAssignSettingsRow
$f = $script:_bulkAssignSettingsForm
$h = Get-AvaloniaHost
$new = @{}
foreach ($tab in $script:_bulkAssignSettingsSchema) {
$applyCtl = $h::FindByName($f, $tab.Apply)
if (-not $applyCtl -or -not $applyCtl.IsChecked) { continue }
$hash = @{}
foreach ($field in $tab.Fields) {
$v = Get-BulkAssignmentSettingValue $f $field
if ($null -ne $v) { $hash[$field.Key] = $v }
}
if ($tab.Nested) {
foreach ($nested in $tab.Nested) {
$nestedHash = @{}
foreach ($field in $nested.Fields) {
$v = Get-BulkAssignmentSettingValue $f $field
if ($null -ne $v) { $nestedHash[$field.Key] = $v }
}
if ($nestedHash.Count -gt 0) {
$nestedHash["@odata.type"] = "#microsoft.graph.$($nested.Type)"
$hash[$nested.Key] = $nestedHash
}
}
}
if ($hash.Count -gt 0) { $new[$tab.Type] = $hash }
}
# Custom save: Health Script tab. Stored as a flat hashtable; the
# public command picks the schedule @odata.type at POST time from
# the scheduleType value here.
$applyHs = $h::FindByName($f, 'chkApplyHealthScript')
if ($applyHs -and $applyHs.IsChecked) {
$hsHash = @{
runRemediationScript = [bool]($h::FindByName($f, 'chkScript_RunRemediation')).IsChecked
}
$st = [string]($h::FindByName($f, 'cbScript_ScheduleType')).SelectedItem
if ($st) {
$hsHash['scheduleType'] = $st
$intervalText = [string]($h::FindByName($f, 'txtScript_Interval')).Text
$parsedInterval = 0
if (-not [string]::IsNullOrWhiteSpace($intervalText) -and [int]::TryParse($intervalText, [ref]$parsedInterval)) {
$hsHash['interval'] = $parsedInterval
}
if ($st -in @('Daily','Once')) {
$timeText = [string]($h::FindByName($f, 'txtScript_Time')).Text
if (-not [string]::IsNullOrWhiteSpace($timeText)) { $hsHash['time'] = $timeText.Trim() }
$hsHash['useUtc'] = [bool]($h::FindByName($f, 'chkScript_UseUtc')).IsChecked
}
if ($st -eq 'Once') {
$dateText = [string]($h::FindByName($f, 'txtScript_Date')).Text
if (-not [string]::IsNullOrWhiteSpace($dateText)) { $hsHash['date'] = $dateText.Trim() }
}
}
$new['deviceHealthScriptAssignment'] = $hsHash
}
$r.Settings = $new
$r.SettingsDisplay = if ($new.Count -gt 0) { "$($new.Count) platform(s)" } else { "(none)" }
# Avalonia DataGrid has no Items.Refresh; rebind to surface the
# SettingsDisplay change (BulkAssignmentRowItem has no INPC).
try {
if ($script:dgBulkAssignList) {
$current = $script:dgBulkAssignList.ItemsSource
$script:dgBulkAssignList.ItemsSource = $null
$script:dgBulkAssignList.ItemsSource = $current
}
} catch { }
$script:_bulkAssignSettingsForm = $null
$script:_bulkAssignSettingsRow = $null
Close-TopModalObject
})
}
if ($btnCancel) {
$btnCancel.add_Click({
$script:_bulkAssignSettingsForm = $null
$script:_bulkAssignSettingsRow = $null
Close-TopModalObject
})
}
$ui.ShowModalForm("App settings - $($Row.GroupName)", $form, $true)
}
# Group search for the picker. Reads module-scope state so it is callable from
# event handlers (no captured locals / GetNewClosure — see [[avalonia-closure-dynamic-module]]).
function Invoke-BulkAssignGroupSearch
{
$st = $script:_bulkAssignPickerState
if (-not $st) { return }
try {
$term = [string]$st.TxtSearch.Text
if ($term.Length -lt 1) {
$st.LstResults.ItemsSource = $null
$script:_bulkAssignGroupResultsMap = @{}
return
}
$escaped = $term.Replace("'", "''")
$url = "groups?`$top=25&`$select=id,displayName&`$filter=startswith(displayName,'$escaped')"
$resp = Invoke-MSGraphAPI -Url $url -TokenId (Get-DefaultTokenId)
$display = [System.Collections.Generic.List[string]]::new()
$map = @{}
if ($resp -and $resp.value) {
foreach ($g in @($resp.value | Sort-Object displayName)) {
$name = [string]$g.displayName
$key = $name
$i = 2
while ($map.ContainsKey($key)) { $key = "$name ($i)"; $i++ }
[void]$display.Add($key)
$map[$key] = [PSCustomObject]@{ Id = [string]$g.id; Name = $name }
}
}
$st.LstResults.ItemsSource = @($display)
$script:_bulkAssignGroupResultsMap = $map
} catch {
Write-LogError "Bulk Assignments: group search failed" $_.Exception
$st.LstResults.ItemsSource = @()
$script:_bulkAssignGroupResultsMap = @{}
}
}
# Group-picker sub-modal. Searches AAD groups via Graph (startsWith), lets
# the user pick include/exclude direction and optionally attach a
# deviceAndAppManagementAssignmentFilter. Stacks over the Bulk Assignments
# form via the modal-container Grid.
function Show-BulkAssignmentGroupPicker
{
$ui = $script:UIProvider
$picker = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/BulkAssignmentsGroupPicker.axaml'))
if (-not $picker) { return }
$script:_bulkAssignGroupPicker = $picker
$hostType = Get-AvaloniaHost
$txtSearch = $hostType::FindByName($picker, 'txtGroupSearch')
$btnSearch = $hostType::FindByName($picker, 'btnGroupSearch')
$lstResults = $hostType::FindByName($picker, 'lstGroupResults')
$cbFilter = $hostType::FindByName($picker, 'cbGroupFilter')
$cbIntent = $hostType::FindByName($picker, 'cbGroupIntent')
$btnOK = $hostType::FindByName($picker, 'btnGroupPickerOK')
$btnCancel = $hostType::FindByName($picker, 'btnGroupPickerCancel')
if (-not $txtSearch -or -not $btnSearch -or -not $lstResults -or -not $cbFilter -or -not $cbIntent) {
Write-Log "Bulk Assignments group picker XAML loaded, but required controls were not found" 3
return
}
# Avalonia ListBox has no DisplayMemberPath; we project results to plain
# strings for display, but keep a parallel id map keyed by displayName so
# OK can resolve the selection back to its id. Search results may have
# duplicate displayNames; suffix duplicates with " (id)" for uniqueness.
$script:_bulkAssignGroupResultsMap = @{}
# Populate assignment filters. Avalonia ComboBox can't bind PSCustomObject
# by DisplayMemberPath either — use parallel string list + id map.
$tokenId = Get-DefaultTokenId
$filterDisplay = [System.Collections.Generic.List[string]]::new()
$filterIdMap = @{}
[void]$filterDisplay.Add("(no filter)")
$filterIdMap["(no filter)"] = $null
try {
$resp = Invoke-MSGraphAPI -Url "deviceManagement/assignmentFilters" -TokenId $tokenId -AllPages
if ($resp -and $resp.value) {
foreach ($f in @($resp.value | Sort-Object displayName)) {
$name = [string]$f.displayName
$key = $name
$i = 2
while ($filterIdMap.ContainsKey($key)) { $key = "$name ($i)"; $i++ }
[void]$filterDisplay.Add($key)
$filterIdMap[$key] = [string]$f.id
}
}
} catch {
Write-LogDebug "Bulk Assignments: failed to load assignment filters: $($_.Exception.Message)"
}
$cbFilter.ItemsSource = @($filterDisplay)
$cbFilter.SelectedIndex = 0
$script:_bulkAssignFilterIdMap = $filterIdMap
# installIntent values from Graph's mobileAppAssignment schema. Display
# text in the ComboBox; map back to value on OK.
$intentDisplayMap = [ordered]@{
'Required' = 'required'
'Available' = 'available'
'Uninstall' = 'uninstall'
'Available without enrollment' = 'availableWithoutEnrollment'
'Not available (hidden)' = 'notAvailable'
}
$cbIntent.ItemsSource = @($intentDisplayMap.Keys)
$cbIntent.SelectedIndex = 0
$script:_bulkAssignIntentMap = $intentDisplayMap
# Module-scope state so handlers resolve controls at click time (no captured
# locals / GetNewClosure / $script:UIProvider — see [[avalonia-closure-dynamic-module]]).
$script:_bulkAssignPickerState = @{
Picker = $picker
TxtSearch = $txtSearch
LstResults = $lstResults
CbFilter = $cbFilter
CbIntent = $cbIntent
}
$btnSearch.add_Click({ Invoke-BulkAssignGroupSearch })
$txtSearch.add_KeyDown({
param($s, $e)
if ($e.Key -eq [Avalonia.Input.Key]::Return) { Invoke-BulkAssignGroupSearch }
})
if ($btnOK) {
$btnOK.add_Click({
$st = $script:_bulkAssignPickerState
if (-not $st) { return }
try {
$h = Get-AvaloniaHost
$sel = $st.LstResults.SelectedItem
if (-not $sel) {
Show-MessageBox "Pick a group from the search results first." "Bulk Assignments" "OK" "Warning" | Out-Null
return
}
$resolved = $script:_bulkAssignGroupResultsMap[[string]$sel]
if (-not $resolved) { return }
$rbExclude = $h::FindByName($st.Picker, 'rbGroupExclude')
$isExclude = [bool]$rbExclude.IsChecked
$targetType = if ($isExclude) { "exclusionGroupAssignmentTarget" } else { "groupAssignmentTarget" }
$filterKey = [string]$st.CbFilter.SelectedItem
$filterId = $script:_bulkAssignFilterIdMap[$filterKey]
$filterName = if ($filterId) { $filterKey } else { $null }
$filterType = "include"
$rbFilterExclude = $h::FindByName($st.Picker, 'rbGroupFilterExclude')
if ($filterId -and [bool]$rbFilterExclude.IsChecked) {
$filterType = "exclude"
}
$intentKey = [string]$st.CbIntent.SelectedItem
$intent = if ($intentKey -and $script:_bulkAssignIntentMap.Contains($intentKey)) {
$script:_bulkAssignIntentMap[$intentKey]
} else { "required" }
Add-BulkAssignmentRow `
-TargetType $targetType `
-GroupId $resolved.Id `
-GroupName $resolved.Name `
-FilterId $filterId `
-FilterName $filterName `
-FilterType $filterType `
-Intent $intent
$script:_bulkAssignGroupPicker = $null
$script:_bulkAssignGroupResultsMap = @{}
$script:_bulkAssignPickerState = $null
Close-TopModalObject
} catch {
Write-LogError "Bulk Assignments group picker OK failed" $_.Exception
}
})
}
if ($btnCancel) {
$btnCancel.add_Click({
$script:_bulkAssignGroupPicker = $null
$script:_bulkAssignGroupResultsMap = @{}
$script:_bulkAssignPickerState = $null
Close-TopModalObject
})
}
$ui.ShowModalForm("Pick a group", $picker, $true)
}
function Show-GraphBulkAssignmentsForm
{
$ui = $script:UIProvider
$form = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/BulkAssignmentsForm.axaml'))
if (-not $form) { return }
$hostType = Get-AvaloniaHost
$script:bulkAssignForm = $form
$script:dgBulkAssignObjects = $hostType::FindByName($form, 'dgBulkAssignObjects')
$script:dgBulkAssignList = $hostType::FindByName($form, 'dgBulkAssignList')
$script:txtBulkAssignStatus = $hostType::FindByName($form, 'txtBulkAssignStatus')
$rbAdd = $hostType::FindByName($form, 'rbBulkAssignAdd')
$rbReplace = $hostType::FindByName($form, 'rbBulkAssignReplace')
$rbRemove = $hostType::FindByName($form, 'rbBulkAssignRemove')
$txtFilter = $hostType::FindByName($form, 'txtBulkAssignNameFilter')
$rbGroup = $hostType::FindByName($form, 'rbBulkAssignViewGroup')
$rbType = $hostType::FindByName($form, 'rbBulkAssignViewType')
$btnApply = $hostType::FindByName($form, 'btnBulkAssignApply')
$btnClose = $hostType::FindByName($form, 'btnBulkAssignClose')
$btnAddGroup = $hostType::FindByName($form, 'btnBulkAssignAddGroup')
$btnAddAllDev = $hostType::FindByName($form, 'btnBulkAssignAddAllDev')
$btnAddAllUsers = $hostType::FindByName($form, 'btnBulkAssignAddAllUsers')
$btnSettings = $hostType::FindByName($form, 'btnBulkAssignSettings')
$btnRemoveSel = $hostType::FindByName($form, 'btnBulkAssignRemoveSel')
$assignmentSettings = [IntuneManagerAssignmentSettings]::new()
# Eligible types: same gate as Set-GraphBulkAssignments so the UI does
# not offer groups/APIs the command will skip.
$script:bulkAssignEligibleTypes = @($script:IntuneTypes | Where-Object {
Test-BulkAssignmentSupported $_
})
$eligibleTypeIds = @($script:bulkAssignEligibleTypes | ForEach-Object { $_.Id })
$script:bulkAssignEligibleGroups = @($script:IntuneGroups | Where-Object {
$_.Title -and ($_.PolicyTypes | Where-Object { $_.Id -in $eligibleTypeIds })
})
$script:bulkAssignMode = "Group"
Update-BulkAssignObjectList
$headerCb = Initialize-AvaloniaGridSelectAllHeader -Grid $script:dgBulkAssignObjects -BindingProperty 'Selected' -InitiallyChecked $true
# Module-scope state so event handlers resolve it at click time (no captured
# locals / GetNewClosure / $script:UIProvider — see [[avalonia-closure-dynamic-module]]).
$script:_bulkAssignState = @{
HeaderCb = $headerCb
Settings = $assignmentSettings
TxtFilter = $txtFilter
BtnApply = $btnApply
BtnClose = $btnClose
}
if ($rbGroup) {
$rbGroup.add_IsCheckedChanged({
param($s, $e)
if (-not $s.IsChecked) { return }
$script:bulkAssignMode = "Group"
Update-BulkAssignObjectList
$st = $script:_bulkAssignState
if ($st -and $st.HeaderCb) { $st.HeaderCb.IsChecked = $true }
})
}
if ($rbType) {
$rbType.add_IsCheckedChanged({
param($s, $e)
if (-not $s.IsChecked) { return }
$script:bulkAssignMode = "Type"
Update-BulkAssignObjectList
$st = $script:_bulkAssignState
if ($st -and $st.HeaderCb) { $st.HeaderCb.IsChecked = $true }
})
}
# Action radio → settings.Action
if ($rbAdd) {
$rbAdd.add_IsCheckedChanged({
param($s, $e)
if ($s.IsChecked -and $script:_bulkAssignState) { $script:_bulkAssignState.Settings.Action = "Add" }
})
}
if ($rbReplace) {
$rbReplace.add_IsCheckedChanged({
param($s, $e)
if ($s.IsChecked -and $script:_bulkAssignState) { $script:_bulkAssignState.Settings.Action = "Replace" }
})
}
if ($rbRemove) {
$rbRemove.add_IsCheckedChanged({
param($s, $e)
if ($s.IsChecked -and $script:_bulkAssignState) { $script:_bulkAssignState.Settings.Action = "Remove" }
})
}
# Select-all behaviour moved into the DataGrid column header via
# Initialize-AvaloniaGridSelectAllHeader (called earlier in this function).
# Assignments list — backing ObservableCollection so adds/removes show
# immediately. Each row is a [BulkAssignmentRowItem] (CLR-typed per
# [[avalonia-binding-needs-clr-types]]).
$script:colBulkAssignList = [System.Collections.ObjectModel.ObservableCollection[object]]::new()
$script:dgBulkAssignList.ItemsSource = $script:colBulkAssignList
if ($btnAddGroup) {
$btnAddGroup.add_Click({ Show-BulkAssignmentGroupPicker })
}
if ($btnAddAllDev) {
$btnAddAllDev.add_Click({
Add-BulkAssignmentRow -TargetType "allDevicesAssignmentTarget" -GroupName "All Devices"
})
}
if ($btnAddAllUsers) {
$btnAddAllUsers.add_Click({
Add-BulkAssignmentRow -TargetType "allLicensedUsersAssignmentTarget" -GroupName "All Users"
})
}
if ($btnSettings) {
$btnSettings.add_Click({
$sel = $script:dgBulkAssignList.SelectedItem
if (-not $sel) {
Show-MessageBox "Select an assignment row first." "Bulk Assignments" "OK" "Information" | Out-Null
return
}
Show-BulkAssignmentSettingsDialog -Row $sel
})
}
if ($btnRemoveSel) {
$btnRemoveSel.add_Click({
$sel = $script:dgBulkAssignList.SelectedItem
if ($sel) { [void]$script:colBulkAssignList.Remove($sel) }
})
}
# Double-tap on a row opens the settings dialog (less destructive than remove).
$script:dgBulkAssignList.add_DoubleTapped({
$sel = $script:dgBulkAssignList.SelectedItem
if ($sel) { Show-BulkAssignmentSettingsDialog -Row $sel }
})
if ($btnApply) {
$btnApply.add_Click({
$st = $script:_bulkAssignState
if (-not $st) { return }
try {
$assignmentSettings = $st.Settings
$selectionIds = Get-BulkAssignSelectedObjectIds
$unit = if ($script:bulkAssignMode -eq "Type") { "policy type" } else { "object group" }
if ($selectionIds.Count -eq 0) {
Show-MessageBox "Select at least one $unit to update." "Bulk Assignments" "OK" "Warning" | Out-Null
return
}
$assignmentSettings.Filter = if ($st.TxtFilter) { [string]$st.TxtFilter.Text } else { '' }
$assignmentSettings.Assignments = @($script:colBulkAssignList)
if ($assignmentSettings.Assignments.Count -eq 0) {
Show-MessageBox "Add at least one assignment target before applying." "Bulk Assignments" "OK" "Warning" | Out-Null
return
}
# Mass-wipe guard for Replace: warn before overwriting
# everything with potentially fewer rows than originally set.
if ($assignmentSettings.Action -eq "Replace") {
$proceed = Show-MessageBox "Replace will OVERWRITE every existing assignment on every matched policy with only the rows above ($($assignmentSettings.Assignments.Count) target(s)).`n`nContinue?" "Confirm replace" "YesNo" "Warning"
if ($proceed -ne "Yes") { return }
}
$assignmentSummary = ($assignmentSettings.Assignments | ForEach-Object {
$parts = @($_.TargetTypeDisplay, $_.GroupName)
if ($_.FilterId) { $parts += "filter: $($_.FilterName) ($($_.FilterType))" }
($parts -join ' / ')
}) -join "`n "
$filterText = if ($assignmentSettings.Filter) { $assignmentSettings.Filter } else { "(none)" }
$confirmMsg = @"
About to update assignments on every policy that matches:
Action : $($assignmentSettings.Action)
Assignments :
$assignmentSummary
Name filter : $filterText
$unit count : $($selectionIds.Count)
Continue?
"@
$confirm = Show-MessageBox $confirmMsg "Confirm Bulk Assignment update" "YesNo" "Warning"
if ($confirm -ne "Yes") { return }
if ($st.BtnApply) { $st.BtnApply.IsEnabled = $false }
if ($st.BtnClose) { $st.BtnClose.IsEnabled = $false }
try {
$startParams = @{ AssignmentSettings = $assignmentSettings }
if ($script:bulkAssignMode -eq "Type") { $startParams.PolicyType = $selectionIds }
else { $startParams.PolicyGroup = $selectionIds }
$summary = Set-GraphBulkAssignments @startParams
Write-Status ""
$unsupportedNote = if ($summary.UnsupportedTypes -and $summary.UnsupportedTypes.Count -gt 0) {
"`nUnsupported types skipped: $($summary.UnsupportedTypes -join ', ')"
} else { "" }
$msg = ("Scanned: {0}`nMatched filter: {1}`nUpdated: {2}`nNo change: {3}`nFailed: {4}`nDuration: {5:hh\:mm\:ss}{6}" -f `
$summary.PoliciesScanned, $summary.PoliciesMatched, $summary.PoliciesUpdated, $summary.PoliciesSkipped, $summary.PoliciesFailed, $summary.Duration, $unsupportedNote)
if ($script:txtBulkAssignStatus) {
$script:txtBulkAssignStatus.Text = ("Last run: updated {0}, no change {1}, failed {2}" -f $summary.PoliciesUpdated, $summary.PoliciesSkipped, $summary.PoliciesFailed)
}
$unknownNote = Get-IntuneUnknownSelectorSummary $summary.UnknownSelectors
if ($unknownNote) { $msg += "`n`n$unknownNote" }
Show-MessageBox $msg "Bulk Assignments" "OK" "Information" | Out-Null
if ($script:IntuneManagerSelectedObject) {
Invoke-IntuneActivateObject $script:IntuneManagerSelectedObject | Out-Null
}
} catch {
Write-LogError "Bulk Assignments run failed" $_.Exception
Show-MessageBox "Bulk Assignments run failed: $($_.Exception.Message)" "Bulk Assignments" "OK" "Error" | Out-Null
} finally {
if ($st.BtnApply) { $st.BtnApply.IsEnabled = $true }
if ($st.BtnClose) { $st.BtnClose.IsEnabled = $true }
}
} catch {
Write-LogError "Bulk Assignments Apply handler failed" $_.Exception
}
})
}
if ($btnClose) {
$btnClose.add_Click({
$script:bulkAssignForm = $null
$script:dgBulkAssignObjects = $null
$script:dgBulkAssignList = $null
$script:bulkAssignObjects = $null
$script:colBulkAssignList = $null
$script:_bulkAssignState = $null
Show-ModalObject
})
}
$form.add_KeyDown({
param($s, $e)
if ($e.Key -eq [Avalonia.Input.Key]::Escape) {
$script:bulkAssignForm = $null
$script:dgBulkAssignObjects = $null
$script:dgBulkAssignList = $null
$script:bulkAssignObjects = $null
$script:colBulkAssignList = $null
$script:_bulkAssignState = $null
Show-ModalObject
$e.Handled = $true
}
})
$ui.ShowModalForm("Bulk Assignments", $form, $true)
}
@@ -0,0 +1,430 @@
# Avalonia port of the Bulk Compare dialog from
# UI/WPF/Extensions/CompareUI.ps1 (Show-GraphBulkCompareForm + helpers).
# New file because Bulk Compare is a self-contained subsystem and the WPF
# original sits in CompareUI.ps1 alongside the single-policy compare; we
# already split that into Extensions/IntuneManagerCompareUI.ps1 (architecture
# rule R9, R12).
#
# Slice 4f: full Bulk Compare form for the four "list-driven" providers
# (export / IntuneWithExport / name / exportedFolders). A fifth, Direct
# policy-pair provider was removed in 2026-09 - a two-object compare belongs
# in the single Compare form, which already picks a second policy.
#
# Helpers used (all in Internal/Compare.ps1, loaded in both UI backends):
# Set-CompareRuntimeOptions, Get-BulkCompareOutputProvider,
# Get-CompareOutputType, Start-BulkCompare.
# Provider classes (Classes/CompareClasses.ps1) inherit CompareProviderBase
# and have real CLR `[string]Name` / `[string]Value` properties + Validate /
# SaveSettings / GetComparePairs methods, so they bind directly to control
# DataContext for the per-provider option panels' TwoWay TextBox bindings.
function Show-GraphBulkCompareForm
{
$ui = $script:UIProvider
$form = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/BulkCompareForm.axaml'))
if (-not $form) { return }
$hostType = Get-AvaloniaHost
$cbProvider = $hostType::FindByName($form, 'cbCompareProvider')
$ccProviderOpts = $hostType::FindByName($form, 'ccContentProviderOptions')
$cbSave = $hostType::FindByName($form, 'cbCompareSave')
$cbOutFmt = $hostType::FindByName($form, 'cbCompareOutputFormat')
$cbType = $hostType::FindByName($form, 'cbCompareType')
$cbDelimiter = $hostType::FindByName($form, 'cbCompareCSVDelimiter')
$cbMultiValueDelim = $hostType::FindByName($form, 'cbCompareMultiValueDelimiter')
$chkIgnoreCore = $hostType::FindByName($form, 'chkIgnoreCoreProperties')
$chkSkipAssignments = $hostType::FindByName($form, 'chkSkipCompareAssignments')
$chkSkipMissingSrc = $hostType::FindByName($form, 'chkSkipMissingSourcePolicies')
$chkSkipMissingDst = $hostType::FindByName($form, 'chkSkipMissingDestinationPolicies')
$grdObjects = $hostType::FindByName($form, 'grdObjectsToCompareSection')
$dgObjects = $hostType::FindByName($form, 'dgObjectsToCompare')
$txtStatus = $hostType::FindByName($form, 'txtBulkCompareStatus')
$btnStart = $hostType::FindByName($form, 'btnStartCompare')
$btnClose = $hostType::FindByName($form, 'btnClose')
# Panel cache + per-provider Value->instance maps + control refs. Event
# handlers reach ALL of this through $script:_bulkCompareFormState -
# closure captures of function locals do NOT survive
# ConvertTo-AvaloniaEventScriptBlock (NewBoundScriptBlock rebinds to the
# module and drops them; only global/module variables fall through).
$state = [ordered]@{
ProviderPanelCache = @{}
ProviderMap = @{}
SaveMap = @{}
OutputProviderMap = @{}
TypeMap = @{}
Rows = @()
Ui = $ui
HostType = $hostType
CbProvider = $cbProvider
CcProviderOpts = $ccProviderOpts
CbSave = $cbSave
CbOutFmt = $cbOutFmt
CbType = $cbType
CbDelimiter = $cbDelimiter
CbMultiValueDelim = $cbMultiValueDelim
ChkIgnoreCore = $chkIgnoreCore
ChkSkipAssignments = $chkSkipAssignments
ChkSkipMissingSrc = $chkSkipMissingSrc
ChkSkipMissingDst = $chkSkipMissingDst
GrdObjects = $grdObjects
DgObjects = $dgObjects
TxtStatus = $txtStatus
BtnStart = $btnStart
BtnClose = $btnClose
}
$script:_bulkCompareFormState = $state
# --- Combo: Provider ---------------------------------------------------------
if ($cbProvider) {
$items = New-Object 'System.Collections.Generic.List[SettingsListItem]'
foreach ($p in @($script:compareProviders)) {
if (-not $p) { continue }
$items.Add([SettingsListItem]@{ Name = [string]$p.Name; Value = [string]$p.Value }) | Out-Null
$state.ProviderMap[[string]$p.Value] = $p
}
$cbProvider.ItemsSource = $items
$defaultProvider = (Get-SettingStoreValue "Compare" "Provider" "export")
$sel = $items | Where-Object { "$($_.Value)" -eq "$defaultProvider" } | Select-Object -First 1
if (-not $sel -and $items.Count -gt 0) { $sel = $items[0] }
if ($sel) { $cbProvider.SelectedItem = $sel }
}
# --- Combo: Save (one-file-per-type vs all) ---------------------------------
if ($cbSave) {
$items = New-Object 'System.Collections.Generic.List[SettingsListItem]'
foreach ($t in @($script:compareOutputTypes)) {
if (-not $t) { continue }
$items.Add([SettingsListItem]@{ Name = [string]$t.Name; Value = [string]$t.Value }) | Out-Null
$state.SaveMap[[string]$t.Value] = $t
}
$cbSave.ItemsSource = $items
$defaultSave = (Get-SettingStoreValue "Compare" "SaveType" "objectType")
$sel = $items | Where-Object { "$($_.Value)" -eq "$defaultSave" } | Select-Object -First 1
if (-not $sel -and $items.Count -gt 0) { $sel = $items[0] }
if ($sel) { $cbSave.SelectedItem = $sel }
}
# --- Combo: Output Format (CSV / JSON) --------------------------------------
if ($cbOutFmt) {
$items = New-Object 'System.Collections.Generic.List[SettingsListItem]'
foreach ($p in @($script:compareOutputProviders)) {
if (-not $p) { continue }
$items.Add([SettingsListItem]@{ Name = [string]$p.Name; Value = [string]$p.Value }) | Out-Null
$state.OutputProviderMap[[string]$p.Value] = $p
}
$cbOutFmt.ItemsSource = $items
$defaultOut = (Get-SettingStoreValue "Compare" "OutputFormat" "csv")
$sel = $items | Where-Object { "$($_.Value)" -eq "$defaultOut" } | Select-Object -First 1
if (-not $sel -and $items.Count -gt 0) { $sel = $items[0] }
if ($sel) { $cbOutFmt.SelectedItem = $sel }
}
# --- Combo: Comparison Type --------------------------------------------------
if ($cbType) {
$items = New-Object 'System.Collections.Generic.List[SettingsListItem]'
foreach ($t in @($script:comparisonTypes)) {
if (-not $t) { continue }
$items.Add([SettingsListItem]@{ Name = [string]$t.Name; Value = [string]$t.Value }) | Out-Null
$state.TypeMap[[string]$t.Value] = $t
}
$cbType.ItemsSource = $items
$defaultType = (Get-SettingStoreValue "Compare" "Type" "property")
$sel = $items | Where-Object { "$($_.Value)" -eq "$defaultType" } | Select-Object -First 1
if (-not $sel -and $items.Count -gt 0) { $sel = $items[0] }
if ($sel) { $cbType.SelectedItem = $sel }
}
# --- Combo: CSV Delimiter ----------------------------------------------------
if ($cbDelimiter) {
$cbDelimiter.ItemsSource = @("", ",", ";", "-", "|")
$defaultDelim = (Get-SettingStoreValue "Compare" "Delimiter" ";")
$cbDelimiter.SelectedItem = $defaultDelim
}
# --- Combo: Multi-Value Delimiter (doc-compare ObjectSeparator) ---------------
if ($cbMultiValueDelim) {
$items = New-Object 'System.Collections.Generic.List[SettingsListItem]'
$items.Add([SettingsListItem]@{ Name = 'New line'; Value = [Environment]::NewLine }) | Out-Null
$items.Add([SettingsListItem]@{ Name = ';'; Value = ';' }) | Out-Null
$items.Add([SettingsListItem]@{ Name = '|'; Value = '|' }) | Out-Null
$cbMultiValueDelim.ItemsSource = $items
$defaultSep = (Get-SettingStoreValue "Compare" "ObjectSeparator" ([Environment]::NewLine))
$sel = $items | Where-Object { "$($_.Value)" -eq "$defaultSep" } | Select-Object -First 1
if (-not $sel) { $sel = $items[0] }
$cbMultiValueDelim.SelectedItem = $sel
}
# --- Checkboxes ----------------------------------------------------------------
if ($chkIgnoreCore) {
$chkIgnoreCore.IsChecked = ((Get-SettingStoreValue "Compare" "IgnoreCoreProperties" "true") -eq "true")
}
if ($chkSkipAssignments) {
$chkSkipAssignments.IsChecked = ((Get-SettingStoreValue "Compare" "SkipCompareAssignments" "false") -eq "true")
}
if ($chkSkipMissingSrc) {
$chkSkipMissingSrc.IsChecked = ((Get-SettingStoreValue "Compare" "SkipMissingSourcePolicies" "false") -eq "true")
}
if ($chkSkipMissingDst) {
$chkSkipMissingDst.IsChecked = ((Get-SettingStoreValue "Compare" "SkipMissingDestinationPolicies" "false") -eq "true")
}
# --- Objects-to-compare grid -------------------------------------------------
$rows = New-Object 'System.Collections.Generic.List[BulkCompareRowItem]'
foreach ($intuneGroup in @($script:IntuneGroups)) {
if (-not $intuneGroup -or -not $intuneGroup.Title) { continue }
$rows.Add([BulkCompareRowItem]@{
Title = [string]$intuneGroup.Title
Selected = $true
ObjectGroup = $intuneGroup
}) | Out-Null
}
$state.Rows = @($rows)
if ($dgObjects) { $dgObjects.ItemsSource = $state.Rows }
# Select/deselect-all moved into the DataGrid column header.
Initialize-AvaloniaGridSelectAllHeader -Grid $dgObjects -BindingProperty 'Selected' -InitiallyChecked $true | Out-Null
# --- Provider panel swap -----------------------------------------------------
$script:_bulkCompareApplyProvider = {
param($Provider)
$st = $script:_bulkCompareFormState
if (-not $st -or -not $st.CcProviderOpts) { return }
if (-not $Provider) {
$st.CcProviderOpts.Content = $null
$st.CcProviderOpts.IsVisible = $false
if ($st.GrdObjects) { $st.GrdObjects.IsVisible = $true }
return
}
$panel = $null
if ($Provider.OptionsXaml) {
if ($st.ProviderPanelCache.ContainsKey($Provider.Value)) {
$panel = $st.ProviderPanelCache[$Provider.Value]
} else {
$panel = $st.Ui.GetXamlObject((Join-Path $script:AppUIRootFolder "XAML/$($Provider.OptionsXaml).axaml"))
if ($panel) {
$st.ProviderPanelCache[$Provider.Value] = $panel
Register-BulkCompareProviderBrowseEvents -Panel $panel -Provider $Provider -HostType $st.HostType
}
}
if ($panel) { $panel.DataContext = $Provider }
}
$st.CcProviderOpts.Content = $panel
$st.CcProviderOpts.IsVisible = ($null -ne $panel)
if ($st.GrdObjects) {
$st.GrdObjects.IsVisible = -not [bool]$Provider.IgnoreGroups
}
}
if ($cbProvider) {
$cbProvider.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock {
param($s, $e)
$st = $script:_bulkCompareFormState
if (-not $st) { return }
$sel = $s.SelectedItem
$provider = if ($sel) { $st.ProviderMap[[string]$sel.Value] } else { $null }
& $script:_bulkCompareApplyProvider $provider
}))
# Initial render.
$initial = $null
if ($cbProvider.SelectedItem) {
$initial = $state.ProviderMap[[string]$cbProvider.SelectedItem.Value]
}
& $script:_bulkCompareApplyProvider $initial
}
# --- Output format → CSV-delimiter enable toggle ----------------------------
$script:_bulkCompareApplyDelimiterEnable = {
$st = $script:_bulkCompareFormState
if (-not $st -or -not $st.CbDelimiter -or -not $st.CbOutFmt -or -not $st.CbOutFmt.SelectedItem) { return }
$sel = $st.OutputProviderMap[[string]$st.CbOutFmt.SelectedItem.Value]
$st.CbDelimiter.IsEnabled = ($sel -is [CompareCSVOutputProvider])
}
if ($cbOutFmt) {
$cbOutFmt.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock { param($s, $e) & $script:_bulkCompareApplyDelimiterEnable }))
& $script:_bulkCompareApplyDelimiterEnable
}
# --- Buttons -----------------------------------------------------------------
if ($btnClose) {
$btnClose.add_Click((ConvertTo-AvaloniaEventScriptBlock {
Show-ModalObject
$script:_bulkCompareFormState = $null
}))
}
if ($btnStart) {
$btnStart.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_bulkCompareFormState
if (-not $st) { return }
$providerSel = if ($st.CbProvider) { $st.CbProvider.SelectedItem } else { $null }
$provider = if ($providerSel) { $st.ProviderMap[[string]$providerSel.Value] } else { $null }
if (-not $provider) {
$st.Ui.ShowMessageBox("No compare provider selected.", "Compare", "OK", "Error")
return
}
# Persist all five user choices (matches WPF order).
if ($st.CbProvider -and $st.CbProvider.SelectedItem) { Save-SettingStoreValue "Compare" "Provider" $st.CbProvider.SelectedItem.Value }
if ($st.CbType -and $st.CbType.SelectedItem) { Save-SettingStoreValue "Compare" "Type" $st.CbType.SelectedItem.Value }
if ($st.CbDelimiter) { Save-SettingStoreValue "Compare" "Delimiter" ([string]$st.CbDelimiter.SelectedItem) }
if ($st.CbOutFmt -and $st.CbOutFmt.SelectedItem) { Save-SettingStoreValue "Compare" "OutputFormat" $st.CbOutFmt.SelectedItem.Value }
if ($st.CbSave -and $st.CbSave.SelectedItem) { Save-SettingStoreValue "Compare" "SaveType" $st.CbSave.SelectedItem.Value }
if ($st.CbMultiValueDelim -and $st.CbMultiValueDelim.SelectedItem) { Save-SettingStoreValue "Compare" "ObjectSeparator" ([string]$st.CbMultiValueDelim.SelectedItem.Value) }
if ($st.ChkIgnoreCore) { Save-SettingStoreValue "Compare" "IgnoreCoreProperties" $(if ($st.ChkIgnoreCore.IsChecked) { "true" } else { "false" }) }
if ($st.ChkSkipAssignments) { Save-SettingStoreValue "Compare" "SkipCompareAssignments" $(if ($st.ChkSkipAssignments.IsChecked) { "true" } else { "false" }) }
if ($st.ChkSkipMissingSrc) { Save-SettingStoreValue "Compare" "SkipMissingSourcePolicies" $(if ($st.ChkSkipMissingSrc.IsChecked) { "true" } else { "false" }) }
if ($st.ChkSkipMissingDst) { Save-SettingStoreValue "Compare" "SkipMissingDestinationPolicies" $(if ($st.ChkSkipMissingDst.IsChecked) { "true" } else { "false" }) }
if ($st.ChkSkipMissingSrc) { $provider.SkipMissingSourcePolicies = [bool]$st.ChkSkipMissingSrc.IsChecked }
if ($st.ChkSkipMissingDst) { $provider.SkipMissingDestinationPolicies = [bool]$st.ChkSkipMissingDst.IsChecked }
if ($st.BtnStart) { $st.BtnStart.IsEnabled = $false }
if ($st.BtnClose) { $st.BtnClose.IsEnabled = $false }
if ($st.TxtStatus) { $st.TxtStatus.Text = "Comparing..." }
Write-Status "Compare objects"
try {
# Push runtime options into Internal/Compare.ps1 — mirrors WPF
# Set-CompareRuntimeOptionsFromUI.
$runtimeArgs = @{}
if ($st.CbType -and $st.CbType.SelectedItem) {
$runtimeArgs.CompareType = [string]$st.CbType.SelectedItem.Value
$cd = $st.TypeMap[[string]$st.CbType.SelectedItem.Value]
if ($cd) { $runtimeArgs.CompareDefinition = $cd }
}
if ($st.CbOutFmt -and $st.CbOutFmt.SelectedItem) {
$op = $st.OutputProviderMap[[string]$st.CbOutFmt.SelectedItem.Value]
if ($op) { $runtimeArgs.OutputProvider = $op }
}
if ($st.CbSave -and $st.CbSave.SelectedItem) {
$runtimeArgs.SaveType = [string]$st.CbSave.SelectedItem.Value
}
if ($st.CbDelimiter -and $null -ne $st.CbDelimiter.SelectedItem) {
$runtimeArgs.CsvDelimiter = [string]$st.CbDelimiter.SelectedItem
}
if ($st.CbMultiValueDelim -and $st.CbMultiValueDelim.SelectedItem) {
$runtimeArgs.ObjectSeparator = [string]$st.CbMultiValueDelim.SelectedItem.Value
}
if ($st.ChkIgnoreCore) {
$runtimeArgs.IgnoreCoreProperties = [bool]$st.ChkIgnoreCore.IsChecked
}
if ($st.ChkSkipAssignments) {
$runtimeArgs.SkipAssignments = [bool]$st.ChkSkipAssignments.IsChecked
}
Set-CompareRuntimeOptions @runtimeArgs
$selectedGroups = @()
if (-not $provider.IgnoreGroups) {
$selectedGroups = @($st.Rows |
Where-Object { $_ -and $_.Selected -and $_.ObjectGroup } |
ForEach-Object { $_.ObjectGroup })
if ($selectedGroups.Count -eq 0) {
$st.Ui.ShowMessageBox("No object types selected.", "Compare", "OK", "Warning")
return
}
}
Start-BulkCompare $provider -SelectedGroups $selectedGroups
if ($st.TxtStatus) { $st.TxtStatus.Text = "Compare finished." }
} catch {
Write-LogError "Bulk compare failed" $_.Exception
$st.Ui.ShowMessageBox("Compare failed: $($_.Exception.Message)", "Compare", "OK", "Error")
if ($st.TxtStatus) { $st.TxtStatus.Text = "Compare failed." }
} finally {
Write-Status ""
if ($st.BtnStart) { $st.BtnStart.IsEnabled = $true }
if ($st.BtnClose) { $st.BtnClose.IsEnabled = $true }
}
}))
}
$form.add_KeyDown((ConvertTo-AvaloniaEventScriptBlock {
param($s, $e)
if ($e.Key -eq [Avalonia.Input.Key]::Escape) {
Show-ModalObject
$e.Handled = $true
}
}.GetNewClosure()))
$ui.ShowModalForm("Bulk Compare Objects", $form, $true)
}
function Register-BulkCompareProviderBrowseEvents
{
# Wires the four Browse buttons that may appear in the per-provider option
# panels (browseExportPath / browseSavePath / browseExportPathSource /
# browseExportPathCompare). Handler context rides on each button's Tag
# (sender-based) - function locals are not available at click time
# (ConvertTo-AvaloniaEventScriptBlock strips closures).
# NOTE: param() must stay the FIRST statement - a $ui assignment above it
# once made 'param' unparseable at call time and broke every panel swap.
param($Panel, $Provider, $HostType)
if (-not $Panel -or -not $Provider) { return }
# Text boxes -> provider properties. The panels' XAML has no bindings and
# nothing synced them, so everything the user typed was discarded - which
# made the "Named Objects in Intune" provider (whose only inputs are two
# text boxes) impossible to use at all. Each box pushes on TextChanged;
# the current provider value seeds the box up front.
$textPropertyPairs = @(
@{ Name = 'txtCompareNameFilter'; Property = 'NameFilter' },
@{ Name = 'txtExportPath'; Property = 'ExportPath' },
@{ Name = 'txtExportPathSource'; Property = 'SourcePath' },
@{ Name = 'txtExportPathCompare'; Property = 'ComparePath' },
@{ Name = 'txtCompareSource'; Property = 'SourcePattern' },
@{ Name = 'txtCompareWith'; Property = 'ComparePattern' },
@{ Name = 'txtSavePath'; Property = 'SavePath' }
)
foreach ($pair in $textPropertyPairs) {
$tb = $HostType::FindByName($Panel, $pair.Name)
if (-not $tb) { continue }
if (-not $Provider.PSObject.Properties[$pair.Property]) { continue }
$tb.Text = [string]$Provider.($pair.Property)
$tb.Tag = @{ Provider = $Provider; Property = $pair.Property }
$tb.add_TextChanged((ConvertTo-AvaloniaEventScriptBlock {
param($S, $E)
$tag = $S.Tag
if ($tag -and $tag.Provider) { $tag.Provider.($tag.Property) = [string]$S.Text }
}))
}
$browsePathPairs = @(
@{ Name = 'browseExportPath'; Property = 'ExportPath'; TextName = 'txtExportPath'; Title = 'Select root folder for compare' },
@{ Name = 'browseSavePath'; Property = 'SavePath'; TextName = 'txtSavePath'; Title = 'Select save folder' },
@{ Name = 'browseExportPathSource'; Property = 'SourcePath'; TextName = 'txtExportPathSource'; Title = 'Select source root folder' },
@{ Name = 'browseExportPathCompare'; Property = 'ComparePath'; TextName = 'txtExportPathCompare'; Title = 'Select compare root folder' }
)
foreach ($pair in $browsePathPairs) {
$btn = $HostType::FindByName($Panel, $pair.Name)
if (-not $btn) { continue }
# Provider classes don't expose every property; skip wiring for
# buttons whose target property doesn't exist on this provider.
if (-not $Provider.PSObject.Properties[$pair.Property]) { continue }
$btn.Tag = @{
Provider = $Provider; Property = $pair.Property; Title = $pair.Title
TextBox = $HostType::FindByName($Panel, $pair.TextName)
}
$btn.add_Click((ConvertTo-AvaloniaEventScriptBlock {
param($s, $e)
$tag = $s.Tag
$current = if ($tag.TextBox) { [string]$tag.TextBox.Text } else { '' }
$folder = $script:UIProvider.ShowFolderPicker($current, [string]$tag.Title)
if ($folder) {
$tag.Provider.($tag.Property) = $folder
# Write the TextBox directly: these panels have no bindings, so
# the old DataContext null/restore refresh changed nothing on
# screen and picking a folder looked like it did nothing.
if ($tag.TextBox) { $tag.TextBox.Text = $folder }
}
}))
}
}
@@ -0,0 +1,100 @@
# Avalonia Bulk Copy dialog (ported from the old project's Copy extension via
# the WPF UI/WPF/Extensions/IntuneManagerBulkCopyUIWPF.ps1). New file per
# architecture rule R9. Thin caller of the public Start-GraphBulkCopy driver
# (R10) — the UI only collects the two name patterns and the selected types.
function Show-GraphBulkCopyForm
{
$ui = $script:UIProvider
$form = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/BulkCopyForm.axaml'))
if (-not $form) { return }
$hostType = Get-AvaloniaHost
$script:dgBulkCopyObjects = $hostType::FindByName($form, 'dgBulkCopyObjects')
$script:txtCopyFromPattern = $hostType::FindByName($form, 'txtCopyFromPattern')
$script:txtCopyToPattern = $hostType::FindByName($form, 'txtCopyToPattern')
$btnStartCopy = $hostType::FindByName($form, 'btnStartCopy')
$btnClose = $hostType::FindByName($form, 'btnClose')
if ($script:txtCopyFromPattern) { $script:txtCopyFromPattern.Text = [string](Get-SettingStoreValue "Copy" "CopyFromPattern") }
if ($script:txtCopyToPattern) { $script:txtCopyToPattern.Text = [string](Get-SettingStoreValue "Copy" "CopyToPattern") }
# Rows are policy TYPES (matching the original Bulk Copy), all selected.
$rows = [System.Collections.Generic.List[BulkCopyRowItem]]::new()
$copyTypes = @($script:IntuneTypes | Where-Object {
$_.Title -and (-not ($_.ShowButtons -is [Object[]]) -or $_.ShowButtons -contains "Copy")
} | Sort-Object Title)
foreach ($intuneType in $copyTypes) {
$rows.Add([BulkCopyRowItem]@{
Title = [string]$intuneType.Title
Selected = $true
ObjectType = $intuneType
})
}
$script:bulkCopyRows = @($rows)
if ($script:dgBulkCopyObjects) { $script:dgBulkCopyObjects.ItemsSource = $script:bulkCopyRows }
Initialize-AvaloniaGridSelectAllHeader -Grid $script:dgBulkCopyObjects -BindingProperty 'Selected' -InitiallyChecked $true | Out-Null
if ($btnStartCopy) {
$btnStartCopy.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$copyFrom = if ($script:txtCopyFromPattern) { ([string]$script:txtCopyFromPattern.Text).Trim() } else { '' }
$copyTo = if ($script:txtCopyToPattern) { ([string]$script:txtCopyToPattern.Text).Trim() } else { '' }
if (-not $copyFrom -or -not $copyTo) {
$script:UIProvider.ShowMessageBox("Both name patterns must be specified", "Bulk Copy", "OK", "Error") | Out-Null
return
}
$selectedTypes = @($script:bulkCopyRows | Where-Object { $_.Selected -and $_.ObjectType })
if ($selectedTypes.Count -eq 0) {
$script:UIProvider.ShowMessageBox("No object types selected.`n`nSelect the types you want to copy.", "Bulk Copy", "OK", "Error") | Out-Null
return
}
Save-SettingStoreValue "Copy" "CopyFromPattern" $copyFrom
Save-SettingStoreValue "Copy" "CopyToPattern" $copyTo
Write-Status "Copy objects" -Block
try {
$summary = Start-GraphBulkCopy -CopyFromPattern $copyFrom -CopyToPattern $copyTo `
-PolicyType @($selectedTypes | ForEach-Object { $_.ObjectType.Id })
Write-Status $null
$msg = ("Copied {0} object(s) across {1} type(s) ({2} skipped because the target name exists) in {3:hh\:mm\:ss}." -f `
$summary.Copied, $summary.Types, $summary.Skipped, $summary.Duration)
$unknownNote = Get-IntuneUnknownSelectorSummary $summary.UnknownSelectors
if ($unknownNote) { $msg += "`n`n$unknownNote" }
$script:UIProvider.ShowMessageBox($msg, "Bulk Copy", "OK", "Information") | Out-Null
}
catch {
Write-Status $null
Write-LogError "Bulk copy failed" $_.Exception
$script:UIProvider.ShowMessageBox("Bulk copy failed:`n`n$($_.Exception.Message)", "Bulk Copy", "OK", "Error") | Out-Null
}
if ($script:IntuneManagerSelectedObject) {
Invoke-IntuneActivateObject $script:IntuneManagerSelectedObject | Out-Null
}
}))
}
if ($btnClose) {
$btnClose.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$script:dgBulkCopyObjects = $null
$script:bulkCopyRows = $null
Show-ModalObject
}))
}
$form.add_KeyDown((ConvertTo-AvaloniaEventScriptBlock {
param($s, $e)
if ($e.Key -eq [Avalonia.Input.Key]::Escape) {
$script:dgBulkCopyObjects = $null
$script:bulkCopyRows = $null
Show-ModalObject
$e.Handled = $true
}
}))
$ui.ShowModalForm("Bulk Copy Objects", $form, $true)
}
@@ -0,0 +1,128 @@
# Avalonia port of the Bulk Delete dialog from
# UI/WPF/Extensions/IntuneManagerUI.ps1 (Show-GraphBulkDeleteForm).
# New file because Bulk Delete is a self-contained subsystem and the WPF
# original sits in the giant IntuneManagerUI file we're trying not to grow
# further (architecture rule R9).
#
# Slice 4c: core Bulk Delete form. Simpler than 4a/4b — no settings-driven
# defaults, just a name filter + per-group selection + a confirm dialog. The
# btnDelete handler is a thin caller of the public Start-GraphBulkDelete driver
# (R10); the confirm prompt is the UI's job. Event handlers use the module-scope
# $script:_* state pattern + bare module functions (no GetNewClosure / captured
# locals / $script:UIProvider) so they resolve correctly at click time
# (see [[avalonia-closure-dynamic-module]]).
function Update-BulkDeleteObjectList
{
if (-not $script:dgBulkDeleteObjects) { return }
$rows = [System.Collections.Generic.List[BulkDeleteRowItem]]::new()
$sortedGroups = $script:bulkDeleteDeletableGroups | Sort-Object Title
foreach ($intuneGroup in $sortedGroups) {
$rows.Add([BulkDeleteRowItem]@{
Title = [string]$intuneGroup.Title
Selected = $false
ObjectGroup = $intuneGroup
})
}
$script:bulkDeleteRows = @($rows)
$script:dgBulkDeleteObjects.ItemsSource = $script:bulkDeleteRows
}
function Show-GraphBulkDeleteForm
{
$ui = $script:UIProvider
$form = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/BulkDeleteForm.axaml'))
if (-not $form) { return }
$hostType = Get-AvaloniaHost
$script:dgBulkDeleteObjects = $hostType::FindByName($form, 'dgBulkDeleteObjects')
$txtFilter = $hostType::FindByName($form, 'txtDeleteNameFilter')
$btnDelete = $hostType::FindByName($form, 'btnDelete')
$btnClose = $hostType::FindByName($form, 'btnClose')
$script:bulkDeleteDeletableGroups = @($script:IntuneGroups | Where-Object {
$_.Title -and (-not ($_.ShowButtons -is [Object[]]) -or $_.ShowButtons -contains "Delete")
})
if ($txtFilter) { $txtFilter.Text = [string](Get-SettingStoreValue "" "DeleteNameFilter") }
$script:_bulkDeleteTxtFilter = $txtFilter
Update-BulkDeleteObjectList
# Select/deselect-all moved into the DataGrid column header.
# Delete defaults to "nothing selected" to keep the destructive action
# explicit, matching the AXAML IsChecked="False" on the header CheckBox.
Initialize-AvaloniaGridSelectAllHeader -Grid $script:dgBulkDeleteObjects -BindingProperty 'Selected' -InitiallyChecked $false | Out-Null
if ($btnDelete) {
$btnDelete.add_Click({
$txtFilter = $script:_bulkDeleteTxtFilter
$selectedGroups = @($script:bulkDeleteRows | Where-Object { $_.Selected -and $_.ObjectGroup })
if ($selectedGroups.Count -eq 0) {
Show-MessageBox "No object types selected.`n`nSelect the types you want to delete." "Bulk Delete" "OK" "Error" | Out-Null
return
}
$nameFilter = if ($txtFilter) { ([string]$txtFilter.Text).Trim() } else { '' }
Save-SettingStoreValue "" "DeleteNameFilter" $nameFilter
$confirmMsg = "Are you sure you want to delete all objects of the selected type(s)?`n`n$($selectedGroups.Count) type(s) selected`n`nEnvironment: $script:OrganizationName"
if ($nameFilter) { $confirmMsg += "`nName filter: $nameFilter" }
if ((Show-MessageBox $confirmMsg "Delete Objects?" "YesNo" "Warning") -ne "Yes") {
return
}
# Thin caller of the public driver (R10) — the confirm prompt above is
# the UI's job; the delete loop runs headless via Start-GraphBulkDelete.
Write-Status "Bulk delete" -Block
try {
$summary = Start-GraphBulkDelete -Filter $nameFilter `
-PolicyGroup @($selectedGroups | ForEach-Object { $_.ObjectGroup.ID })
# Delete shows nothing on plain success; an id that matched nothing is
# the one outcome the user must not miss.
$unknownNote = Get-IntuneUnknownSelectorSummary $summary.UnknownSelectors
if ($unknownNote) { Show-MessageBox $unknownNote "Bulk Delete" "OK" "Warning" | Out-Null }
}
catch {
Write-LogError "Bulk delete failed" $_.Exception
Show-MessageBox "Bulk delete failed:`n`n$($_.Exception.Message)" "Bulk Delete" "OK" "Error" | Out-Null
}
# Match WPF (IntuneManagerBulkDeleteUIWPF.ps1): refresh the main view's
# object list and leave the Bulk Delete form open so the user can run
# another delete or close it explicitly. btnClose / Escape tear down the
# $script: state - don't null it here while the form is still live.
if ($script:IntuneManagerSelectedObject) {
Invoke-IntuneActivateObject $script:IntuneManagerSelectedObject | Out-Null
}
Write-Status ""
})
}
if ($btnClose) {
$btnClose.add_Click({
$script:dgBulkDeleteObjects = $null
$script:bulkDeleteRows = $null
$script:_bulkDeleteTxtFilter = $null
Show-ModalObject
})
}
$form.add_KeyDown({
param($s, $e)
if ($e.Key -eq [Avalonia.Input.Key]::Escape) {
$script:dgBulkDeleteObjects = $null
$script:bulkDeleteRows = $null
$script:_bulkDeleteTxtFilter = $null
Show-ModalObject
$e.Handled = $true
}
})
$ui.ShowModalForm("Bulk Delete", $form, $true)
}
@@ -0,0 +1,599 @@
# Avalonia bulk-documentation dialog. New file because Bulk Documentation is a
# self-contained subsystem (R9 — keep IntuneManagerUIAvalonia from growing
# further) and the WPF original at UI/WPF/Extensions/IntuneManagerUI.ps1 isn't
# the only consumer; the same Start-GraphBulkDocumentation function it calls
# also drives the silent batch path (Tests/Setup/Invoke-AutomationOrchestrator)
# so the UI logic stays thin.
function Update-BulkDocumentationObjectList
{
if (-not $script:dgBulkDocObjects) { return }
$rows = [System.Collections.Generic.List[BulkDocumentationRowItem]]::new()
if ($script:bulkDocMode -eq "Object") {
foreach ($policy in @($script:bulkDocPolicyObjects | Sort-Object Name)) {
$rows.Add([BulkDocumentationRowItem]@{
Title = [string]$policy.Name
Selected = $true
ObjectGroup = $null
ObjectType = $policy.PolicyType
PolicyObject = $policy
})
}
} elseif ($script:bulkDocMode -eq "Type") {
$documentableGroupIds = @($script:bulkDocDocumentableGroups | ForEach-Object { $_.Id })
$sortedTypes = $script:IntuneTypes |
Where-Object { $_.PolicyGroup -and ($documentableGroupIds -contains $_.PolicyGroup.Id) } |
Sort-Object Title
foreach ($intuneType in $sortedTypes) {
$rows.Add([BulkDocumentationRowItem]@{
Title = [string]$intuneType.Title
Selected = $true
ObjectGroup = $null
ObjectType = $intuneType
})
}
} else {
$sortedGroups = $script:bulkDocDocumentableGroups | Sort-Object Title
foreach ($intuneGroup in $sortedGroups) {
$rows.Add([BulkDocumentationRowItem]@{
Title = [string]$intuneGroup.Title
Selected = $true
ObjectGroup = $intuneGroup
ObjectType = $null
})
}
}
$script:bulkDocRows = @($rows)
$script:dgBulkDocObjects.ItemsSource = $script:bulkDocRows
}
function Get-BulkDocumentationSelectedIds
{
if ($null -eq $script:bulkDocRows) { return @() }
if ($script:bulkDocMode -eq "Type") {
return @($script:bulkDocRows |
Where-Object { $_.Selected -and $_.ObjectType -and $_.ObjectType.Id } |
ForEach-Object { $_.ObjectType.Id })
}
return @($script:bulkDocRows |
Where-Object { $_.Selected -and $_.ObjectGroup -and $_.ObjectGroup.Id } |
ForEach-Object { $_.ObjectGroup.Id })
}
function Get-BulkDocumentationSelectedObjects
{
if ($null -eq $script:bulkDocRows) { return @() }
return @($script:bulkDocRows |
Where-Object { $_.Selected -and $_.PolicyObject } |
ForEach-Object { $_.PolicyObject })
}
function ConvertTo-AvaloniaComboItem {
# Wrap whatever the caller passes (string / PSCustomObject{Name,Value} /
# PSCustomObject{EnglishName,Name} for languages) in a NameValueComboItem
# so DisplayMemberBinding="{Binding Name|EnglishName}" finds a real CLR
# property to render. SelectedItem.Value is preserved for the existing
# readers; .Source carries the original object for callers that need
# extra fields (Get-DocumentationLanguages returns CultureInfo).
param($Item)
if ($null -eq $Item) { return $null }
if ($Item -is [NameValueComboItem]) { return $Item }
$row = [NameValueComboItem]::new()
$row.Source = $Item
if ($Item -is [string]) {
$row.Name = $Item; $row.EnglishName = $Item; $row.Value = $Item
return $row
}
if ($Item.PSObject.Properties['EnglishName']) {
$row.Name = [string]$Item.EnglishName
$row.EnglishName = [string]$Item.EnglishName
$row.Value = if ($Item.PSObject.Properties['Value']) { $Item.Value } elseif ($Item.PSObject.Properties['Name']) { $Item.Name } else { $Item.EnglishName }
return $row
}
if ($Item.PSObject.Properties['Name']) {
$row.Name = [string]$Item.Name
$row.EnglishName = [string]$Item.Name
$row.Value = if ($Item.PSObject.Properties['Value']) { $Item.Value } else { $Item.Name }
return $row
}
$row.Name = [string]$Item; $row.EnglishName = [string]$Item; $row.Value = $Item
return $row
}
function Set-AvaloniaDocumentationCombo {
param($HostType, $Form, [string]$Name, $Items, $SelectedValue)
$control = $HostType::FindByName($Form, $Name)
if (-not $control) { return }
$projected = @($Items | ForEach-Object { ConvertTo-AvaloniaComboItem $_ })
$control.ItemsSource = $projected
# Display field comes from DisplayMemberBinding on the control in
# BulkDocumentationForm.axaml - nothing to set here.
$match = @($projected | Where-Object { $_.Value -eq $SelectedValue }) | Select-Object -First 1
$control.SelectedItem = if ($match) { $match } elseif ($projected.Count -gt 0) { $projected[0] } else { $null }
}
# Avalonia parity for Set-WpfDocumentationConditionalVisibility — show/hide
# $TargetNames whenever $TriggerName's selected value equals $VisibleWhen.
# State goes in $script:_docCondVisibility so the event handler can re-resolve
# controls by name; closures captured via GetNewClosure() get scrubbed by
# ConvertTo-AvaloniaEventScriptBlock (see feedback_avalonia_closure_dynamic_module).
function Set-AvaloniaDocumentationConditionalVisibility {
param(
$HostType, $Form,
[string]$TriggerName,
[string]$VisibleWhen,
[string[]]$TargetNames
)
$trigger = $HostType::FindByName($Form, $TriggerName)
if (-not $trigger) { return }
$targets = @($TargetNames | ForEach-Object { $HostType::FindByName($Form, $_) } | Where-Object { $_ })
if ($targets.Count -eq 0) { return }
if (-not $script:_docCondVisibility) { $script:_docCondVisibility = @{} }
$script:_docCondVisibility[$TriggerName] = [PSCustomObject]@{
VisibleWhen = $VisibleWhen
TargetNames = $TargetNames
HostType = $HostType
Form = $Form
}
Update-AvaloniaDocumentationConditionalVisibility -TriggerName $TriggerName
$trigger.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock {
# $this is the trigger control in Avalonia event handlers.
Update-AvaloniaDocumentationConditionalVisibility -TriggerName $this.Name
}))
}
function Update-AvaloniaDocumentationConditionalVisibility {
param([string]$TriggerName)
if (-not $script:_docCondVisibility -or -not $script:_docCondVisibility.ContainsKey($TriggerName)) { return }
$entry = $script:_docCondVisibility[$TriggerName]
$trigger = $entry.HostType::FindByName($entry.Form, $TriggerName)
if (-not $trigger) { return }
$item = $trigger.SelectedItem
$current = if ($null -eq $item) { $null }
elseif ($item.PSObject.Properties['Value']) { $item.Value }
elseif ($item.PSObject.Properties['Name']) { $item.Name }
else { $item }
$visible = [string]$current -eq $entry.VisibleWhen
foreach ($name in $entry.TargetNames) {
$t = $entry.HostType::FindByName($entry.Form, $name)
if ($t) { $t.IsVisible = $visible }
}
}
function Initialize-AvaloniaDocumentationOptions {
param($HostType, $Form)
Set-AvaloniaDocumentationCombo $HostType $Form 'cbDocumentationLanguage' (Get-DocumentationLanguages | Sort-Object EnglishName) (Get-DocumentationSetting 'Language' 'en')
Set-AvaloniaDocumentationCombo $HostType $Form 'cbDocumentationPropertySeparator' @(',',';','-','|') (Get-DocumentationSetting 'PropertySeparator' ';')
Set-AvaloniaDocumentationCombo $HostType $Form 'cbDocumentationObjectSeparator' @([PSCustomObject]@{Name='New line';Value=[Environment]::NewLine},[PSCustomObject]@{Name=';';Value=';'},[PSCustomObject]@{Name='|';Value='|'}) (Get-DocumentationSetting 'ObjectSeparator' ([Environment]::NewLine))
Set-AvaloniaDocumentationCombo $HostType $Form 'cbNotConfiguredText' @([PSCustomObject]@{Name='Not configured (localized)';Value='notConfigured'},[PSCustomObject]@{Name='Empty';Value='empty'},[PSCustomObject]@{Name="Don't change";Value='asis'}) (Get-DocumentationSetting 'NotConfiguredText' 'notConfigured')
Set-AvaloniaDocumentationCombo $HostType $Form 'cbValueOutputProperty' @([PSCustomObject]@{Name='Value';Value='value'},[PSCustomObject]@{Name='Value with label';Value='valueWithLabel'}) (Get-DocumentationSetting 'ValueOutputProperty' 'value')
$propertyModes = @([PSCustomObject]@{Name='Simple';Value='simple'},[PSCustomObject]@{Name='Extended';Value='extended'},[PSCustomObject]@{Name='Custom';Value='custom'})
$fileModes = @([PSCustomObject]@{Name='Single file';Value='Full'},[PSCustomObject]@{Name='One file per object';Value='Object'})
Set-AvaloniaDocumentationCombo $HostType $Form 'cbCSVDocumentationProperties' $propertyModes (Get-DocumentationSetting 'CSVExportProperties' 'simple')
Set-AvaloniaDocumentationCombo $HostType $Form 'cbCSVDelimiter' @('',',',';','-','|') (Get-DocumentationSetting 'CSVDelimiter' '')
Set-AvaloniaDocumentationCombo $HostType $Form 'cbJsonOutputFileType' @([PSCustomObject]@{Name='Single file';Value='Full'},[PSCustomObject]@{Name='One file per object type';Value='ObjectType'}) (Get-DocumentationSetting 'JSONOutputFileType' 'Full')
Set-AvaloniaDocumentationCombo $HostType $Form 'cbHTMLDocumentFileType' $fileModes (Get-DocumentationSetting 'HTMLDocumentFileType' 'Full')
Set-AvaloniaDocumentationCombo $HostType $Form 'cbAtlassianDocumentFileType' $fileModes (Get-DocumentationSetting 'AtlassianDocumentFileType' 'Full')
Set-AvaloniaDocumentationCombo $HostType $Form 'cbMDDocumentFileType' $fileModes (Get-DocumentationSetting 'MDDocumentFileType' 'Full')
Set-AvaloniaDocumentationCombo $HostType $Form 'cbWordExportProperties' $propertyModes (Get-DocumentationSetting 'WordExportProperties' 'simple')
Set-AvaloniaDocumentationCombo $HostType $Form 'cbWordDocumentFormat' @([PSCustomObject]@{Name='DOCX';Value='wdFormatDocumentDefault'},[PSCustomObject]@{Name='Strict Open XML';Value='wdFormatStrictOpenXMLDocument'},[PSCustomObject]@{Name='PDF';Value='wdFormatPDF'}) (Get-DocumentationSetting 'WordDocumentFormat' 'wdFormatDocumentDefault')
Set-AvaloniaDocumentationCombo $HostType $Form 'cbWordDocumentationLevel' @([PSCustomObject]@{Name='Full';Value='full'},[PSCustomObject]@{Name='Limited';Value='limited'},[PSCustomObject]@{Name='Basic';Value='basic'}) (Get-DocumentationSetting 'WordDocumentationLevel' 'full')
Set-AvaloniaDocumentationCombo $HostType $Form 'cbWordTableCaptionPosition' @([PSCustomObject]@{Name='Below table';Value='below'},[PSCustomObject]@{Name='Above table';Value='above'}) (Get-DocumentationSetting 'WordTableCaptionPosition' 'below')
$text = @{txtCSVDocumentationPath='CSVDocumentationPath';txtCSVCustomProperties='CSVCustomDisplayProperties';txtJsonDocumentName='JSONDocumentName';txtHTMLDocumentName='HTMLDocumentName';txtHTMLCSSFile='HTMLCSSFile';txtHTMLTitleProperty='HTMLTitleProperty';txtAtlassianDocumentName='AtlassianDocumentName';txtAtlassianTitleProperty='AtlassianTitleProperty';txtMDDocumentName='MDDocumentName';txtMDCSSFile='MDCSSFile';txtMDTitleProperty='MDTitleProperty';txtWordDocumentName='WordDocumentName';txtWordDocumentTemplate='WordDocumentTemplate';txtWordCustomDisplayProperties='WordCustomDisplayProperties';txtWordDocumentationLimitMaxLength='WordDocumentationLimitMaxLength';txtWordDocumentationLimitTruncateLength='WordDocumentationLimitTruncateLength';txtWordTitleProperty='WordTitleProperty';txtWordSubjectProperty='WordSubjectProperty';txtWordContentControls='WordContentControls';txtWordCoverPage='WordCoverPage';txtWordHeader1Style='WordHeader1Style';txtWordHeader2Style='WordHeader2Style';txtWordHeader3Style='WordHeader3Style';txtWordTableStyle='WordTableStyle';txtWordTableHeaderStyle='WordTableHeaderStyle';txtWordCategoryHeaderStyle='WordCategoryHeaderStyle';txtWordSubCategoryHeaderStyle='WordSubCategoryHeaderStyle';txtWordTableTextStyle='WordTableTextStyle';txtWordScriptTableStyle='WordScriptTableStyle';txtWordScriptStyle='WordScriptStyle'}
foreach($name in $text.Keys) {
$c = $HostType::FindByName($Form, $name)
# Skip when an AXAML file doesn't carry the field yet — assigning to a
# null control would throw NullReferenceException and abort the whole
# form load.
if ($c) { $c.Text = [string](Get-DocumentationSetting $text[$name] '') }
}
foreach($pair in @(@('chkSkipNotConfigured','SkipNotConfigured',$false),@('chkSkipDefaultValues','SkipDefaultValues',$false),@('chkSkipDisabled','SkipDisabled',$true),@('chkSetUnconfiguredValue','SetUnconfiguredValue',$true),@('chkSetDefaultValue','SetDefaultValue',$false),@('chkIncludeScripts','IncludeScripts',$true),@('chkExcludeScriptSignature','ExcludeScriptSignature',$false),@('chkExcludeAssignments','ExcludeAssignments',$false),@('chkIncludePolicyId','IncludePolicyId',$false),@('chkSkipDocumentInfo','SkipDocumentInfo',$false),@('chkFallbackDocumentation','FallbackDocumentation',$true),@('chkCSVAddObjectType','CSVAddObjectType',$true),@('chkCSVAddCompanyName','CSVAddCompanyName',$false),@('chkJsonOpenFile','JSONOpenFile',$true),@('chkHTMLOpenFile','HTMLOpenFile',$true),@('chkAtlassianOpenFile','AtlassianOpenFile',$true),@('chkMDIncludeCSS','MDIncludeCSS',$true),@('chkMDOpenFile','MDOpenFile',$true),@('chkMDDocumentSkipDate','MDDocumentSkipDate',$false),@('chkWordDocumentationLimitAttach','WordDocumentationLimitAttach',$false),@('chkWordAddCategories','WordAddCategories',$true),@('chkWordAddSubCategories','WordAddSubCategories',$true),@('chkWordOpenDocument','WordOpenDocument',$true),@('chkWordAttachJsonFile','WordAttachJsonFile',$false))) {
$c = $HostType::FindByName($Form, $pair[0])
if ($c) { $c.IsChecked = (Get-DocumentationSetting $pair[1] $pair[2]) -eq $true }
}
# Conditional-visibility wiring (Word custom-properties row, Word
# limit-options grid, CSV custom-properties row). Mirrors the WPF
# Set-WpfDocumentationConditionalVisibility calls.
Set-AvaloniaDocumentationConditionalVisibility -HostType $HostType -Form $Form -TriggerName 'cbWordExportProperties' -VisibleWhen 'custom' `
-TargetNames @('spWordCustomProperties','txtWordCustomDisplayProperties')
Set-AvaloniaDocumentationConditionalVisibility -HostType $HostType -Form $Form -TriggerName 'cbWordDocumentationLevel' -VisibleWhen 'limited' `
-TargetNames @('gdWordDocumentationLimitOptions')
Set-AvaloniaDocumentationConditionalVisibility -HostType $HostType -Form $Form -TriggerName 'cbCSVDocumentationProperties' -VisibleWhen 'custom' `
-TargetNames @('spCSVCustomProperties','txtCSVCustomProperties')
}
function Get-AvaloniaDocumentationOptions {
param($HostType, $Form)
function C($n) { $HostType::FindByName($Form,$n) }
# Return $null when the control is missing so Save-DocumentationOptionsDefaults
# skips writing — avoids erasing a real persisted value with an empty string
# from a non-existent XAML field.
function Txt($n) { $c = C $n; if ($c) { [string]$c.Text } else { $null } }
function Sel($n) { $c = C $n; if (-not $c) { return $null }; $v=$c.SelectedItem; if($v -and $v.PSObject.Properties['Value']){$v.Value}elseif($v -and $v.PSObject.Properties['Name']){$v.Name}else{$v} }
function Chk($n) { $c = C $n; if ($c) { [bool]$c.IsChecked } else { $null } }
@{Language=Sel 'cbDocumentationLanguage';PropertySeparator=Sel 'cbDocumentationPropertySeparator';ObjectSeparator=Sel 'cbDocumentationObjectSeparator';NameFilter=Txt 'txtDocumentFilter';SourceFolder=Txt 'txtDocumentFromFolder';SkipNotConfigured=Chk 'chkSkipNotConfigured';SkipDefaultValues=Chk 'chkSkipDefaultValues';SkipDisabled=Chk 'chkSkipDisabled';SetUnconfiguredValue=Chk 'chkSetUnconfiguredValue';SetDefaultValue=Chk 'chkSetDefaultValue';IncludeScripts=Chk 'chkIncludeScripts';ExcludeScriptSignature=Chk 'chkExcludeScriptSignature';ExcludeAssignments=Chk 'chkExcludeAssignments';IncludePolicyId=Chk 'chkIncludePolicyId';SkipDocumentInfo=Chk 'chkSkipDocumentInfo'; FallbackDocumentation=Chk 'chkFallbackDocumentation';NotConfiguredText=Sel 'cbNotConfiguredText';ValueOutputProperty=Sel 'cbValueOutputProperty';Outputs=@{csv=@{CSVDocumentationPath=Txt 'txtCSVDocumentationPath';CSVExportProperties=Sel 'cbCSVDocumentationProperties';CSVCustomDisplayProperties=Txt 'txtCSVCustomProperties';CSVDelimiter=Sel 'cbCSVDelimiter';CSVAddObjectType=Chk 'chkCSVAddObjectType';CSVAddCompanyName=Chk 'chkCSVAddCompanyName'};json=@{JSONDocumentName=Txt 'txtJsonDocumentName';JSONOpenFile=Chk 'chkJsonOpenFile';JSONOutputFileType=Sel 'cbJsonOutputFileType'};html=@{HTMLDocumentName=Txt 'txtHTMLDocumentName';HTMLCSSFile=Txt 'txtHTMLCSSFile';HTMLTitleProperty=Txt 'txtHTMLTitleProperty';HTMLOpenFile=Chk 'chkHTMLOpenFile';HTMLDocumentFileType=Sel 'cbHTMLDocumentFileType'};atlassian=@{AtlassianDocumentName=Txt 'txtAtlassianDocumentName';AtlassianTitleProperty=Txt 'txtAtlassianTitleProperty';AtlassianOpenFile=Chk 'chkAtlassianOpenFile';AtlassianDocumentFileType=Sel 'cbAtlassianDocumentFileType'};md=@{MDDocumentName=Txt 'txtMDDocumentName';MDCSSFile=Txt 'txtMDCSSFile';MDTitleProperty=Txt 'txtMDTitleProperty';MDIncludeCSS=Chk 'chkMDIncludeCSS';MDOpenFile=Chk 'chkMDOpenFile';MDDocumentSkipDate=Chk 'chkMDDocumentSkipDate';MDDocumentFileType=Sel 'cbMDDocumentFileType'};word=@{WordDocumentName=Txt 'txtWordDocumentName';WordDocumentTemplate=Txt 'txtWordDocumentTemplate';WordExportProperties=Sel 'cbWordExportProperties';WordCustomDisplayProperties=Txt 'txtWordCustomDisplayProperties';WordDocumentFormat=Sel 'cbWordDocumentFormat';WordDocumentationLevel=Sel 'cbWordDocumentationLevel';WordDocumentationLimitMaxLength=Txt 'txtWordDocumentationLimitMaxLength';WordDocumentationLimitTruncateLength=Txt 'txtWordDocumentationLimitTruncateLength';WordDocumentationLimitAttach=Chk 'chkWordDocumentationLimitAttach';WordAddCategories=Chk 'chkWordAddCategories';WordAddSubCategories=Chk 'chkWordAddSubCategories';WordOpenDocument=Chk 'chkWordOpenDocument';WordAttachJsonFile=Chk 'chkWordAttachJsonFile';WordTitleProperty=Txt 'txtWordTitleProperty';WordSubjectProperty=Txt 'txtWordSubjectProperty';WordContentControls=Txt 'txtWordContentControls';WordCoverPage=Txt 'txtWordCoverPage';WordHeader1Style=Txt 'txtWordHeader1Style';WordHeader2Style=Txt 'txtWordHeader2Style';WordHeader3Style=Txt 'txtWordHeader3Style';WordTableStyle=Txt 'txtWordTableStyle';WordTableHeaderStyle=Txt 'txtWordTableHeaderStyle';WordCategoryHeaderStyle=Txt 'txtWordCategoryHeaderStyle';WordSubCategoryHeaderStyle=Txt 'txtWordSubCategoryHeaderStyle';WordTableTextStyle=Txt 'txtWordTableTextStyle';WordTableCaptionPosition=Sel 'cbWordTableCaptionPosition';WordScriptTableStyle=Txt 'txtWordScriptTableStyle';WordScriptStyle=Txt 'txtWordScriptStyle'}}}
}
function Add-AvaloniaDocumentationFileBrowse {
param($HostType, $Form, [string]$ButtonName, [string]$TextName, [switch]$Save, [string]$Title, [string]$Extension, [string]$FilterName, [string]$FilterPattern)
$button = $HostType::FindByName($Form, $ButtonName)
$text = $HostType::FindByName($Form, $TextName)
if(-not $button -or -not $text) { return }
# Per-button context rides on the sender's Tag: this helper is called once
# per registry-declared picker (7+ times), so a single module-scope slot
# would be overwritten by each successive call - and the captured locals it
# used before were stripped, leaving every provider's browse button dead.
$button.Tag = [PSCustomObject]@{
TextBox = $text; Save = [bool]$Save; Title = $Title
Extension = $Extension; FilterName = $FilterName; FilterPattern = $FilterPattern
}
$button.add_Click((ConvertTo-AvaloniaEventScriptBlock {
param($S, $E)
$cfg = $S.Tag
if(-not $cfg -or -not $cfg.TextBox) { return }
$hostT = Get-AvaloniaHost
$current = [string]$cfg.TextBox.Text
if($cfg.Save) {
$suggested = if($current) { [IO.Path]::GetFileName($current) } else { '' }
$picked = $hostT::SaveFilePicker($script:Window, $cfg.Title, $suggested, $cfg.Extension, $cfg.FilterName, $cfg.FilterPattern)
} else {
$start = if($current) { [IO.Path]::GetDirectoryName($current) } else { '' }
$picked = $hostT::OpenFilePicker($script:Window, $cfg.Title, $start, $cfg.FilterName, $cfg.FilterPattern)
}
if($picked) { $cfg.TextBox.Text = $picked }
}))
}
function Set-AvaloniaDocumentationOutputPanel {
param($HostType, $Form, [string]$OutputValue)
foreach($output in [DocumentationRegistry]::Outputs) {
$panel = $HostType::FindByName($Form, "pnlDocumentationOutput$($output.Name)")
if($panel) { $panel.IsVisible = ($output.Value -eq $OutputValue) }
}
}
function Show-GraphBulkDocumentationForm
{
param([object[]]$PolicyObject)
# $script: scope-prefixed references and bare module-private function
# CORRECTION (2026-08-25): this note previously claimed the opposite.
# $script: variables and bare module-function lookups DO resolve inside
# Avalonia handlers; it is function-LOCAL captures that get stripped by
# ConvertTo-AvaloniaEventScriptBlock. Handler state therefore lives in
# $script:_bulkDocFormState, never in captured locals.
$ui = $script:UIProvider
$form = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML\BulkDocumentationForm.axaml'))
if (-not $form) { return }
$hostType = Get-AvaloniaHost
$script:dgBulkDocObjects = $hostType::FindByName($form, 'dgObjectsToDocument')
$cbType = $hostType::FindByName($form, 'cbDocumentationType')
$txtFolder = $hostType::FindByName($form, 'txtDocumentationOutputFolder')
$btnBrowse = $hostType::FindByName($form, 'browseDocumentationOutputFolder')
$txtSource = $hostType::FindByName($form, 'txtDocumentFromFolder')
$btnSource = $hostType::FindByName($form, 'browseDocumentFromFolder')
$txtLang = $hostType::FindByName($form, 'cbDocumentationLanguage')
$chkIncScr = $hostType::FindByName($form, 'chkIncludeScripts')
$chkExcSig = $hostType::FindByName($form, 'chkExcludeScriptSignature')
$chkExcAssign = $hostType::FindByName($form, 'chkExcludeAssignments')
$chkIncPolId = $hostType::FindByName($form, 'chkIncludePolicyId')
$rbGroup = $hostType::FindByName($form, 'rbBulkDocViewGroup')
$rbType = $hostType::FindByName($form, 'rbBulkDocViewType')
$pnlListBy = $hostType::FindByName($form, 'pnlDocumentationListBy')
# The label rows are Label + info-icon inside a horizontal StackPanel, so the
# wrapper is what has to be hidden - toggling the inner Label alone leaves the
# panel occupying its row with the info icon still showing.
$pnlFilterLbl = $hostType::FindByName($form, 'pnlDocumentFilterLabel')
$txtFilter = $hostType::FindByName($form, 'txtDocumentFilter')
$pnlSourceLbl = $hostType::FindByName($form, 'pnlDocumentFromFolderLabel')
$pnlSource = $hostType::FindByName($form, 'pnlDocumentFromFolder')
$btnDoc = $hostType::FindByName($form, 'btnDocument')
$btnClose = $hostType::FindByName($form, 'btnClose')
$btnPreview = $hostType::FindByName($form, 'btnPreviewDocumentation')
$btnCopyRaw = $hostType::FindByName($form, 'btnCopyRawDocumentation')
$btnCopyBasic = $hostType::FindByName($form, 'btnCopyBasicDocumentation')
$btnCopySettings = $hostType::FindByName($form, 'btnCopySettingsDocumentation')
$txtRaw = $hostType::FindByName($form, 'txtDocumentationRawData')
$script:_bulkDocFormState = [ordered]@{
HostType = $hostType; Form = $form
CbType = $cbType; TxtFolder = $txtFolder; TxtSource = $txtSource
TxtLang = $txtLang; ChkIncScr = $chkIncScr; ChkExcSig = $chkExcSig
ChkExcAssign = $chkExcAssign; ChkIncPolId = $chkIncPolId
TxtFilter = $txtFilter; TxtRaw = $txtRaw; HeaderCb = $null
}
$script:bulkDocPreview = @()
# ---- Populate output-format combo from the registry ----
if ($cbType) {
# Project to NameValueComboItem so Avalonia's DisplayMemberBinding can
# read .Name off a real CLR property (PSCustomObject NoteProperties
# don't bind — see [[avalonia-binding-needs-clr-types]]).
$outputs = @([DocumentationRegistry]::Outputs |
Sort-Object Name |
ForEach-Object { ConvertTo-AvaloniaComboItem ([PSCustomObject]@{ Name = $_.Name; Value = $_.Value }) })
$cbType.ItemsSource = $outputs
if ($outputs.Count -gt 0) {
$last = Get-SettingStoreValue "Documentation" "OutputType" "json"
$match = $outputs | Where-Object Value -EQ $last | Select-Object -First 1
$cbType.SelectedItem = if ($match) { $match } else { $outputs[0] }
}
$selectedOutput = if($cbType.SelectedItem) { [string]$cbType.SelectedItem.Value } else { '' }
Set-AvaloniaDocumentationOutputPanel $hostType $form $selectedOutput
$cbType.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_bulkDocFormState
if (-not $st) { return }
$value = if($st.CbType -and $st.CbType.SelectedItem) { [string]$st.CbType.SelectedItem.Value } else { '' }
Set-AvaloniaDocumentationOutputPanel $st.HostType $st.Form $value
}))
}
# ---- Restore persisted form values ----
if ($txtFolder) { $txtFolder.Text = [string](Get-SettingStoreValue "Documentation" "OutputFolder" "") }
if ($txtSource) { $txtSource.Text = [string](Get-DocumentationSetting 'SourceFolder' '') }
if ($txtFilter) { $txtFilter.Text = [string](Get-DocumentationSetting 'NameFilter' '') }
Initialize-AvaloniaDocumentationOptions $hostType $form
# ---- Populate object list (same Groups filter as Bulk Export) ----
$script:bulkDocDocumentableGroups = @($script:IntuneGroups | Where-Object {
$_.Title -and (-not ($_.ShowButtons -is [Object[]]) -or $_.ShowButtons -contains "Document")
})
$script:bulkDocPolicyObjects = @($PolicyObject | Where-Object { $null -ne $_ })
$script:bulkDocMode = if($script:bulkDocPolicyObjects.Count -gt 0) { "Object" } else { "Group" }
if($pnlListBy) { $pnlListBy.IsVisible = ($script:bulkDocMode -ne "Object") }
foreach($bulkOnlyControl in @($pnlFilterLbl,$txtFilter,$pnlSourceLbl,$pnlSource)) {
if($bulkOnlyControl) { $bulkOnlyControl.IsVisible = ($script:bulkDocMode -ne "Object") }
}
Update-BulkDocumentationObjectList
if ($btnBrowse) {
$btnBrowse.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_bulkDocFormState
if (-not $st) { return }
$folder = $script:UIProvider.ShowFolderPicker(([string]$st.TxtFolder.Text), 'Select root folder for documentation')
if ($folder -and $st.TxtFolder) { $st.TxtFolder.Text = $folder }
}))
}
if ($btnSource) {
$btnSource.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_bulkDocFormState
if (-not $st) { return }
$folder = $script:UIProvider.ShowFolderPicker(([string]$st.TxtSource.Text), 'Select exported source folder')
if ($folder -and $st.TxtSource) { $st.TxtSource.Text = $folder }
}))
}
$btnCSVFolder = $hostType::FindByName($form, 'browseCSVDocumentationPath')
if ($btnCSVFolder) {
$btnCSVFolder.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_bulkDocFormState
if (-not $st) { return }
$tb = $st.HostType::FindByName($st.Form, 'txtCSVDocumentationPath')
$current = if ($tb) { [string]$tb.Text } else { '' }
$folder = $script:UIProvider.ShowFolderPicker($current, 'Select CSV documentation folder')
if ($folder -and $tb) { $tb.Text = $folder }
}))
}
# Registry-driven file-browse wiring: each output provider optionally declares
# a FileBrowseControls array in its Add-DocumentationOutputProvider hashtable
# (see e.g. DocumentationOutputHTML.ps1). Any browse button in the XAML whose
# name matches an entry gets wired here — adding a new provider does not
# require editing this file. Buttons whose XAML controls are absent (e.g. a
# provider that declares controls the current XAML doesn't include) are
# ignored by Add-AvaloniaDocumentationFileBrowse's null-check.
foreach($output in [DocumentationRegistry]::Outputs) {
if(-not $output.PSObject.Properties['FileBrowseControls']) { continue }
foreach($fb in @($output.FileBrowseControls)) {
$params = @{
HostType = $hostType
Form = $form
ButtonName = $fb.Button
TextName = $fb.TextBox
Title = $fb.Title
FilterName = $fb.FilterName
FilterPattern = $fb.FilterPattern
}
if($fb.Save) { $params['Save'] = $true }
if($fb.Extension) { $params['Extension'] = $fb.Extension }
Add-AvaloniaDocumentationFileBrowse @params
}
}
# Select/deselect-all moved into the DataGrid column header.
$headerCb = Initialize-AvaloniaGridSelectAllHeader -Grid $script:dgBulkDocObjects -BindingProperty 'Selected' -InitiallyChecked $true
$script:_bulkDocFormState.HeaderCb = $headerCb
if ($rbGroup) {
$rbGroup.add_IsCheckedChanged((ConvertTo-AvaloniaEventScriptBlock {
param($s, $e)
if (-not $s.IsChecked) { return }
$script:bulkDocMode = "Group"
Update-BulkDocumentationObjectList
$st = $script:_bulkDocFormState
if ($st -and $st.HeaderCb) { $st.HeaderCb.IsChecked = $true }
}))
}
if ($rbType) {
$rbType.add_IsCheckedChanged((ConvertTo-AvaloniaEventScriptBlock {
param($s, $e)
if (-not $s.IsChecked) { return }
$script:bulkDocMode = "Type"
Update-BulkDocumentationObjectList
$st = $script:_bulkDocFormState
if ($st -and $st.HeaderCb) { $st.HeaderCb.IsChecked = $true }
}))
}
if ($btnDoc) {
$btnDoc.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_bulkDocFormState
if (-not $st) { return }
$selection = if($script:bulkDocMode -eq "Object") { Get-BulkDocumentationSelectedObjects } else { Get-BulkDocumentationSelectedIds }
$unit = if ($script:bulkDocMode -eq "Object") { "policy" } elseif ($script:bulkDocMode -eq "Type") { "policy type" } else { "object group" }
if ($selection.Count -eq 0) {
$ui.ShowMessageBox("Select at least one $unit to document.", "Bulk Documentation", "OK", "Warning") | Out-Null
return
}
# Read the selected output format
$outputValue = $null
if ($st.CbType -and $st.CbType.SelectedItem) { $outputValue = [string]$st.CbType.SelectedItem.Value }
if (-not $outputValue) {
$ui.ShowMessageBox("Select an output format.", "Bulk Documentation", "OK", "Warning") | Out-Null
return
}
$folder = if ($st.TxtFolder) { [string]$st.TxtFolder.Text } else { $null }
if ($folder) { $folder = $folder.Trim().Trim('"').Trim("'") }
$options = Get-AvaloniaDocumentationOptions $st.HostType $st.Form
if (-not $folder) {
# Registry-driven fallback: ask the provider where it stores
# its output path and whether that path is a folder. Same
# pattern as the WPF twin — adding a provider needs no change
# here, just PrimaryPathOption/PathIsFolder on registration.
$provider = [DocumentationRegistry]::FindOutput($outputValue)
$selectedPath = if($provider -and $provider.PSObject.Properties['PrimaryPathOption'] -and $provider.PrimaryPathOption) {
$providerOpts = $options.Outputs[$outputValue]
if($providerOpts) { $providerOpts[$provider.PrimaryPathOption] }
}
if($selectedPath) {
$folder = if($provider -and $provider.PSObject.Properties['PathIsFolder'] -and $provider.PathIsFolder) { $selectedPath }
else { Split-Path -Parent $selectedPath }
}
if(-not $folder) { $folder = [Environment]::GetFolderPath('MyDocuments') }
}
try { $folder = [IO.Path]::GetFullPath($folder) }
catch {
$ui.ShowMessageBox("Invalid output folder path: $($_.Exception.Message)", "Bulk Documentation", "OK", "Error") | Out-Null
return
}
$language = [string]$options.Language
if (-not $language) { $language = 'en' }
$defaultName = "%Organization%-%Date%"
if(-not $options.Outputs.csv.CSVDocumentationPath) { $options.Outputs.csv.CSVDocumentationPath = $folder }
if(-not $options.Outputs.json.JSONDocumentName) { $options.Outputs.json.JSONDocumentName = Join-Path $folder "$defaultName.json" }
if(-not $options.Outputs.html.HTMLDocumentName) { $options.Outputs.html.HTMLDocumentName = Join-Path $folder "$defaultName.html" }
# Confluence storage format is XHTML, so .html keeps it openable in a browser.
if(-not $options.Outputs.atlassian.AtlassianDocumentName) { $options.Outputs.atlassian.AtlassianDocumentName = Join-Path $folder "$defaultName.html" }
if(-not $options.Outputs.md.MDDocumentName) { $options.Outputs.md.MDDocumentName = Join-Path $folder "$defaultName.md" }
if(-not $options.Outputs.word.WordDocumentName) { $options.Outputs.word.WordDocumentName = Join-Path $folder "$defaultName.docx" }
# Persist UI defaults for next time. The public call below receives
# the complete options object and never depends on these settings.
Save-SettingStoreValue "Documentation" "OutputType" $outputValue
Save-SettingStoreValue "Documentation" "OutputFolder" $folder
Save-DocumentationOptionsDefaults $options
$startParams = @{
OutputFormat = $outputValue
Language = $language
Options = $options
}
if ($script:bulkDocMode -ne "Object" -and $options.SourceFolder) { $startParams.SourceFolder = $options.SourceFolder }
if ($script:bulkDocMode -eq "Type") {
$startParams.PolicyType = $selection
} elseif ($script:bulkDocMode -eq "Group") {
$startParams.PolicyGroup = $selection
}
Write-Log "Documentation starting. Mode=$script:bulkDocMode Format=$outputValue Folder=$folder Selection=$($selection -join ',')"
try {
if($script:bulkDocMode -eq "Object") {
[void]($selection | Start-GraphBulkDocumentation @startParams)
} else {
[void](Start-GraphBulkDocumentation @startParams)
}
Write-Status ""
$ui.ShowMessageBox(("Documentation finished. Output folder:`n{0}" -f $folder), "Bulk Documentation", "OK", "Information") | Out-Null
} catch {
Write-LogError "Bulk documentation failed" $_.Exception
Write-Status ""
$ui.ShowMessageBox("Bulk documentation failed: $($_.Exception.Message)", "Bulk Documentation", "OK", "Error") | Out-Null
}
}))
}
if ($btnPreview) {
$btnPreview.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$selection = if($script:bulkDocMode -eq "Object") { Get-BulkDocumentationSelectedObjects } else { Get-BulkDocumentationSelectedIds }
if ($selection.Count -eq 0) { return }
$st = $script:_bulkDocFormState
if (-not $st) { return }
$options = Get-AvaloniaDocumentationOptions $st.HostType $st.Form
$params = if ($script:bulkDocMode -eq 'Type') { @{PolicyType=$selection} } elseif($script:bulkDocMode -eq 'Group') { @{PolicyGroup=$selection} } else { @{} }
if($script:bulkDocMode -eq 'Object') {
$policies = $selection
} elseif ($options.SourceFolder) {
$options.SourceTenantUnavailable = $true
Initialize-DocumentationSourceTenantContext -SourceFolder $options.SourceFolder
$policies = Get-DocumentationPoliciesFromSourceFolder -SourceFolder $options.SourceFolder @params
} else {
$policies = Get-GraphPolicies @params
}
$script:bulkDocPreview = @($policies | Select-Object -First 5 | Get-GraphDocumentation -Language $options.Language -Options $options)
if ($st.TxtRaw) { $st.TxtRaw.Text = ($script:bulkDocPreview | ConvertTo-Json -Depth 10) }
}))
}
if ($btnCopyBasic) {
$btnCopyBasic.add_Click((ConvertTo-AvaloniaEventScriptBlock {
@($script:bulkDocPreview | ForEach-Object BasicInfo) | Select-Object Name,Value | ConvertTo-Csv -NoTypeInformation | Set-Clipboard
}))
}
if ($btnCopySettings) {
$btnCopySettings.add_Click((ConvertTo-AvaloniaEventScriptBlock {
@($script:bulkDocPreview | ForEach-Object FilteredSettings) | ConvertTo-Csv -NoTypeInformation | Set-Clipboard
}))
}
if ($btnCopyRaw) {
$btnCopyRaw.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_bulkDocFormState
if ($st -and $st.TxtRaw) { [string]$st.TxtRaw.Text | Set-Clipboard }
}))
}
if ($btnClose) {
$btnClose.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$script:dgBulkDocObjects = $null
$script:bulkDocRows = $null
$script:bulkDocPolicyObjects = $null
$script:_bulkDocFormState = $null
Close-TopModalObject
}))
}
$form.add_KeyDown((ConvertTo-AvaloniaEventScriptBlock {
param($s, $e)
if ($e.Key -eq [Avalonia.Input.Key]::Escape) {
$script:dgBulkDocObjects = $null
$script:bulkDocRows = $null
$script:bulkDocPolicyObjects = $null
$script:_bulkDocFormState = $null
Close-TopModalObject
$e.Handled = $true
}
}))
$title = if($script:bulkDocMode -eq 'Object') { 'Document' } else { 'Bulk Documentation' }
$ui.ShowModalForm($title, $form, $true)
}
# Compatibility wrapper for the main-view Document button. The shared form
# switches to Object mode and displays the selected policies.
function Show-IntuneManagerDocumentForm
{
param([object[]]$PolicyObject)
Show-GraphBulkDocumentationForm -PolicyObject $PolicyObject
}
@@ -0,0 +1,269 @@
# Avalonia port of the Bulk Export dialog from
# UI/WPF/Extensions/IntuneManagerUI.ps1 (Show-GraphBulkExportForm + helpers).
# New file because Bulk Export is a self-contained subsystem and the WPF
# original sits in the giant IntuneManagerUI file we're trying not to grow
# further (architecture rule R9).
function Update-BulkExportObjectList
{
if (-not $script:dgBulkExportObjects) { return }
$rows = [System.Collections.Generic.List[BulkExportRowItem]]::new()
if ($script:bulkExportMode -eq "Type") {
$exportableGroupIds = @($script:bulkExportExportableGroups | ForEach-Object { $_.Id })
$sortedTypes = $script:IntuneTypes |
Where-Object { $_.PolicyGroup -and ($exportableGroupIds -contains $_.PolicyGroup.Id) } |
Sort-Object Title
foreach ($intuneType in $sortedTypes) {
$rows.Add([BulkExportRowItem]@{
Title = [string]$intuneType.Title
Selected = $true
ObjectGroup = $null
ObjectType = $intuneType
})
}
} else {
$sortedGroups = $script:bulkExportExportableGroups | Sort-Object Title
foreach ($intuneGroup in $sortedGroups) {
$defaultSelected = ?? $intuneGroup.BulkExport $true
$rows.Add([BulkExportRowItem]@{
Title = [string]$intuneGroup.Title
Selected = [bool]$defaultSelected
ObjectGroup = $intuneGroup
ObjectType = $null
})
}
}
$script:bulkExportRows = @($rows)
$script:dgBulkExportObjects.ItemsSource = $script:bulkExportRows
}
function Get-BulkExportSelectedIds
{
if ($null -eq $script:bulkExportRows) { return @() }
if ($script:bulkExportMode -eq "Type") {
return @($script:bulkExportRows |
Where-Object { $_.Selected -and $_.ObjectType -and $_.ObjectType.Id } |
ForEach-Object { $_.ObjectType.Id })
}
return @($script:bulkExportRows |
Where-Object { $_.Selected -and $_.ObjectGroup -and $_.ObjectGroup.Id } |
ForEach-Object { $_.ObjectGroup.Id })
}
function Show-GraphBulkExportForm
{
# CORRECTION (2026-08-25): the note that used to sit here had it exactly
# backwards. Inside Avalonia handlers, $script: variables and bare
# module-function lookups DO resolve - it is the function-LOCAL captures
# that are stripped by ConvertTo-AvaloniaEventScriptBlock. Handler state
# therefore lives in $script:_bulkExportFormState.
$ui = $script:UIProvider
$form = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/BulkExportForm.axaml'))
if (-not $form) { return }
$hostType = Get-AvaloniaHost
$script:dgBulkExportObjects = $hostType::FindByName($form, 'dgObjectsToExport')
$txtPath = $hostType::FindByName($form, 'txtExportPath')
$btnBrowse = $hostType::FindByName($form, 'browseExportPath')
$txtFilter = $hostType::FindByName($form, 'txtExportNameFilter')
$chkAssign = $hostType::FindByName($form, 'chkExportAssignments')
$chkCompany = $hostType::FindByName($form, 'chkAddCompanyName')
$txtNested = $hostType::FindByName($form, 'txtNestedGroupLevels')
$rbGroup = $hostType::FindByName($form, 'rbBulkExportViewGroup')
$rbType = $hostType::FindByName($form, 'rbBulkExportViewType')
$btnExport = $hostType::FindByName($form, 'btnExport')
$btnClose = $hostType::FindByName($form, 'btnClose')
$exportSettings = [IntuneManagerExportSettings]::new()
# Handlers are re-bound to the module and lose function-local captures, so
# every control they touch lives here instead (same pattern as Bulk
# Compare). Without this the Export, Close, Escape and Browse handlers all
# threw on their first line and the dialog was unusable.
$script:_bulkExportFormState = [ordered]@{
TxtPath = $null; TxtFilter = $null; ChkAssign = $null; ChkCompany = $null
TxtNested = $null; HeaderCb = $null
ExportSettings = $exportSettings
}
# Register dynamic export-properties for every exportable policy type up
# front so toggling Group/API doesn't re-register and we never miss a type
# that only appears in the other view.
$script:bulkExportExportableGroups = @($script:IntuneGroups | Where-Object {
$_.Title -and (-not ($_.ShowButtons -is [Object[]]) -or $_.ShowButtons -contains "Export")
})
foreach ($intuneGroup in $script:bulkExportExportableGroups) {
$intuneGroup.PolicyTypes | Add-IntuneManagerExportProperties -ExportSettings $exportSettings
$intuneGroup.PolicyTypes | Add-IntuneManagerExportUIExtensions -Form $form
}
# Push current values into controls. IntuneManagerExportSettings has no
# INotifyPropertyChanged so two-way binding would render but never
# round-trip — read back imperatively on Export click.
if ($txtPath) { $txtPath.Text = [string]$exportSettings.ExportFolder }
if ($txtFilter) { $txtFilter.Text = [string]$exportSettings.Filter }
if ($chkAssign) { $chkAssign.IsChecked = [bool]$exportSettings.ExportAssignments }
if ($chkCompany) { $chkCompany.IsChecked = [bool]$exportSettings.AddCompanyName }
if ($txtNested) { $txtNested.Text = [string]$exportSettings.ExportNestedGroupLevels }
$script:_bulkExportFormState.TxtPath = $txtPath
$script:_bulkExportFormState.TxtFilter = $txtFilter
$script:_bulkExportFormState.ChkAssign = $chkAssign
$script:_bulkExportFormState.ChkCompany = $chkCompany
$script:_bulkExportFormState.TxtNested = $txtNested
$script:bulkExportMode = "Group"
Update-BulkExportObjectList
if ($btnBrowse) {
$btnBrowse.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_bulkExportFormState
if (-not $st) { return }
$folder = $script:UIProvider.ShowFolderPicker(([string]$st.TxtPath.Text), 'Select root folder for export')
if ($folder) {
$st.ExportSettings.ExportFolder = $folder
if ($st.TxtPath) { $st.TxtPath.Text = $folder }
}
}))
}
# Select/deselect-all column header — initialised here so the per-mode
# rebind below can simply flip it back on with $headerCb.IsChecked = $true.
$headerCb = Initialize-AvaloniaGridSelectAllHeader -Grid $script:dgBulkExportObjects -BindingProperty 'Selected' -InitiallyChecked $true
$script:_bulkExportFormState.HeaderCb = $headerCb
if ($rbGroup) {
$rbGroup.add_IsCheckedChanged((ConvertTo-AvaloniaEventScriptBlock {
param($s, $e)
if (-not $s.IsChecked) { return }
$script:bulkExportMode = "Group"
Update-BulkExportObjectList
$st = $script:_bulkExportFormState
if ($st -and $st.HeaderCb) { $st.HeaderCb.IsChecked = $true }
}))
}
if ($rbType) {
$rbType.add_IsCheckedChanged((ConvertTo-AvaloniaEventScriptBlock {
param($s, $e)
if (-not $s.IsChecked) { return }
$script:bulkExportMode = "Type"
Update-BulkExportObjectList
$st = $script:_bulkExportFormState
if ($st -and $st.HeaderCb) { $st.HeaderCb.IsChecked = $true }
}))
}
# NOTE: the "Save settings for batch job" button was removed 2026-06-12 per
# user decision — scheduled/CI runs use the public drivers directly
# (Start-GraphBulkExport -SettingsFile files can still be produced with
# Save-GraphBulkExportSettings from a script).
if ($btnExport) {
$btnExport.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_bulkExportFormState
if (-not $st) { return }
Set-BulkExportSettingsFromForm
$exportSettings = $st.ExportSettings
$selection = Get-BulkExportSelectedIds
$unit = if ($script:bulkExportMode -eq "Type") { "policy type" } else { "object group" }
if ($selection.Count -eq 0) {
$ui.ShowMessageBox("Select at least one $unit to export.", "Bulk Export", "OK", "Warning") | Out-Null
return
}
if ([string]::IsNullOrWhiteSpace($exportSettings.ExportFolder)) {
$ui.ShowMessageBox("Select an export folder.", "Bulk Export", "OK", "Warning") | Out-Null
return
}
try { $resolvedPath = [IO.Path]::GetFullPath($exportSettings.ExportFolder) }
catch {
$ui.ShowMessageBox("Invalid export folder path: $($_.Exception.Message)", "Bulk Export", "OK", "Error") | Out-Null
return
}
$exportSettings.ExportFolder = $resolvedPath
Write-Log "Bulk export starting. Folder: $resolvedPath"
$startParams = @{ ExportSettings = $exportSettings }
if ($script:bulkExportMode -eq "Type") {
$startParams.PolicyType = $selection
} else {
$startParams.PolicyGroup = $selection
}
try {
$summary = Start-GraphBulkExport @startParams
Write-Status ""
$msg = ("Exported {0} policies across {1} types ({2} failed) in {3:hh\:mm\:ss}.`n`nFolder:`n{4}" -f `
$summary.Policies, $summary.Types, $summary.Failed, $summary.Duration, $resolvedPath)
$unknownNote = Get-IntuneUnknownSelectorSummary $summary.UnknownSelectors
if ($unknownNote) { $msg += "`n`n$unknownNote" }
$ui.ShowMessageBox($msg, "Bulk Export", "OK", "Information") | Out-Null
} catch {
Write-LogError "Bulk export failed" $_.Exception
Write-Status ""
$ui.ShowMessageBox("Bulk export failed: $($_.Exception.Message)", "Bulk Export", "OK", "Error") | Out-Null
}
$script:dgBulkExportObjects = $null
$script:bulkExportRows = $null
$script:_bulkExportFormState = $null
Close-TopModalObject
}))
}
if ($btnClose) {
$btnClose.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$script:dgBulkExportObjects = $null
$script:bulkExportRows = $null
$script:_bulkExportFormState = $null
Close-TopModalObject
}))
}
$form.add_KeyDown((ConvertTo-AvaloniaEventScriptBlock {
param($s, $e)
if ($e.Key -eq [Avalonia.Input.Key]::Escape) {
$script:dgBulkExportObjects = $null
$script:bulkExportRows = $null
$script:_bulkExportFormState = $null
Close-TopModalObject
$e.Handled = $true
}
}))
$ui.ShowModalForm("Bulk Export", $form, $true)
}
function Set-BulkExportSettingsFromForm
{
# Pulls the live form values into the settings object. A module function
# rather than a captured scriptblock: handlers cannot reach captures.
$st = $script:_bulkExportFormState
if (-not $st -or -not $st.ExportSettings) { return }
$s = $st.ExportSettings
if ($st.TxtPath) {
$raw = [string]$st.TxtPath.Text
if ($raw) { $raw = $raw.Trim().Trim('"').Trim("'") }
$s.ExportFolder = $raw
}
if ($st.TxtFilter) { $s.Filter = [string]$st.TxtFilter.Text }
if ($st.ChkAssign) { $s.ExportAssignments = [bool]$st.ChkAssign.IsChecked }
if ($st.ChkCompany) { $s.AddCompanyName = [bool]$st.ChkCompany.IsChecked }
if ($st.TxtNested) {
$parsed = 0
if ([int]::TryParse([string]$st.TxtNested.Text, [ref]$parsed) -and $parsed -ge 1) {
$s.ExportNestedGroupLevels = $parsed
}
}
}
@@ -0,0 +1,261 @@
# Avalonia port of the Bulk Import dialog from
# UI/WPF/Extensions/IntuneManagerUI.ps1 (Show-GraphBulkImportForm).
# New file because Bulk Import is a self-contained subsystem and the WPF
# original sits in the giant IntuneManagerUI file we're trying not to grow
# further (architecture rule R9).
#
# Slice 4b: core Bulk Import form. Per-policy-type Add-IntuneManagerImportUIExtensions
# wrapper is invoked but currently a no-op — no class extension defines
# AddUIImportExtensions in either tree.
#
# Thin caller of the public Start-GraphBulkImport driver (R10). The old
# "Save settings for batch job" button was removed 2026-06-12 per user
# decision — scheduled runs call the driver directly.
function Update-BulkImportObjectList
{
if (-not $script:dgBulkImportObjects) { return }
$rows = [System.Collections.Generic.List[BulkImportRowItem]]::new()
$sortedGroups = $script:bulkImportImportableGroups | Sort-Object Title
foreach ($intuneGroup in $sortedGroups) {
$defaultSelected = ?? $intuneGroup.BulkImport $true
$rows.Add([BulkImportRowItem]@{
Title = [string]$intuneGroup.Title
Selected = [bool]$defaultSelected
ObjectGroup = $intuneGroup
})
}
$script:bulkImportRows = @($rows)
$script:dgBulkImportObjects.ItemsSource = $script:bulkImportRows
}
function Show-GraphBulkImportForm
{
$ui = $script:UIProvider
$form = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/BulkImportForm.axaml'))
if (-not $form) { return }
$hostType = Get-AvaloniaHost
$script:dgBulkImportObjects = $hostType::FindByName($form, 'dgObjectsToImport')
$txtPath = $hostType::FindByName($form, 'txtImportPath')
$btnBrowse = $hostType::FindByName($form, 'browseImportPath')
$txtFilter = $hostType::FindByName($form, 'txtImportNameFilter')
$lblMigInfo = $hostType::FindByName($form, 'lblMigrationTableInfo')
$chkScopes = $hostType::FindByName($form, 'chkImportScopes')
$chkAssign = $hostType::FindByName($form, 'chkImportAssignments')
$chkReplaceDeps = $hostType::FindByName($form, 'chkReplaceDependencyIDs')
$cbImportType = $hostType::FindByName($form, 'cbImportType')
$btnImport = $hostType::FindByName($form, 'btnImport')
$btnClose = $hostType::FindByName($form, 'btnClose')
$importSettings = [IntuneManagerImportSettings]::new()
# Handlers lose function-local captures when module-bound, so every
# control they touch lives in module scope (Bulk Compare pattern).
$script:_bulkImportFormState = [ordered]@{
TxtPath = $txtPath
TxtFilter = $txtFilter
ChkScopes = $chkScopes
ChkAssign = $chkAssign
ChkReplaceDeps = $chkReplaceDeps
LblMigInfo = $lblMigInfo
CbImportType = $cbImportType
ImportSettings = $importSettings
}
$script:bulkImportImportableGroups = @($script:IntuneGroups | Where-Object {
$_.Title -and (-not ($_.ShowButtons -is [Object[]]) -or $_.ShowButtons -contains "Import")
})
# Per-type UI extensions (no-op today — no class defines
# AddUIImportExtensions — but matches the WPF call site so future
# extensions hook in automatically).
foreach ($intuneGroup in $script:bulkImportImportableGroups) {
$intuneGroup.PolicyTypes | Add-IntuneManagerImportUIExtensions -Form $form
}
# Push current values into controls. IntuneManagerImportSettings has no
# INotifyPropertyChanged so two-way binding would render but never
# round-trip — read back imperatively on Import click.
if ($txtPath) { $txtPath.Text = [string]$importSettings.ImportFolder }
if ($chkScopes) { $chkScopes.IsChecked = [bool]$importSettings.ImportScopeTags }
if ($chkAssign) { $chkAssign.IsChecked = [bool]$importSettings.ImportAssignments }
if ($chkReplaceDeps) { $chkReplaceDeps.IsChecked = [bool]$importSettings.ReplaceDependencyIDs }
# Import-type ComboBox: NameValueObject -> SettingsListItem (same convention
# as Slice 3e single-policy import).
$importTypeItems = @()
foreach ($opt in $script:IntuneManagerImportOptions) {
$importTypeItems += [SettingsListItem]@{
Name = [string]$opt.Name
Value = [string]$opt.Value
}
}
if ($cbImportType) {
$cbImportType.ItemsSource = $importTypeItems
$current = $importTypeItems | Where-Object { $_.Value -eq [string]$importSettings.ImportType } | Select-Object -First 1
if (-not $current -and $importTypeItems.Count -gt 0) { $current = $importTypeItems[0] }
if ($current) { $cbImportType.SelectedItem = $current }
}
Update-BulkImportObjectList
# Migration-info refresh: reads the Groups/MigrationTable.json under the
# given folder, sets sameTenant -> ReplaceDependencyIDs default. Same
# behaviour as Slice 3e's loadFromFolder, minus the policy-file scan.
# Runs here (not earlier) because it reads the module-scope state.
if ($importSettings.ImportFolder) {
Update-BulkImportMigrationInfo -Folder $importSettings.ImportFolder
}
if ($btnBrowse) {
$btnBrowse.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_bulkImportFormState
if (-not $st) { return }
$folder = $script:UIProvider.ShowFolderPicker(([string]$st.TxtPath.Text), 'Select root folder for import')
if ($folder) {
$st.ImportSettings.ImportFolder = $folder
if ($st.TxtPath) { $st.TxtPath.Text = $folder }
Update-BulkImportMigrationInfo -Folder $folder
}
}))
}
if ($cbImportType) {
$cbImportType.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock {
param($s, $e)
$st = $script:_bulkImportFormState
if ($st -and $s.SelectedItem) {
$st.ImportSettings.ImportType = [string]$s.SelectedItem.Value
}
}))
}
# Select/deselect-all moved into the DataGrid column header.
Initialize-AvaloniaGridSelectAllHeader -Grid $script:dgBulkImportObjects -BindingProperty 'Selected' -InitiallyChecked $true | Out-Null
if ($btnImport) {
$btnImport.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_bulkImportFormState
if (-not $st) { return }
$importSettings = $st.ImportSettings
$importFolder = if ($st.TxtPath) { [string]$st.TxtPath.Text } else { '' }
if ($importFolder) { $importFolder = $importFolder.Trim().Trim('"').Trim("'") }
try { $importFolder = Expand-FileName $importFolder } catch { }
if (-not [IO.Directory]::Exists($importFolder)) {
$ui.ShowMessageBox("Import folder not found:`n$importFolder", "Bulk Import", "OK", "Error") | Out-Null
return
}
$importSettings.ImportFolder = $importFolder
if ($st.ChkScopes) { $importSettings.ImportScopeTags = [bool]$st.ChkScopes.IsChecked }
if ($st.ChkAssign) { $importSettings.ImportAssignments = [bool]$st.ChkAssign.IsChecked }
if ($st.ChkReplaceDeps) { $importSettings.ReplaceDependencyIDs = [bool]$st.ChkReplaceDeps.IsChecked }
$selectedGroups = @($script:bulkImportRows | Where-Object { $_.Selected -and $_.ObjectGroup })
if ($selectedGroups.Count -eq 0) {
$ui.ShowMessageBox("No object types selected.", "Bulk Import", "OK", "Warning") | Out-Null
return
}
Save-SettingStoreValue "" "LastUsedRoot" $importFolder
$nameFilter = if ($st.TxtFilter) { ([string]$st.TxtFilter.Text).Trim() } else { '' }
# The UI is a thin caller of the public driver (R10) — the same
# import runs headless via Start-GraphBulkImport, which also
# persists the checkbox settings (the import pipeline reads them
# via Get-SettingValue) and honours ClearCacheBeforeExportImport.
Write-Status "Bulk import" -Block
$totalImported = 0
try {
$summary = Start-GraphBulkImport -ImportFolder $importFolder -Filter $nameFilter `
-PolicyGroup @($selectedGroups | ForEach-Object { $_.ObjectGroup.ID }) `
-ImportAssignments $importSettings.ImportAssignments `
-ImportScopeTags $importSettings.ImportScopeTags `
-ReplaceDependencyIDs $importSettings.ReplaceDependencyIDs `
-ImportType $importSettings.ImportType
$totalImported = $summary.Imported
}
catch {
Write-LogError "Bulk import failed" $_.Exception
$ui.ShowMessageBox("Bulk import failed:`n`n$($_.Exception.Message)", "Bulk Import", "OK", "Error") | Out-Null
}
Write-Status ""
$unknownNote = Get-IntuneUnknownSelectorSummary $summary.UnknownSelectors
if ($totalImported -eq 0) {
$msg = "No objects were imported.`nVerify the import folder and exported files."
if ($unknownNote) { $msg += "`n`n$unknownNote" }
$ui.ShowMessageBox($msg, "Bulk Import", "OK", "Warning") | Out-Null
} else {
$msg = ("Imported {0} object(s) from:`n{1}" -f $totalImported, $importFolder)
if ($unknownNote) { $msg += "`n`n$unknownNote" }
$ui.ShowMessageBox($msg, "Bulk Import", "OK", "Information") | Out-Null
if ($script:IntuneManagerSelectedObject) {
Invoke-IntuneActivateObject $script:IntuneManagerSelectedObject | Out-Null
}
}
$script:dgBulkImportObjects = $null
$script:bulkImportRows = $null
$script:_bulkImportFormState = $null
Show-ModalObject
}))
}
if ($btnClose) {
$btnClose.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$script:dgBulkImportObjects = $null
$script:bulkImportRows = $null
$script:_bulkImportFormState = $null
Show-ModalObject
}))
}
$form.add_KeyDown((ConvertTo-AvaloniaEventScriptBlock {
param($s, $e)
if ($e.Key -eq [Avalonia.Input.Key]::Escape) {
$script:dgBulkImportObjects = $null
$script:bulkImportRows = $null
$script:_bulkImportFormState = $null
Show-ModalObject
$e.Handled = $true
}
}))
$ui.ShowModalForm("Bulk Import", $form, $true)
}
function Update-BulkImportMigrationInfo
{
# Reads MigrationTable.json for the folder and reflects same-tenant state
# on the form. Module function, not a captured scriptblock - handlers
# cannot reach captures.
param([string]$Folder)
$st = $script:_bulkImportFormState
if (-not $st) { return }
if (-not $Folder -or -not [IO.Directory]::Exists($Folder)) {
if ($st.LblMigInfo) { $st.LblMigInfo.Text = '' }
return
}
try {
$migrationInfo, $sameTenant = Get-MigrationTableInfo $Folder $script:OrganizationId
$st.ImportSettings.SameTenant = [bool]$sameTenant
if ($st.LblMigInfo) { $st.LblMigInfo.Text = [string]$migrationInfo }
if ($st.ChkReplaceDeps) {
$st.ChkReplaceDeps.IsEnabled = (-not $sameTenant)
$st.ChkReplaceDeps.IsChecked = (-not $sameTenant)
}
} catch {
Write-LogError "Failed to read migration table info" $_.Exception
}
}
@@ -0,0 +1,368 @@
# Avalonia port of the Bulk Scope Tag dialog from
# UI/WPF/Extensions/IntuneManagerUI.ps1 (Show-GraphBulkScopeTagForm + helpers).
# New file because Bulk Scope Tags is a self-contained subsystem and the WPF
# original sits in the giant IntuneManagerUI file we're trying not to grow
# further (architecture rule R9).
#
# Slice 4d: Bulk Scope Tags. Largest of the bulk forms — combines the
# Group/API DataGrid (Slice 4a-style) with the dual-list scope-tag picker
# from the Copy dialog (Slice 3d3 — see [[CopyDialogScopeTagItem]]).
# Driver Set-GraphBulkScopeTags lives in Public/, so the click handler just
# preflight-validates, prompts, and forwards to the cmdlet.
#
# Get-BulkScopeTagCatalog lives in Internal/IntuneScopeTags.ps1 and is
# shared with the WPF tree.
function Update-BulkScopeTagObjectList
{
if (-not $script:dgBulkScopeTagObjects) { return }
$rows = [System.Collections.Generic.List[BulkScopeTagRowItem]]::new()
if ($script:bulkScopeTagMode -eq "Type") {
$sortedTypes = $script:bulkScopeTagEligibleTypes | Sort-Object Title
foreach ($pt in $sortedTypes) {
$rows.Add([BulkScopeTagRowItem]@{
Title = [string]$pt.Title
Selected = $true
ObjectGroup = $null
ObjectType = $pt
})
}
} else {
$sortedGroups = $script:bulkScopeTagEligibleGroups | Sort-Object Title
foreach ($grp in $sortedGroups) {
$rows.Add([BulkScopeTagRowItem]@{
Title = [string]$grp.Title
Selected = $true
ObjectGroup = $grp
ObjectType = $null
})
}
}
$script:bulkScopeTagRows = @($rows)
$script:dgBulkScopeTagObjects.ItemsSource = $script:bulkScopeTagRows
}
function Get-BulkScopeTagSelectedObjectIds
{
if ($null -eq $script:bulkScopeTagRows) { return @() }
if ($script:bulkScopeTagMode -eq "Type") {
return @($script:bulkScopeTagRows |
Where-Object { $_.Selected -and $_.ObjectType -and $_.ObjectType.Id } |
ForEach-Object { $_.ObjectType.Id })
}
return @($script:bulkScopeTagRows |
Where-Object { $_.Selected -and $_.ObjectGroup -and $_.ObjectGroup.Id } |
ForEach-Object { $_.ObjectGroup.Id })
}
# Rebuild both dual-list collections from the catalog + the AssignedIds set.
# Reads module-scope state so it is safe to call from event handlers (no
# captured locals / GetNewClosure — see [[avalonia-closure-dynamic-module]]).
function Sync-BulkScopeTagLists
{
$st = $script:_bulkScopeTagState
if (-not $st) { return }
$state = $st.State
$state.AvailableCollection.Clear()
$state.AssignedCollection.Clear()
foreach ($tag in $state.AllScopeTags) {
if ($state.AssignedIds.Contains([string]$tag.Id)) { [void]$state.AssignedCollection.Add($tag) }
else { [void]$state.AvailableCollection.Add($tag) }
}
}
function Move-BulkScopeTagSelection
{
param([string]$Action, [string[]]$Ids)
$st = $script:_bulkScopeTagState
if (-not $st -or $Ids.Count -eq 0) { return }
$state = $st.State
if ($Action -eq 'assign') { foreach ($id in $Ids) { [void]$state.AssignedIds.Add([string]$id) } }
else { foreach ($id in $Ids) { [void]$state.AssignedIds.Remove([string]$id) } }
Sync-BulkScopeTagLists
}
function Show-GraphBulkScopeTagForm
{
$ui = $script:UIProvider
$form = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/BulkScopeTagForm.axaml'))
if (-not $form) { return }
$hostType = Get-AvaloniaHost
$script:dgBulkScopeTagObjects = $hostType::FindByName($form, 'dgBulkScopeTagObjects')
$lstAvail = $hostType::FindByName($form, 'lstBulkScopeTagAvailable')
$lstSelected = $hostType::FindByName($form, 'lstBulkScopeTagSelected')
$txtStatus = $hostType::FindByName($form, 'txtBulkScopeTagStatus')
$rbAdd = $hostType::FindByName($form, 'rbBulkScopeTagAdd')
$rbReplace = $hostType::FindByName($form, 'rbBulkScopeTagReplace')
$rbRemove = $hostType::FindByName($form, 'rbBulkScopeTagRemove')
$chkClean = $hostType::FindByName($form, 'chkBulkScopeTagCleanupOrphans')
$txtFilter = $hostType::FindByName($form, 'txtBulkScopeTagNameFilter')
$rbGroup = $hostType::FindByName($form, 'rbBulkScopeTagViewGroup')
$rbType = $hostType::FindByName($form, 'rbBulkScopeTagViewType')
$btnApply = $hostType::FindByName($form, 'btnBulkScopeTagApply')
$btnClose = $hostType::FindByName($form, 'btnBulkScopeTagClose')
$scopeTagSettings = [IntuneManagerScopeTagSettings]::new()
# Eligible types/groups: same gate as the WPF original (must have a
# ScopeTagProperty defined on the IntuneType).
$script:bulkScopeTagEligibleTypes = @($script:IntuneTypes | Where-Object { $_.ScopeTagProperty })
$script:bulkScopeTagEligibleGroups = @($script:IntuneGroups | Where-Object {
$_.Title -and ($_.PolicyTypes | Where-Object { $_.ScopeTagProperty })
})
$script:bulkScopeTagMode = "Group"
Update-BulkScopeTagObjectList
$headerCb = Initialize-AvaloniaGridSelectAllHeader -Grid $script:dgBulkScopeTagObjects -BindingProperty 'Selected' -InitiallyChecked $true
# ── Scope tag picker (dual-list) state ──────────────────────────────────
# ObservableCollections bound to the two ListBoxes, a HashSet of selected
# ids as source of truth, rebuilt by Sync-BulkScopeTagLists /
# Move-BulkScopeTagSelection.
$state = [PSCustomObject]@{
AllScopeTags = @()
AssignedIds = [System.Collections.Generic.HashSet[string]]::new()
AvailableCollection = [System.Collections.ObjectModel.ObservableCollection[object]]::new()
AssignedCollection = [System.Collections.ObjectModel.ObservableCollection[object]]::new()
}
# Module-scope state so event handlers resolve it at click time (no captured
# locals / GetNewClosure / $script:UIProvider — see [[avalonia-closure-dynamic-module]]).
$script:_bulkScopeTagState = @{
State = $state
HeaderCb = $headerCb
RbReplace = $rbReplace
RbRemove = $rbRemove
ChkClean = $chkClean
TxtFilter = $txtFilter
TxtStatus = $txtStatus
LstAvail = $lstAvail
LstSelected = $lstSelected
BtnApply = $btnApply
BtnClose = $btnClose
Settings = $scopeTagSettings
}
if ($rbGroup) {
$rbGroup.add_IsCheckedChanged({
param($s, $e)
if (-not $s.IsChecked) { return }
$script:bulkScopeTagMode = "Group"
Update-BulkScopeTagObjectList
$st = $script:_bulkScopeTagState
if ($st -and $st.HeaderCb) { $st.HeaderCb.IsChecked = $true }
})
}
if ($rbType) {
$rbType.add_IsCheckedChanged({
param($s, $e)
if (-not $s.IsChecked) { return }
$script:bulkScopeTagMode = "Type"
Update-BulkScopeTagObjectList
$st = $script:_bulkScopeTagState
if ($st -and $st.HeaderCb) { $st.HeaderCb.IsChecked = $true }
})
}
if ($lstAvail) { $lstAvail.ItemsSource = $state.AvailableCollection }
if ($lstSelected) { $lstSelected.ItemsSource = $state.AssignedCollection }
if ($txtStatus) { $txtStatus.Text = "Loading scope tags..." }
if ($lstAvail) {
$lstAvail.add_DoubleTapped({
try {
$st = $script:_bulkScopeTagState
if (-not $st -or $st.LstAvail.SelectedItems.Count -eq 0) { return }
$ids = @($st.LstAvail.SelectedItems | ForEach-Object { [string]$_.Id })
Move-BulkScopeTagSelection 'assign' $ids
} catch { Write-LogError "Bulk Scope Tag double-tap (available) failed" $_.Exception }
})
}
if ($lstSelected) {
$lstSelected.add_DoubleTapped({
try {
$st = $script:_bulkScopeTagState
if (-not $st -or $st.LstSelected.SelectedItems.Count -eq 0) { return }
$ids = @($st.LstSelected.SelectedItems | ForEach-Object { [string]$_.Id })
Move-BulkScopeTagSelection 'unassign' $ids
} catch { Write-LogError "Bulk Scope Tag double-tap (selected) failed" $_.Exception }
})
}
# Add / Remove buttons - same moves as the double-taps, but discoverable.
$btnAddTag = $hostType::FindByName($form, 'btnBulkScopeTagAddTag')
$btnRemoveTag = $hostType::FindByName($form, 'btnBulkScopeTagRemoveTag')
if ($btnAddTag) {
$btnAddTag.add_Click({
try {
$st = $script:_bulkScopeTagState
if (-not $st -or $st.LstAvail.SelectedItems.Count -eq 0) { return }
$ids = @($st.LstAvail.SelectedItems | ForEach-Object { [string]$_.Id })
Move-BulkScopeTagSelection 'assign' $ids
} catch { Write-LogError "Bulk Scope Tag Add button failed" $_.Exception }
})
}
if ($btnRemoveTag) {
$btnRemoveTag.add_Click({
try {
$st = $script:_bulkScopeTagState
if (-not $st -or $st.LstSelected.SelectedItems.Count -eq 0) { return }
$ids = @($st.LstSelected.SelectedItems | ForEach-Object { [string]$_.Id })
Move-BulkScopeTagSelection 'unassign' $ids
} catch { Write-LogError "Bulk Scope Tag Remove button failed" $_.Exception }
})
}
# Apply — preflight + confirm + Set-GraphBulkScopeTags. All work runs
# inside the click handler because Show-ModalForm is non-blocking.
if ($btnApply) {
$btnApply.add_Click({
$st = $script:_bulkScopeTagState
if (-not $st) { return }
try {
$state = $st.State
$scopeTagSettings = $st.Settings
# Action radio -> settings.Action
if ($st.RbReplace -and $st.RbReplace.IsChecked) { $scopeTagSettings.Action = "Replace" }
elseif ($st.RbRemove -and $st.RbRemove.IsChecked) { $scopeTagSettings.Action = "Remove" }
else { $scopeTagSettings.Action = "Add" }
$scopeTagSettings.CleanupOrphans = if ($st.ChkClean) { [bool]$st.ChkClean.IsChecked } else { $false }
$scopeTagSettings.Filter = if ($st.TxtFilter) { [string]$st.TxtFilter.Text } else { '' }
$scopeTagSettings.ScopeTagIds = @($state.AssignedIds)
$selectionIds = Get-BulkScopeTagSelectedObjectIds
$unit = if ($script:bulkScopeTagMode -eq "Type") { "policy type" } else { "object group" }
if ($selectionIds.Count -eq 0) {
Show-MessageBox "Select at least one $unit to update." "Bulk Scope Tags" "OK" "Warning" | Out-Null
return
}
if ($scopeTagSettings.ScopeTagIds.Count -eq 0 -and -not $scopeTagSettings.CleanupOrphans) {
Show-MessageBox "Pick at least one scope tag, or enable 'Cleanup orphans' for a pure cleanup pass." "Bulk Scope Tags" "OK" "Warning" | Out-Null
return
}
# Mass-wipe guard: Replace with no selected tags strips every
# tag (including Default) from every matched policy.
if ($scopeTagSettings.Action -eq "Replace" -and $scopeTagSettings.ScopeTagIds.Count -eq 0) {
$proceed = Show-MessageBox "Replace with no scope tags selected will REMOVE every tag (including Default) from every policy that matches the filter.`n`nMost Intune policies require the Default tag and the API will reject empty roleScopeTagIds - you may end up with a large failure count.`n`nContinue?" "Confirm wipe-all" "YesNo" "Warning"
if ($proceed -ne "Yes") { return }
}
# Default-missing soft guard. Add/Replace with tags selected
# but Default not included is usually a mistake.
if ($scopeTagSettings.Action -in @("Add","Replace") -and
$scopeTagSettings.ScopeTagIds.Count -gt 0 -and
$scopeTagSettings.ScopeTagIds -notcontains "0") {
$proceed = Show-MessageBox "The 'Default' scope tag (Id 0) is not in the selected list.`n`nIntune usually requires Default to be present; PATCH calls may fail for policies that end up without it.`n`nContinue anyway?" "Default scope tag missing" "YesNo" "Warning"
if ($proceed -ne "Yes") { return }
}
$tagNames = @($state.AllScopeTags |
Where-Object { $state.AssignedIds.Contains([string]$_.Id) } |
ForEach-Object { $_.Name })
$tagList = if ($tagNames.Count -gt 0) { $tagNames -join ", " } else { "(none - cleanup only)" }
$cleanup = if ($scopeTagSettings.CleanupOrphans) { "yes" } else { "no" }
$filterText = if ($scopeTagSettings.Filter) { $scopeTagSettings.Filter } else { "(none)" }
$confirmMsg = @"
About to update scope tags on every policy that matches:
Action : $($scopeTagSettings.Action)
Tags : $tagList
Cleanup orphans : $cleanup
Name filter : $filterText
$unit count : $($selectionIds.Count)
Continue?
"@
$confirm = Show-MessageBox $confirmMsg "Confirm Bulk Scope Tag update" "YesNo" "Warning"
if ($confirm -ne "Yes") { return }
# Disable both buttons while the run executes — a stray click
# would otherwise fire a parallel pass against the same set.
if ($st.BtnApply) { $st.BtnApply.IsEnabled = $false }
if ($st.BtnClose) { $st.BtnClose.IsEnabled = $false }
try {
$startParams = @{ ScopeTagSettings = $scopeTagSettings }
if ($script:bulkScopeTagMode -eq "Type") { $startParams.PolicyType = $selectionIds }
else { $startParams.PolicyGroup = $selectionIds }
$summary = Set-GraphBulkScopeTags @startParams
Write-Status ""
$msg = ("Scanned: {0}`nMatched filter: {1}`nUpdated: {2}`nNo change: {3}`nFailed: {4}`nDuration: {5:hh\:mm\:ss}" -f `
$summary.PoliciesScanned, $summary.PoliciesMatched, $summary.PoliciesUpdated, $summary.PoliciesSkipped, $summary.PoliciesFailed, $summary.Duration)
if ($st.TxtStatus) {
$st.TxtStatus.Text = ("Last run: updated {0}, no change {1}, failed {2}" -f $summary.PoliciesUpdated, $summary.PoliciesSkipped, $summary.PoliciesFailed)
}
$unknownNote = Get-IntuneUnknownSelectorSummary $summary.UnknownSelectors
if ($unknownNote) { $msg += "`n`n$unknownNote" }
Show-MessageBox $msg "Bulk Scope Tags" "OK" "Information" | Out-Null
if ($script:IntuneManagerSelectedObject) {
Invoke-IntuneActivateObject $script:IntuneManagerSelectedObject | Out-Null
}
} catch {
Write-LogError "Bulk Scope Tags run failed" $_.Exception
Show-MessageBox "Bulk Scope Tags run failed: $($_.Exception.Message)" "Bulk Scope Tags" "OK" "Error" | Out-Null
} finally {
if ($st.BtnApply) { $st.BtnApply.IsEnabled = $true }
if ($st.BtnClose) { $st.BtnClose.IsEnabled = $true }
}
} catch {
Write-LogError "Bulk Scope Tags Apply handler failed" $_.Exception
}
})
}
if ($btnClose) {
$btnClose.add_Click({
$script:dgBulkScopeTagObjects = $null
$script:bulkScopeTagRows = $null
$script:_bulkScopeTagState = $null
Show-ModalObject
})
}
$form.add_KeyDown({
param($s, $e)
if ($e.Key -eq [Avalonia.Input.Key]::Escape) {
$script:dgBulkScopeTagObjects = $null
$script:bulkScopeTagRows = $null
$script:_bulkScopeTagState = $null
Show-ModalObject
$e.Handled = $true
}
})
$ui.ShowModalForm("Bulk Scope Tags", $form, $true)
# Load the catalog after the form has rendered. Cache lookup is cheap;
# the live fetch falls back inside Get-BulkScopeTagCatalog when cold.
try {
$scopeTagLoadError = $null
$catalog = @(Get-BulkScopeTagCatalog -TokenId (Get-DefaultTokenId) -LoadError ([ref]$scopeTagLoadError))
$state.AllScopeTags = @($catalog | ForEach-Object {
[CopyDialogScopeTagItem]@{ Id = [string]$_.Id; Name = [string]$_.Name }
})
Sync-BulkScopeTagLists
if ($txtStatus) { $txtStatus.Text = "$($state.AllScopeTags.Count) scope tag(s) loaded" }
if ($scopeTagLoadError) {
$ui.ShowMessageBox("Could not load scope tags from the tenant.`n`n$scopeTagLoadError`n`nThe picker will only show 'Default'.", "Bulk Scope Tags", "OK", "Warning") | Out-Null
}
} catch {
Write-LogError "Bulk Scope Tags: failed to initialize scope tag picker" $_.Exception
if ($txtStatus) { $txtStatus.Text = "Scope tag loading failed" }
}
}
@@ -0,0 +1,682 @@
# Avalonia port of the Compare dialog from UI/WPF/Extensions/CompareUI.ps1
# (Show-GraphCompareForm). New file because Compare is a self-contained
# subsystem we don't want to graft onto the WPF CompareUI.ps1 file
# (architecture rule R9, R12).
#
# Slice 3d1 scope:
# - Intune-vs-Intune compare path only. The user must check 2 rows in
# dgIntuneManagerObjects before clicking Compare; if 1 is checked, the
# "browse second Intune object" path is stubbed (3d2/3d3 will add the
# ObjectPicker sub-modal).
#
# Slice 3d2 adds:
# - File compare branch — CompareFileOptions.axaml mounted into
# ccCompareInputOptions; browseCompareObject calls
# (Get-AvaloniaHost)::OpenFilePicker. File compare path in
# btnStartCompare loads the file via $source.PolicyType.GetObject
# and passes the result to Compare-PolicyObjects. Mirrors WPF lines
# 475-525 of UI/WPF/Extensions/CompareUI.ps1.
#
# Slice 3d3 adds:
# - Match-based row coloring via DataGrid.LoadingRow + Row.Foreground swap
# (Avalonia has no DataTrigger; Foreground inheritance cascades into the
# TextBlock cells, matching the WPF DataGridCell.Foreground swap).
# - btnCompareSave wired through (Get-AvaloniaHost)::SaveFilePicker +
# Get-CompareOutputProviderByExtension + $provider.FormatRows pipeline.
# - btnCompareCopy wired through Get-CompareCsvInfo | Set-Clipboard.
#
# Still deferred:
# - Browse-pick second Intune object (ObjectPicker sub-modal) — later slice.
#
# Data layer notes:
# - $script:comparisonTypes / $script:compareOutputProviders are live views onto
# [CompareRegistry] (registered at module load in Internal/Compare.ps1; the
# "doc" type self-registers from Internal/Documentation.ps1). Not bindable
# in Avalonia (avalonia-binding-needs-clr-types), so we project each
# into [SettingsListItem] (Name + Value) for ComboBox display and
# keep a parallel hashtable that maps Value -> original PSCustomObject
# so we can hand the source object back to Set-CompareRuntimeOptions.
# - Compare-PolicyObjects returns PSCustomObject rows; we project those
# into [CompareResultRowItem] before assigning DataGrid.ItemsSource.
function Invoke-IntuneManagerCompare
{
# Mirror of the WPF btnCompare handler in UI/WPF/Extensions/IntuneManagerUI.ps1:
# prefer rows the user has check-boxed; fall back to the highlighted row.
if (-not $script:dgIntuneManagerObjects) { return }
# Scan the grid's CURRENT (filtered) ItemsSource so a checked row hidden
# by the filter is never compared; fall back to the highlighted row.
$selectedRows = @(@($script:dgIntuneManagerObjects.ItemsSource) | Where-Object { $_ -and $_.IsSelected })
if ($selectedRows.Count -eq 0 -and $script:dgIntuneManagerObjects.SelectedItem) {
$selectedRows = @($script:dgIntuneManagerObjects.SelectedItem)
}
if ($selectedRows.Count -eq 0) { return }
# Show-GraphCompareForm wants the underlying IntunePolicyBase objects,
# not the IntuneObjectRowItem wrappers.
$policies = @($selectedRows | ForEach-Object { $_.Source } | Where-Object { $_ })
if ($policies.Count -eq 0) {
Write-Log "Compare: $($selectedRows.Count) row(s) selected but none carried a policy object" 3
return
}
Show-GraphCompareForm $policies
}
function Show-GraphCompareForm
{
param([object[]]$Policies)
$ui = $script:UIProvider
if (-not $Policies -or $Policies.Count -eq 0) { return }
$compareForm = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/CompareForm.axaml'))
if (-not $compareForm) { return }
$hostType = Get-AvaloniaHost
$tabCompare = $hostType::FindByName($compareForm, 'tabCompare')
$txtIntuneObject = $hostType::FindByName($compareForm, 'txtIntuneObject')
$cbCompareInput = $hostType::FindByName($compareForm, 'cbCompareInput')
$ccCompareInputOpts = $hostType::FindByName($compareForm, 'ccCompareInputOptions')
$cbCompareType = $hostType::FindByName($compareForm, 'cbCompareType')
$cbOutputFormat = $hostType::FindByName($compareForm, 'cbSingleCompareOutputFormat')
$chkIgnoreCore = $hostType::FindByName($compareForm, 'chkIgnoreCoreProperties')
$cbCompareFilter = $hostType::FindByName($compareForm, 'cbCompareFilter')
$cbMultiValueDelim = $hostType::FindByName($compareForm, 'cbCompareMultiValueDelimiter')
$chkSkipAssignments = $hostType::FindByName($compareForm, 'chkSkipCompareAssignments')
$txtSummary = $hostType::FindByName($compareForm, 'txtCompareSummary')
$dgCompareInfo = $hostType::FindByName($compareForm, 'dgCompareInfo')
$btnSwap = $hostType::FindByName($compareForm, 'btnSwap')
$btnStartCompare = $hostType::FindByName($compareForm, 'btnStartCompare')
$btnClose = $hostType::FindByName($compareForm, 'btnClose')
$btnCompareSave = $hostType::FindByName($compareForm, 'btnCompareSave')
$btnCompareCopy = $hostType::FindByName($compareForm, 'btnCompareCopy')
# State for this dialog instance. Lives in MODULE scope: handlers are
# re-bound by ConvertTo-AvaloniaEventScriptBlock, which strips
# function-local captures - the previous captured $state left Compare,
# Swap, Save, Copy and both browse handlers throwing on their first line.
$state = [ordered]@{
Source = $Policies[0]
Target = if ($Policies.Count -ge 2) { $Policies[1] } else { $null }
AllRows = @()
InputType = 'intune'
TypeMap = @{}
OutputMap = @{}
InputMap = @{}
IntunePanel = $null
IntunePanelTxt = $null
FilePanel = $null
FilePanelTxt = $null
HostType = $hostType
Form = $compareForm
TabCompare = $tabCompare
TxtIntuneObject = $txtIntuneObject
CcCompareInputOpts = $ccCompareInputOpts
CbCompareType = $cbCompareType
CbOutputFormat = $cbOutputFormat
CbCompareInput = $cbCompareInput
ChkIgnoreCore = $chkIgnoreCore
CbCompareFilter = $cbCompareFilter
CbMultiValueDelim = $cbMultiValueDelim
ChkSkipAssignments = $chkSkipAssignments
TxtSummary = $txtSummary
DgCompareInfo = $dgCompareInfo
}
$script:_compareFormState = $state
if ($txtIntuneObject) { $txtIntuneObject.Text = [string]$state.Source.Name }
# --- Match-based row coloring ------------------------------------------------
# Avalonia has no DataTrigger; emulate the WPF DataGridCell.Foreground swap
# by hooking LoadingRow and setting Row.Foreground based on the bound
# CompareResultRowItem.Match. Foreground cascades into TextBlock cells via
# property inheritance, matching the WPF Foreground={DynamicResource
# MismatchColor} effect. Match=$null (skipped/ignored) leaves the row at
# the default Foreground.
if ($dgCompareInfo) {
$dgCompareInfo.add_LoadingRow((ConvertTo-AvaloniaEventScriptBlock {
param($s, $e)
$st = $script:_compareFormState
if (-not $st) { return }
$row = $e.Row
$item = $row.DataContext
if ($null -eq $item) { return }
if ($item.Match -eq $false) {
$brush = $null
if ([Avalonia.Application]::Current -and [Avalonia.Application]::Current.Resources) {
[void][Avalonia.Application]::Current.TryGetResource('MismatchColor', $row.ActualThemeVariant, [ref]$brush)
}
if ($brush) { $row.Foreground = $brush }
} else {
$row.ClearValue([Avalonia.Controls.DataGridRow]::ForegroundProperty)
}
}))
}
# --- Combo: Comparison Type --------------------------------------------------
if ($cbCompareType) {
$items = New-Object 'System.Collections.Generic.List[SettingsListItem]'
foreach ($t in @($script:comparisonTypes)) {
$li = [SettingsListItem]@{ Name = [string]$t.Name; Value = [string]$t.Value }
$items.Add($li) | Out-Null
$state.TypeMap[[string]$t.Value] = $t
}
$cbCompareType.ItemsSource = $items
$defaultType = (Get-SettingStoreValue "Compare" "Type" "property")
$sel = $items | Where-Object { "$($_.Value)" -eq "$defaultType" } | Select-Object -First 1
if (-not $sel -and $items.Count -gt 0) { $sel = $items[0] }
if ($sel) { $cbCompareType.SelectedItem = $sel }
}
# --- Combo: Output Format ----------------------------------------------------
if ($cbOutputFormat) {
$items = New-Object 'System.Collections.Generic.List[SettingsListItem]'
foreach ($p in @($script:compareOutputProviders)) {
$li = [SettingsListItem]@{ Name = [string]$p.Name; Value = [string]$p.Value }
$items.Add($li) | Out-Null
$state.OutputMap[[string]$p.Value] = $p
}
$cbOutputFormat.ItemsSource = $items
$defaultOut = (Get-SettingStoreValue "Compare" "SingleOutputFormat" "csv")
$sel = $items | Where-Object { "$($_.Value)" -eq "$defaultOut" } | Select-Object -First 1
if (-not $sel -and $items.Count -gt 0) { $sel = $items[0] }
if ($sel) { $cbOutputFormat.SelectedItem = $sel }
}
# --- Combo: Compare Input (file vs intune) ----------------------------------
if ($cbCompareInput) {
$items = New-Object 'System.Collections.Generic.List[SettingsListItem]'
$items.Add([SettingsListItem]@{ Name = 'File'; Value = 'file' }) | Out-Null
$items.Add([SettingsListItem]@{ Name = 'Intune Object'; Value = 'intune' }) | Out-Null
$state.InputMap['file'] = 'file'
$state.InputMap['intune'] = 'intune'
$cbCompareInput.ItemsSource = $items
# Default: prefer "intune" if a target is already populated;
# otherwise honour the saved setting.
$defaultInput = if ($state.Target) { 'intune' } else { (Get-SettingStoreValue "Compare" "InputType" "file") }
$sel = $items | Where-Object { "$($_.Value)" -eq "$defaultInput" } | Select-Object -First 1
if (-not $sel) { $sel = $items[1] } # 'intune'
if ($sel) {
$cbCompareInput.SelectedItem = $sel
$state.InputType = [string]$sel.Value
}
$cbCompareInput.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock {
param($s, $e)
$st = $script:_compareFormState
if (-not $st) { return }
$val = if ($s.SelectedItem) { [string]$s.SelectedItem.Value } else { 'intune' }
$st.InputType = $val
Set-CompareInputPanel -State $st -HostType $st.HostType -Container $st.CcCompareInputOpts -Type $val
}))
# Initial panel render.
Set-CompareInputPanel -State $state -HostType $hostType -Container $ccCompareInputOpts -Type $state.InputType
}
# --- Combo: Multi-Value Delimiter (doc-compare ObjectSeparator) ---------------
if ($cbMultiValueDelim) {
$items = New-Object 'System.Collections.Generic.List[SettingsListItem]'
$items.Add([SettingsListItem]@{ Name = 'New line'; Value = [Environment]::NewLine }) | Out-Null
$items.Add([SettingsListItem]@{ Name = ';'; Value = ';' }) | Out-Null
$items.Add([SettingsListItem]@{ Name = '|'; Value = '|' }) | Out-Null
$cbMultiValueDelim.ItemsSource = $items
$defaultSep = (Get-SettingStoreValue "Compare" "ObjectSeparator" ([Environment]::NewLine))
$sel = $items | Where-Object { "$($_.Value)" -eq "$defaultSep" } | Select-Object -First 1
if (-not $sel) { $sel = $items[0] }
$cbMultiValueDelim.SelectedItem = $sel
}
# --- Checkboxes --------------------------------------------------------------
if ($chkIgnoreCore) {
$chkIgnoreCore.IsChecked = ((Get-SettingStoreValue "Compare" "IgnoreCoreProperties" "true") -eq "true")
}
if ($chkSkipAssignments) {
$chkSkipAssignments.IsChecked = ((Get-SettingStoreValue "Compare" "SkipCompareAssignments" "false") -eq "true")
}
if ($cbCompareFilter) {
# 3-way result filter (All / Mismatch / Match). Mirror the other combos in
# this file (SettingsListItem + LoadXaml template) - Avalonia's binder needs
# CLR-typed items.
$filterItems = New-Object 'System.Collections.Generic.List[SettingsListItem]'
$filterItems.Add([SettingsListItem]@{ Name = 'All properties'; Value = 'All' }) | Out-Null
$filterItems.Add([SettingsListItem]@{ Name = 'Mismatches only'; Value = 'Mismatch' }) | Out-Null
$filterItems.Add([SettingsListItem]@{ Name = 'Matches only'; Value = 'Match' }) | Out-Null
$cbCompareFilter.ItemsSource = $filterItems
$savedFilter = Get-SettingStoreValue "Compare" "ResultFilter" "All"
$sel = $filterItems | Where-Object { "$($_.Value)" -eq "$savedFilter" } | Select-Object -First 1
if (-not $sel) { $sel = $filterItems[0] }
$cbCompareFilter.SelectedItem = $sel
$cbCompareFilter.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock {
param($s, $e)
$st = $script:_compareFormState
if (-not $st) { return }
$mode = if ($s.SelectedItem) { [string]$s.SelectedItem.Value } else { 'All' }
Update-CompareGridRows -State $st -Grid $st.DgCompareInfo -Filter $mode
}))
}
# --- Buttons -----------------------------------------------------------------
if ($btnClose) {
$btnClose.add_Click((ConvertTo-AvaloniaEventScriptBlock {
Show-ModalObject
# Holds both hydrated policies plus every compare row - release it.
$script:_compareFormState = $null
}))
}
if ($btnSwap) {
$btnSwap.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_compareFormState
if (-not $st) { return }
if (-not $st.Source -or -not $st.Target) { return }
$tmp = $st.Source
$st.Source = $st.Target
$st.Target = $tmp
if ($st.TxtIntuneObject) { $st.TxtIntuneObject.Text = [string]$st.Source.Name }
if ($st.IntunePanelTxt) { $st.IntunePanelTxt.Text = [string]$st.Target.Name }
}))
}
if ($btnStartCompare) {
$btnStartCompare.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_compareFormState
if (-not $st) { return }
$inputType = $st.InputType
# Resolve the second object early so we fail fast with a useful
# message before persisting settings.
$secondPolicy = $null
if ($inputType -eq 'file') {
$compareFile = if ($st.FilePanelTxt) { [string]$st.FilePanelTxt.Text } else { '' }
if ([string]::IsNullOrWhiteSpace($compareFile)) {
$ui.ShowMessageBox("No file selected for comparison.", "Compare", "OK", "Error")
return
}
if (-not [IO.File]::Exists($compareFile)) {
$ui.ShowMessageBox("File '$compareFile' not found.", "Compare", "OK", "Error")
return
}
if (-not $st.Source -or -not $st.Source.PolicyType) {
$ui.ShowMessageBox("Source object has no PolicyType - cannot load file.", "Compare", "OK", "Error")
return
}
try {
$secondPolicy = $st.Source.PolicyType.GetObject([IO.FileInfo]$compareFile)
} catch {
Write-LogError "Failed to load compare file '$compareFile'" $_.Exception
$ui.ShowMessageBox("Failed to load file '$compareFile': $($_.Exception.Message)", "Compare", "OK", "Error")
return
}
if (-not $secondPolicy) {
$ui.ShowMessageBox("Failed to load file '$compareFile'. Object type may not match.", "Compare", "OK", "Error")
return
}
Save-SettingStoreValue "Compare" "LastFile" $compareFile
} else {
if (-not $st.Target) {
$ui.ShowMessageBox("No second Intune object selected. Pre-select two objects in the list (check the boxes) before opening Compare. Browse-pick is not yet ported.", "Compare", "OK", "Error")
return
}
$secondPolicy = $st.Target
}
# Persist user choices.
if ($st.CbCompareType -and $st.CbCompareType.SelectedItem) {
Save-SettingStoreValue "Compare" "Type" $st.CbCompareType.SelectedItem.Value
}
if ($st.CbCompareInput -and $st.CbCompareInput.SelectedItem) {
Save-SettingStoreValue "Compare" "InputType" $st.CbCompareInput.SelectedItem.Value
}
if ($st.CbOutputFormat -and $st.CbOutputFormat.SelectedItem) {
Save-SettingStoreValue "Compare" "SingleOutputFormat" $st.CbOutputFormat.SelectedItem.Value
}
if ($st.ChkIgnoreCore) {
Save-SettingStoreValue "Compare" "IgnoreCoreProperties" $(if ($st.ChkIgnoreCore.IsChecked) { "true" } else { "false" })
}
if ($st.CbCompareFilter -and $st.CbCompareFilter.SelectedItem) {
Save-SettingStoreValue "Compare" "ResultFilter" ([string]$st.CbCompareFilter.SelectedItem.Value)
}
if ($st.CbMultiValueDelim -and $st.CbMultiValueDelim.SelectedItem) {
Save-SettingStoreValue "Compare" "ObjectSeparator" ([string]$st.CbMultiValueDelim.SelectedItem.Value)
}
if ($st.ChkSkipAssignments) {
Save-SettingStoreValue "Compare" "SkipCompareAssignments" $(if ($st.ChkSkipAssignments.IsChecked) { "true" } else { "false" })
}
# Push runtime options into the Internal/Compare.ps1 module so the
# comparison engine sees the same values WPF would have set via
# Set-CompareRuntimeOptionsFromUI.
$runtimeArgs = @{}
if ($st.CbCompareType -and $st.CbCompareType.SelectedItem) {
$runtimeArgs.CompareType = [string]$st.CbCompareType.SelectedItem.Value
$cd = $st.TypeMap[[string]$st.CbCompareType.SelectedItem.Value]
if ($cd) { $runtimeArgs.CompareDefinition = $cd }
}
if ($st.ChkIgnoreCore) {
$runtimeArgs.IgnoreCoreProperties = [bool]$st.ChkIgnoreCore.IsChecked
}
if ($st.CbOutputFormat -and $st.CbOutputFormat.SelectedItem) {
$op = $st.OutputMap[[string]$st.CbOutputFormat.SelectedItem.Value]
if ($op) { $runtimeArgs.OutputProvider = $op }
}
if ($st.CbMultiValueDelim -and $st.CbMultiValueDelim.SelectedItem) {
$runtimeArgs.ObjectSeparator = [string]$st.CbMultiValueDelim.SelectedItem.Value
}
if ($st.ChkSkipAssignments) {
$runtimeArgs.SkipAssignments = [bool]$st.ChkSkipAssignments.IsChecked
}
Set-CompareRuntimeOptions @runtimeArgs
Write-Status "Compare objects"
try {
Resolve-FullPolicyForCompare $st.Source
if ($inputType -eq 'intune') {
Resolve-FullPolicyForCompare $secondPolicy
} else {
# File-loaded policy: warn if @odata.type doesn't match.
$sourceType = $null; $fileType = $null
try { $sourceType = $st.Source.Object.'@OData.Type' } catch { }
try { $fileType = $secondPolicy.Object.'@OData.Type' } catch { }
if ($sourceType -and $fileType -and $sourceType -ne $fileType) {
$answer = $ui.ShowMessageBox("The object types do not match.`n`nDo you want to compare the objects anyway?", "Compare", "YesNo", "Warning")
if ($answer -ne "Yes") { Write-Status ""; return }
}
}
$rawRows = @(Compare-PolicyObjects @($st.Source, $secondPolicy))
} catch {
Write-LogError "Compare-PolicyObjects failed" $_.Exception
$ui.ShowMessageBox("Compare failed: $($_.Exception.Message)", "Error", "OK", "Error")
Write-Status ""
return
}
$st.AllRows = @($rawRows | ForEach-Object {
# Tri-state Match glyph, mirroring the WPF CompareForm.xaml column:
# checkmark = match, ballot-X = mismatch, em-dash = ignored ($null).
# Built from [char] codes so the source stays ASCII (see
# [[ascii-only-string-literals]]) - the literal glyph exists only in
# the rendered string, never in the .ps1 bytes. Mismatch rows are
# already tinted red by the LoadingRow handler, which cascades to
# this cell too (matching the red WPF ballot-X).
$glyph = [char]0x2014
if ($_.Match -eq $true) { $glyph = [char]0x2713 }
elseif ($_.Match -eq $false) { $glyph = [char]0x2717 }
[CompareResultRowItem]@{
PropertyName = [string]$_.PropertyName
Category = [string]$_.Category
SubCategory = [string]$_.SubCategory
Object1Value = [string]$_.Object1Value
Object2Value = [string]$_.Object2Value
Match = $_.Match
MatchGlyph = [string]$glyph
}
})
$mode = if ($st.CbCompareFilter -and $st.CbCompareFilter.SelectedItem) { [string]$st.CbCompareFilter.SelectedItem.Value } else { 'All' }
Update-CompareGridRows -State $st -Grid $st.DgCompareInfo -Filter $mode
Update-CompareSummaryText -State $st -TextBlock $st.TxtSummary
if ($st.TabCompare) { $st.TabCompare.SelectedIndex = 1 }
Write-Status ""
}))
}
if ($btnCompareSave) {
$btnCompareSave.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_compareFormState
if (-not $st) { return }
if (-not $st.AllRows -or $st.AllRows.Count -eq 0) { return }
# Pick output provider from the SelectedItem (combobox model has the
# underlying CompareOutputProviderBase via OutputMap), with CSV fallback.
$outProv = $null
if ($st.CbOutputFormat -and $st.CbOutputFormat.SelectedItem) {
$outProv = $st.OutputMap[[string]$st.CbOutputFormat.SelectedItem.Value]
}
if (-not $outProv) { $outProv = [CompareCSVOutputProvider]::new() }
$defaultExt = if ($outProv.Extension) { [string]$outProv.Extension } else { 'csv' }
$filterName = if ($defaultExt -eq 'json') { 'JSON files' } else { 'CSV files' }
$filterPattern = "*.$defaultExt"
$startDir = Get-SettingStoreValue "Compare" "LastSaveDirectory"
if (-not $startDir) { $startDir = Get-SettingValue "RootFolder" }
$picked = (Get-AvaloniaHost)::SaveFilePicker(
$script:Window,
'Save compare results',
[string]$st.Source.Name,
$defaultExt,
$filterName,
$filterPattern,
[string]$startDir)
if (-not $picked) { return }
try {
Save-SettingStoreValue "Compare" "LastSaveDirectory" ([IO.FileInfo]$picked).DirectoryName
$ext = [IO.Path]::GetExtension($picked).TrimStart('.')
$writeProv = Get-CompareOutputProviderByExtension $ext
if (-not $writeProv) { $writeProv = $outProv }
# FormatRows wants the Compare-PolicyObjects shape (PSCustomObject
# with PropertyName/Object1Value/...); CompareResultRowItem mirrors
# those property names so duck-typing through Get-Member works.
$propsArgs = @{}
if ($st.CbCompareType -and $st.CbCompareType.SelectedItem) {
$propsArgs.CompareType = [string]$st.CbCompareType.SelectedItem.Value
$propsArgs.CompareDefinition = $st.TypeMap[[string]$st.CbCompareType.SelectedItem.Value]
}
$props = Get-CompareOutputProps @propsArgs
$writeProv.FormatRows($st.AllRows, $props) | Out-File -LiteralPath $picked -Force -Encoding UTF8
} catch {
Write-LogError "Save compare results failed" $_.Exception
$ui.ShowMessageBox("Save failed: $($_.Exception.Message)", "Error", "OK", "Error")
}
}))
}
if ($btnCompareCopy) {
$btnCompareCopy.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_compareFormState
if (-not $st) { return }
if (-not $st.AllRows -or $st.AllRows.Count -eq 0) { return }
try {
$compareType = if ($st.CbCompareType -and $st.CbCompareType.SelectedItem) {
[string]$st.CbCompareType.SelectedItem.Value
} else { 'property' }
$compareDef = $st.TypeMap[$compareType]
(Get-CompareCsvInfo $st.AllRows $st.Source $compareType $compareDef) | Set-Clipboard
} catch {
Write-LogError "Copy compare results failed" $_.Exception
$ui.ShowMessageBox("Copy failed: $($_.Exception.Message)", "Error", "OK", "Error")
}
}))
}
$compareForm.add_KeyDown((ConvertTo-AvaloniaEventScriptBlock {
param($s, $e)
$st = $script:_compareFormState
if (-not $st) { return }
if ($e.Key -eq [Avalonia.Input.Key]::Escape) {
Show-ModalObject
$e.Handled = $true
}
}))
$ui.ShowModalForm("Compare Intune Objects", $compareForm, $true)
}
function Set-CompareInputPanel
{
param($State, $HostType, $Container, [string]$Type)
$ui = $script:UIProvider
if (-not $Container) { return }
if ($Type -eq 'intune') {
if (-not $State.IntunePanel) {
$State.IntunePanel = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/CompareIntuneObjectOptions.axaml'))
if ($State.IntunePanel) {
$State.IntunePanelTxt = $HostType::FindByName($State.IntunePanel, 'txtCompareIntuneObject')
$browse = $HostType::FindByName($State.IntunePanel, 'browseIntuneObject')
if ($browse) {
$browse.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_compareFormState
if (-not $st) { return }
$sourceType = if ($st.Source) { $st.Source.PolicyType } else { $null }
if (-not $sourceType) {
Show-MessageBox "Select a source policy first." "Compare" "OK" "Warning" | Out-Null
return
}
$sourceId = if ($st.Source) { $st.Source.Id } else { $null }
# Seed with same-type policies already cached in the main view so the
# dialog opens with something in it and no Graph call. Searching or
# "Load all in scope" goes to Graph from there.
$initial = @()
if ($script:IntuneManagerAllRows) {
$initial = @(
$script:IntuneManagerAllRows |
Where-Object { $_.Source -and $_.Source.PolicyType -and $_.Source.PolicyType.ID -eq $sourceType.ID -and (-not $sourceId -or $_.Source.Id -ne $sourceId) } |
ForEach-Object { $_.Source }
)
}
$exclude = @()
if ($sourceId) { $exclude = @($sourceId) }
$picked = Show-PolicySearchDialog `
-Title ("Select Intune Object to Compare ({0})" -f $sourceType.Title) `
-DefaultPolicyTypeId $sourceType.ID `
-ExcludeIds $exclude `
-InitialItems $initial
if ($picked) {
$st.Target = $picked
if ($st.IntunePanelTxt) { $st.IntunePanelTxt.Text = [string]$picked.Name }
}
}))
}
}
}
if ($State.IntunePanelTxt -and $State.Target) {
$State.IntunePanelTxt.Text = [string]$State.Target.Name
}
$Container.Content = $State.IntunePanel
} else {
if (-not $State.FilePanel) {
$State.FilePanel = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/CompareFileOptions.axaml'))
if ($State.FilePanel) {
$State.FilePanelTxt = $HostType::FindByName($State.FilePanel, 'txtCompareFile')
$browseFile = $HostType::FindByName($State.FilePanel, 'browseCompareObject')
# Pre-populate from saved last-file path so the user doesn't
# have to re-browse on every reopen.
$lastFile = Get-SettingStoreValue "Compare" "LastFile"
if ($lastFile -and $State.FilePanelTxt) { $State.FilePanelTxt.Text = $lastFile }
if ($browseFile) {
$browseFile.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_compareFormState
if (-not $st) { return }
# Pick a sensible starting directory: Compare/LastFile's
# parent if set, else the source's PolicyType.Folder
# under LastUsedFullPath, else nothing (host falls back
# to default).
$startDir = $null
$cachedFile = Get-SettingStoreValue "Compare" "LastFile"
if ($cachedFile) {
try { $startDir = ([IO.FileInfo]$cachedFile).DirectoryName } catch { }
}
if (-not $startDir) {
$rootHint = Get-SettingStoreValue "" "LastUsedFullPath"
if ($rootHint) {
try { $rootHint = [IO.Directory]::GetParent($rootHint).FullName } catch { }
}
if ($rootHint -and $st.Source -and $st.Source.PolicyType -and $st.Source.PolicyType.Folder) {
$candidate = [IO.Path]::Combine($rootHint, $st.Source.PolicyType.Folder)
if ([IO.Directory]::Exists($candidate)) { $startDir = $candidate }
elseif ([IO.Directory]::Exists($rootHint)) { $startDir = $rootHint }
} elseif ($rootHint) {
$startDir = $rootHint
}
}
$picked = (Get-AvaloniaHost)::OpenFilePicker($script:Window, 'Select compare file', $startDir, 'JSON files', '*.json')
if ($picked) {
if ($st.FilePanelTxt) { $st.FilePanelTxt.Text = $picked }
Save-SettingStoreValue "Compare" "LastFile" $picked
}
}))
}
}
}
$Container.Content = $State.FilePanel
}
}
function Update-CompareGridRows
{
# $Filter: All (everything) / Mismatch (Match -eq $false) / Match (Match -eq $true).
# The 'Match' view hides ignored ($null) rows, same as 'Mismatch' hides them.
param($State, $Grid, [string]$Filter = 'All')
if (-not $Grid) { return }
# Build a TYPED list rather than piping through Where-Object: the
# pipeline stamps a PSObject wrapper on every item, and Avalonia's
# DataGrid binder reflects on the runtime type and renders blank cells
# against the wrapper (same trap as the assignments filter - see
# [[avalonia-binding-needs-clr-types]]).
$rows = [System.Collections.Generic.List[CompareResultRowItem]]::new()
foreach ($row in @($State.AllRows)) {
if (-not $row) { continue }
$keep = switch ($Filter) {
'Mismatch' { $row.Match -eq $false }
'Match' { $row.Match -eq $true }
default { $true }
}
if ($keep) { $rows.Add($row) }
}
$Grid.ItemsSource = $rows
}
function Update-CompareSummaryText
{
param($State, $TextBlock)
if (-not $TextBlock) { return }
$items = @($State.AllRows)
$total = $items.Count
$mismatches = @($items | Where-Object { $_.Match -eq $false }).Count
$skipped = @($items | Where-Object { $null -eq $_.Match }).Count
$matched = $total - $mismatches - $skipped
$tail = if ($skipped -gt 0) { ", $skipped ignored" } else { '' }
$TextBlock.Text = "$total properties - $matched matched, $mismatches mismatched$tail"
}
# Category / SubCategory columns are only meaningful for settings-based
# comparisons; Internal/Compare.ps1 raises CompareColumnVisibilityChanged to
# show or hide them. WPF wires this (IntuneManagerCompareUIWPF.ps1:1); the
# Avalonia port never did, so both columns stayed visible - and empty - for
# property compares.
Add-AppEventHandler "CompareColumnVisibilityChanged" "Set-CompareGridColumnVisibility"
function Set-CompareGridColumnVisibility
{
param($ShowCategory = $false, $ShowSubCategory = $false)
$st = $script:_compareFormState
$grid = if ($st) { $st.DgCompareInfo } else { $null }
if (-not $grid) { return }
foreach ($col in $grid.Columns) {
$header = [string]$col.Header
if ($header -eq 'Category') { $col.IsVisible = [bool]$ShowCategory }
if ($header -eq 'SubCategory') { $col.IsVisible = [bool]$ShowSubCategory }
}
}
@@ -0,0 +1,411 @@
# Avalonia port of the Copy dialog from UI/WPF/Extensions/IntuneManagerUI.ps1
# (Copy-IntuneManagerPolicy). New file because Copy is a self-contained
# subsystem and the WPF original sits in the giant CoreUI/IntuneManagerUI
# files we're trying not to grow further (architecture rule R9).
#
# Slice 3b1: core copy without scope tags.
# Slice 3b2: scope-tag dual-list. Panels in CopyDialog.axaml shown only
# when the source policy type defines a ScopeTagProperty; mirrors the
# WPF impl with the canonical assigned-ids HashSet + ObservableCollections
# and re-seeds by NAME on tenant change so cross-tenant copies pre-pick
# the same-named tags in the destination.
function Copy-IntuneManagerPolicy
{
$ui = $script:UIProvider
if (-not $script:dgIntuneManagerObjects -or -not $script:dgIntuneManagerObjects.SelectedItem) {
$ui.ShowMessageBox("No object selected`n`nSelect the $($script:IntuneManagerSelectedObject.Title) item you want to copy", "Error", "OK", "Error")
return
}
$sourceRow = $script:dgIntuneManagerObjects.SelectedItem
$sourceItem = $sourceRow.Source
if (-not $sourceItem) {
Write-Log "Copy: selected row carries no policy object" 3
$ui.ShowMessageBox("Cannot copy: the selected item has no underlying policy object.", "Copy", "OK", "Error")
return
}
$copyForm = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/CopyDialog.axaml'))
if (-not $copyForm) { return }
$hostType = Get-AvaloniaHost
$cbDestTenant = $hostType::FindByName($copyForm, 'cbDestinationTenant')
$txtName = $hostType::FindByName($copyForm, 'txtObjectName')
$txtDesc = $hostType::FindByName($copyForm, 'txtObjectDescription')
$btnOk = $hostType::FindByName($copyForm, 'btnOk')
$btnCancel = $hostType::FindByName($copyForm, 'btnCancel')
$pnlScopeTagsLabel = $hostType::FindByName($copyForm, 'pnlCopyScopeTagsLabel')
$grdScopeTags = $hostType::FindByName($copyForm, 'grdCopyScopeTags')
$lstAvailable = $hostType::FindByName($copyForm, 'lstCopyAvailableScopeTags')
$lstAssigned = $hostType::FindByName($copyForm, 'lstCopyAssignedScopeTags')
$btnAssign = $hostType::FindByName($copyForm, 'btnCopyScopeTagAssign')
$btnUnassign = $hostType::FindByName($copyForm, 'btnCopyScopeTagUnassign')
# Default new-name pattern. PolicyType.CopyDefaultName allows %Name%-style
# placeholders against properties of the source row; mirror the WPF
# substitution loop. Falls back to "Original - Copy".
$newName = "$($sourceRow.Name) - Copy"
$copyDefault = $null
try {
if ($sourceItem.PolicyType -and $sourceItem.PolicyType.CopyDefaultName) {
$copyDefault = $sourceItem.PolicyType.CopyDefaultName
}
} catch { }
if ($copyDefault) {
$newName = $copyDefault
foreach ($p in $sourceRow.PSObject.Properties) {
$val = [string]$sourceRow.$($p.Name)
$newName = $newName -replace "%$($p.Name)%", $val
}
}
if ($txtName) { $txtName.Text = $newName }
# Description gating: HasDescription=$false on the type means the policy
# has no description field at all; greyed out so the user knows it won't
# round-trip.
$hasDescription = $true
try {
if ($sourceItem.HasDescription -eq $false) { $hasDescription = $false }
} catch { }
if ($txtDesc) {
if ($hasDescription) {
try { $txtDesc.Text = [string]$sourceItem.Description } catch { }
} else {
$txtDesc.IsEnabled = $false
}
}
# Tenant combo: project Get-TokenInfo entries into CopyDialogTenantItem so
# the ItemTemplate can bind to TenantNameEx (Avalonia binding doesn't
# walk PSObject NoteProperty — see [[avalonia-binding-needs-clr-types]]).
$tenantItems = @()
foreach ($t in (Get-TokenInfo)) {
if (-not $t) { continue }
$name = [string]$t.TenantName
if ($t.IsDefault) { $name = "$name (Current)" }
$tenantItems += [CopyDialogTenantItem]@{
TenantNameEx = $name
Id = if ($null -ne $t.Id) { [int]$t.Id } else { 0 }
IsDefault = [bool]$t.IsDefault
Source = $t
}
}
if ($cbDestTenant) {
$cbDestTenant.ItemsSource = $tenantItems
$defaultItem = $tenantItems | Where-Object { $_.IsDefault } | Select-Object -First 1
if (-not $defaultItem -and $tenantItems.Count -gt 0) { $defaultItem = $tenantItems[0] }
if ($defaultItem) { $cbDestTenant.SelectedItem = $defaultItem }
}
# ---- Scope tags (dual-list) ----------------------------------------------
# Mirrors WPF UI/WPF/Extensions/IntuneManagerUI.ps1 ~lines 1947-2114. Single
# source of truth = the AssignedIds HashSet on $state; ObservableCollections
# are re-derived on every change. Cross-tenant assignment is matched by
# NAME so picking a different tenant still pre-checks same-named tags.
$state = @{
ScopeTagPropName = $null
SourceScopeTagNames = @()
AllScopeTags = @()
AssignedIds = [System.Collections.Generic.HashSet[string]]::new()
AvailableCollection = [System.Collections.ObjectModel.ObservableCollection[object]]::new()
AssignedCollection = [System.Collections.ObjectModel.ObservableCollection[object]]::new()
LstAvail = $lstAvailable
LstAssigned = $lstAssigned
}
# Event handlers must reach this through $script: - closure captures do
# not survive ConvertTo-AvaloniaEventScriptBlock (NewBoundScriptBlock
# rebinds to the module and drops the .GetNewClosure() state).
$script:_copyDialogTagState = $state
try {
if ($sourceItem.PolicyType -and $sourceItem.PolicyType.ScopeTagProperty) {
$state.ScopeTagPropName = [string]$sourceItem.PolicyType.ScopeTagProperty
}
} catch { }
if ($state.ScopeTagPropName) {
# Capture source tag NAMES so cross-tenant re-seeds work.
$sourceTagIds = @()
try {
if ($sourceItem.Object -and $sourceItem.Object.PSObject.Properties[$state.ScopeTagPropName]) {
$sourceTagIds = @($sourceItem.Object.($state.ScopeTagPropName) | ForEach-Object { [string]$_ })
}
} catch { }
try {
$srcDeps = Get-GraphDependencySourceObjects $sourceItem -DefaultPoliciesOnly
if ($srcDeps -and $srcDeps.ContainsKey('ScopeTags')) {
foreach ($id in $sourceTagIds) {
$tag = @($srcDeps['ScopeTags']) | Where-Object { [string]$_.Id -eq $id } | Select-Object -First 1
if ($tag) { $state.SourceScopeTagNames += [string]$tag.Name }
}
}
} catch { Write-LogDebug "Source scope-tag name capture failed: $($_.Exception.Message)" }
if ($pnlScopeTagsLabel) { $pnlScopeTagsLabel.IsVisible = $true }
if ($grdScopeTags) { $grdScopeTags.IsVisible = $true }
if ($lstAvailable) {
$lstAvailable.ItemsSource = $state.AvailableCollection
}
if ($lstAssigned) {
$lstAssigned.ItemsSource = $state.AssignedCollection
}
# All three helpers live in $script: and read $script: state only -
# local captures would be lost the moment an event handler runs them.
$script:_copyDialogSyncTagLists = {
$st = $script:_copyDialogTagState
if (-not $st) { return }
$st.AvailableCollection.Clear()
$st.AssignedCollection.Clear()
foreach ($tag in $st.AllScopeTags) {
if ($st.AssignedIds.Contains($tag.Id)) {
[void]$st.AssignedCollection.Add($tag)
} else {
[void]$st.AvailableCollection.Add($tag)
}
}
}
# Reload destination tenant's scope tags. Cache lookup first; live
# fetch on cold cache. Dedupes by Id (cache may carry both the
# synthetic Default and a real Default tag).
$script:_copyDialogReloadTags = {
param([int]$DestTokenId)
$st = $script:_copyDialogTagState
if (-not $st) { return }
$destTags = @()
try {
$destTokenInfo = Get-TokenInfo $DestTokenId
if ($destTokenInfo) {
$cacheId = "DependencyObjects_$($destTokenInfo.TenantId)"
$deps = Get-CacheObject $cacheId
if ($deps -and $deps.ContainsKey('ScopeTags')) {
$destTags = @($deps['ScopeTags'])
}
}
} catch { Write-LogDebug "Copy scope tags: cache lookup failed: $($_.Exception.Message)" }
if ($destTags.Count -eq 0) {
try {
$destTags = @(Get-GraphPolicies -PolicyType 'ScopeTags' -TokenId $DestTokenId)
$destTags = @([PSCustomObject]@{ ID = 0; Name = 'Default' }) + $destTags
} catch { Write-LogError "Failed to load scope tags for destination tenant" $_.Exception }
}
$dedup = @()
$seenIds = [System.Collections.Generic.HashSet[string]]::new()
foreach ($tag in $destTags) {
$idStr = [string]$tag.Id
if (-not $seenIds.Add($idStr)) { continue }
$dedup += [CopyDialogScopeTagItem]@{ Id = $idStr; Name = [string]$tag.Name }
}
$st.AllScopeTags = @($dedup | Sort-Object Name)
$st.AssignedIds.Clear()
$seedNames = [System.Collections.Generic.HashSet[string]]::new()
foreach ($n in $st.SourceScopeTagNames) { [void]$seedNames.Add($n) }
foreach ($tag in $st.AllScopeTags) {
if ($seedNames.Contains($tag.Name)) { [void]$st.AssignedIds.Add($tag.Id) }
}
& $script:_copyDialogSyncTagLists
}
if ($cbDestTenant -and $cbDestTenant.SelectedItem) {
& $script:_copyDialogReloadTags ([int]$cbDestTenant.SelectedItem.Id)
}
if ($cbDestTenant) {
$cbDestTenant.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock {
param($s, $e)
if ($s.SelectedItem) { & $script:_copyDialogReloadTags ([int]$s.SelectedItem.Id) }
}))
}
$script:_copyDialogMoveTags = {
param([string]$Action, [string[]]$Ids)
$st = $script:_copyDialogTagState
if (-not $st -or $Ids.Count -eq 0) { return }
if ($Action -eq 'assign') {
foreach ($id in $Ids) { [void]$st.AssignedIds.Add([string]$id) }
} else {
foreach ($id in $Ids) { [void]$st.AssignedIds.Remove([string]$id) }
}
& $script:_copyDialogSyncTagLists
}
if ($btnAssign) {
$btnAssign.add_Click((ConvertTo-AvaloniaEventScriptBlock {
try {
$st = $script:_copyDialogTagState
if (-not $st -or -not $st.LstAvail -or $st.LstAvail.SelectedItems.Count -eq 0) { return }
$ids = @($st.LstAvail.SelectedItems | ForEach-Object { [string]$_.Id })
& $script:_copyDialogMoveTags 'assign' $ids
} catch { Write-LogError "Copy scope-tag assign handler failed" $_.Exception }
}))
}
if ($btnUnassign) {
$btnUnassign.add_Click((ConvertTo-AvaloniaEventScriptBlock {
try {
$st = $script:_copyDialogTagState
if (-not $st -or -not $st.LstAssigned -or $st.LstAssigned.SelectedItems.Count -eq 0) { return }
$ids = @($st.LstAssigned.SelectedItems | ForEach-Object { [string]$_.Id })
& $script:_copyDialogMoveTags 'unassign' $ids
} catch { Write-LogError "Copy scope-tag unassign handler failed" $_.Exception }
}))
}
# Avalonia's MouseDoubleClick equivalent on ListBox is DoubleTapped.
if ($lstAvailable) {
$lstAvailable.add_DoubleTapped((ConvertTo-AvaloniaEventScriptBlock {
try {
$st = $script:_copyDialogTagState
if (-not $st -or -not $st.LstAvail -or $st.LstAvail.SelectedItems.Count -eq 0) { return }
$ids = @($st.LstAvail.SelectedItems | ForEach-Object { [string]$_.Id })
& $script:_copyDialogMoveTags 'assign' $ids
} catch { Write-LogError "Copy scope-tag double-tap (available) failed" $_.Exception }
}))
}
if ($lstAssigned) {
$lstAssigned.add_DoubleTapped((ConvertTo-AvaloniaEventScriptBlock {
try {
$st = $script:_copyDialogTagState
if (-not $st -or -not $st.LstAssigned -or $st.LstAssigned.SelectedItems.Count -eq 0) { return }
$ids = @($st.LstAssigned.SelectedItems | ForEach-Object { [string]$_.Id })
& $script:_copyDialogMoveTags 'unassign' $ids
} catch { Write-LogError "Copy scope-tag double-tap (assigned) failed" $_.Exception }
}))
}
}
# Result box captured by the button closures — they set Confirmed before
# tearing down the modal.
# ShowModalForm is non-blocking, so the copy runs from the OK/Enter handlers
# (Invoke-IntuneManagerCopyExecute) while state is live - stashed in script
# scope so the module-scope helper can read the controls after dispatch.
$script:_imCopyFormState = @{
SourceRow = $sourceRow
SourceItem = $sourceItem
TxtName = $txtName
TxtDesc = $txtDesc
CbDestTenant = $cbDestTenant
BtnOk = $btnOk
State = $state
}
if ($btnOk) {
$btnOk.add_Click((ConvertTo-AvaloniaEventScriptBlock {
try { Invoke-IntuneManagerCopyExecute } catch { Write-LogError "Copy execution failed" $_.Exception }
Show-ModalObject
$script:_imCopyFormState = $null
$script:_copyDialogTagState = $null
if ($script:dgIntuneManagerObjects) { $script:dgIntuneManagerObjects.Focus() }
}.GetNewClosure()))
}
if ($btnCancel) {
$btnCancel.add_Click((ConvertTo-AvaloniaEventScriptBlock {
Show-ModalObject
$script:_imCopyFormState = $null
$script:_copyDialogTagState = $null
if ($script:dgIntuneManagerObjects) { $script:dgIntuneManagerObjects.Focus() }
}.GetNewClosure()))
}
# Avalonia has no IsDefault/IsCancel on Button — handle Enter/Escape.
$copyForm.add_KeyDown((ConvertTo-AvaloniaEventScriptBlock {
param($s, $e)
$stCopy = $script:_imCopyFormState
if ($e.Key -eq [Avalonia.Input.Key]::Enter -and $stCopy -and $stCopy.BtnOk -and $stCopy.BtnOk.IsEnabled) {
try { Invoke-IntuneManagerCopyExecute } catch { Write-LogError "Copy execution failed" $_.Exception }
Show-ModalObject
$script:_imCopyFormState = $null
$script:_copyDialogTagState = $null
$e.Handled = $true
} elseif ($e.Key -eq [Avalonia.Input.Key]::Escape) {
Show-ModalObject
$script:_imCopyFormState = $null
$script:_copyDialogTagState = $null
$e.Handled = $true
}
}.GetNewClosure()))
$ui.ShowModalForm("Copy object", $copyForm, $true)
# Focus + select the name so the user can type a new value immediately.
if ($txtName) {
try { $txtName.Focus() } catch { }
try { $txtName.SelectAll() } catch { }
}
}
function Invoke-IntuneManagerCopyExecute
{
# Runs the copy for the live $script:_imCopyFormState. Called from the OK/Enter
# handlers (ShowModalForm does not block, so it can't run after it returns).
$st = $script:_imCopyFormState
if (-not $st) { return }
$ui = $script:UIProvider
$sourceRow = $st.SourceRow
$sourceItem = $st.SourceItem
$txtName = $st.TxtName
$txtDesc = $st.TxtDesc
$cbDestTenant = $st.CbDestTenant
$state = $st.State
$finalName = if ($txtName) { [string]$txtName.Text } else { '' }
if ([string]::IsNullOrWhiteSpace($finalName)) {
Write-Log "New name cannot be empty. Copy object skipped" 2
Write-Status ""
return
}
$finalDesc = $null
if ($txtDesc -and $txtDesc.IsEnabled) {
$finalDesc = [string]$txtDesc.Text
}
$tokenId = $null
if ($cbDestTenant -and $cbDestTenant.SelectedItem) {
$tokenId = $cbDestTenant.SelectedItem.Id
}
Write-Status "Copy $($sourceRow.Name)"
$copyArgs = @{
InputObject = $sourceItem
Name = $finalName
Description = $finalDesc
}
if ($null -ne $tokenId) { $copyArgs['TokenId'] = $tokenId }
# Forward scope-tag picks (when section was shown). Pass even an empty
# array so the new copy explicitly reflects user intent — Copy-GraphPolicy
# only overrides the cloned JSON when this is non-null. Read from the
# canonical AssignedIds set rather than the UI-derived collection.
if ($state.ScopeTagPropName -and $null -ne $state.AssignedIds) {
$copyArgs['ScopeTagIds'] = @($state.AssignedIds)
}
$newPolicies = $null
try {
$newPolicies = Copy-GraphPolicy @copyArgs
} catch {
Write-LogError "Copy-GraphPolicy failed" $_.Exception
$ui.ShowMessageBox("Copy failed: $($_.Exception.Message)", "Error", "OK", "Error")
Write-Status ""
return
}
if ($newPolicies -and (Get-SettingValue "RefreshObjectsAfterCopy") -eq $true) {
Invoke-IntuneActivateObject $script:IntuneManagerSelectedObject
}
Write-Status ""
if ($script:dgIntuneManagerObjects) { $script:dgIntuneManagerObjects.Focus() }
}
@@ -0,0 +1,54 @@
# Per-view Delete confirmation (Remove-GraphObjectsUI).
# Split out of IntuneManagerUIAvalonia.ps1 on 2026-06-03 to match the WPF tree's
# per-feature layout (architecture rule R9 — keep the Avalonia shell from growing further).
function Remove-GraphObjectsUI
{
$ui = $script:UIProvider
if (-not $script:dgIntuneManagerObjects -or $null -eq $script:IntuneManagerAllRows) { return }
# Prefer the IsSelected-checked rows; fall back to the highlighted row.
# Scan the grid's CURRENT (filtered) ItemsSource - never the unfiltered
# backing list, or a checked row hidden by the filter would be deleted
# without ever being visible to the user.
$selectedRows = @(@($script:dgIntuneManagerObjects.ItemsSource) | Where-Object { $_ -and $_.IsSelected })
if ($selectedRows.Count -eq 0 -and $script:dgIntuneManagerObjects.SelectedItem) {
$selectedRows = @($script:dgIntuneManagerObjects.SelectedItem)
}
if ($selectedRows.Count -eq 0) {
$ui.ShowMessageBox("No object selected`n`nSelect items you want to delete", "Error", "OK", "Error")
return
}
$confirm = $ui.ShowMessageBox("Are you sure you want to delete $($selectedRows.Count) object(s)?`n`nEnvironment: $($script:OrganizationName)", "Delete Objects?", "YesNo", "Warning")
if ($confirm -ne "Yes") { return }
# Remove-GraphPolicy expects the underlying IntunePolicyBase, not the
# CLR row wrapper — pull .Source off each row before piping in.
$policiesToRemove = @($selectedRows | ForEach-Object { $_.Source } | Where-Object { $_ })
if ($policiesToRemove.Count -eq 0) {
Write-Log "Delete: $($selectedRows.Count) row(s) confirmed but none carried a policy object" 3
$ui.ShowMessageBox("No object selected`n`nSelect items you want to delete", "Error", "OK", "Error")
return
}
$deletedPolicies = @($policiesToRemove | Remove-GraphPolicy)
if ($deletedPolicies.Count -gt 0) {
$deletedIds = @($deletedPolicies | ForEach-Object { [string]$_.Id })
# Drop deleted rows from the cached unfiltered list, then refresh the
# filtered ItemsSource so the grid reflects the new state.
$remaining = [System.Collections.Generic.List[object]]::new()
foreach ($row in $script:IntuneManagerAllRows) {
if ($deletedIds -notcontains [string]$row.ID) { [void]$remaining.Add($row) }
}
$script:IntuneManagerAllRows = $remaining
Invoke-FilterBoxChanged $script:IntuneManagementFilterTextBox `
$script:dgIntuneManagerObjects -ForceUpdate `
-ObjectsCountTextBox $script:IntuneManagementObjectsCount
}
Write-Status ""
}
@@ -0,0 +1,754 @@
# Avalonia port of UI/WPF/Extensions/IntuneManagerUI.ps1::Show-IntuneManagerDetailedView
# (lines 666-1135 in WPF). Lives in its own file per architecture R9 — Details
# is a self-contained subsystem and the WPF original sits in the giant
# IntuneManagerUI file we're trying not to grow further.
#
# Slice 3f: ObjectDetails.axaml + Show-IntuneManagerDetailedView.
# - JSON tab: txtValue + Load full + Copy
# - Settings tab: name + description + scope-tag dual-list + Save (PATCH)
# - Columns tab: Basic/Object property pickers + ObjectColumnInfo editor
# + Up/Down/Delete/Clear/Override + Reset/Save
#
# Per-policy-type AddUIDetailsExtension is invoked at the bottom — Avalonia
# variants live in UI/Avalonia/ClassExtensions/Intune*ClassUIExtension*.ps1
# and route through Add-AvaloniaDetailsButton (see
# Extensions/IntuneManagerExtensionHooks.ps1) instead of building WPF
# controls directly.
#
# Dirty-tracking signatures + Confirm-DetailsViewClose work the same as WPF,
# but read from the captured $state hashtable rather than $script: vars so
# multiple modal opens don't trample each other.
function Show-IntuneManagerDetailedView
{
param(
$FormTitle = "",
[switch]$NoLoadFull
)
$ui = $script:UIProvider
if (-not $script:dgIntuneManagerObjects) { return }
$selectedRow = $script:dgIntuneManagerObjects.SelectedItem
if (-not $selectedRow -and $script:dgIntuneManagerObjects.ItemsSource) {
$selectedRow = @($script:dgIntuneManagerObjects.ItemsSource |
Where-Object { $_.PSObject.Properties['IsSelected'] -and $_.IsSelected -eq $true } |
Select-Object -First 1)
if ($selectedRow.Count -gt 0) { $selectedRow = $selectedRow[0] }
}
if (-not $selectedRow) { return }
# IntuneObjectRowItem.Source holds the underlying IntunePolicyBase, which
# is what JsonObject / Object / Get() etc. live on. WPF read directly from
# the row because rows there were the IntunePolicyBase itself.
$selectedItem = $selectedRow.Source
if (-not $selectedItem -or -not $selectedItem.JsonObject) {
Write-Log "View: selected row carries no policy object" 3
return
}
$detailsForm = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/ObjectDetails.axaml'))
if (-not $detailsForm) { return }
$hostType = Get-AvaloniaHost
# State captured in closures rather than $script: vars so reopening the
# dialog doesn't trample a still-running instance and so the close
# confirmation sees the right form.
$state = @{
Form = $detailsForm
SelectedItem = $selectedItem
ScopeTagPropName = $null
AllScopeTags = @()
AssignedIds = [System.Collections.Generic.HashSet[string]]::new()
AvailableCollection = [System.Collections.ObjectModel.ObservableCollection[object]]::new()
AssignedCollection = [System.Collections.ObjectModel.ObservableCollection[object]]::new()
ColObjectProperties = [System.Collections.ObjectModel.ObservableCollection[object]]::new()
InitialSettingsSig = ""
InitialColumnsSig = ""
LstAvail = $null
LstAssigned = $null
TxtValue = $null
LstBasic = $null
LstObjProps = $null
LstObjCols = $null
GrdObjCols = $null
ChkOverride = $null
HostType = $null
}
# Event handlers must reach this through $script: - closure captures do
# not survive ConvertTo-AvaloniaEventScriptBlock (NewBoundScriptBlock
# rebinds to the module and drops the .GetNewClosure() state).
$script:_detailsTagState = $state
if (-not $FormTitle) { $FormTitle = $selectedItem.PolicyName }
if ($selectedItem.Name) { $FormTitle = "$FormTitle - $($selectedItem.Name)" }
# ---- JSON tab ----------------------------------------------------------
$txtValue = $hostType::FindByName($detailsForm, 'txtValue')
$state.TxtValue = $txtValue
$state.HostType = $hostType
$btnFull = $hostType::FindByName($detailsForm, 'btnFull')
$btnCopy = $hostType::FindByName($detailsForm, 'btnCopy')
if ($txtValue) { $txtValue.Text = [string]$selectedItem.JsonString }
if ($btnFull -and $selectedItem.PolicyType.AllowFullDetails -eq $false) {
$btnFull.IsVisible = $false
}
if ($btnCopy) {
$btnCopy.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_detailsTagState
if (-not $st) { return }
try {
$st = $script:_detailsTagState
if ($st -and $st.TxtValue -and $st.TxtValue.Text) { $st.TxtValue.Text | Set-Clipboard }
} catch { Write-LogError "Details Copy failed" $_.Exception }
}))
}
if ($btnFull) {
$btnFull.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_detailsTagState
if (-not $st) { return }
try {
$st = $script:_detailsTagState
if (-not $st -or -not $st.SelectedItem) { return }
Write-Status "Get full object $($st.SelectedItem.Name)"
[void]$st.SelectedItem.Get()
if ($st.SelectedItem.IsFullObject -and $st.TxtValue) {
$st.TxtValue.Text = [string]$st.SelectedItem.JsonString
}
Write-Status ""
} catch { Write-LogError "Load full failed" $_.Exception; Write-Status "" }
}))
}
# ---- Settings tab ------------------------------------------------------
$txtName = $hostType::FindByName($detailsForm, 'txtObjectName')
$txtDesc = $hostType::FindByName($detailsForm, 'txtObjectDescription')
if ($txtName) { $txtName.Text = [string]$selectedItem.Name }
if ($txtDesc) { $txtDesc.Text = [string]$selectedItem.Description }
# Snapshots for the save-confirmation dialog: it names the object and
# says exactly what changed, computed against these open-time values via
# $script: state (reliable regardless of handler capture behavior).
$state.TxtName = $txtName
$state.TxtDesc = $txtDesc
$state.OriginalName = if ($txtName) { [string]$txtName.Text } else { [string]$selectedItem.Name }
$state.OriginalDesc = if ($txtDesc) { [string]$txtDesc.Text } else { '' }
$pnlScopeTagsLabel = $hostType::FindByName($detailsForm, 'pnlScopeTagsLabel')
$grdScopeTags = $hostType::FindByName($detailsForm, 'grdScopeTags')
$lstAvail = $hostType::FindByName($detailsForm, 'lstAvailableScopeTags')
$lstAssigned = $hostType::FindByName($detailsForm, 'lstAssignedScopeTags')
$btnTagAssign = $hostType::FindByName($detailsForm, 'btnScopeTagAssign')
$btnTagUnassign = $hostType::FindByName($detailsForm, 'btnScopeTagUnassign')
$btnSettingsSave = $hostType::FindByName($detailsForm, 'btnObjectSettingsSave')
if ($selectedItem.PolicyType.ScopeTagProperty) {
$state.ScopeTagPropName = [string]$selectedItem.PolicyType.ScopeTagProperty
}
if (-not $state.ScopeTagPropName) {
if ($pnlScopeTagsLabel) { $pnlScopeTagsLabel.IsVisible = $false }
if ($grdScopeTags) { $grdScopeTags.IsVisible = $false }
} else {
$allScopeTags = @()
try {
$depObjects = Get-GraphDependencySourceObjects $selectedItem -DefaultPoliciesOnly
if ($depObjects -and $depObjects.ContainsKey('ScopeTags')) {
$allScopeTags = @($depObjects['ScopeTags'])
}
} catch { Write-LogDebug "Scope tag dependency cache lookup failed: $($_.Exception.Message)" }
if ($allScopeTags.Count -eq 0) {
try {
$allScopeTags = @(Get-GraphPolicies -PolicyType 'ScopeTags' -TokenId $selectedItem._TokenId)
} catch { Write-LogError "Failed to load scope tags" $_.Exception }
}
$currentIds = $null
if ($selectedItem.Object -and $selectedItem.Object.PSObject.Properties[$state.ScopeTagPropName]) {
$currentIds = $selectedItem.Object.($state.ScopeTagPropName)
}
foreach ($id in @($currentIds)) {
if ($null -ne $id) { [void]$state.AssignedIds.Add([string]$id) }
}
$state.OriginalAssignedIds = [System.Collections.Generic.HashSet[string]]::new($state.AssignedIds)
# Project to CopyDialogScopeTagItem (already a CLR class with Id/Name) —
# same shape works here. Dedupe by Id (cache may carry both the
# synthetic Default and a real Default tag).
$dedup = @()
$seenIds = [System.Collections.Generic.HashSet[string]]::new()
foreach ($tag in $allScopeTags) {
$idStr = [string]$tag.Id
if (-not $seenIds.Add($idStr)) { continue }
$dedup += [CopyDialogScopeTagItem]@{ Id = $idStr; Name = [string]$tag.Name }
}
$state.AllScopeTags = @($dedup | Sort-Object Name)
if ($lstAvail) {
$lstAvail.ItemsSource = $state.AvailableCollection
}
if ($lstAssigned) {
$lstAssigned.ItemsSource = $state.AssignedCollection
}
$state.LstAvail = $lstAvail
$state.LstAssigned = $lstAssigned
# Helpers live in $script: and read $script: state only - local
# captures would be lost the moment an event handler runs them.
$script:_detailsSyncTagLists = {
$st = $script:_detailsTagState
if (-not $st) { return }
$st.AvailableCollection.Clear()
$st.AssignedCollection.Clear()
foreach ($tag in $st.AllScopeTags) {
if ($st.AssignedIds.Contains($tag.Id)) {
[void]$st.AssignedCollection.Add($tag)
} else {
[void]$st.AvailableCollection.Add($tag)
}
}
}
& $script:_detailsSyncTagLists
$script:_detailsMoveTags = {
param([string]$Action, [string[]]$Ids)
$st = $script:_detailsTagState
if (-not $st -or $Ids.Count -eq 0) { return }
if ($Action -eq 'assign') {
foreach ($id in $Ids) { [void]$st.AssignedIds.Add([string]$id) }
} else {
foreach ($id in $Ids) { [void]$st.AssignedIds.Remove([string]$id) }
}
& $script:_detailsSyncTagLists
}
if ($btnTagAssign) {
$btnTagAssign.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_detailsTagState
if (-not $st) { return }
try {
$st = $script:_detailsTagState
if (-not $st -or -not $st.LstAvail -or $st.LstAvail.SelectedItems.Count -eq 0) {
Write-Status "Select one or more scope tag(s) in the Available list first"
return
}
$ids = @($st.LstAvail.SelectedItems | ForEach-Object { [string]$_.Id })
& $script:_detailsMoveTags 'assign' $ids
} catch { Write-LogError "Scope-tag assign handler failed" $_.Exception }
}))
}
if ($btnTagUnassign) {
$btnTagUnassign.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_detailsTagState
if (-not $st) { return }
try {
$st = $script:_detailsTagState
if (-not $st -or -not $st.LstAssigned -or $st.LstAssigned.SelectedItems.Count -eq 0) {
Write-Status "Select one or more scope tag(s) in the Assigned list first"
return
}
$ids = @($st.LstAssigned.SelectedItems | ForEach-Object { [string]$_.Id })
& $script:_detailsMoveTags 'unassign' $ids
} catch { Write-LogError "Scope-tag unassign handler failed" $_.Exception }
}))
}
# Avalonia's MouseDoubleClick equivalent on ListBox is DoubleTapped.
if ($lstAvail) {
$lstAvail.add_DoubleTapped((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_detailsTagState
if (-not $st) { return }
try {
$st = $script:_detailsTagState
if (-not $st -or -not $st.LstAvail -or $st.LstAvail.SelectedItems.Count -eq 0) { return }
$ids = @($st.LstAvail.SelectedItems | ForEach-Object { [string]$_.Id })
& $script:_detailsMoveTags 'assign' $ids
} catch { Write-LogError "Scope-tag double-tap (available) failed" $_.Exception }
}))
}
if ($lstAssigned) {
$lstAssigned.add_DoubleTapped((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_detailsTagState
if (-not $st) { return }
try {
$st = $script:_detailsTagState
if (-not $st -or -not $st.LstAssigned -or $st.LstAssigned.SelectedItems.Count -eq 0) { return }
$ids = @($st.LstAssigned.SelectedItems | ForEach-Object { [string]$_.Id })
& $script:_detailsMoveTags 'unassign' $ids
} catch { Write-LogError "Scope-tag double-tap (assigned) failed" $_.Exception }
}))
}
}
if ($btnSettingsSave) {
$btnSettingsSave.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_detailsTagState
if (-not $st) { return }
try {
# Name the object and say what changed - all read from the
# $script: state snapshots taken at form open.
$st = $script:_detailsTagState
$originalName = if ($st -and $st.OriginalName) { [string]$st.OriginalName } else { [string]$st.SelectedItem.Name }
$changes = @()
if ($st -and $st.TxtName) {
$newName = [string]$st.TxtName.Text
if ($newName -and $newName -cne $originalName) { $changes += "New name: '$newName'" }
}
if ($st -and $st.TxtDesc -and ([string]$st.TxtDesc.Text) -cne [string]$st.OriginalDesc) {
$changes += "Description updated"
}
if ($st -and $null -ne $st.OriginalAssignedIds -and $null -ne $st.AssignedIds) {
$added = @($st.AssignedIds | Where-Object { -not $st.OriginalAssignedIds.Contains($_) }).Count
$removed = @($st.OriginalAssignedIds | Where-Object { -not $st.AssignedIds.Contains($_) }).Count
if ($added -or $removed) { $changes += "Scope tags: $added added, $removed removed" }
}
$confirmMsg = "Are you sure you want to update '$originalName'?"
if ($changes.Count) { $confirmMsg += "`n`n" + ($changes -join "`n") }
if (($ui.ShowMessageBox($confirmMsg, "Update object information?", "YesNo", "Warning")) -ne "Yes") {
return
}
Write-Status "Update object information for $($st.SelectedItem.Name)"
$nameValue = if ($st.TxtName) { [string]$st.TxtName.Text } else { '' }
if (-not $nameValue) {
$ui.ShowMessageBox("Name property must not be empty!", "Error", "OK", "Error")
Write-Status ""
return
}
$nameProp = (?? $st.SelectedItem.PolicyType.NameProperty "displayName")
$idProp = (?? $st.SelectedItem.PolicyType.IDProperty "id")
$updateHT = @{}
$updateHT.Add($idProp, $st.SelectedItem.ID)
$updateHT.Add($nameProp, $nameValue)
if ($st.SelectedItem.Object."@odata.type") {
$updateHT.Add("@odata.type", $st.SelectedItem.JsonObject."@odata.type")
}
if ($st.TxtDesc -and $st.TxtDesc.IsEnabled -eq $true) {
$descValue = [string]$st.TxtDesc.Text
if (-not $descValue -and $st.SelectedItem.Description) {
$updateHT["description"] = ""
} elseif ($descValue) {
$updateHT["description"] = $descValue
}
}
if ($st.ScopeTagPropName -and $null -ne $st.AssignedIds) {
$assignedIds = @($st.AssignedIds)
# Soft guard: Intune treats roleScopeTagIds as containing "0"
# (Default) by default. A non-empty list omitting Default is
# usually rejected by Graph; warn before letting the PATCH
# 400 silently. Empty list isn't flagged — that's a deliberate
# reset.
if ($assignedIds.Count -gt 0 -and $assignedIds -notcontains "0") {
$proceed = $ui.ShowMessageBox("The 'Default' scope tag (Id 0) is not in the assigned list.`n`nIntune usually requires Default to be present and the API call may fail.`n`nContinue anyway?", "Default scope tag missing", "YesNo", "Warning")
if ($proceed -ne "Yes") { Write-Status ""; return }
}
$updateHT.Add($st.ScopeTagPropName, $assignedIds)
}
$updateObj = [PSCustomObject]$updateHT
$api = "$($st.SelectedItem.PolicyType.API)/$($st.SelectedItem.ID)"
$json = $updateObj | ConvertTo-Json -Depth 20
$ret = Invoke-MSGraphAPI $api -HttpMethod "PATCH" -Content $json -FullResponseObject -TokenId $st.SelectedItem._TokenId
Write-Status ""
if ($ret.Success -eq $false) {
Write-Log "Failed to update object information for $($st.SelectedItem.Name). Error: $($ret.StatusCode) - $($ret.StatusDescription)"
$ui.ShowMessageBox("Object information could not be verified!`n`nCheck the log file", "Update warning", "OK", "Warning")
} else {
Write-Log "Object information updated for $($st.SelectedItem.Name)"
# Refresh in-memory object so the parent grid's ScopeTags
# column reflects the new value. Clearing _ScopeTags forces
# the lazy getter to re-resolve. Avalonia DataGrid has no
# Items.Refresh — rebind ItemsSource to force re-pull (the
# row item is also a CLR class without INPC).
if ($st.ScopeTagPropName -and $null -ne $st.AssignedIds) {
$assignedIds = @($st.AssignedIds)
try { $st.SelectedItem.Object.($st.ScopeTagPropName) = $assignedIds } catch { }
$st.SelectedItem._ScopeTags = $null
$st.SelectedItem._ScopeTagsString = $null
}
try {
# IntuneObjectRowItem carries SNAPSHOT strings copied at
# build time, so re-binding alone still showed the old
# name / scope tags. Update the row that wraps this
# policy before the rebind.
$current = $script:dgIntuneManagerObjects.ItemsSource
foreach ($row in @($current)) {
if (-not $row -or -not [object]::ReferenceEquals($row.Source, $st.SelectedItem)) { continue }
$row.Name = [string]$st.SelectedItem.Name
try { $row.ScopeTags = [string]$st.SelectedItem.ScopeTags } catch { }
}
$script:dgIntuneManagerObjects.ItemsSource = $null
$script:dgIntuneManagerObjects.ItemsSource = $current
} catch { Write-LogDebug "DataGrid refresh failed: $($_.Exception.Message)" }
$st.InitialSettingsSig = Get-DetailsSettingsSignature -State $st
}
} catch { Write-LogError "Settings Save failed" $_.Exception; Write-Status "" }
}))
}
# ---- Columns tab -------------------------------------------------------
$lstBasic = $hostType::FindByName($detailsForm, 'lstBasicProperties')
$lstObjProps = $hostType::FindByName($detailsForm, 'lstObjectProperties')
$lstObjCols = $hostType::FindByName($detailsForm, 'lstObjectColumns')
$btnBasicAdd = $hostType::FindByName($detailsForm, 'btnBasicColumnsAdd')
$btnObjAdd = $hostType::FindByName($detailsForm, 'btnObjectColumnsAdd')
$btnColUp = $hostType::FindByName($detailsForm, 'btnObjectColumnsMoveUp')
$btnColDown = $hostType::FindByName($detailsForm, 'btnObjectColumnsMoveDown')
$btnColDelete = $hostType::FindByName($detailsForm, 'btnObjectColumnsDelete')
$btnColClear = $hostType::FindByName($detailsForm, 'btnObjectColumnsClear')
$btnColReset = $hostType::FindByName($detailsForm, 'btnObjectColumnsReset')
$btnColSave = $hostType::FindByName($detailsForm, 'btnObjectColumnsSave')
$grdObjCols = $hostType::FindByName($detailsForm, 'grdObjectColumns')
$chkOverride = $hostType::FindByName($detailsForm, 'chkObjectColumnOverride')
$lblColConfig = $hostType::FindByName($detailsForm, 'lblObjectColumnsConfig')
$state.LstBasic = $lstBasic
$state.LstObjProps = $lstObjProps
$state.LstObjCols = $lstObjCols
$state.GrdObjCols = $grdObjCols
$state.ChkOverride = $chkOverride
$skipBasic = @("JsonObject", "Object", "JsonString", "TenantId", "TokenId", "IsSelected")
$arrBasic = foreach ($prop in ($selectedItem.PSObject.Properties | Where-Object { $skipBasic -notcontains $_.Name })) {
[DetailsPropertyItem]@{ Name = $prop.Name; Value = $prop; Source = "Basic" }
}
# Typed cast, not a bare pipeline: Sort-Object re-wraps each item in a
# PSObject, which Avalonia's binder cannot see through, so the list would
# render blank rows against DisplayMemberBinding.
$arrBasic = [DetailsPropertyItem[]]@($arrBasic | Sort-Object -Property Name)
$skipObj = @("@odata.editLink")
$arrObjProps = foreach ($prop in ($selectedItem.Object.PSObject.Properties | Where-Object { $skipObj -notcontains $_.Name -and $_.Name -notlike "*?@odata.*" })) {
[DetailsPropertyItem]@{ Name = $prop.Name; Value = $prop; Source = "Object" }
}
$arrObjProps = [DetailsPropertyItem[]]@($arrObjProps | Sort-Object -Property Name)
# Display fields come from DisplayMemberBinding in ObjectDetails.axaml.
if ($lstBasic) {
$lstBasic.ItemsSource = $arrBasic
}
if ($lstObjProps) {
$lstObjProps.ItemsSource = $arrObjProps
}
if ($lstObjCols) {
$lstObjCols.ItemsSource = $state.ColObjectProperties
$lstObjCols.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_detailsTagState
if (-not $st) { return }
if ($st.GrdObjCols) { $st.GrdObjCols.DataContext = $st.LstObjCols.SelectedItem }
}))
}
if ($btnBasicAdd) {
$btnBasicAdd.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_detailsTagState
if (-not $st) { return }
$sel = if ($st.LstBasic) { $st.LstBasic.SelectedItem } else { $null }
if ($sel) { $st.ColObjectProperties.Add(([ObjectColumnInfo]::new($sel.Name, ""))) }
}))
}
if ($btnObjAdd) {
$btnObjAdd.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_detailsTagState
if (-not $st) { return }
$sel = if ($st.LstObjProps) { $st.LstObjProps.SelectedItem } else { $null }
if ($sel) { $st.ColObjectProperties.Add(([ObjectColumnInfo]::new("Object." + $sel.Name, ""))) }
}))
}
if ($btnColUp) {
$btnColUp.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_detailsTagState
if (-not $st) { return }
if (-not $st.LstObjCols) { return }
$idx = $st.LstObjCols.SelectedIndex
if ($idx -gt 0) {
$tmp = $st.ColObjectProperties[$idx]
$st.ColObjectProperties.RemoveAt($idx)
$st.ColObjectProperties.Insert(($idx - 1), $tmp)
$st.LstObjCols.SelectedIndex = $idx - 1
}
}))
}
if ($btnColDown) {
$btnColDown.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_detailsTagState
if (-not $st) { return }
if (-not $st.LstObjCols) { return }
$idx = $st.LstObjCols.SelectedIndex
if ($idx -ge 0 -and $idx -lt ($st.ColObjectProperties.Count - 1)) {
$tmp = $st.ColObjectProperties[$idx]
$st.ColObjectProperties.RemoveAt($idx)
$st.ColObjectProperties.Insert(($idx + 1), $tmp)
$st.LstObjCols.SelectedIndex = $idx + 1
}
}))
}
if ($btnColDelete) {
$btnColDelete.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_detailsTagState
if (-not $st) { return }
if (-not $st.LstObjCols) { return }
$idx = $st.LstObjCols.SelectedIndex
if ($idx -ge 0) {
if (($ui.ShowMessageBox("Are you sure you want to remove selected column?", "Remove Columns?", "YesNo", "Warning")) -ne "Yes") {
return
}
$st.ColObjectProperties.RemoveAt($idx)
}
}))
}
if ($btnColClear) {
$btnColClear.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_detailsTagState
if (-not $st) { return }
if (($ui.ShowMessageBox("Are you sure you want to clear custom column settings?", "Clear Custom Columns?", "YesNo", "Warning")) -ne "Yes") {
return
}
$st.ColObjectProperties.Clear()
}))
}
if ($btnColReset) {
$btnColReset.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_detailsTagState
if (-not $st) { return }
$st.ColObjectProperties.Clear()
Show-DetailsObjectDefaultColumns -State $st -Reset
}))
}
if ($btnColSave) {
$btnColSave.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_detailsTagState
if (-not $st) { return }
try {
$selectedType = Get-DetailsSelectedObjectTypeString
if (-not $selectedType) { return }
if (($ui.ShowMessageBox("Are you sure you want to save custom column settings?", "Save Custom Columns?", "YesNo", "Warning")) -ne "Yes") {
return
}
if ($st.ColObjectProperties.Count -gt 0) {
$arrCols = @()
if ($st.ChkOverride -and $st.ChkOverride.IsChecked -eq $true) { $arrCols += "0" }
foreach ($col in $st.ColObjectProperties) {
$tmp = $col.Property
if ($col.Header -and $col.Header -cne $col.Property) {
$tmp = "$($tmp)=$($col.Header)"
}
$arrCols += $tmp
}
Save-SettingStoreValue "IntuneManager\ObjectColumns\$selectedType" "$($script:IntuneManagerSelectedObject.Id)" ($arrCols -join ",")
} else {
Remove-SettingStoreValue "IntuneManager\ObjectColumns\$selectedType" "$($script:IntuneManagerSelectedObject.Id)"
}
Show-DetailsObjectDefaultColumns -State $st
$st.InitialColumnsSig = Get-DetailsColumnsSignature -State $st
} catch { Write-LogError "Columns Save failed" $_.Exception }
}))
}
Show-DetailsObjectDefaultColumns -State $state
$state.InitialSettingsSig = Get-DetailsSettingsSignature -State $state
$state.InitialColumnsSig = Get-DetailsColumnsSignature -State $state
# Hook ConfirmClose so the Show-ModalForm close button checks for unsaved
# changes. Mirrors the WPF impl which attached this as a ScriptMethod.
#
# PowerShell ScriptMethod scriptblocks invoked through Add-Member execute
# outside the defining module's session state — module-scoped functions
# like Test-DetailsViewHasUnsavedChanges / Show-MessageBox aren't visible
# by name. Capture them as module-bound scriptblocks first (`${function:X}`
# preserves the binding) and invoke via `&` from inside the closure.
#
# NOTE: ConvertTo-AvaloniaEventScriptBlock would STRIP these closure
# captures (NewBoundScriptBlock loses GetNewClosure state), so the SB
# would fail with "expression after & not valid" when invoked. The
# captured ${function:X} SBs already carry their own module binding,
# so we don't need ConvertTo here — plain GetNewClosure is sufficient.
$sbTestDirty = ${function:Test-DetailsViewHasUnsavedChanges}
$sbShowMsgBox = ${function:Show-MessageBox}
$sbClearState = ${function:Clear-DetailsTagState}
$detailsForm | Add-Member -MemberType ScriptMethod -Name ConfirmClose -Value ({
# Release the state on every path that actually closes - it pins the
# selected policy including its full JSON after "Load full".
if (-not (& $sbTestDirty -State $state)) { & $sbClearState; return $true }
$result = & $sbShowMsgBox `
-Text "You have unsaved changes in Settings or Columns.`n`nClose without saving?" `
-Caption "Unsaved changes" -Button "YesNo" -Icon "Warning"
if ($result -eq "Yes") { & $sbClearState; return $true }
return $false
}.GetNewClosure()) -Force
# Per-policy-type Details buttons (Download/Edit for Script/PolicyFile,
# Upload/Download for Application). Routes through Add-AvaloniaDetailsButton
# to insert into pnlButtons. Failures here must not block the modal.
if ($selectedItem.PolicyType.AddUIDetailsExtension) {
try { $selectedItem.PolicyType.AddUIDetailsExtension($detailsForm) }
catch { Write-LogError "AddUIDetailsExtension failed for $($selectedItem.PolicyType.Name)" $_.Exception }
}
$ui.ShowModalForm($FormTitle, $detailsForm)
}
# ---------------------------------------------------------------------------
# Helpers (private to this file). State is passed in explicitly rather than
# pulled from $script: vars so a future "open two details dialogs at once"
# flow doesn't require restructuring.
# ---------------------------------------------------------------------------
function Get-DetailsSelectedObjectTypeString
{
if ($script:IntuneManagerSelectedObject -is [IntunePolicyGroupBase]) { return "PolicyGroup" }
if ($script:IntuneManagerSelectedObject -is [IntunePolicyTypeBase]) { return "PolicyType" }
return ""
}
function Show-DetailsObjectDefaultColumns
{
param(
[Parameter(Mandatory)] $State,
[switch]$Reset
)
$hostType = Get-AvaloniaHost
$lblColConfig = $hostType::FindByName($State.Form, 'lblObjectColumnsConfig')
$chkOverride = $hostType::FindByName($State.Form, 'chkObjectColumnOverride')
if ($Reset -ne $true) {
$selectedType = Get-DetailsSelectedObjectTypeString
if (-not $selectedType) { return }
$strColSettings = Get-SettingStoreValue "IntuneManager\ObjectColumns\$selectedType" "$($script:IntuneManagerSelectedObject.Id)"
} else {
$strColSettings = ""
}
$State.ColObjectProperties.Clear()
$defaultColumns = $script:IntuneManagerSelectedObject.ViewProperties
if ($strColSettings) {
$arrColSettings = $strColSettings -split ",|;"
if ($chkOverride) {
$chkOverride.IsChecked = ($arrColSettings.Count -gt 0 -and $arrColSettings[0] -eq "0")
}
$start = 0
if ($arrColSettings.Count -gt 0 -and ($arrColSettings[0] -eq "0" -or $arrColSettings[0] -eq "1")) {
$start = 1
}
$colArr = @()
for ($i = $start; $i -lt $arrColSettings.Count; $i++) {
$colProp, $colHeader = $arrColSettings[$i].Split("=")
if (-not $colHeader) { $colHeader = $colProp }
$State.ColObjectProperties.Add([ObjectColumnInfo]::new($colProp, $colHeader))
$colArr += $colProp
}
if ($arrColSettings.Count -eq 0 -or $arrColSettings[0] -ne "0") {
$colArr = @($defaultColumns) + $colArr
}
if ($lblColConfig) { $lblColConfig.Text = ($colArr -join ',') }
} else {
if ($lblColConfig) { $lblColConfig.Text = "$($defaultColumns -join ',') (Default)" }
}
}
function Get-DetailsSettingsSignature
{
param([Parameter(Mandatory)] $State)
if (-not $State.Form) { return "" }
$hostType = Get-AvaloniaHost
$txtName = $hostType::FindByName($State.Form, 'txtObjectName')
$txtDesc = $hostType::FindByName($State.Form, 'txtObjectDescription')
$scopeTagIds = @()
if ($State.ScopeTagPropName -and $null -ne $State.AssignedIds) {
$scopeTagIds = @($State.AssignedIds | Sort-Object)
}
$descriptionEnabled = $false
if ($txtDesc) { $descriptionEnabled = [bool]$txtDesc.IsEnabled }
$signature = [PSCustomObject]@{
Name = if ($txtName) { [string]$txtName.Text } else { "" }
DescriptionEnabled = $descriptionEnabled
Description = if ($descriptionEnabled -and $txtDesc) { [string]$txtDesc.Text } else { $null }
ScopeTagProperty = [string]$State.ScopeTagPropName
ScopeTagIds = $scopeTagIds
}
return ($signature | ConvertTo-Json -Depth 10 -Compress)
}
function Get-DetailsColumnsSignature
{
param([Parameter(Mandatory)] $State)
if (-not $State.Form) { return "" }
$hostType = Get-AvaloniaHost
$chkOverride = $hostType::FindByName($State.Form, 'chkObjectColumnOverride')
$columns = @()
foreach ($col in @($State.ColObjectProperties)) {
if (-not $col) { continue }
$columns += [PSCustomObject]@{ Property = [string]$col.Property; Header = [string]$col.Header }
}
$signature = [PSCustomObject]@{
OverrideDefaultColumns = if ($chkOverride) { [bool]$chkOverride.IsChecked } else { $false }
Columns = $columns
}
return ($signature | ConvertTo-Json -Depth 20 -Compress)
}
function Test-DetailsViewHasUnsavedChanges
{
param([Parameter(Mandatory)] $State)
if (-not $State.Form) { return $false }
$settingsDirty = $false
$columnsDirty = $false
try {
if ($State.InitialSettingsSig) {
$settingsDirty = ((Get-DetailsSettingsSignature -State $State) -ne $State.InitialSettingsSig)
}
} catch { Write-LogDebug "Details settings dirty check failed: $($_.Exception.Message)" }
try {
if ($State.InitialColumnsSig) {
$columnsDirty = ((Get-DetailsColumnsSignature -State $State) -ne $State.InitialColumnsSig)
}
} catch { Write-LogDebug "Details columns dirty check failed: $($_.Exception.Message)" }
return ($settingsDirty -or $columnsDirty)
}
function Clear-DetailsTagState
{
# Called from the ConfirmClose ScriptMethod, which runs outside the module's
# session state - a bare $script: assignment there would target the caller's
# scope, not the module's. Capture this as ${function:Clear-DetailsTagState}
# and invoke it with & so the write lands in module scope.
$script:_detailsTagState = $null
}
@@ -0,0 +1,208 @@
# Avalonia port of the Export dialog from UI/WPF/Extensions/IntuneManagerUI.ps1
# (Show-IntuneManagerExportForm). New file because Export is a self-contained
# subsystem and we don't want to grow the giant IntuneManagerUI files further
# (architecture rule R9).
#
# Same closure-free design rationale as IntuneManagerImportUI.ps1: form state
# lives in $script:_imExportFormState so click handlers can be plain
# scriptblocks (no .GetNewClosure() — that puts the SB into a dynamic module
# with no module-private function resolution).
function Show-IntuneManagerExportForm
{
if (-not $script:dgIntuneManagerObjects) { return }
$ui = $script:UIProvider
$exportForm = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/ExportForm.axaml'))
if (-not $exportForm) { return }
$hostType = Get-AvaloniaHost
$txtPath = $hostType::FindByName($exportForm, 'txtExportPath')
$browse = $hostType::FindByName($exportForm, 'browseExportPath')
$chkObj = $hostType::FindByName($exportForm, 'chkAddObjectType')
$chkComp = $hostType::FindByName($exportForm, 'chkAddCompanyName')
$chkAssign = $hostType::FindByName($exportForm, 'chkExportAssignments')
$btnSel = $hostType::FindByName($exportForm, 'btnExportSelected')
$btnAll = $hostType::FindByName($exportForm, 'btnExportAll')
$btnCancel = $hostType::FindByName($exportForm, 'btnCancel')
$lblSel = $hostType::FindByName($exportForm, 'lblSelectedObject')
# Build the settings instance and let the data-side hooks add their own
# NoteProperties (per-type extra export options). These reach onto the
# CLR class via Add-Member so we can still read them after Save().
$exportSettings = [IntuneManagerExportSettings]::new()
$policyTypes = Get-IntuneManagerSelectedPolicyTypes
$policyTypes | Add-IntuneManagerExportProperties -ExportSettings $exportSettings
$policyTypes | Add-IntuneManagerExportUIExtensions -Form $exportForm
# Avalonia bindings would render against IntuneManagerExportSettings
# but the class has no INotifyPropertyChanged, so two-way round-trip
# silently fails. Push current values into controls and read them back
# imperatively on click.
if ($txtPath) { $txtPath.Text = [string]$exportSettings.ExportFolder }
if ($chkObj) { $chkObj.IsChecked = [bool]$exportSettings.AddObjectType }
if ($chkComp) { $chkComp.IsChecked = [bool]$exportSettings.AddCompanyName }
if ($chkAssign) { $chkAssign.IsChecked = [bool]$exportSettings.ExportAssignments }
# Selection summary label + Export Selected enable state mirror the WPF
# behaviour: prefer IsSelected-checked rows over the highlighted row.
# Scan the grid's CURRENT (filtered) ItemsSource - a row checked before the
# filter was typed must not be exported while invisible.
$checkedRows = @(@($script:dgIntuneManagerObjects.ItemsSource) | Where-Object { $_ -and $_.IsSelected })
$highlighted = $script:dgIntuneManagerObjects.SelectedItem
if ($lblSel) {
if ($checkedRows.Count -gt 0) {
$lblSel.Content = "$($checkedRows.Count) selected object(s)"
} elseif ($highlighted) {
$lblSel.Content = "Selected object: $($highlighted.Name)"
}
}
if ($btnSel) {
$btnSel.IsEnabled = ($checkedRows.Count -gt 0) -or ($null -ne $highlighted)
}
# Module-scope state container. Click handlers reference by name —
# no .GetNewClosure() so they survive Avalonia delegate dispatch.
$script:_imExportFormState = @{
Settings = $exportSettings
TxtPath = $txtPath
ChkObj = $chkObj
ChkComp = $chkComp
ChkAssign = $chkAssign
CheckedRows = $checkedRows
Highlighted = $highlighted
Action = $null
}
if ($browse) {
$browse.add_Click({
$st = $script:_imExportFormState
$ui2 = $script:UIProvider
if (-not $st) { return }
$folder = $ui2.ShowFolderPicker('Select root folder for export')
if ($folder) {
$st.Settings.ExportFolder = $folder
if ($st.TxtPath) { $st.TxtPath.Text = $folder }
}
})
}
if ($btnSel) {
$btnSel.add_Click({
$st = $script:_imExportFormState
if (-not $st) { return }
$st.Action = 'Selected'
# Keep the dialog open when the export bailed out on validation
# (e.g. no folder picked) - it used to close and discard the input.
$ranOk = $false
try { $ranOk = [bool](Invoke-IntuneManagerExportExecute) } catch { Write-LogError "Export execution failed" $_.Exception }
if (-not $ranOk) { return }
$script:UIProvider.CloseTopModalObject()
$script:_imExportFormState = $null
if ($script:dgIntuneManagerObjects) { $script:dgIntuneManagerObjects.Focus() }
})
}
if ($btnAll) {
$btnAll.add_Click({
$st = $script:_imExportFormState
if (-not $st) { return }
$st.Action = 'All'
$ranOk = $false
try { $ranOk = [bool](Invoke-IntuneManagerExportExecute) } catch { Write-LogError "Export execution failed" $_.Exception }
if (-not $ranOk) { return }
$script:UIProvider.CloseTopModalObject()
$script:_imExportFormState = $null
if ($script:dgIntuneManagerObjects) { $script:dgIntuneManagerObjects.Focus() }
})
}
if ($btnCancel) {
$btnCancel.add_Click({
$script:UIProvider.CloseTopModalObject()
$script:_imExportFormState = $null
if ($script:dgIntuneManagerObjects) { $script:dgIntuneManagerObjects.Focus() }
})
}
$exportForm.add_KeyDown({
param($s, $e)
if ($e.Key -eq [Avalonia.Input.Key]::Escape) {
$script:UIProvider.CloseTopModalObject()
$script:_imExportFormState = $null
$e.Handled = $true
}
})
# ShowModalForm is non-blocking; export runs inside the button handlers via
# Invoke-IntuneManagerExportExecute while the form state is live.
$ui.ShowModalForm("Export $($script:IntuneManagerSelectedObject.Title) objects", $exportForm, $true)
}
function Invoke-IntuneManagerExportExecute
{
# Runs the export for the live $script:_imExportFormState. Called from the
# Export button handlers (ShowModalForm does not block).
$st = $script:_imExportFormState
if (-not $st -or -not $st.Action -or $st.Action -eq 'Cancel') { return }
$ui = $script:UIProvider
$exportSettings = $st.Settings
$txtPath = $st.TxtPath
$chkObj = $st.ChkObj
$chkComp = $st.ChkComp
$chkAssign = $st.ChkAssign
$checkedRows = @($st.CheckedRows)
$highlighted = $st.Highlighted
# Pull form state back into the settings object before exporting.
if ($txtPath) { $exportSettings.ExportFolder = [string]$txtPath.Text }
if ($chkObj) { $exportSettings.AddObjectType = [bool]$chkObj.IsChecked }
if ($chkComp) { $exportSettings.AddCompanyName = [bool]$chkComp.IsChecked }
if ($chkAssign) { $exportSettings.ExportAssignments = [bool]$chkAssign.IsChecked }
if ([string]::IsNullOrWhiteSpace($exportSettings.ExportFolder)) {
$ui.ShowMessageBox("Pick an export root folder first.", "Export", "OK", "Error") | Out-Null
return $false
}
$rowsToExport = @()
if ($st.Action -eq 'Selected') {
if ($checkedRows.Count -gt 0) {
$rowsToExport = $checkedRows
} elseif ($highlighted) {
$rowsToExport = @($highlighted)
}
} else {
# Export All — use the current ItemsSource (filtered) to match what
# the user sees.
if ($script:dgIntuneManagerObjects.ItemsSource) {
$rowsToExport = @($script:dgIntuneManagerObjects.ItemsSource)
}
}
if ($rowsToExport.Count -eq 0) {
Write-Log "No rows to export" 2
return $false
}
Write-Status "Export $($script:IntuneManagerSelectedObject.Title)"
# Export-GraphPolicy expects IntunePolicyBase, not the CLR row wrapper —
# pull .Source off each row before piping.
$policiesToExport = @($rowsToExport | ForEach-Object { $_.Source } | Where-Object { $_ })
try {
$policiesToExport | Export-GraphPolicy -ExportSettings $exportSettings
} catch {
Write-LogError "Export-GraphPolicy failed" $_.Exception
$ui.ShowMessageBox("Export failed: $($_.Exception.Message)", "Error", "OK", "Error") | Out-Null
Write-Status ""
return $false
}
$exportSettings.Save()
Write-Status ""
if ($script:dgIntuneManagerObjects) { $script:dgIntuneManagerObjects.Focus() }
return $true
}
@@ -0,0 +1,229 @@
# Avalonia parallel of UI/WPF/Extensions/IntuneManagerUI.ps1's
# Add-IntuneManagerExportUIExtensions / Add-IntuneManagerImportUIExtensions
# wrappers, plus shared helpers used by ClassExtensions/*UIExtension.ps1.
#
# The wrappers iterate the selected policy types and call their attached
# AddUIExportExtensions / AddUIImportExtensions ScriptMethods. The
# Avalonia versions of those ScriptMethods live in
# UI/Avalonia/ClassExtensions/ — same method names so the wrappers stay
# WPF-compatible. New file per architecture R9 (this is a small new
# subsystem; folding it into IntuneManagerUI.ps1 would push that file
# further away from "one public function per file").
function Add-IntuneManagerExportUIExtensions
{
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[IntunePolicyTypeBase[]]
$InputObject,
[Parameter(Mandatory = $true)]
[Object]
$Form
)
Begin { Write-Log "Add Export UI Extensions - Start" }
Process {
foreach ($policyType in $InputObject) {
if ($policyType.AddUIExportExtensions) {
try { $policyType.AddUIExportExtensions($Form) }
catch { Write-LogError "AddUIExportExtensions failed for $($policyType.Name)" $_.Exception }
}
}
}
End { Write-Log "Add Export UI Extensions - Done" }
}
function Add-IntuneManagerImportUIExtensions
{
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[IntunePolicyTypeBase[]]
$InputObject,
[Parameter(Mandatory = $true)]
[Object]
$Form
)
Begin { Write-Log "Add Import UI Extensions - Start" }
Process {
foreach ($policyType in $InputObject) {
if ($policyType.AddUIImportExtensions) {
try { $policyType.AddUIImportExtensions($Form) }
catch { Write-LogError "AddUIImportExtensions failed for $($policyType.Name)" $_.Exception }
}
}
}
End { Write-Log "Add Import UI Extensions - Done" }
}
function Add-AvaloniaExportPropertyCheckbox
{
# Shared helper used by every per-type Export extension to graft a
# labeled CheckBox onto grdExportProperties. Returns the CheckBox so
# callers can wire add_IsCheckedChanged. Returns $null if the form
# doesn't have the grid (e.g. wrong dialog), or if a checkbox with
# the same name was already added (prevents double-add on re-open).
param(
[Parameter(Mandatory)] $Form,
[Parameter(Mandatory)] [string] $CheckboxName,
[Parameter(Mandatory)] [string] $LabelText,
[string] $ToolTip,
[bool] $InitialChecked = $true
)
$hostType = Get-AvaloniaHost
$grdExportProperties = $hostType::FindByName($Form, 'grdExportProperties')
if (-not $grdExportProperties) { return $null }
# Idempotency guard — same as the WPF helper.
foreach ($child in @($grdExportProperties.Children)) {
if ($child -is [Avalonia.Controls.CheckBox] -and $child.Name -eq $CheckboxName) {
return $child
}
}
$rd = [Avalonia.Controls.RowDefinition]::new()
$rd.Height = [Avalonia.Controls.GridLength]::Auto
$grdExportProperties.RowDefinitions.Add($rd)
$rowIndex = $grdExportProperties.RowDefinitions.Count - 1
$label = [Avalonia.Controls.Label]::new()
$label.Content = $LabelText
$label.Margin = [Avalonia.Thickness]::new(0, 5, 5, 0)
$label.VerticalAlignment = [Avalonia.Layout.VerticalAlignment]::Center
if ($ToolTip) { [Avalonia.Controls.ToolTip]::SetTip($label, $ToolTip) }
$chk = [Avalonia.Controls.CheckBox]::new()
$chk.Name = $CheckboxName
$chk.IsChecked = $InitialChecked
$chk.Margin = [Avalonia.Thickness]::new(0, 5, 0, 0)
$chk.VerticalAlignment = [Avalonia.Layout.VerticalAlignment]::Center
[Avalonia.Controls.Grid]::SetRow($label, $rowIndex)
[Avalonia.Controls.Grid]::SetColumn($label, 0)
[Avalonia.Controls.Grid]::SetRow($chk, $rowIndex)
[Avalonia.Controls.Grid]::SetColumn($chk, 1)
$grdExportProperties.Children.Add($label) | Out-Null
$grdExportProperties.Children.Add($chk) | Out-Null
return $chk
}
function Add-AvaloniaDetailsButton
{
# Shared helper used by per-type Details extensions to insert a
# button into the ObjectDetails JSON tab's pnlButtons WrapPanel.
# The Avalonia WrapPanel exposes Children.Insert(int, control), so
# we mirror the WPF Insert(0, ...) pattern that puts new buttons
# at the left of "Load full" / "Copy".
param(
[Parameter(Mandatory)] $Form,
[Parameter(Mandatory)] [string] $Name,
[Parameter(Mandatory)] [string] $Content,
[Parameter(Mandatory)] [scriptblock] $OnClick,
[int] $InsertIndex = 0
)
$hostType = Get-AvaloniaHost
$buttonPanel = $hostType::FindByName($Form, 'pnlButtons')
if (-not $buttonPanel) { return $null }
$btn = [Avalonia.Controls.Button]::new()
$btn.Name = $Name
$btn.Content = $Content
$btn.MinWidth = 100
$btn.Margin = [Avalonia.Thickness]::new(0, 0, 5, 0)
# Module-bind the handler: an unbound scriptblock runs against the caller's
# session state at click time, where the module's private functions
# (Save-IntuneScriptContent, Start-DownloadAppContent, Write-LogError, ...)
# do not resolve - every per-type Details button failed silently.
$btn.add_Click((ConvertTo-AvaloniaEventScriptBlock $OnClick))
if ($InsertIndex -lt 0 -or $InsertIndex -gt $buttonPanel.Children.Count) {
$buttonPanel.Children.Add($btn) | Out-Null
} else {
$buttonPanel.Children.Insert($InsertIndex, $btn)
}
return $btn
}
function Show-AvaloniaScriptEditor
{
# param() MUST be the first statement in the body - only comments may precede
# it. With a statement in front, PowerShell parses `param(...)` as a COMMAND
# named "param" instead of a parameter block: the file still parses, the
# function silently ends up with NO parameters, and every call fails on
# "A parameter cannot be found that matches parameter name 'Title'". So
# $ui is assigned below the block, not above it.
#
# Avalonia equivalent of UI/WPF/XAML/EditScriptDialog.xaml + the
# Invoke-EditScript / Invoke-EditPolicyFile WPF flows. Hosted as a
# nested grdModal level via Show-ModalForm so it stacks on top of
# the Object Details modal; dismissed via Close-TopModalObject
# (Show-ModalObject with no args would tear down the parent Details
# modal too).
#
# Accepts an OnSave callback rather than returning text — Show-ModalForm
# is non-blocking on the overlay path, so the save work has to execute
# inside the Save click handler. The callback receives the post-edit
# text as its only argument.
param(
[Parameter(Mandatory)] [string] $Title,
[Parameter(Mandatory)] [string] $InitialText,
[Parameter(Mandatory)] [scriptblock] $OnSave
)
$ui = $script:UIProvider
$editForm = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/EditScriptDialog.axaml'))
if (-not $editForm) { return }
$hostType = Get-AvaloniaHost
$txtTitle = $hostType::FindByName($editForm, 'txtEditScriptTitle')
$txtScript = $hostType::FindByName($editForm, 'txtScriptText')
$btnSave = $hostType::FindByName($editForm, 'btnSaveScriptEdit')
$btnCancel = $hostType::FindByName($editForm, 'btnCancelScriptEdit')
if ($txtTitle) { $txtTitle.Text = $Title }
if ($txtScript) { $txtScript.Text = $InitialText }
# The editor's text box and save callback live in module scope: captured
# locals are stripped from the handler, so Save used to close the dialog
# having silently discarded every edit.
$script:_scriptEditorState = @{ TxtScript = $txtScript; OnSave = $OnSave }
if ($btnSave) {
$btnSave.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_scriptEditorState
if (-not $st) { Close-TopModalObject; return }
$newText = if ($st.TxtScript) { [string]$st.TxtScript.Text } else { '' }
try { if ($st.OnSave) { & $st.OnSave $newText } }
catch { Write-LogError "Script editor OnSave callback failed" $_.Exception }
$script:_scriptEditorState = $null
Close-TopModalObject
}))
}
if ($btnCancel) {
$btnCancel.add_Click((ConvertTo-AvaloniaEventScriptBlock {
Close-TopModalObject
}.GetNewClosure()))
}
$editForm.add_KeyDown((ConvertTo-AvaloniaEventScriptBlock {
param($s, $e)
if ($e.Key -eq [Avalonia.Input.Key]::Escape) {
Close-TopModalObject
$e.Handled = $true
}
}.GetNewClosure()))
$ui.ShowModalForm($Title, $editForm, $true)
}
@@ -0,0 +1,428 @@
# Avalonia port of the Import dialog from UI/WPF/Extensions/IntuneManagerUI.ps1
# (Show-IntuneManagerImportForm + supporting helpers). New file because
# Import is a self-contained subsystem and the WPF original sits in the
# giant CoreUI/IntuneManagerUI files we're trying not to grow further
# (architecture rule R9).
#
# CLOSURE-FREE design (after 2026-05-30 fix): PowerShell's `.GetNewClosure()`
# puts a scriptblock into a brand-new DynamicModule that has no access to
# IntuneManagement's module-private functions (Save-SettingStoreValue,
# Get-MigrationTableInfo, Write-Log, ...). `ConvertTo-AvaloniaEventScriptBlock`
# then calls NewBoundScriptBlock which strips the closure entirely.
# Both effects = click handlers crash with "property not found" /
# "term not recognized". To avoid this, the form's state lives in
# `$script:_imImportFormState`, the helper logic is exposed as real
# module-scope functions (`Invoke-IntuneManagerImportLoad`,
# `Update-IntuneManagerImportMatch`), and click handlers are plain
# scriptblocks (no .GetNewClosure()) that reference the script-scope state
# and call the helper functions by name.
#
# The match-resolution helpers (Resolve-IntuneImportUpdateTarget,
# New-IntuneImportMatchResult, Get-IntuneImportPolicyReferenceTokens,
# Normalize-IntuneImportPolicyName) live in Internal/IntuneManager.ps1 and
# are shared with the WPF tree.
function Update-IntuneManagerImportMatch
{
# Operates on $script:_imImportFormState. Recomputes ImportAction /
# ImportMatch / ImportMatchStrategy / ImportTarget on every visible
# row, then forces a DataGrid refresh (no INotifyPropertyChanged on
# IntuneImportRowItem). Called from cbImportType selection-changed and
# from the load helper after a fresh load.
$st = $script:_imImportFormState
if (-not $st -or -not $st.DG -or -not $st.DG.ItemsSource) { return }
$importType = [string]$st.Settings.ImportType
$sameTenant = [bool]$st.Settings.SameTenant
$existing = @()
if ($script:dgIntuneManagerObjects) {
$existing = @($script:dgIntuneManagerObjects.ItemsSource | ForEach-Object {
if ($_ -and $_.Source) { $_.Source } else { $_ }
} | Where-Object { $_ })
}
foreach ($row in @($st.DG.ItemsSource)) {
if (-not $row -or -not $row.Source) { continue }
$match = Resolve-IntuneImportUpdateTarget -ImportPolicy $row.Source -ExistingPolicies $existing -SameTenant $sameTenant -ImportType $importType
$row.ImportAction = [string]$match.Action
$row.ImportMatch = [string]$match.Message
$row.ImportMatchStrategy = [string]$match.Strategy
$row.ImportTarget = $match.Target
}
$current = @($st.DG.ItemsSource)
$st.DG.ItemsSource = $null
$st.DG.ItemsSource = $current
}
function Invoke-IntuneManagerImportLoad
{
# Reads $script:_imImportFormState. Loads files from $Folder into
# the form's DataGrid + populates migration-table info. Surface any
# error via message box so the user sees the cause; the previous
# silent-return path swallowed Get-MigrationTableInfo / Get-PoliciesFromFolder
# failures.
param([string]$Folder, [bool]$KeepStatus, [switch]$Quiet)
$st = $script:_imImportFormState
if (-not $st) { return }
$ui = $script:UIProvider
if (-not $Folder) { return }
Write-Status "Get policy objects from $Folder"
try { $Folder = Expand-FileName $Folder } catch { }
if (-not [IO.Directory]::Exists($Folder)) {
if (-not $KeepStatus) { Write-Status "" }
# -Quiet is used for the seeded-folder load that runs BEFORE the dialog
# is shown: a popup there appears out of nowhere with no dialog behind
# it. An explicit Browse/Get files still warns.
if (-not $Quiet) {
$ui.ShowMessageBox("Folder does not exist:`n$Folder", "Import", "OK", "Warning")
} else {
Write-Log "Import: seeded folder does not exist: $Folder" 2
}
return
}
try {
$params = @{}
$curPolicyTypes = Get-IntuneManagerSelectedPolicyTypes
$subFolders = @()
foreach ($pt in $curPolicyTypes) {
if ([IO.Directory]::Exists([IO.Path]::Combine($Folder, $pt.Folder))) {
$subFolders += $pt.Folder
}
}
if ($subFolders.Count -gt 0) { $params['SubFolders'] = $subFolders }
if ($curPolicyTypes) { $params['PolicyTypes'] = $curPolicyTypes }
# Collect into a typed List + sort in place. Routing through
# `Sort-Object ObjectName` would stamp a PSObject ETS wrapper on each
# IntuneImportRowItem and Avalonia's DataGrid text-column binder
# reflects on the runtime type and renders blank cells against the
# wrapper (see [[avalonia-binding-needs-clr-types]]).
$rows = [System.Collections.Generic.List[IntuneImportRowItem]]::new()
foreach ($policy in @(Get-PoliciesFromFolder $Folder @params)) {
$obj = [PSCustomObject]$policy
$row = [IntuneImportRowItem]::new()
$row.Selected = $true
$row.Source = $obj
$row.ObjectName = [string]$obj.Name
$row.PolicyType = [string]$obj.PolicyName
$row.PolicyBase = [string]$obj.PolicyBaseName
$row.Platform = [string]$obj.Platform
$row.FileName = if ($obj.FileInfo) { [string]$obj.FileInfo.Name } else { '' }
[void]$rows.Add($row)
}
$rows.Sort([Comparison[IntuneImportRowItem]]{
param($a, $b)
[string]::Compare($a.ObjectName, $b.ObjectName, [StringComparison]::OrdinalIgnoreCase)
})
if ($st.DG) { $st.DG.ItemsSource = $rows }
Save-SettingStoreValue "" "LastUsedFullPath" $Folder
$migrationInfo, $sameTenant = Get-MigrationTableInfo $Folder $script:organizationId
$st.Settings.SameTenant = [bool]$sameTenant
if ($st.LblMigInfo) { $st.LblMigInfo.Text = [string]$migrationInfo }
if ($st.ChkRepl) {
$st.ChkRepl.IsEnabled = (-not $sameTenant)
$st.ChkRepl.IsChecked = (-not $sameTenant)
$st.Settings.ReplaceDependencyIDs = (-not $sameTenant)
}
Update-IntuneManagerImportMatch
} catch {
# Log the internals; show the user only the message. The raw
# script/line dump had no WPF counterpart.
Write-LogError "Invoke-IntuneManagerImportLoad failed" $_.Exception
$ui.ShowMessageBox("Could not read the import folder:`n`n$($_.Exception.Message)", "Import error", "OK", "Error")
}
if (-not $KeepStatus) { Write-Status "" }
}
function Show-IntuneManagerImportForm
{
$ui = $script:UIProvider
$policyTypes = Get-IntuneManagerSelectedPolicyTypes
if (($policyTypes | Measure-Object).Count -eq 0) {
$ui.ShowMessageBox("No object types selected.", "Error", "OK", "Error")
return
}
$importForm = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/ImportForm.axaml'))
if (-not $importForm) { return }
Write-Status "Load import form"
$hostType = Get-AvaloniaHost
$txtFolder = $hostType::FindByName($importForm, 'txtImportFolder')
$browseFolder = $hostType::FindByName($importForm, 'browseImportFolder')
$lblMigInfo = $hostType::FindByName($importForm, 'lblMigrationTableInfo')
$chkScopes = $hostType::FindByName($importForm, 'chkImportScopes')
$chkAssign = $hostType::FindByName($importForm, 'chkImportAssignments')
$chkReplaceDeps = $hostType::FindByName($importForm, 'chkReplaceDependencyIDs')
$cbImportType = $hostType::FindByName($importForm, 'cbImportType')
$dgImport = $hostType::FindByName($importForm, 'dgObjectsToImport')
$btnGetFiles = $hostType::FindByName($importForm, 'btnGetFiles')
$btnImport = $hostType::FindByName($importForm, 'btnImportSelected')
$btnCancel = $hostType::FindByName($importForm, 'btnCancel')
# Settings instance + per-type extras (data-side hooks).
$importSettings = [IntuneManagerImportSettings]::new()
$policyTypes | Add-IntuneManagerImportProperties -ImportSettings $importSettings
$policyTypes | Add-IntuneManagerImportUIExtensions -Form $importForm
# Prefer the last-used full path, but if it points into a per-type subfolder
# of the currently-selected policy types, climb up one level so the parent
# is used (matches WPF heuristic — recommended behaviour for multi-type
# imports).
$path = Get-SettingStoreValue "" "LastUsedFullPath"
if ($path) {
try {
$di = [IO.DirectoryInfo]$path
if ($policyTypes | Where-Object { $_.Folder -eq $di.Name }) {
$path = $di.Parent.FullName
}
if (-not [IO.Directory]::Exists($path)) {
$path = Get-SettingStoreValue "" "LastUsedRoot"
}
} catch { }
if ($path) { $importSettings.ImportFolder = $path }
}
if ($txtFolder) { $txtFolder.Text = [string]$importSettings.ImportFolder }
if ($chkScopes) { $chkScopes.IsChecked = [bool]$importSettings.ImportScopeTags }
if ($chkAssign) { $chkAssign.IsChecked = [bool]$importSettings.ImportAssignments }
if ($chkReplaceDeps) { $chkReplaceDeps.IsChecked = [bool]$importSettings.ReplaceDependencyIDs }
# Import type ComboBox: NameValueObject -> SettingsListItem.
$importTypeItems = @()
foreach ($opt in $script:IntuneManagerImportOptions) {
$importTypeItems += [SettingsListItem]@{
Name = [string]$opt.Name
Value = [string]$opt.Value
}
}
if ($cbImportType) {
$cbImportType.ItemsSource = $importTypeItems
$current = $importTypeItems | Where-Object { $_.Value -eq [string]$importSettings.ImportType } | Select-Object -First 1
if (-not $current -and $importTypeItems.Count -gt 0) { $current = $importTypeItems[0] }
if ($current) { $cbImportType.SelectedItem = $current }
}
# Module-scope state container. Avoids .GetNewClosure() in event
# handlers (see file-level comment). Cleared when the form closes.
$script:_imImportFormState = @{
Settings = $importSettings
Form = $importForm
DG = $dgImport
LblMigInfo = $lblMigInfo
ChkRepl = $chkReplaceDeps
TxtFolder = $txtFolder
Confirmed = $false
}
# --- Event wiring (NO closures — references $script:_imImportFormState by name) ---
if ($browseFolder) {
$browseFolder.add_Click({
$st = $script:_imImportFormState
$ui2 = $script:UIProvider
if (-not $st) { return }
$start = if ($st.TxtFolder) { [string]$st.TxtFolder.Text } else { $null }
$folder = $ui2.ShowFolderPicker($start, 'Select root folder for import')
if ($folder) {
if ($st.TxtFolder) { $st.TxtFolder.Text = $folder }
$st.Settings.ImportFolder = $folder
Invoke-IntuneManagerImportLoad -Folder $folder -KeepStatus $false
}
})
}
if ($btnGetFiles) {
$btnGetFiles.add_Click({
$st = $script:_imImportFormState
if (-not $st) { return }
$folder = if ($st.TxtFolder) { [string]$st.TxtFolder.Text } else { $st.Settings.ImportFolder }
$st.Settings.ImportFolder = $folder
Invoke-IntuneManagerImportLoad -Folder $folder -KeepStatus $false
})
}
if ($cbImportType) {
$cbImportType.add_SelectionChanged({
param($s, $e)
$st = $script:_imImportFormState
if (-not $st) { return }
if ($s.SelectedItem) {
$st.Settings.ImportType = [string]$s.SelectedItem.Value
}
Update-IntuneManagerImportMatch
})
}
# Two-way mirror for the simple checkboxes.
if ($chkScopes) {
$chkScopes.add_IsCheckedChanged({
param($s, $e)
$st = $script:_imImportFormState
if ($st) { $st.Settings.ImportScopeTags = [bool]$s.IsChecked }
})
}
if ($chkAssign) {
$chkAssign.add_IsCheckedChanged({
param($s, $e)
$st = $script:_imImportFormState
if ($st) { $st.Settings.ImportAssignments = [bool]$s.IsChecked }
})
}
if ($chkReplaceDeps) {
$chkReplaceDeps.add_IsCheckedChanged({
param($s, $e)
$st = $script:_imImportFormState
if ($st) { $st.Settings.ReplaceDependencyIDs = [bool]$s.IsChecked }
})
}
# Select/deselect-all moved into the DataGrid column header.
Initialize-AvaloniaGridSelectAllHeader -Grid $dgImport -BindingProperty 'Selected' -InitiallyChecked $true | Out-Null
if ($btnImport) {
$btnImport.add_Click({
# Run the import while the form state is still live, then close + clear.
$st = $script:_imImportFormState
if (-not $st) { return }
try { Invoke-IntuneManagerImportExecute }
catch { Write-LogError "Import execution failed" $_.Exception }
Show-ModalObject
$script:_imImportFormState = $null
if ($script:dgIntuneManagerObjects) { $script:dgIntuneManagerObjects.Focus() }
})
}
if ($btnCancel) {
$btnCancel.add_Click({
Show-ModalObject
$script:_imImportFormState = $null
if ($script:dgIntuneManagerObjects) { $script:dgIntuneManagerObjects.Focus() }
})
}
$importForm.add_KeyDown({
param($s, $e)
if ($e.Key -eq [Avalonia.Input.Key]::Escape) {
Show-ModalObject
$script:_imImportFormState = $null
$e.Handled = $true
}
})
# Initial load against the seeded folder (if any). KeepStatus=true so
# Write-Status "" runs after the modal opens, not before.
if ($importSettings.ImportFolder) {
Invoke-IntuneManagerImportLoad -Folder $importSettings.ImportFolder -KeepStatus $true -Quiet
}
# ShowModalForm is non-blocking, so the import cannot run after it returns
# (the handlers fire later, on the dispatcher loop). It runs inside the
# btnImport handler via Invoke-IntuneManagerImportExecute while state is live.
$ui.ShowModalForm("Import objects", $importForm, $true)
Write-Status ""
}
function Invoke-IntuneManagerImportExecute
{
# Runs the actual import for the live $script:_imImportFormState. Called from
# the Import button handler (not after ShowModalForm, which does not block).
$st = $script:_imImportFormState
if (-not $st) { return }
$ui = $script:UIProvider
Write-Status "Import selected objects"
# Honour the ClearCacheBeforeExportImport setting (bit 8 = manual import).
Invoke-GraphCacheClearBeforeOperation -Operation ManualImport
$importType = [string]$st.Settings.ImportType
# Persist the per-import toggles so the import pipeline (Get-SettingValue) honours them.
$st.Settings.Save()
$rows = @()
if ($st.DG -and $st.DG.ItemsSource) {
$rows = @($st.DG.ItemsSource | Where-Object { $_ -and $_.Selected })
}
if ($rows.Count -eq 0) {
Write-Log "No files selected for import" 2
Write-Status ""
return
}
# Re-resolve match info up to the moment of import — the user may have
# toggled rows or changed ImportType after the last refresh. Put state
# back briefly so the helper can read it.
$script:_imImportFormState = $st
Update-IntuneManagerImportMatch
$script:_imImportFormState = $null
$existing = @()
if ($script:dgIntuneManagerObjects) {
$existing = @($script:dgIntuneManagerObjects.ItemsSource | ForEach-Object {
if ($_ -and $_.Source) { $_.Source } else { $_ }
} | Where-Object { $_ })
}
$policiesToImport = @()
$updatedPolicies = @()
foreach ($row in $rows) {
$importPolicy = $row.Source
if (-not $importPolicy) { continue }
$match = Resolve-IntuneImportUpdateTarget -ImportPolicy $importPolicy -ExistingPolicies $existing -SameTenant $st.Settings.SameTenant -ImportType $importType
if ($match.Action -eq "Update" -and $importType -eq "update") {
try {
$updated = $importPolicy.UpdateObject($match.Target, (Get-DefaultTokenId))
if ($updated) { $updatedPolicies += $updated }
} catch { Write-LogError "UpdateObject failed for $($importPolicy.Name)" $_.Exception }
}
elseif ($match.Action -eq "Replace") {
try {
$replaced = Invoke-IntuneImportReplace -ImportPolicy $importPolicy -Target $match.Target -ImportType $importType
if ($replaced) { $updatedPolicies += $replaced }
} catch { Write-LogError "Replace failed for $($importPolicy.Name)" $_.Exception }
}
elseif ($match.Action -eq "Ambiguous") {
Write-Log "Skip import/update for $($importPolicy.Name) ($($importPolicy.PolicyName)): $($match.Message)" 2
}
elseif ($match.Action -eq "Skip") {
Write-Log "Skip import/update for $($importPolicy.Name) ($($importPolicy.PolicyName)): $($match.Message)"
}
else {
$policiesToImport += $importPolicy
}
}
$importedPolicies = @()
if ($policiesToImport.Count -gt 0) {
try {
$importedPolicies = @($policiesToImport | Import-GraphPolicy)
} catch {
Write-LogError "Import-GraphPolicy failed" $_.Exception
$ui.ShowMessageBox("Import failed: $($_.Exception.Message)", "Error", "OK", "Error")
Write-Status ""
return
}
}
if ($importedPolicies.Count -gt 0 -or $updatedPolicies.Count -gt 0) {
Invoke-IntuneActivateObject $script:IntuneManagerSelectedObject | Out-Null
}
Write-Status ""
if ($script:dgIntuneManagerObjects) { $script:dgIntuneManagerObjects.Focus() }
}
@@ -0,0 +1,119 @@
# Scoped policy picker (Avalonia).
#
# Avalonia port of UI/WPF/Extensions/IntuneManagerPolicySearchUIWPF.ps1. Thin
# wrapper over Show-ObjectPickerDialog: it supplies the scope list and a search
# handler, so the dialog queries Graph for the typed name inside the selected
# policy group / policy type instead of enumerating the whole tenant up front.
# The search itself lives in Internal/PolicySearch.ps1 and is shared with WPF
# (R10 - this file only collects input and renders).
function Show-PolicySearchDialog
{
param(
[string] $Title = "Select policy",
# Preselects this policy type in the "Search in" dropdown.
[string] $DefaultPolicyTypeId,
# Ids to keep out of the results (e.g. the policy being compared).
[string[]] $ExcludeIds,
# Rows to show before the first search. The compare flows seed this
# from the main view's already-loaded list so the dialog opens with
# something useful in it.
[object[]] $InitialItems
)
$scopes = Get-PolicySearchScopes -DefaultPolicyTypeId $DefaultPolicyTypeId
if (@($scopes).Count -eq 0) {
Show-MessageBox "No policy types are available to search." "Select policy" "OK" "Warning" | Out-Null
return $null
}
$defaultKey = Get-PolicySearchDefaultScopeKey -Scopes $scopes -PolicyTypeId $DefaultPolicyTypeId
# Empty unless more than one tenant is signed in; the dialog hides the combo
# in that case and every search runs against the default token.
$tenants = Get-PolicySearchTenants
$defaultTenantKey = Get-PolicySearchDefaultTenantKey -Tenants $tenants
$cols = @(
[PSCustomObject]@{ Header = "Name"; Binding = "Name" }
[PSCustomObject]@{ Header = "Type"; Binding = "PolicyType.Title" }
)
# Handler inputs go on module scope, not into a closure: the handlers are
# rebound to the module, which strips captured locals (see the note in
# Show-ObjectPickerDialog).
$script:_policySearchState = @{
ExcludeIds = $ExcludeIds
MinLength = Get-PolicySearchMinLength
}
$searchHandler = (ConvertTo-AvaloniaEventScriptBlock {
param($SearchText, $Scope)
$st = $script:_policySearchState
if (-not $Scope) { return @() }
if ([String]::IsNullOrWhiteSpace($SearchText) -or $SearchText.Trim().Length -lt $st.MinLength) {
Set-PickerStatusText "Type at least $($st.MinLength) characters, or use 'Load all in scope'."
return @()
}
# $null when the dialog is single-tenant, in which case the default
# token is used.
$tenant = Get-PickerSelectedTenant
$tokenId = if ($tenant) { [int]$tenant.TokenId } else { Get-DefaultTokenId }
$where = if ($tenant) { " in $($tenant.Title)" } else { "" }
Write-Status "Searching $($Scope.Title.Trim())$where..."
try {
$found = @(Search-IntunePolicies -Scope $Scope -SearchText $SearchText.Trim() -ExcludeIds $st.ExcludeIds -TokenId $tokenId)
if ($found.Count -eq 0) {
Set-PickerStatusText "No object in $($Scope.Title.Trim())$where matches '$($SearchText.Trim())'."
}
else {
Set-PickerStatusText "$($found.Count) object(s) found$where."
}
return $found
}
finally {
Write-Status ""
}
})
$loadHandler = (ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_policySearchState
$scope = Get-PickerSelectedScope
if (-not $scope) { return @() }
$tenant = Get-PickerSelectedTenant
$tokenId = if ($tenant) { [int]$tenant.TokenId } else { Get-DefaultTokenId }
$where = if ($tenant) { " in $($tenant.Title)" } else { "" }
Write-Status "Loading all objects in $($scope.Title.Trim())$where..."
try {
$loaded = @(Search-IntunePolicies -Scope $scope -ExcludeIds $st.ExcludeIds -TokenId $tokenId)
Set-PickerStatusText "$($loaded.Count) object(s) in $($scope.Title.Trim())$where."
return $loaded
}
finally {
Write-Status ""
}
})
try {
return Show-ObjectPickerDialog `
-Title $Title `
-Items $InitialItems `
-DisplayColumns $cols `
-Scopes $scopes `
-SelectedScopeKey $defaultKey `
-Tenants $tenants `
-SelectedTenantKey $defaultTenantKey `
-SearchHandler $searchHandler `
-LoadHandler $loadHandler `
-LoadLabel "Load all in scope"
}
finally {
$script:_policySearchState = $null
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,375 @@
# UI/Avalonia counterpart of UI/WPF/Extensions/MSGraphAuthenticationUI.ps1.
#
# Scope: event handlers + dialog ports that depend on Avalonia controls
# (Show-MSALDecodedToken, Show-CloudPickerMenu, the four AuthenticationXxx event
# handlers, Invoke-MSALUIAppInitialized). The non-UI auth helpers
# (Invoke-AuthProviderInteractiveLogin, Get-MSALUserInfo) live in
# Internal/MSGraphAuthentication.ps1 and are shared by both UI trees.
#
# Deliberately NOT ported here (yet):
# - Get-MSALUserProfile — replaced by an inline Avalonia helper inside
# Show-AuthenticationInfo for the simplified initials button. The full
# cached-account picker / popup version lives in WPF only for now.
# - Get-MSALUserPhotoEllips — deferred until the popup infrastructure
# (Show-Popup) is ported.
Add-AppEventHandler "AppInitialized" "Invoke-MSALUIAppInitialized"
function Show-MSALDecodedToken
{
<#
.SYNOPSIS
Render a decoded JWT (header + payload) in a modal DataGrid.
.DESCRIPTION
Port of UI/WPF/Extensions/MSGraphAuthenticationUI.ps1 Show-MSALDecodedToken.
Same formatting rules: exp/iat/nbf/xms_tcdt -> local DateTime,
acrs/amr/scp -> newline-joined, wids -> resolved Entra role displayName
when available. Output is a list of [TokenInfoRow] CLR instances bound
into a DataGrid with auto-generated columns.
#>
param($TokenData, $Title)
$ui = $script:UIProvider
if (-not $TokenData -or -not $TokenData.Header) { return }
$tokenArr = @()
foreach ($prop in ($TokenData.Header | Get-Member | Where-Object MemberType -eq NoteProperty)) {
$tokenArr += [TokenInfoRow]@{
Name = $prop.Name
Value = $TokenData.Header."$($prop.Name)"
}
}
# Each claim yields one or more GRID ROWS rather than one row holding a
# multi-line string. Newline-joining a long list put a single row several
# times taller than the rest (8 permissions measured 147px against 33px),
# and the DataGrid estimates its scroll extent from the row heights it has
# realized - so that one outlier made the extent swing as it scrolled in and
# out of view, giving a jumpy, flickering scrollbar and a mouse wheel that
# appeared to run through the table several times. One value per row keeps
# every row a uniform height and stays fully readable.
foreach ($prop in ($TokenData.Payload | Get-Member | Where-Object MemberType -eq NoteProperty)) {
$raw = $TokenData.Payload."$($prop.Name)"
if ($prop.Name -in @('exp','iat','nbf','xms_tcdt')) {
$values = @([datetime]::new(1970, 1, 1, 0, 0, 0, 0, [System.DateTimeKind]::Utc).AddSeconds($raw).ToLocalTime())
}
elseif ($prop.Name -in @('acrs','amr')) {
# Short arrays; a single ';'-joined line stays well inside one row.
$values = @($raw -join ';')
}
elseif ($prop.Name -in @('wids')) {
if (-not $script:AADRoles) {
# Will fail if RoleManagement.Read.Directory permission isn't
# granted. -NoError swallows the failure; we just render the
# raw GUIDs without a friendly name.
$script:AADRoles = (Invoke-MSGraphAPI -url "/directoryRoles?`$select=roleTemplateId,displayName" -ODataMetadata 'minimal' -Noerror).value
}
$values = @()
foreach ($wid in $raw) {
$text = $wid
$role = ($script:AADRoles | Where-Object roleTemplateId -eq $wid)
if ($role) { $text = "$text ($($role.displayName))" }
$values += $text
}
}
elseif ($prop.Name -eq 'scp') {
# Delegated tokens pack the scopes into one space-separated string.
$values = @(([string]$raw).Split(' ') | Where-Object { $_ })
}
else {
# Covers 'roles' (an array on app-only tokens) and any other array
# claim: one row each, so nothing ever renders as "System.Object[]".
$values = @($raw)
}
foreach ($v in $values) {
$tokenArr += [TokenInfoRow]@{
Name = $prop.Name
Value = $v
}
}
}
$dg = [Avalonia.Controls.DataGrid]::new()
$dg.AutoGenerateColumns = $true
$dg.IsReadOnly = $true
$dg.CanUserSortColumns = $true
$dg.CanUserResizeColumns = $true
$dg.GridLinesVisibility = [Avalonia.Controls.DataGridGridLinesVisibility]::Horizontal
$dg.MinWidth = 600
$dg.MinHeight = 400
$dg.ItemsSource = $tokenArr
$ui.ShowModalForm($Title, $dg)
}
function Show-CloudPickerMenu
{
<#
.SYNOPSIS
Modal dialog to choose a target cloud before sign-in.
.DESCRIPTION
Avalonia port of UI/WPF/Extensions/MSGraphAuthenticationUI.ps1
Show-CloudPickerMenu. Returns the selected Cloud value (string) on OK,
$null on Cancel. -Persist saves the choice to DefaultCloud for future
zero-config sign-ins.
.NOTES
$script:Clouds items are PSCustomObjects (Name / Value); converted to
[SettingsListItem] CLR instances so Avalonia ComboBox bindings resolve.
#>
param(
[string]$Default,
[switch]$Persist
)
$ui = $script:UIProvider
$dialog = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/CloudPickerMenu.axaml'))
if (-not $dialog) { return $null }
$cbCloud = (Get-AvaloniaHost)::FindByName($dialog, 'cbCloud')
if (-not $cbCloud) { return $null }
$items = @()
foreach ($cloud in @($script:Clouds)) {
if (-not $cloud) { continue }
$items += [SettingsListItem]@{
Name = [string]$cloud.Name
Value = $cloud.Value
}
}
$cbCloud.ItemsSource = $items
if (-not $Default) { $Default = Get-DefaultCloud }
$selected = $items | Where-Object { "$($_.Value)" -eq "$Default" } | Select-Object -First 1
if (-not $selected -and $items.Count -gt 0) { $selected = $items[0] }
if ($selected) { $cbCloud.SelectedItem = $selected }
# Module scope, not captures: the handlers are re-bound and lose locals, so
# Sign in / Cancel never closed the dialog and it always returned $null -
# "Sign in to a different cloud..." could never actually sign in. Reset on
# entry so a previous open cannot leak its answer into this one.
$script:_cloudPickerDialog = $dialog
$script:_cloudPickerCombo = $cbCloud
$script:_cloudPickerResult = $null
$ui.AddXamlEvent($dialog, 'btnLogin', 'Add_Click', ({
if ($script:_cloudPickerCombo -and $script:_cloudPickerCombo.SelectedItem) {
$script:_cloudPickerResult = [string]$script:_cloudPickerCombo.SelectedItem.Value
}
if ($script:_cloudPickerDialog) { $script:_cloudPickerDialog.Close() }
}))
$ui.AddXamlEvent($dialog, 'btnCancel', 'Add_Click', ({
$script:_cloudPickerResult = $null
if ($script:_cloudPickerDialog) { $script:_cloudPickerDialog.Close() }
}))
(Get-AvaloniaHost)::ShowDialog($dialog, $script:Window)
$picked = $script:_cloudPickerResult
$script:_cloudPickerDialog = $null
$script:_cloudPickerCombo = $null
$script:_cloudPickerResult = $null
if (-not $picked) { return $null }
if ($Persist) { Set-DefaultCloud $picked }
return [string]$picked
}
# Filters out Microsoft personal / consumer (MSA) accounts. Those accounts live
# on the well-known tenant 9188040d-6c67-4c5b-b112-36a304b66dad and cannot be
# used to sign into Entra-tenant resources (Intune / Graph) — surfacing them in
# the picker only confuses the user. Keep work/school accounts plus accounts
# where tenant info isn't available. (Verbatim from WPF MSGraphAuthenticationUI.)
function Test-IsPersonalMSAAccount {
param($Account)
if(-not $Account -or -not $Account.HomeAccountId) { return $false }
$tid = $Account.HomeAccountId.TenantId
return ($tid -eq "9188040d-6c67-4c5b-b112-36a304b66dad")
}
function Invoke-AvaloniaCachedAccountSignIn {
param($Account)
if(-not $Account) { return }
Write-Status "Logging in with $($Account.UserName)"
try {
if (Connect-EntraEnvironment -User $Account.UserName -DefaultToken) {
# AuthenticatedNewToken event handler refreshes the UI.
}
}
catch {
Write-LogError "Cached-account sign-in failed" $_.Exception
}
finally {
Write-Status ""
}
}
# Build one row in the cached-account picker — Avalonia version of WPF
# Add-CachedUser. Each row is a 2-column Grid: a click-to-login button on the
# left that calls Connect-EntraEnvironment with the cached username, and a
# Forget button on the right that disconnects (if signed in under this account)
# and evicts the MSAL cache entry.
function Add-CachedUser {
param($Account, $ParentObj)
$ui = $script:UIProvider
try {
$row = [Avalonia.Controls.Grid]::new()
$row.Margin = [Avalonia.Thickness]::new(0, 5, 0, 0)
$row.ColumnDefinitions.Add([Avalonia.Controls.ColumnDefinition]::new([Avalonia.Controls.GridLength]::new(1, [Avalonia.Controls.GridUnitType]::Star)))
$row.ColumnDefinitions.Add([Avalonia.Controls.ColumnDefinition]::new([Avalonia.Controls.GridLength]::Auto))
$tenantName = Get-SettingStoreValue $Account.HomeAccountId.TenantId "_Name" $Account.HomeAccountId.TenantId
$loginBtn = [Avalonia.Controls.Button]::new()
$loginBtn.HorizontalAlignment = [Avalonia.Layout.HorizontalAlignment]::Stretch
$loginBtn.HorizontalContentAlignment = [Avalonia.Layout.HorizontalAlignment]::Left
$loginBtn.Cursor = [Avalonia.Input.Cursor]::new([Avalonia.Input.StandardCursorType]::Hand)
$loginBtn.Tag = $Account
# TextBlock.Inlines + Run/LineBreak to render the two-line username/tenant
# label without needing a separate StackPanel host.
$loginText = [Avalonia.Controls.TextBlock]::new()
$loginText.Inlines.Add([Avalonia.Controls.Documents.Run]::new($Account.UserName))
$loginText.Inlines.Add([Avalonia.Controls.Documents.LineBreak]::new())
$loginText.Inlines.Add([Avalonia.Controls.Documents.Run]::new([string]$tenantName))
$loginBtn.Content = $loginText
$loginBtn.add_Click((ConvertTo-AvaloniaEventScriptBlock {
param($S, $E)
$acct = $S.Tag
$ui.HidePopup()
Invoke-AvaloniaDeferredAction -Argument $acct -Action {
param($selectedAccount)
Invoke-AvaloniaCachedAccountSignIn $selectedAccount
}
}))
$row.Children.Add($loginBtn) | Out-Null
$forgetBtn = [Avalonia.Controls.Button]::new()
# U+1F5D1 (🗑) is outside the BMP and won't fit in a single 16-bit
# [char]. ConvertFromUtf32 returns the proper surrogate-pair string.
$forgetBtn.Content = [char]::ConvertFromUtf32(0x1F5D1)
$forgetBtn.Margin = [Avalonia.Thickness]::new(5, 0, 0, 0)
[Avalonia.Controls.ToolTip]::SetTip($forgetBtn, 'Forget this account')
$forgetBtn.Cursor = [Avalonia.Input.Cursor]::new([Avalonia.Input.StandardCursorType]::Hand)
$forgetBtn.Tag = $Account
[Avalonia.Controls.Grid]::SetColumn($forgetBtn, 1)
$forgetBtn.add_Click((ConvertTo-AvaloniaEventScriptBlock {
param($S, $E)
$acct = $S.Tag
Write-Status "Removing $($acct.UserName)"
try {
# If the account is currently signed in under any token, disconnect
# properly first so the event fires and any default-token promotion
# runs. Always also evict from the on-disk MSAL cache.
$matchingTokens = @($script:MSALTokens.Values | Where-Object {
$_.Token -and $_.Token.Account -and
$_.Token.Account.HomeAccountId.Identifier -eq $acct.HomeAccountId.Identifier
})
foreach ($tok in $matchingTokens) { Disconnect-EntraEnvironment -TokenID $tok.Id }
Remove-MSALAccount -Account $acct
# Walk up to the StackPanel (row.Parent) and drop the row.
$parent = $S.Parent # the row Grid
if ($parent -and $parent.Parent) {
[void]$parent.Parent.Children.Remove($parent)
}
} catch {
Write-LogError "Failed to forget account $($acct.UserName)" $_.Exception
}
Write-Status ""
}))
$row.Children.Add($forgetBtn) | Out-Null
$ParentObj.Children.Add($row) | Out-Null
} catch {
Write-LogError "Add-CachedUser failed" $_.Exception
}
}
#region Event functions
function Invoke-MSALUIEventNewAuthentication
{
[CmdLetbinding()]
param($TokenInfo)
# Provider-neutral tenant/user sync and the ScopeTags/AssignmentFilters
# dependency-cache preload used to happen here. Both moved to
# Invoke-AuthCoreOnNewToken in Internal/AuthenticationCore.ps1, which
# subscribes to the same "AuthenticatedNewToken" event and runs regardless
# of UI mode -- headless flows (OAuth AppId+Secret in Azure Automation)
# get the same state updates that were previously UI-exclusive.
#
# This handler now does only UI-specific work: MSAL profile enrichment
# (Graph /me, profile photo, JWT-derived app name) and the auth-info bar
# refresh. Update-MSALUserProfile self-guards on $script:MSALDefaultToken
# so it's a no-op for non-MSAL providers.
$ui = $script:UIProvider
if($TokenInfo.IsDefault) {
Update-MSALUserProfile
$ui.ShowAuthenticationInfo()
}
}
function Invoke-MSALUIEventTokenRefreshed
{
[CmdLetbinding()]
param($TokenInfo)
# A silent renewal keeps the same user/tenant, so skip the heavy profile
# enrichment (Graph /me + photo) and full object reload that a NEW token
# triggers - just redraw the auth-info bar so the shown token expiry reflects
# the renewed token. Only the default token drives the visible profile.
if($TokenInfo.IsDefault) {
$ui = $script:UIProvider
if($ui) {
try { $ui.ShowAuthenticationInfo() }
catch { Write-LogError "ShowAuthenticationInfo failed on token refresh" $_.Exception }
}
}
}
function Invoke-MSALUIEventUserDisconnected
{
[CmdLetbinding()]
param($TokenInfo)
$ui = $script:UIProvider
if($TokenInfo -and $TokenInfo.TenantID) {
try { Clear-TenantCache -TenantId $TokenInfo.TenantID }
catch { Write-LogError "Failed to clear tenant cache on disconnect" $_.Exception }
}
Get-MSALUserInfo
$ui.ShowAuthenticationInfo()
}
function Invoke-MSALUIEventAuthenticationFailed
{
$ui = $script:UIProvider
# Best-effort enrichment; must never stop the redraw. On a hard failure the
# redraw reverts the avatar to the Sign-in button (Get-AvaloniaUserProfile's
# expired/no-token branch). Guard so a throw can't skip ShowAuthenticationInfo.
try { Get-MSALUserInfo } catch { Write-LogError "Get-MSALUserInfo failed on AuthenticationFailed" $_.Exception }
$ui.ShowAuthenticationInfo()
# Also refresh the environment/tenant badge so it hides when the session is gone
# (Set-EnvironmentInfo's expiry guard does the hiding). Explicit here so it doesn't
# depend on the enrichment above reaching its own SetEnvironmentInfo call.
try { $ui.SetEnvironmentInfo() } catch { Write-LogError "SetEnvironmentInfo failed on AuthenticationFailed" $_.Exception }
}
function Invoke-MSALUIAppInitialized
{
Add-AppEventHandler "AuthenticatedNewToken" "Invoke-MSALUIEventNewAuthentication"
Add-AppEventHandler "AuthenticationTokenRefresh" "Invoke-MSALUIEventTokenRefreshed"
Add-AppEventHandler "AuthenticationUserDisconnected" "Invoke-MSALUIEventUserDisconnected"
Add-AppEventHandler "AuthenticationFailed" "Invoke-MSALUIEventAuthenticationFailed"
}
#endregion
@@ -0,0 +1,102 @@
# Render parsed Markdown into an Avalonia TextBlock.
#
# Mirror of UI/WPF/Extensions/MarkdownRenderWPF.ps1. The parse is shared via
# Internal/MarkdownSimple.ps1 (R10); only the inline construction differs, so the
# Avalonia types stay inside UI/Avalonia (R12).
#
# Translation notes vs the WPF version:
# - Inlines live in Avalonia.Controls.Documents, not System.Windows.Documents.
# - No Bold element; Run.FontWeight carries it, which is what WPF uses too.
# - Run has no text constructor overload, so Text is set as a property.
# - No Hyperlink inline. Links use the pattern this repo already uses for inline
# links in Welcome.axaml: a Button with Classes="link" (styled in
# Themes/Styles.axaml) wrapped in an InlineUIContainer.
function Set-AvaloniaMarkdownText {
[CmdletBinding()]
param(
[Parameter(Mandatory)] $TextBlock,
[string]$Markdown
)
if (-not $TextBlock) { return }
if ($TextBlock.Inlines) { $TextBlock.Inlines.Clear() }
if ([string]::IsNullOrEmpty($Markdown)) { return }
$baseSize = if ($TextBlock.FontSize -gt 0) { [double]$TextBlock.FontSize } else { 12.0 }
$style = Get-SimpleMarkdownStyle
$codeBrush = [Avalonia.Media.SolidColorBrush]::new([Avalonia.Media.Color]::Parse($style.CodeBackground))
$codeFont = [Avalonia.Media.FontFamily]::new($style.CodeFontFamily)
foreach ($block in (ConvertFrom-SimpleMarkdown $Markdown)) {
if ($block.Kind -eq 'Blank') {
$TextBlock.Inlines.Add([Avalonia.Controls.Documents.LineBreak]::new())
continue
}
$lead = switch ($block.Kind) {
'ListItem' { (' ' * ($block.Indent * 4)) + $block.Marker + ' ' }
'Quote' { $style.QuoteMarker }
default { $null }
}
if ($lead) {
$leadRun = [Avalonia.Controls.Documents.Run]::new()
$leadRun.Text = $lead
$TextBlock.Inlines.Add($leadRun)
}
foreach ($inline in $block.Inlines) {
if ($inline.LineBreak) {
$TextBlock.Inlines.Add([Avalonia.Controls.Documents.LineBreak]::new())
if ($block.Kind -eq 'ListItem') {
$pad = [Avalonia.Controls.Documents.Run]::new()
$pad.Text = ' ' * (($block.Indent * 4) + $block.Marker.Length + 1)
$TextBlock.Inlines.Add($pad)
}
continue
}
if ($inline.Url) {
# Captured by value into the handler: $inline is the loop variable
# and would otherwise be whatever the loop ended on by click time.
$linkUrl = [string]$inline.Url
$button = [Avalonia.Controls.Button]::new()
$button.Content = [string]$inline.Text
$button.Classes.Add('link')
$button.Add_Click({
param($clickSender, $clickArgs)
Open-ExternalUri ([string]$clickSender.Tag)
})
# Tag carries the URL so the handler reads it off the sender rather
# than closing over a loop variable.
$button.Tag = $linkUrl
$container = [Avalonia.Controls.Documents.InlineUIContainer]::new()
$container.Child = $button
$TextBlock.Inlines.Add($container)
continue
}
$run = [Avalonia.Controls.Documents.Run]::new()
$run.Text = [string]$inline.Text
if ($inline.Bold -or $block.Kind -eq 'Heading') {
$run.FontWeight = [Avalonia.Media.FontWeight]::Bold
}
if ($block.Kind -eq 'Heading') {
$run.FontSize = Get-SimpleMarkdownHeadingSize -Level $block.Level -BaseSize $baseSize
}
if ($inline.Code -or $block.Kind -eq 'CodeBlock') {
$run.FontFamily = $codeFont
$run.Background = $codeBrush
}
if ($block.Kind -eq 'Quote') {
$run.FontStyle = [Avalonia.Media.FontStyle]::Italic
}
$TextBlock.Inlines.Add($run)
}
$TextBlock.Inlines.Add([Avalonia.Controls.Documents.LineBreak]::new())
}
}
@@ -0,0 +1,21 @@
# Avalonia counterpart of UI/WPF/Extensions/UIMenuIconsWPF.ps1.
#
# ClassExtensions/IntuneCommonUIExtensions.ps1 attaches LoadIconImage/GetImage
# to every policy-type and group singleton at import time (the closures must
# capture the module-bound loader then) but no longer parses the icons there.
# Get-IntuneViewItems calls this right before it projects the singletons into
# ViewMenuItem rows, so the visuals load once the splash is up and the menu
# needs them. Each item keeps its own instance: a visual can only have one parent.
function Initialize-MenuItemIcons
{
param($Items)
foreach($item in @($Items))
{
if($null -eq $item) { continue }
if($null -ne $item.IconImage) { continue }
if(-not $item.PSObject.Methods['LoadIconImage']) { continue }
try { $item.LoadIconImage() } catch { }
}
}
@@ -0,0 +1,61 @@
<#
Platform-specific main-window chrome.
MainWindow.axaml is authored Windows-first: ExtendClientAreaToDecorationsHint
plus PreferSystemChrome merge our title bar into the OS caption area, and the
title-bar content grid reserves 140px on the right for the Win11
minimize/maximize/close buttons that the OS paints over our content.
That hint is only honoured by the Win32 and macOS-native backends. Avalonia's
X11 backend does not remove the window-manager decoration, so on Linux the WM
keeps drawing its own title bar and the app draws a second one underneath it,
complete with 140px of dead space reserved for buttons that are not there.
macOS does honour the hint, but puts its traffic lights on the LEFT, so the
same reservation has to move to the other side or the app icon and File menu
end up underneath them.
Windows keeps the authored layout untouched.
#>
# Width reserved inside the extended client area for OS-painted caption buttons.
$script:IMWindowsCaptionWidth = 140
$script:IMMacTrafficLightWidth = 80
function Set-MainWindowChrome {
<#
.SYNOPSIS
Adapts the main window chrome to the running platform.
.DESCRIPTION
Called from Show-MainWindow after MainWindow.axaml is loaded and before the
window is shown. On Windows this is a no-op: the XAML is already correct.
#>
param($Window)
if (-not $Window) { return }
if ($script:IsWindowsOS) { return }
if ($IsMacOS) {
# The hint works here, so we keep the merged title bar and only move the
# caption reservation from the right (Windows) to the left (traffic lights).
Set-XamlProperty $Window 'grdTitleBarContent' 'Margin' `
([Avalonia.Thickness]::new($script:IMMacTrafficLightWidth, 0, 0, 0))
return
}
# Linux/X11, and any other backend that ignores the hint: hand the title bar
# back to the window manager so we do not end up with two of them.
try {
$Window.ExtendClientAreaToDecorationsHint = $false
} catch {
Write-LogDebug "Set-MainWindowChrome: could not disable extended client area: $($_.Exception.Message)"
}
# Nothing overlaps our content now, so drop the caption reservation and hide
# the duplicate centred view title. The WM caption already shows Window.Title,
# which Set-MainTitle keeps in sync with the same text.
Set-XamlProperty $Window 'grdTitleBarContent' 'Margin' ([Avalonia.Thickness]::new(0))
Set-XamlProperty $Window 'txtTitleViewName' 'IsVisible' $false
}