mirror of
https://github.com/Micke-K/IntuneManagement.git
synced 2026-09-28 19:05:38 +02:00
IntuneManagement 4.0.0-beta1
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user