mirror of
https://github.com/Micke-K/IntuneManagement.git
synced 2026-09-28 10:55:38 +02:00
IntuneManagement 4.0.0-beta1
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
@@ -0,0 +1,640 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Markup.Xaml.Styling;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Themes.Fluent;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace IntuneManagement.AvaloniaHost;
|
||||
|
||||
public sealed class IntuneManagementApp : Application
|
||||
{
|
||||
public static string ThemeVariant { get; set; } = "Default";
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
Styles.Add(new FluentTheme());
|
||||
|
||||
// DataGrid ships in a separate package with its own theme XAML. Add
|
||||
// the StyleInclude here so DataGrid controls render with Fluent
|
||||
// styling instead of as unstyled placeholders. Wrapped in try/catch
|
||||
// so a missing Avalonia.Controls.DataGrid.dll (e.g. older restore)
|
||||
// doesn't kill app startup — DataGrid just won't be usable.
|
||||
try
|
||||
{
|
||||
var dgStylesUri = new Uri("avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml");
|
||||
Styles.Add(new StyleInclude(dgStylesUri) { Source = dgStylesUri });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Trace.WriteLine("DataGrid theme include failed: " + ex.Message);
|
||||
}
|
||||
|
||||
RequestedThemeVariant = string.Equals(ThemeVariant, "Dark", StringComparison.OrdinalIgnoreCase)
|
||||
? Avalonia.Styling.ThemeVariant.Dark
|
||||
: Avalonia.Styling.ThemeVariant.Light;
|
||||
}
|
||||
}
|
||||
|
||||
// AvaloniaHost: PowerShell-driven Avalonia host running on the calling thread.
|
||||
//
|
||||
// Model: Setup runs on the PowerShell thread. That thread becomes Avalonia's
|
||||
// UI thread. RunMainWindow blocks running the dispatcher loop until the last
|
||||
// window closes. Because PS thread == UI thread, no cross-thread marshaling
|
||||
// is needed for UI work, and PowerShell scriptblocks attached to events fire
|
||||
// on the same thread that subscribed them — exactly like WPF in PowerShell.
|
||||
public static class Host
|
||||
{
|
||||
private static ClassicDesktopStyleApplicationLifetime _lifetime;
|
||||
private static bool _setup;
|
||||
private static readonly object _gate = new();
|
||||
private static string[] _droppedAssemblies;
|
||||
|
||||
[DllImport("/usr/lib/libSystem.B.dylib")]
|
||||
private static extern int pthread_main_np();
|
||||
|
||||
public static void Initialize(string themeVariant = "Default")
|
||||
{
|
||||
if (OperatingSystem.IsMacOS() && pthread_main_np() == 0)
|
||||
throw new InvalidOperationException(
|
||||
"Avalonia on macOS requires the process main thread. Launch with Start-Avalonia.command.");
|
||||
lock (_gate)
|
||||
{
|
||||
if (_setup) return;
|
||||
IntuneManagementApp.ThemeVariant = themeVariant ?? "Default";
|
||||
|
||||
_lifetime = new ClassicDesktopStyleApplicationLifetime
|
||||
{
|
||||
Args = Array.Empty<string>(),
|
||||
ShutdownMode = ShutdownMode.OnLastWindowClose
|
||||
};
|
||||
|
||||
AppBuilder.Configure<IntuneManagementApp>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace()
|
||||
.SetupWithLifetime(_lifetime);
|
||||
|
||||
SanitizeXamlTypeSystem();
|
||||
|
||||
_setup = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Avalonia's runtime XAML loader resolves types by walking every assembly in
|
||||
// the AppDomain and calling GetExportedTypes() on each. That call throws for
|
||||
// an assembly whose type references cannot be resolved, and the exception
|
||||
// propagates out of the loader, so a single unreadable assembly stops every
|
||||
// XAML file in the app from parsing.
|
||||
//
|
||||
// On Windows, PowerShell's assembly resolver answers some requests out of the
|
||||
// .NET Framework GAC (System.Web.Extensions v4.0 is loaded during pwsh
|
||||
// startup). Those assemblies reference .NET Framework types that do not exist
|
||||
// on modern .NET - System.Web ships as a facade with no types at all - so
|
||||
// enumerating them throws TypeLoadException. Office interop assemblies, which
|
||||
// the documentation subsystem loads for Word output, fail the same way.
|
||||
//
|
||||
// Drop those assemblies from the loader's type system before the first parse.
|
||||
// The XAML in this app only references Avalonia and our own types, so nothing
|
||||
// that can be dropped here was ever reachable from markup. On Linux and macOS
|
||||
// no such assembly is loaded and this is a no-op.
|
||||
//
|
||||
// Returns the names of the assemblies that were dropped. Reflection is used
|
||||
// against internal Avalonia members, so every failure path degrades to
|
||||
// "changed nothing" rather than breaking startup.
|
||||
public static string[] SanitizeXamlTypeSystem()
|
||||
{
|
||||
if (_droppedAssemblies != null) return _droppedAssemblies;
|
||||
|
||||
const BindingFlags Flags = BindingFlags.Public | BindingFlags.NonPublic |
|
||||
BindingFlags.Static | BindingFlags.Instance;
|
||||
var dropped = new List<string>();
|
||||
try
|
||||
{
|
||||
var loaderAsm = typeof(AvaloniaRuntimeXamlLoader).Assembly;
|
||||
var compiler = loaderAsm.GetType("Avalonia.Markup.Xaml.XamlIl.AvaloniaXamlIlRuntimeCompiler");
|
||||
var initSre = compiler?.GetMethod("InitializeSre", Flags);
|
||||
var tsField = compiler?.GetField("_sreTypeSystem", Flags);
|
||||
if (initSre == null || tsField == null) return _droppedAssemblies = Array.Empty<string>();
|
||||
|
||||
// Build the type system so it can be inspected before it is used.
|
||||
// This same walk is what fails, but the type system field is assigned
|
||||
// before the failing step, so the object is available afterwards
|
||||
// either way - the throw is expected here and is not an error.
|
||||
try { initSre.Invoke(null, null); } catch { }
|
||||
|
||||
var typeSystem = tsField.GetValue(null);
|
||||
var listField = typeSystem?.GetType().GetField("_assemblies", Flags);
|
||||
var assemblies = listField?.GetValue(typeSystem) as IList;
|
||||
if (assemblies == null) return _droppedAssemblies = Array.Empty<string>();
|
||||
|
||||
var doomed = new List<object>();
|
||||
foreach (var entry in assemblies)
|
||||
{
|
||||
if (entry == null) continue;
|
||||
var clr = entry.GetType().GetProperty("Assembly", Flags)?.GetValue(entry) as Assembly;
|
||||
if (clr == null) continue;
|
||||
try
|
||||
{
|
||||
clr.GetExportedTypes();
|
||||
}
|
||||
catch
|
||||
{
|
||||
doomed.Add(entry);
|
||||
dropped.Add(clr.GetName().Name);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var entry in doomed) assemblies.Remove(entry);
|
||||
|
||||
// Re-run initialization now that the type system can be walked end to
|
||||
// end. The first attempt aborted partway, leaving the xmlns mappings
|
||||
// and emit context unset; this pass completes them.
|
||||
if (doomed.Count > 0) initSre.Invoke(null, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Trace.WriteLine("SanitizeXamlTypeSystem skipped: " + ex.Message);
|
||||
}
|
||||
|
||||
return _droppedAssemblies = dropped.ToArray();
|
||||
}
|
||||
|
||||
public static object LoadXaml(string xaml, string baseUri = null)
|
||||
{
|
||||
EnsureSetup();
|
||||
Uri uri = baseUri != null ? new Uri(baseUri) : null;
|
||||
return AvaloniaRuntimeXamlLoader.Load(xaml, localAssembly: null, rootInstance: null, uri: uri);
|
||||
}
|
||||
|
||||
// Parse a Styles or ResourceDictionary file and merge it into Application.Current.
|
||||
// Used to load theme files so XAML across the app can resolve {DynamicResource ...}
|
||||
// lookups for brushes and pick up control style setters.
|
||||
public static void LoadStyles(string xamlPath)
|
||||
{
|
||||
EnsureSetup();
|
||||
var text = File.ReadAllText(xamlPath);
|
||||
var uri = new Uri(new Uri("file:///" + xamlPath.Replace('\\', '/')).AbsoluteUri);
|
||||
var obj = AvaloniaRuntimeXamlLoader.Load(text, localAssembly: null, rootInstance: null, uri: uri);
|
||||
switch (obj)
|
||||
{
|
||||
case Avalonia.Styling.Styles styles:
|
||||
Application.Current.Styles.Add(styles);
|
||||
break;
|
||||
case Avalonia.Styling.Style style:
|
||||
Application.Current.Styles.Add(style);
|
||||
break;
|
||||
case Avalonia.Controls.ResourceDictionary rd:
|
||||
Application.Current.Resources.MergedDictionaries.Add(rd);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
"LoadStyles: expected Styles, Style, or ResourceDictionary in " + xamlPath +
|
||||
"; got " + (obj?.GetType().FullName ?? "null"));
|
||||
}
|
||||
}
|
||||
|
||||
public static object FindByName(object root, string name)
|
||||
{
|
||||
if (root is not Control control) return null;
|
||||
return (object)control.FindControl<Control>(name) ?? control.FindNameScope()?.Find(name);
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool EnableWindow(IntPtr hWnd, bool bEnable);
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
|
||||
private static IntPtr GetHwnd(Window w) =>
|
||||
w?.TryGetPlatformHandle() is { } h ? h.Handle : IntPtr.Zero;
|
||||
|
||||
// Run the main window: assigns it as MainWindow, calls lifetime.Start which
|
||||
// blocks pumping the dispatcher loop until the last window closes.
|
||||
public static int RunMainWindow(Window window)
|
||||
{
|
||||
EnsureSetup();
|
||||
_lifetime.MainWindow = window;
|
||||
return _lifetime.Start(Array.Empty<string>());
|
||||
}
|
||||
|
||||
// Run a short, bounded nested dispatcher loop. Dispatcher.RunJobs() only
|
||||
// drains work that has already reached Avalonia's managed queue; it does not
|
||||
// provide the platform event loop needed to turn native mouse/keyboard input
|
||||
// into routed events while synchronous PowerShell code owns the UI thread.
|
||||
// Authentication waits call this repeatedly so their status-overlay Cancel
|
||||
// button remains genuinely clickable without allowing an unbounded nested
|
||||
// loop or moving PowerShell state onto another thread.
|
||||
public static void PumpEventsOnce(int maximumMilliseconds = 10)
|
||||
{
|
||||
EnsureSetup();
|
||||
if (!Dispatcher.UIThread.CheckAccess())
|
||||
throw new InvalidOperationException("PumpEventsOnce must run on the Avalonia UI thread.");
|
||||
|
||||
maximumMilliseconds = Math.Clamp(maximumMilliseconds, 1, 50);
|
||||
var frame = new DispatcherFrame();
|
||||
var timer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(maximumMilliseconds)
|
||||
};
|
||||
|
||||
EventHandler tick = null;
|
||||
tick = (_, _) =>
|
||||
{
|
||||
timer.Stop();
|
||||
timer.Tick -= tick;
|
||||
frame.Continue = false;
|
||||
};
|
||||
|
||||
timer.Tick += tick;
|
||||
timer.Start();
|
||||
try
|
||||
{
|
||||
Dispatcher.UIThread.PushFrame(frame);
|
||||
}
|
||||
finally
|
||||
{
|
||||
timer.Stop();
|
||||
timer.Tick -= tick;
|
||||
frame.Continue = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Modal-style dialog: use Avalonia's owned ShowDialog so the owner remains
|
||||
// visually stable, then pump a nested DispatcherFrame until the dialog closes.
|
||||
// Avalonia's Window.ShowDialog<T>(owner) returns a Task and is awkward to
|
||||
// await synchronously; this approach gives sync semantics matching WPF.
|
||||
public static void ShowDialog(Window dialog, Window owner = null)
|
||||
{
|
||||
EnsureSetup();
|
||||
|
||||
var frame = new DispatcherFrame();
|
||||
|
||||
if (owner != null)
|
||||
{
|
||||
dialog.WindowStartupLocation = WindowStartupLocation.Manual;
|
||||
dialog.Position = new PixelPoint(
|
||||
owner.Position.X + 40,
|
||||
owner.Position.Y + 40);
|
||||
}
|
||||
|
||||
if (owner != null)
|
||||
{
|
||||
dialog.ShowDialog(owner).ContinueWith(_ =>
|
||||
Dispatcher.UIThread.Post(() => frame.Continue = false));
|
||||
}
|
||||
else
|
||||
{
|
||||
dialog.Closed += (_, _) => frame.Continue = false;
|
||||
dialog.Show();
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.PushFrame(frame);
|
||||
}
|
||||
|
||||
// Synchronous folder picker. The async StorageProvider API is awkward to
|
||||
// bridge to a PowerShell caller that needs a return value; same approach
|
||||
// as ShowDialog — push a nested DispatcherFrame, set Continue=false on the
|
||||
// UI thread once the task completes.
|
||||
public static string OpenFolderPicker(Window owner, string title)
|
||||
{
|
||||
return OpenFolderPicker(owner, title, null);
|
||||
}
|
||||
|
||||
public static string OpenFolderPicker(Window owner, string title, string suggestedStartFolder)
|
||||
{
|
||||
EnsureSetup();
|
||||
var topLevel = owner != null ? TopLevel.GetTopLevel(owner) : null;
|
||||
if (topLevel?.StorageProvider == null) return null;
|
||||
|
||||
var options = new FolderPickerOpenOptions
|
||||
{
|
||||
AllowMultiple = false,
|
||||
Title = string.IsNullOrEmpty(title) ? "Select folder" : title
|
||||
};
|
||||
|
||||
// Folder resolution and the picker run in one async flow - blocking on
|
||||
// TryGetFolderFromPathAsync before the frame is pushed would deadlock the
|
||||
// dispatcher thread (see SaveFilePicker).
|
||||
async Task<string> PickAsync()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(suggestedStartFolder) && Directory.Exists(suggestedStartFolder))
|
||||
{
|
||||
try
|
||||
{
|
||||
var folder = await topLevel.StorageProvider.TryGetFolderFromPathAsync(new Uri(suggestedStartFolder));
|
||||
if (folder != null) options.SuggestedStartLocation = folder;
|
||||
}
|
||||
catch { /* swallow - picker just opens in default location */ }
|
||||
}
|
||||
|
||||
var picked = await topLevel.StorageProvider.OpenFolderPickerAsync(options);
|
||||
return (picked != null && picked.Count > 0) ? picked[0].Path.LocalPath : null;
|
||||
}
|
||||
|
||||
string result = null;
|
||||
var frame = new DispatcherFrame();
|
||||
|
||||
PickAsync().ContinueWith(t =>
|
||||
{
|
||||
string r = (t.Status == TaskStatus.RanToCompletion) ? t.Result : null;
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
result = r;
|
||||
frame.Continue = false;
|
||||
});
|
||||
});
|
||||
|
||||
Dispatcher.UIThread.PushFrame(frame);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Copy text to the system clipboard. Called from the UI thread while the
|
||||
// auth flow surfaces a device-code; like the pickers we run the async
|
||||
// Clipboard API under a nested DispatcherFrame rather than blocking on it
|
||||
// (a GetResult() on the UI thread would deadlock the continuation). Uses
|
||||
// Avalonia's clipboard (X11/Wayland direct) so it works on a bare Linux box
|
||||
// where PowerShell's Set-Clipboard is a no-op.
|
||||
public static void SetClipboardText(Window owner, string text)
|
||||
{
|
||||
EnsureSetup();
|
||||
var topLevel = owner != null ? TopLevel.GetTopLevel(owner) : null;
|
||||
var clipboard = topLevel?.Clipboard;
|
||||
if (clipboard == null) return;
|
||||
|
||||
var frame = new DispatcherFrame();
|
||||
clipboard.SetTextAsync(text ?? string.Empty).ContinueWith(_ =>
|
||||
Dispatcher.UIThread.Post(() => frame.Continue = false));
|
||||
Dispatcher.UIThread.PushFrame(frame);
|
||||
}
|
||||
|
||||
// Synchronous open-file picker. Single-select. filterName/filterPattern
|
||||
// mirror SaveFilePicker — display name + semicolon-separated patterns.
|
||||
// Returns null on cancel.
|
||||
public static string OpenFilePicker(Window owner, string title, string suggestedStartFolder, string filterName, string filterPattern)
|
||||
{
|
||||
EnsureSetup();
|
||||
var topLevel = owner != null ? TopLevel.GetTopLevel(owner) : null;
|
||||
if (topLevel?.StorageProvider == null) return null;
|
||||
|
||||
var options = new FilePickerOpenOptions
|
||||
{
|
||||
Title = string.IsNullOrEmpty(title) ? "Open file" : title,
|
||||
AllowMultiple = false
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(filterName) && !string.IsNullOrEmpty(filterPattern))
|
||||
{
|
||||
options.FileTypeFilter = new[]
|
||||
{
|
||||
new FilePickerFileType(filterName)
|
||||
{
|
||||
Patterns = filterPattern.Split(';', StringSplitOptions.RemoveEmptyEntries)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// One async flow - see SaveFilePicker for why this must not block.
|
||||
async Task<string> PickAsync()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(suggestedStartFolder) && Directory.Exists(suggestedStartFolder))
|
||||
{
|
||||
try
|
||||
{
|
||||
var folder = await topLevel.StorageProvider.TryGetFolderFromPathAsync(new Uri(suggestedStartFolder));
|
||||
if (folder != null) options.SuggestedStartLocation = folder;
|
||||
}
|
||||
catch { /* swallow - picker just opens in default location */ }
|
||||
}
|
||||
|
||||
var picked = await topLevel.StorageProvider.OpenFilePickerAsync(options);
|
||||
return (picked != null && picked.Count > 0) ? picked[0].Path.LocalPath : null;
|
||||
}
|
||||
|
||||
string result = null;
|
||||
var frame = new DispatcherFrame();
|
||||
|
||||
PickAsync().ContinueWith(t =>
|
||||
{
|
||||
string r = (t.Status == TaskStatus.RanToCompletion) ? t.Result : null;
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
result = r;
|
||||
frame.Continue = false;
|
||||
});
|
||||
});
|
||||
|
||||
Dispatcher.UIThread.PushFrame(frame);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Synchronous save-file picker. filterName/filterPattern model the WPF
|
||||
// "Json (*.json)|*.json" pairs: callers pass a display name and a
|
||||
// semicolon-separated list of patterns ("*.json;*.txt").
|
||||
// Keeps the original 6-argument signature working; forwards with no start folder.
|
||||
public static string SaveFilePicker(Window owner, string title, string suggestedFileName, string defaultExtension, string filterName, string filterPattern)
|
||||
{
|
||||
return SaveFilePicker(owner, title, suggestedFileName, defaultExtension, filterName, filterPattern, null);
|
||||
}
|
||||
|
||||
public static string SaveFilePicker(Window owner, string title, string suggestedFileName, string defaultExtension, string filterName, string filterPattern, string suggestedStartFolder)
|
||||
{
|
||||
EnsureSetup();
|
||||
var topLevel = owner != null ? TopLevel.GetTopLevel(owner) : null;
|
||||
if (topLevel?.StorageProvider == null) return null;
|
||||
|
||||
var options = new FilePickerSaveOptions
|
||||
{
|
||||
Title = string.IsNullOrEmpty(title) ? "Save" : title,
|
||||
SuggestedFileName = suggestedFileName,
|
||||
DefaultExtension = defaultExtension
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(filterName) && !string.IsNullOrEmpty(filterPattern))
|
||||
{
|
||||
options.FileTypeChoices = new[]
|
||||
{
|
||||
new FilePickerFileType(filterName)
|
||||
{
|
||||
Patterns = filterPattern.Split(';', StringSplitOptions.RemoveEmptyEntries)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve the start folder (WPF's InitialDirectory) and show the picker in
|
||||
// ONE async flow. Blocking on TryGetFolderFromPathAsync before the frame is
|
||||
// pushed would deadlock: this runs on the dispatcher thread, so any
|
||||
// continuation the storage provider posts back to it could never run.
|
||||
async Task<string> PickAsync()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(suggestedStartFolder) && Directory.Exists(suggestedStartFolder))
|
||||
{
|
||||
try
|
||||
{
|
||||
var startFolder = await topLevel.StorageProvider.TryGetFolderFromPathAsync(new Uri(suggestedStartFolder));
|
||||
if (startFolder != null) options.SuggestedStartLocation = startFolder;
|
||||
}
|
||||
catch { /* swallow - picker just opens in the default location */ }
|
||||
}
|
||||
|
||||
var file = await topLevel.StorageProvider.SaveFilePickerAsync(options);
|
||||
return file?.Path.LocalPath;
|
||||
}
|
||||
|
||||
string result = null;
|
||||
var frame = new DispatcherFrame();
|
||||
|
||||
PickAsync().ContinueWith(t =>
|
||||
{
|
||||
string r = (t.Status == TaskStatus.RanToCompletion) ? t.Result : null;
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
result = r;
|
||||
frame.Continue = false;
|
||||
});
|
||||
});
|
||||
|
||||
Dispatcher.UIThread.PushFrame(frame);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static void Shutdown()
|
||||
{
|
||||
if (!_setup) return;
|
||||
_lifetime?.Shutdown();
|
||||
}
|
||||
|
||||
// Factory for IntunePolicyPathConverter: PowerShell's [FullName]::new()
|
||||
// resolution walks every loaded assembly and calls GetTypes() on each.
|
||||
// After Avalonia's runtime XAML loader has compiled .axaml files into
|
||||
// dynamic assemblies, GetTypes() on those throws ReflectionTypeLoadException
|
||||
// and the PS resolution fails. Routing through this factory reuses the
|
||||
// already-resolved Host type (assigned to $script:AvaloniaHostType at
|
||||
// init time, before any XAML compilation), so no fresh scan happens.
|
||||
public static IValueConverter CreatePathConverter() => new IntunePolicyPathConverter();
|
||||
|
||||
private static void EnsureSetup()
|
||||
{
|
||||
if (!_setup)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"AvaloniaHost is not initialized. Call IntuneManagement.AvaloniaHost.Host.Initialize() first.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Walks a dotted property path on an arbitrary value (CLR object or PSObject).
|
||||
// Used by the Intune Manager DataGrid for the user-defined "ObjectColumns"
|
||||
// override — those paths reach into the original IntunePolicyBase / its
|
||||
// underlying PSCustomObject (`Object`) and so do not resolve against the
|
||||
// canonical fields on IntuneObjectRowItem. The binding points at the row's
|
||||
// `Source` and passes the path string as ConverterParameter; this converter
|
||||
// steps through PSObject.Properties at each segment, which catches both CLR
|
||||
// properties and PSObject NoteProperties (Avalonia's binder ignores the
|
||||
// latter on its own — see avalonia-binding-needs-clr-types).
|
||||
//
|
||||
// Lives in C# because PowerShell `class X : SomeAvaloniaType` resolves the
|
||||
// base type at parse time, before Initialize-AvaloniaRuntime has loaded the
|
||||
// Avalonia assemblies.
|
||||
//
|
||||
// PSObject access is via late-bound reflection rather than a compile-time
|
||||
// reference to System.Management.Automation: that assembly's transitive
|
||||
// references (e.g. System.Linq.Expressions v10) outpace the .NET ref-pack
|
||||
// shipped with the SDK (v8), and Roslyn rejects the cross-version mismatch
|
||||
// with CS1705. Reflection sidesteps the compile-time check entirely.
|
||||
public sealed class IntunePolicyPathConverter : IValueConverter
|
||||
{
|
||||
private static readonly Type _psObjectType =
|
||||
Type.GetType("System.Management.Automation.PSObject, System.Management.Automation");
|
||||
private static readonly MethodInfo _asPSObject =
|
||||
_psObjectType?.GetMethod("AsPSObject", new[] { typeof(object) });
|
||||
private static readonly PropertyInfo _propertiesProp =
|
||||
_psObjectType?.GetProperty("Properties");
|
||||
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if (value == null || parameter == null) return null;
|
||||
var path = parameter.ToString();
|
||||
if (string.IsNullOrWhiteSpace(path)) return null;
|
||||
|
||||
object current = value;
|
||||
foreach (var segment in path.Split('.'))
|
||||
{
|
||||
if (current == null) return null;
|
||||
|
||||
object next = null;
|
||||
try
|
||||
{
|
||||
if (TryGetPSProperty(current, segment, out var psValue))
|
||||
{
|
||||
next = psValue;
|
||||
}
|
||||
else
|
||||
{
|
||||
var clrType = current.GetType();
|
||||
var clrProp = clrType.GetProperty(segment);
|
||||
if (clrProp != null)
|
||||
{
|
||||
next = clrProp.GetValue(current);
|
||||
}
|
||||
else
|
||||
{
|
||||
var clrField = clrType.GetField(segment);
|
||||
if (clrField != null)
|
||||
{
|
||||
next = clrField.GetValue(current);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
|
||||
if (current == null) return null;
|
||||
if (current is string || current.GetType().IsPrimitive) return current;
|
||||
return current.ToString();
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryGetPSProperty(object instance, string name, out object value)
|
||||
{
|
||||
value = null;
|
||||
if (_psObjectType == null || _asPSObject == null || _propertiesProp == null)
|
||||
return false;
|
||||
|
||||
var psObj = _asPSObject.Invoke(null, new[] { instance });
|
||||
if (psObj == null) return false;
|
||||
|
||||
var properties = _propertiesProp.GetValue(psObj);
|
||||
if (properties == null) return false;
|
||||
|
||||
var indexer = properties.GetType().GetProperty("Item", new[] { typeof(string) });
|
||||
var prop = indexer?.GetValue(properties, new object[] { name });
|
||||
if (prop == null) return false;
|
||||
|
||||
var valueProp = prop.GetType().GetProperty("Value");
|
||||
if (valueProp == null) return false;
|
||||
|
||||
value = valueProp.GetValue(prop);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Library</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>disable</Nullable>
|
||||
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
||||
<ProduceReferenceAssembly>false</ProduceReferenceAssembly>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>false</SelfContained>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.2.3" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.2.3" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.3" />
|
||||
<PackageReference Include="Avalonia.Markup.Xaml.Loader" Version="11.2.3" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.3" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace IntuneManagement.AvaloniaPayload;
|
||||
|
||||
internal static class Empty
|
||||
{
|
||||
public const string Marker = "Avalonia payload project — exists only to publish the Avalonia binaries.";
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!--
|
||||
.NET startup hook that runs a PowerShell script on the process main thread
|
||||
inside the user's own pwsh (see StartupHook.cs). The output is a single
|
||||
dependency-free DLL committed under Bin/MainThreadHook; nothing else ships.
|
||||
|
||||
net8.0 is the floor: pwsh 7.4 runs on .NET 8 and every later pwsh runtime
|
||||
(9, 10, ...) loads a net8.0 assembly unchanged.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<AssemblyName>IntuneManagement.MainThreadHook</AssemblyName>
|
||||
<RootNamespace>IntuneManagement.MainThreadHook</RootNamespace>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Deterministic>true</Deterministic>
|
||||
<DebugType>none</DebugType>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
<!-- A hook is loaded into a running app; it has no runtimeconfig or deps of its own. -->
|
||||
<GenerateRuntimeConfigurationFiles>false</GenerateRuntimeConfigurationFiles>
|
||||
<GenerateDependencyFile>false</GenerateDependencyFile>
|
||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!--
|
||||
Compile-time reference only (no runtime/native assets are copied): at run
|
||||
time the hook binds to the System.Management.Automation that pwsh already
|
||||
loaded. Referencing the OLDEST supported engine matters - .NET resolves a
|
||||
reference to a lower assembly version against the newer loaded one, never
|
||||
the other way round - so 7.4.x here means the DLL works in 7.4, 7.5, 7.6,
|
||||
7.7 and whatever comes next without a rebuild.
|
||||
-->
|
||||
<PackageReference Include="Microsoft.PowerShell.SDK" Version="7.4.15" ExcludeAssets="runtime;native;contentFiles;build;buildTransitive" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,185 @@
|
||||
// Runs the PowerShell startup script on the process MAIN thread, inside the
|
||||
// user's own pwsh.
|
||||
//
|
||||
// Why: Cocoa (macOS) only allows the GUI - NSApplication, windows, dialogs - on
|
||||
// the first thread of the process. A normal pwsh pipeline runs on a worker
|
||||
// thread (ConsoleHost's ReuseThread runspace), so Avalonia cannot be started
|
||||
// from an ordinary `pwsh -File`. Windows (STA relaunch) and Linux (X11/Wayland)
|
||||
// have no such rule; this hook is only engaged on macOS, though it works
|
||||
// anywhere for testing.
|
||||
//
|
||||
// How: .NET calls StartupHook.Initialize() on the main thread BEFORE the app's
|
||||
// Main when DOTNET_STARTUP_HOOKS names this DLL. If IM_MAIN_THREAD_HOOK=1 is
|
||||
// also set and pwsh was started with -File, Initialize opens a UseCurrentThread
|
||||
// runspace right here, runs the script and terminates the process - pwsh's own
|
||||
// console Main never executes; the process is just the engine + runtime host.
|
||||
// Without the env var (child processes, ad-hoc pwsh) it returns immediately and
|
||||
// pwsh starts normally.
|
||||
//
|
||||
// Because it executes inside pwsh, the hook needs no PowerShell SDK, no separate
|
||||
// .NET install and no version matching: whatever pwsh the user has (7.4 or
|
||||
// newer, on .NET 8/9/10/...) brings its own engine, runtime and $PSHOME/ref
|
||||
// compile references, all consistent with each other by construction.
|
||||
using System.Globalization;
|
||||
using System.Management.Automation;
|
||||
using System.Management.Automation.Host;
|
||||
using System.Management.Automation.Runspaces;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace IntuneManagement.MainThreadHook
|
||||
{
|
||||
// Contract used by the PowerShell side (Start-Avalonia.ps1, CoreUIAvalonia.ps1,
|
||||
// tests): the type exists only when the hook engaged, and Verify() throws
|
||||
// if the caller is not on the thread the script was started on.
|
||||
public static class MainThread
|
||||
{
|
||||
public static int ManagedId { get; internal set; }
|
||||
|
||||
[DllImport("/usr/lib/libSystem.B.dylib")]
|
||||
private static extern int pthread_main_np();
|
||||
|
||||
public static bool IsNativeMainThread => !OperatingSystem.IsMacOS() || pthread_main_np() != 0;
|
||||
|
||||
public static void Verify()
|
||||
{
|
||||
if (Environment.CurrentManagedThreadId != ManagedId || !IsNativeMainThread)
|
||||
throw new InvalidOperationException("The macOS GUI must run on the process main thread.");
|
||||
}
|
||||
}
|
||||
|
||||
// This is a GUI script host, not an interactive console: output is forwarded
|
||||
// through the streams, nested prompts are deliberately unsupported, and
|
||||
// SetShouldExit captures a script-level `exit N`.
|
||||
internal sealed class HookHost : PSHost
|
||||
{
|
||||
public int? ExitCode { get; private set; }
|
||||
public override Guid InstanceId { get; } = Guid.NewGuid();
|
||||
public override string Name => "IntuneManagement.MainThreadHook";
|
||||
public override Version Version => new(1, 0);
|
||||
public override PSHostUserInterface UI => null!;
|
||||
public override CultureInfo CurrentCulture => CultureInfo.CurrentCulture;
|
||||
public override CultureInfo CurrentUICulture => CultureInfo.CurrentUICulture;
|
||||
public override void SetShouldExit(int exitCode) => ExitCode = exitCode;
|
||||
public override void EnterNestedPrompt() => throw new NotSupportedException("Nested prompts are not supported by the main-thread hook.");
|
||||
public override void ExitNestedPrompt() => throw new NotSupportedException("Nested prompts are not supported by the main-thread hook.");
|
||||
public override void NotifyBeginApplication() { }
|
||||
public override void NotifyEndApplication() { }
|
||||
}
|
||||
|
||||
internal static class ScriptRunner
|
||||
{
|
||||
public const string EngageVariable = "IM_MAIN_THREAD_HOOK";
|
||||
|
||||
// Returns null when this pwsh was not started with -File <script>.
|
||||
internal static (string Script, List<string> ScriptArgs)? ParseCommandLine(string[] args)
|
||||
{
|
||||
for (int i = 1; i < args.Length - 1; i++)
|
||||
{
|
||||
if (args[i].Equals("-File", StringComparison.OrdinalIgnoreCase) ||
|
||||
args[i].Equals("-f", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return (Path.GetFullPath(args[i + 1]), args.Skip(i + 2).ToList());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Do not make this async: every script and UI callback must stay on this thread.
|
||||
internal static int Run(string script, List<string> rest)
|
||||
{
|
||||
MainThread.ManagedId = Environment.CurrentManagedThreadId;
|
||||
try
|
||||
{
|
||||
MainThread.Verify();
|
||||
if (!File.Exists(script)) throw new FileNotFoundException("Startup script not found", script);
|
||||
|
||||
var host = new HookHost();
|
||||
using var runspace = RunspaceFactory.CreateRunspace(host, InitialSessionState.CreateDefault());
|
||||
runspace.ThreadOptions = PSThreadOptions.UseCurrentThread;
|
||||
runspace.Open();
|
||||
Runspace.DefaultRunspace = runspace;
|
||||
|
||||
using var ps = PowerShell.Create();
|
||||
ps.Runspace = runspace;
|
||||
ps.Streams.Error.DataAdded += (_, e) => Console.Error.WriteLine(ps.Streams.Error[e.Index]);
|
||||
ps.Streams.Warning.DataAdded += (_, e) => Console.Error.WriteLine("WARNING: " + ps.Streams.Warning[e.Index]);
|
||||
ps.Streams.Information.DataAdded += (_, e) => Console.WriteLine(ps.Streams.Information[e.Index].MessageData);
|
||||
|
||||
ps.AddCommand(script);
|
||||
// Tokens after the script path follow pwsh -File conventions:
|
||||
// "-Name value" binds a named parameter, a bare "-Switch" a switch,
|
||||
// anything else is positional.
|
||||
for (int i = 0; i < rest.Count; i++)
|
||||
{
|
||||
if (rest[i].Length > 1 && rest[i][0] == '-')
|
||||
{
|
||||
string name = rest[i].TrimStart('-');
|
||||
if (i + 1 < rest.Count && !(rest[i + 1].Length > 1 && rest[i + 1][0] == '-'))
|
||||
ps.AddParameter(name, rest[++i]);
|
||||
else
|
||||
ps.AddParameter(name);
|
||||
}
|
||||
else ps.AddArgument(rest[i]);
|
||||
}
|
||||
|
||||
foreach (var value in ps.Invoke()) Console.WriteLine(value);
|
||||
|
||||
// Only a TERMINATING failure, or an explicit exit code, is a failure.
|
||||
//
|
||||
// What must NOT reach the caller is ps.HadErrors on its own: it is set
|
||||
// by ANY error record the pipeline ever saw, including ones the script
|
||||
// itself caught in a try/catch or silenced with -ErrorAction
|
||||
// SilentlyContinue. This application does that hundreds of times in a
|
||||
// normal run (probing for an optional file, a registry value that may
|
||||
// not exist, ...), so returning 1 for it made every healthy macOS
|
||||
// launch end in
|
||||
// Main-thread launch failed (1). See the error above.
|
||||
// from Start-Avalonia.ps1 - and with no error above, because an error
|
||||
// the script already handled never prints.
|
||||
//
|
||||
// A real terminating error still reports: ps.Invoke() throws and the
|
||||
// catch below returns 1.
|
||||
if (host.ExitCode is int hostExit) return hostExit;
|
||||
|
||||
// A script-file `exit N` does NOT reach SetShouldExit through the
|
||||
// hosting API. It ends the pipeline with HadErrors set, no error
|
||||
// record, and $LASTEXITCODE holding N - that combination is the
|
||||
// signature of an explicit exit, which is why all three are required
|
||||
// here. A handled error matches the first two but leaves
|
||||
// $LASTEXITCODE alone, so it correctly falls through to 0.
|
||||
if (ps.HadErrors && ps.Streams.Error.Count == 0 &&
|
||||
runspace.SessionStateProxy.GetVariable("LASTEXITCODE") is int exitCode && exitCode != 0)
|
||||
return exitCode;
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine(ex.Message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The runtime's startup-hook contract: a class named StartupHook in the global
|
||||
// namespace with a static, parameterless void Initialize().
|
||||
internal static class StartupHook
|
||||
{
|
||||
public static void Initialize()
|
||||
{
|
||||
// Never let child processes (browser launch, nested pwsh, dotnet) inherit
|
||||
// the hook. The runtime has already read this variable for this process.
|
||||
Environment.SetEnvironmentVariable("DOTNET_STARTUP_HOOKS", null);
|
||||
|
||||
if (Environment.GetEnvironmentVariable(IntuneManagement.MainThreadHook.ScriptRunner.EngageVariable) != "1")
|
||||
return;
|
||||
Environment.SetEnvironmentVariable(IntuneManagement.MainThreadHook.ScriptRunner.EngageVariable, null);
|
||||
|
||||
var launch = IntuneManagement.MainThreadHook.ScriptRunner.ParseCommandLine(Environment.GetCommandLineArgs());
|
||||
if (launch == null) return; // not `pwsh -File ...`: let pwsh start normally
|
||||
|
||||
int code = IntuneManagement.MainThreadHook.ScriptRunner.Run(launch.Value.Script, launch.Value.ScriptArgs);
|
||||
Environment.Exit(code); // pwsh's own Main must never run
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
# Rebuilds Bin/MainThreadHook/IntuneManagement.MainThreadHook.dll from
|
||||
# MainThreadHook/StartupHook.cs. Needs the .NET 8+ SDK and NuGet access; the
|
||||
# output is a single small DLL that IS committed (unlike the old SDK-bundling
|
||||
# launcher), so run this only when the source changes and commit the result.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
|
||||
$project = Join-Path $PSScriptRoot 'MainThreadHook/MainThreadHook.csproj'
|
||||
$build = Join-Path $PSScriptRoot 'MainThreadHook/bin/publish'
|
||||
$output = Join-Path $root 'Bin/MainThreadHook'
|
||||
|
||||
& dotnet build $project -c Release -o $build --nologo
|
||||
if ($LASTEXITCODE -ne 0) { throw "Main-thread hook build failed ($LASTEXITCODE)" }
|
||||
|
||||
New-Item -ItemType Directory -Path $output -Force | Out-Null
|
||||
Copy-Item (Join-Path $build 'IntuneManagement.MainThreadHook.dll') $output -Force
|
||||
Get-Item (Join-Path $output 'IntuneManagement.MainThreadHook.dll') | Select-Object FullName, Length
|
||||
@@ -0,0 +1,59 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$OutputDirectory,
|
||||
# Publish for a platform other than the one running this script. NuGet hands out
|
||||
# the RID-specific native packages regardless of host OS, so "osx-arm64" from a
|
||||
# Windows box produces the real macOS .dylib set - which is how the committed Mac
|
||||
# binaries were built without a Mac. Both osx RIDs yield the same universal
|
||||
# (x86_64 + arm64) dylibs, so either one covers every Mac.
|
||||
[ValidateSet('win-x64', 'win-arm64', 'osx-x64', 'osx-arm64', 'linux-x64', 'linux-arm64')]
|
||||
[string]$RuntimeIdentifier
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$bootstrapDir = $PSScriptRoot
|
||||
$avaloniaRoot = Split-Path -Parent $bootstrapDir
|
||||
$uiRoot = Split-Path -Parent $avaloniaRoot
|
||||
$projectRoot = Split-Path -Parent $uiRoot
|
||||
|
||||
if (-not $OutputDirectory) {
|
||||
$OutputDirectory = Join-Path $projectRoot 'Bin/Avalonia'
|
||||
}
|
||||
|
||||
if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) {
|
||||
throw "dotnet SDK is required. Install .NET 8 SDK from https://dot.net and retry."
|
||||
}
|
||||
|
||||
# Publish for the current OS so the right native libs land in the output:
|
||||
# Windows gets libSkiaSharp.dll/av_libglesv2.dll, Linux gets libSkiaSharp.so,
|
||||
# macOS gets the .dylib. The managed Avalonia assemblies are identical across
|
||||
# platforms. dotnet publish doesn't clean the output dir, so publishing on a
|
||||
# second OS into the same Bin/Avalonia just adds that OS's natives alongside the
|
||||
# existing ones — keeping the committed folder usable cross-platform.
|
||||
$arch = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant()
|
||||
$isWin = $IsWindows -or $PSVersionTable.PSEdition -eq 'Desktop'
|
||||
$rid =
|
||||
if ($RuntimeIdentifier) { $RuntimeIdentifier }
|
||||
elseif ($isWin) { "win-$arch" }
|
||||
elseif ($IsMacOS) { "osx-$arch" }
|
||||
else { "linux-$arch" }
|
||||
|
||||
Write-Host "Publishing Avalonia binaries ($rid) to $OutputDirectory" -ForegroundColor Cyan
|
||||
|
||||
$publishArgs = @(
|
||||
'publish',
|
||||
(Join-Path $bootstrapDir 'AvaloniaPayload.csproj'),
|
||||
'-c', 'Release',
|
||||
'-r', $rid,
|
||||
'--self-contained', 'false',
|
||||
'-o', $OutputDirectory
|
||||
)
|
||||
|
||||
& dotnet @publishArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "dotnet publish failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
Write-Host "Avalonia binaries published." -ForegroundColor Green
|
||||
Write-Host "Set `$env:IM_UI_BACKEND = 'Avalonia' (or run UI/Avalonia/Start-Avalonia.ps1) to launch with Avalonia." -ForegroundColor Yellow
|
||||
@@ -0,0 +1,19 @@
|
||||
# Avalonia UI extension for the Atlassian documentation output provider.
|
||||
#
|
||||
# Attaches file-browse metadata onto the registered [DocumentationRegistry]
|
||||
# entry so the bulk-doc form's browse-button-wiring loop can render a Save
|
||||
# picker for the Atlassian output file. The provider file itself
|
||||
# (Internal/Documentation/OutputProviders/DocumentationOutputAtlassian.ps1)
|
||||
# stays free of XAML control names and toolkit-specific dialog options —
|
||||
# only the active UI backend's ClassExtensions load per session, so no
|
||||
# WPF/Avalonia conflict is possible.
|
||||
#
|
||||
# Unlike HTML there is no CSS row: Confluence storage format carries no
|
||||
# stylesheet, so the panel has a single browse control.
|
||||
|
||||
$provider = [DocumentationRegistry]::FindOutput('atlassian')
|
||||
if ($provider) {
|
||||
Add-Member -InputObject $provider -MemberType NoteProperty -Force -Name FileBrowseControls -Value @(
|
||||
@{ Button='browseAtlassianDocumentName'; TextBox='txtAtlassianDocumentName'; Save=$true; Title='Save Atlassian documentation'; Extension='html'; FilterName='Confluence storage format'; FilterPattern='*.html' }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# Avalonia UI extension for the HTML documentation output provider.
|
||||
#
|
||||
# Attaches file-browse metadata onto the registered [DocumentationRegistry]
|
||||
# entry so the bulk-doc form's browse-button-wiring loop can render Save
|
||||
# and Open pickers for the HTML output file and its CSS reference. The
|
||||
# provider file itself (Internal/Documentation/OutputProviders/DocumentationOutputHTML.ps1)
|
||||
# stays free of XAML control names and toolkit-specific dialog options —
|
||||
# only the active UI backend's ClassExtensions load per session, so no
|
||||
# WPF/Avalonia conflict is possible.
|
||||
|
||||
$provider = [DocumentationRegistry]::FindOutput('html')
|
||||
if ($provider) {
|
||||
Add-Member -InputObject $provider -MemberType NoteProperty -Force -Name FileBrowseControls -Value @(
|
||||
@{ Button='browseHTMLDocumentName'; TextBox='txtHTMLDocumentName'; Save=$true; Title='Save HTML documentation'; Extension='html'; FilterName='HTML files'; FilterPattern='*.html' }
|
||||
@{ Button='browseHTMLCSSFile'; TextBox='txtHTMLCSSFile'; Save=$false; Title='Select HTML CSS file'; FilterName='CSS files'; FilterPattern='*.css' }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# Avalonia UI extension for the Markdown documentation output provider.
|
||||
# See DocumentationOutputHTMLUIExtension.ps1 for the rationale + pattern.
|
||||
|
||||
$provider = [DocumentationRegistry]::FindOutput('md')
|
||||
if ($provider) {
|
||||
Add-Member -InputObject $provider -MemberType NoteProperty -Force -Name FileBrowseControls -Value @(
|
||||
@{ Button='browseMDDocumentName'; TextBox='txtMDDocumentName'; Save=$true; Title='Save Markdown documentation'; Extension='md'; FilterName='Markdown files'; FilterPattern='*.md' }
|
||||
@{ Button='browseMDCSSFile'; TextBox='txtMDCSSFile'; Save=$false; Title='Select Markdown CSS file'; FilterName='CSS files'; FilterPattern='*.css' }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# Avalonia UI extension for the Word documentation output provider.
|
||||
# See DocumentationOutputHTMLUIExtension.ps1 for the rationale + pattern.
|
||||
|
||||
$provider = [DocumentationRegistry]::FindOutput('word')
|
||||
if ($provider) {
|
||||
Add-Member -InputObject $provider -MemberType NoteProperty -Force -Name FileBrowseControls -Value @(
|
||||
@{ Button='browseWordDocumentName'; TextBox='txtWordDocumentName'; Save=$true; Title='Save Word documentation'; Extension='docx'; FilterName='Word files'; FilterPattern='*.docx' }
|
||||
@{ Button='browseWordDocumentTemplate'; TextBox='txtWordDocumentTemplate'; Save=$false; Title='Select Word template'; FilterName='Word templates'; FilterPattern='*.dotx;*.dotm' }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
# Avalonia parallel of UI/WPF/ClassExtensions/IntuneApplicationClassUIExtensions.ps1.
|
||||
# Attaches AddUIExportExtensions / AddUIDetailsExtension to ApplicationType
|
||||
# only. Same scope-loss + R13 constraints documented in
|
||||
# IntuneScriptClassUIExtension.ps1 — helpers captured up front, OpenFileDialog
|
||||
# / SaveFileDialog routed through Host::OpenFilePicker / Host::SaveFilePicker.
|
||||
#
|
||||
# Two non-trivial differences from the script flow:
|
||||
#
|
||||
# 1. Export adds TWO checkboxes — "Export script" (shared with the script
|
||||
# path; chains through Add-AvaloniaExportPropertyCheckbox under the
|
||||
# `chkExportScript` name) and an Application-specific
|
||||
# "Export application file" (`chkExportAppContent`). The latter's
|
||||
# initial state comes from a persisted setting, not a hard-coded `$true`.
|
||||
#
|
||||
# 2. Download is a multi-step pipeline: SaveFilePicker for the encrypted
|
||||
# content, Find-AppEncryptionFile to locate the matching .json,
|
||||
# OpenFilePicker fallback when the json is missing, Start-DecryptFile to
|
||||
# produce the unencrypted .zip, then delete the .encrypted intermediate.
|
||||
|
||||
##############################################
|
||||
# Helpers
|
||||
##############################################
|
||||
|
||||
function Invoke-UploadAppFileAvalonia
|
||||
{
|
||||
param($Row)
|
||||
|
||||
if (-not $Row -or -not $Row.Source) { return }
|
||||
$appItem = $Row.Source
|
||||
$obj = $appItem.Object
|
||||
|
||||
if ($obj.publishingState -ne "notPublished") {
|
||||
$confirm = $script:UIProvider.ShowMessageBox("Are you sure you want to upload a new file for the app?`n`nApplication:`n$($appItem.Name)", "Update app file?", "YesNo", "Warning")
|
||||
if ($confirm -ne "Yes") { return }
|
||||
}
|
||||
|
||||
$pkgPath = Get-SettingValue "IntuneAppPackagesFolder"
|
||||
$picked = (Get-AvaloniaHost)::OpenFilePicker(
|
||||
$script:Window,
|
||||
'Select Intune Win32 file',
|
||||
$pkgPath,
|
||||
'Intune Win32',
|
||||
'*.intunewin')
|
||||
if (-not $picked) { return }
|
||||
|
||||
Write-Status "Import $($obj.displayName) file"
|
||||
Start-ApplicationImportFile $appItem $picked
|
||||
Write-Status ""
|
||||
}
|
||||
|
||||
function Invoke-DownloadAppFileAvalonia
|
||||
{
|
||||
param($Row)
|
||||
|
||||
if (-not $Row -or -not $Row.Source) { return }
|
||||
$appItem = $Row.Source
|
||||
$obj = $appItem.Object
|
||||
|
||||
# Prefer the package fileName; fall back to the app display name so the save
|
||||
# dialog never defaults to a bare ".encrypted" (win32 list rows may not carry
|
||||
# a fileName).
|
||||
$baseName = if ($obj.FileName) { [string]$obj.FileName } elseif ($obj.displayName) { [string]$obj.displayName } else { 'app' }
|
||||
|
||||
Write-Status "Download file"
|
||||
try {
|
||||
$pkgPath = Get-SettingValue "IntuneAppDownloadFolder" (Get-SettingValue "IntuneAppPackagesFolder")
|
||||
$picked = (Get-AvaloniaHost)::SaveFilePicker(
|
||||
$script:Window,
|
||||
'Save encrypted intunewin',
|
||||
($baseName + ".encrypted"),
|
||||
'encrypted',
|
||||
'Encrypted intunewin',
|
||||
'*.encrypted')
|
||||
if (-not $picked) { return }
|
||||
|
||||
$contentFileObj = Start-DownloadAppContent $appItem $picked
|
||||
|
||||
if (-not [IO.File]::Exists($picked)) { return }
|
||||
|
||||
$fullPath = Find-AppEncryptionFile $obj $contentFileObj $pkgPath
|
||||
if (-not [IO.File]::Exists($fullPath)) {
|
||||
$browse = $script:UIProvider.ShowMessageBox("Could not find decryption file for $($obj.displayName)`nApp Id: $($obj.id)`nContent version $($obj.committedContentVersion)`n`nDo you want to browse for the file?", "Encryption file not found", "YesNo", "Warning")
|
||||
if ($browse -eq "Yes") {
|
||||
$alt = (Get-AvaloniaHost)::OpenFilePicker(
|
||||
$script:Window,
|
||||
'Select decryption json',
|
||||
$pkgPath,
|
||||
'JSON files',
|
||||
'*.json')
|
||||
if ($alt) { $fullPath = $alt }
|
||||
}
|
||||
}
|
||||
|
||||
if ([IO.File]::Exists($fullPath)) {
|
||||
Write-Status "Decrypting file"
|
||||
$encryptionInfo = ConvertFrom-Json ([IO.File]::ReadAllText($fullPath))
|
||||
if ($encryptionInfo.fileEncryptionInfo) { $encryptionInfo = $encryptionInfo.fileEncryptionInfo }
|
||||
$zipName = ($baseName -replace 'intunewin$', 'zip')
|
||||
if ($zipName -eq $baseName) { $zipName = "$baseName.zip" }
|
||||
$destination = [IO.Path]::Combine($pkgPath, $zipName)
|
||||
Start-DecryptFile $picked $destination $encryptionInfo.encryptionKey $encryptionInfo.initializationVector
|
||||
try { [IO.File]::Delete($picked) }
|
||||
catch { Write-LogError "Failed to delete exported encrypted file" $_.Exception }
|
||||
} else {
|
||||
Write-Log "Decryption file for $($obj.displayName) not found. Skipping decryption" 2
|
||||
}
|
||||
}
|
||||
finally {
|
||||
# Always clear the status overlay - an error mid-download previously left
|
||||
# it stuck on screen and looked like a hang.
|
||||
Write-Status ""
|
||||
}
|
||||
}
|
||||
|
||||
##############################################
|
||||
# Captures
|
||||
##############################################
|
||||
|
||||
$sbAddCheckbox = ${function:Add-AvaloniaExportPropertyCheckbox}
|
||||
$sbAddDetailsBtn = ${function:Add-AvaloniaDetailsButton}
|
||||
$sbSetCache = ${function:Set-CacheObject}
|
||||
$sbWriteLogError = ${function:Write-LogError}
|
||||
$sbGetSetting = ${function:Get-SettingStoreValue}
|
||||
$sbInvokeUploadApp = ${function:Invoke-UploadAppFileAvalonia}
|
||||
$sbInvokeDownloadApp = ${function:Invoke-DownloadAppFileAvalonia}
|
||||
|
||||
##############################################
|
||||
# ScriptBlocks
|
||||
##############################################
|
||||
|
||||
$SBAddApplicationUIExportExtension = {
|
||||
param($Form)
|
||||
|
||||
if (-not $Form) { return }
|
||||
|
||||
# "Export script" checkbox (shared with script-typed exports).
|
||||
$chkScript = & $sbAddCheckbox -Form $Form `
|
||||
-CheckboxName 'chkExportScript' `
|
||||
-LabelText 'Export script' `
|
||||
-ToolTip 'Export the script associated with selected profiles' `
|
||||
-InitialChecked $true
|
||||
if ($chkScript) {
|
||||
& $sbSetCache "ExportScripts" $chkScript.IsChecked
|
||||
$chkScript.add_IsCheckedChanged({
|
||||
param($s, $e)
|
||||
& $sbSetCache "ExportScripts" $s.IsChecked
|
||||
}.GetNewClosure())
|
||||
}
|
||||
|
||||
# "Export application file" checkbox — initial state from setting.
|
||||
$initial = ((& $sbGetSetting "Intune" "ExportAppFile" "false") -eq "true")
|
||||
$chkApp = & $sbAddCheckbox -Form $Form `
|
||||
-CheckboxName 'chkExportAppContent' `
|
||||
-LabelText 'Export application file' `
|
||||
-ToolTip 'Export the application file. Note: Application file will only be exported if encryption file is found.' `
|
||||
-InitialChecked $initial
|
||||
if ($chkApp) {
|
||||
& $sbSetCache "ExportAppContent" $chkApp.IsChecked
|
||||
$chkApp.add_IsCheckedChanged({
|
||||
param($s, $e)
|
||||
& $sbSetCache "ExportAppContent" $s.IsChecked
|
||||
}.GetNewClosure())
|
||||
}
|
||||
}.GetNewClosure()
|
||||
|
||||
$SBAddApplicationUIDetailsExtension = {
|
||||
param($Form)
|
||||
|
||||
if (-not $Form) { return }
|
||||
|
||||
$onUpload = {
|
||||
param($s, $e)
|
||||
$row = $script:dgIntuneManagerObjects.SelectedItem
|
||||
if (-not $row) { return }
|
||||
try { & $sbInvokeUploadApp $row }
|
||||
catch { & $sbWriteLogError "Upload application file failed" $_.Exception }
|
||||
}.GetNewClosure()
|
||||
|
||||
$onDownload = {
|
||||
param($s, $e)
|
||||
$row = $script:dgIntuneManagerObjects.SelectedItem
|
||||
if (-not $row) { return }
|
||||
try { & $sbInvokeDownloadApp $row }
|
||||
catch { & $sbWriteLogError "Download application file failed" $_.Exception }
|
||||
}.GetNewClosure()
|
||||
|
||||
# Insert order matches the WPF original: Upload at 0 first, then Download
|
||||
# at 0 — net layout left-to-right is Download, Upload, [existing buttons].
|
||||
& $sbAddDetailsBtn -Form $Form -Name 'btnUploadAppfile' -Content 'Upload' `
|
||||
-InsertIndex 0 -OnClick $onUpload | Out-Null
|
||||
& $sbAddDetailsBtn -Form $Form -Name 'btnDownloadAppfile' -Content 'Download' `
|
||||
-InsertIndex 0 -OnClick $onDownload | Out-Null
|
||||
}.GetNewClosure()
|
||||
|
||||
##############################################
|
||||
# Init
|
||||
##############################################
|
||||
|
||||
function Invoke-InitializeApplicationUIExtensions
|
||||
{
|
||||
$typeClasses = @()
|
||||
try { $typeClasses += Get-SingletonObject "ApplicationType" } catch {}
|
||||
|
||||
foreach ($typeClass in $typeClasses) {
|
||||
if (-not $typeClass) { continue }
|
||||
Add-ObjectMethod $typeClass "AddUIExportExtensions" $SBAddApplicationUIExportExtension
|
||||
Add-ObjectMethod $typeClass "AddUIDetailsExtension" $SBAddApplicationUIDetailsExtension
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-InitializeApplicationUIExtensions
|
||||
@@ -0,0 +1,68 @@
|
||||
# Avalonia counterpart of UI/WPF/ClassExtensions/IntuneCommonUIExtensions.ps1.
|
||||
# Attaches LoadIconImage / GetImage to every IntunePolicyType + IntunePolicyGroup
|
||||
# singleton so left-nav menu items (and downstream consumers via $item.IconImage)
|
||||
# get a real Avalonia visual instead of $null.
|
||||
#
|
||||
# Two divergences from the WPF original:
|
||||
# 1. File extension is .axaml (the converter under XAML/Icons/_Convert-Icons.ps1
|
||||
# writes Avalonia-flavoured copies of the WPF .xaml shape sources).
|
||||
# 2. Get-XamlObject here is the Avalonia loader (Extensions/CoreUI.ps1), so
|
||||
# the Image returned is an Avalonia Viewbox/Canvas tree that the
|
||||
# lstMenuItems DataTemplate's <ContentControl Content="{Binding IconImage}"/>
|
||||
# can host directly — no WPF→Avalonia conversion at runtime.
|
||||
|
||||
$SBAddCommonUILoadImage = [scriptblock] {
|
||||
try {
|
||||
$this.IconImage = $this.GetImage()
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
# Capture the module-bound Get-XamlObject + UI root path at attachment time.
|
||||
# Per [[scriptmethod-loses-module-scope]], Add-Member ScriptMethod scriptblocks
|
||||
# can't reliably resolve module-scoped vars/functions by name. Routing through
|
||||
# $script:UIProvider here also fails because the methods are attached at module
|
||||
# load, *before* the loader instantiates the provider.
|
||||
#
|
||||
# Only the methods are attached here. The icons themselves are parsed later, by
|
||||
# Initialize-MenuItemIcons (Extensions/UIMenuIcons*.ps1) when a menu is built -
|
||||
# after the splash is up, not on the Import-Module critical path.
|
||||
$_getXamlObjectFn = ${function:Get-XamlObject}
|
||||
$_uiRootFolder = $script:AppUIRootFolder
|
||||
|
||||
$SBAddCommonUIGetImage = [scriptblock] {
|
||||
if (-not $this.Icon) { return $null }
|
||||
$iconPath = Join-Path $_uiRootFolder ("XAML/Icons/" + $this.Icon + ".axaml")
|
||||
if (-not [IO.File]::Exists($iconPath)) { return $null }
|
||||
return (& $_getXamlObjectFn $iconPath)
|
||||
}.GetNewClosure()
|
||||
|
||||
function Invoke-InitializeCommonUIExtensions {
|
||||
$typeClasses = @()
|
||||
$groupClasses = @()
|
||||
|
||||
Get-SubClasses "IntunePolicyTypeBase" | ForEach-Object {
|
||||
if (Test-ClassIsAbstract $_) { return }
|
||||
$policyTypeName = $_.Name
|
||||
try {
|
||||
$tmpClass = Get-SingletonObject $_.Name
|
||||
if ($null -ne $tmpClass.PolicyGroup) {
|
||||
$typeClasses += $tmpClass
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to initialize class $policyTypeName." $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
Get-SubClasses "IntunePolicyGroupBase" | ForEach-Object {
|
||||
$groupClasses += Get-SingletonObject $_.Name
|
||||
}
|
||||
|
||||
foreach ($class in ($typeClasses + $groupClasses)) {
|
||||
Add-ObjectMethod $class "LoadIconImage" $SBAddCommonUILoadImage
|
||||
Add-ObjectMethod $class "GetImage" $SBAddCommonUIGetImage
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-InitializeCommonUIExtensions
|
||||
@@ -0,0 +1,182 @@
|
||||
# Avalonia parallel of UI/WPF/ClassExtensions/IntunePolicyFileClassUIExtension.ps1.
|
||||
# Same shape as IntuneScriptClassUIExtension.ps1; differences:
|
||||
#
|
||||
# * The "JSON property to edit" is dynamic — `_PolicyFileAttributes[0]` on
|
||||
# the type class points at the property holding base64 file bytes (e.g.
|
||||
# "fileContents" for custom attribute scripts). The TODO note about
|
||||
# "Support multiple attributes" carries over from the WPF original.
|
||||
# * Download writes raw bytes (no UTF-8 BOM stripping) to preserve binary
|
||||
# payloads. Filename defaults to JsonObject.FileName, falling back to
|
||||
# "<attribute>.json".
|
||||
#
|
||||
# Same scope-loss + R13 constraints as IntuneScriptClassUIExtension.ps1 —
|
||||
# helpers captured up front and OnClick / OnSave SBs use .GetNewClosure().
|
||||
|
||||
##############################################
|
||||
# Helpers
|
||||
##############################################
|
||||
|
||||
function Invoke-DownloadPolicyFileAvalonia
|
||||
{
|
||||
param($Policy)
|
||||
|
||||
if (-not $Policy) { return }
|
||||
if ($Policy.IsFullObject -eq $false) { [void]$Policy.Get() }
|
||||
|
||||
$attribute = $Policy.PolicyType._PolicyFileAttributes[0]
|
||||
if (-not $Policy.JsonObject.$attribute) { return }
|
||||
|
||||
$fileName = ?? $Policy.JsonObject.FileName "$($attribute).json"
|
||||
Write-Log "Download PowerShell file '$fileName' from $($Policy.Name)"
|
||||
|
||||
$picked = (Get-AvaloniaHost)::SaveFilePicker(
|
||||
$script:Window,
|
||||
'Save file',
|
||||
[string]$fileName,
|
||||
$null,
|
||||
'All files',
|
||||
'*.*')
|
||||
if (-not $picked) { return }
|
||||
|
||||
[IO.File]::WriteAllBytes($picked, ([System.Convert]::FromBase64String($Policy.JsonObject.$attribute)))
|
||||
}
|
||||
|
||||
function Invoke-EditPolicyFileAvalonia
|
||||
{
|
||||
param($Policy)
|
||||
|
||||
if (-not $Policy) { return }
|
||||
if ($Policy.IsFullObject -eq $false) { [void]$Policy.Get() }
|
||||
|
||||
$attribute = $Policy.PolicyType._PolicyFileAttributes[0] # TODO: Support multiple attributes
|
||||
if (-not $Policy.JsonObject.$attribute) { return }
|
||||
|
||||
$policyFileText = [System.Text.Encoding]::UTF8.GetString(
|
||||
[System.Convert]::FromBase64String($Policy.JsonObject.$attribute))
|
||||
|
||||
Show-AvaloniaScriptEditor `
|
||||
-Title "Edit: $($Policy.JsonObject.displayName)" `
|
||||
-InitialText $policyFileText `
|
||||
-OnSave {
|
||||
param([string]$NewText)
|
||||
|
||||
$pre = [System.Text.Encoding]::UTF8.GetPreamble()
|
||||
$utfBom = [System.Text.Encoding]::UTF8.GetString($pre)
|
||||
if ($NewText.StartsWith($utfBom)) { $NewText = $NewText.Remove(0, $utfBom.Length) }
|
||||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($NewText)
|
||||
$encodedText = [Convert]::ToBase64String($bytes)
|
||||
$attr = $Policy.PolicyType._PolicyFileAttributes[0]
|
||||
|
||||
if ($Policy.JsonObject.$attr -eq $encodedText) { return }
|
||||
|
||||
$confirm = $script:UIProvider.ShowMessageBox("Are you sure you want to update the file?`n`nObject:`n$($Policy.Name)", "Update file?", "YesNo", "Warning")
|
||||
if ($confirm -ne "Yes") { return }
|
||||
|
||||
Write-Status "Update $($Policy.Name)"
|
||||
$cloned = $Policy.Clone()
|
||||
$cloned.JsonObject.$attr = $encodedText
|
||||
Remove-GraphPropertiesForImport $cloned $cloned.JsonObject
|
||||
foreach ($prop in $Policy.PolicyType.PropertiesToRemoveForUpdate) {
|
||||
Remove-Property $cloned.JsonObject $prop
|
||||
}
|
||||
Remove-Property $cloned.JsonObject "Assignments"
|
||||
Remove-Property $cloned.JsonObject "isAssigned"
|
||||
|
||||
if ($Policy.Set($null, $cloned.JsonString, "PATCH", $null)) {
|
||||
Write-Log "File saved successfully"
|
||||
} else {
|
||||
Write-Log "Failed to save script" 3
|
||||
}
|
||||
Write-Status ""
|
||||
}.GetNewClosure()
|
||||
}
|
||||
|
||||
##############################################
|
||||
# Captures
|
||||
##############################################
|
||||
|
||||
$sbAddCheckbox = ${function:Add-AvaloniaExportPropertyCheckbox}
|
||||
$sbAddDetailsBtn = ${function:Add-AvaloniaDetailsButton}
|
||||
$sbSetCache = ${function:Set-CacheObject}
|
||||
$sbWriteLogError = ${function:Write-LogError}
|
||||
$sbInvokeDownloadPolicyFile = ${function:Invoke-DownloadPolicyFileAvalonia}
|
||||
$sbInvokeEditPolicyFile = ${function:Invoke-EditPolicyFileAvalonia}
|
||||
|
||||
##############################################
|
||||
# ScriptBlocks
|
||||
##############################################
|
||||
|
||||
$SBAddPolicyFileUIExportExtension = {
|
||||
param($Form)
|
||||
|
||||
if (-not $Form) { return }
|
||||
|
||||
$chk = & $sbAddCheckbox -Form $Form `
|
||||
-CheckboxName 'chkExportPolicyFiles' `
|
||||
-LabelText 'Export file' `
|
||||
-ToolTip 'Export the files associated with selected profiles' `
|
||||
-InitialChecked $true
|
||||
if (-not $chk) { return }
|
||||
|
||||
& $sbSetCache "ExportPolicyFiles" $chk.IsChecked
|
||||
|
||||
$chk.add_IsCheckedChanged({
|
||||
param($s, $e)
|
||||
& $sbSetCache "ExportPolicyFiles" $s.IsChecked
|
||||
}.GetNewClosure())
|
||||
}.GetNewClosure()
|
||||
|
||||
$SBAddPolicyFileUIDetailsExtension = {
|
||||
param($Form)
|
||||
|
||||
if (-not $Form) { return }
|
||||
|
||||
$onDownload = {
|
||||
param($s, $e)
|
||||
$row = $script:dgIntuneManagerObjects.SelectedItem
|
||||
$sel = if ($row) { $row.Source } else { $null }
|
||||
if (-not $sel) { return }
|
||||
try { & $sbInvokeDownloadPolicyFile $sel }
|
||||
catch { & $sbWriteLogError "Download policy file failed" $_.Exception }
|
||||
}.GetNewClosure()
|
||||
|
||||
$onEdit = {
|
||||
param($s, $e)
|
||||
$row = $script:dgIntuneManagerObjects.SelectedItem
|
||||
$sel = if ($row) { $row.Source } else { $null }
|
||||
if (-not $sel) { return }
|
||||
try { & $sbInvokeEditPolicyFile $sel }
|
||||
catch { & $sbWriteLogError "Edit policy file failed" $_.Exception }
|
||||
}.GetNewClosure()
|
||||
|
||||
& $sbAddDetailsBtn -Form $Form -Name 'btnDownload' -Content 'Download' `
|
||||
-InsertIndex 0 -OnClick $onDownload | Out-Null
|
||||
& $sbAddDetailsBtn -Form $Form -Name 'btnEdit' -Content 'Edit' `
|
||||
-InsertIndex 1 -OnClick $onEdit | Out-Null
|
||||
}.GetNewClosure()
|
||||
|
||||
##############################################
|
||||
# Init
|
||||
##############################################
|
||||
|
||||
function Invoke-InitializePolicyFileUIExtensions
|
||||
{
|
||||
$typeClasses = @()
|
||||
|
||||
Get-SubClasses "IntunePolicyTypeBase" | ForEach-Object {
|
||||
if (Test-ClassIsAbstract $_) { return }
|
||||
try {
|
||||
$tmpClass = Get-SingletonObject $_.Name
|
||||
if ($null -ne $tmpClass.PolicyGroup -and $tmpClass._PolicyFileAttributes.Count -gt 0) {
|
||||
$typeClasses += $tmpClass
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
foreach ($typeClass in $typeClasses) {
|
||||
Add-ObjectMethod $typeClass "AddUIExportExtensions" $SBAddPolicyFileUIExportExtension
|
||||
Add-ObjectMethod $typeClass "AddUIDetailsExtension" $SBAddPolicyFileUIDetailsExtension
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-InitializePolicyFileUIExtensions
|
||||
@@ -0,0 +1,184 @@
|
||||
# Avalonia parallel of UI/WPF/ClassExtensions/IntuneScriptClassUIExtension.ps1.
|
||||
# Same ScriptMethod names (AddUIExportExtensions / AddUIDetailsExtension) so
|
||||
# the Add-IntuneManagerExportUIExtensions wrapper from
|
||||
# Extensions/IntuneManagerExtensionHooks.ps1 invokes them transparently.
|
||||
#
|
||||
# Two cross-cutting concerns:
|
||||
#
|
||||
# * [[scriptmethod-loses-module-scope]] — Add-Member ScriptMethod scriptblocks
|
||||
# execute outside the defining module's session state and can't see
|
||||
# module-scoped functions by name. Every helper used inside the SBs is
|
||||
# captured up front via `${function:X}` and invoked through `&`. The outer
|
||||
# SB calls .GetNewClosure() so the captures travel with it; nested OnClick
|
||||
# handlers do the same so their inner-scope copies survive too.
|
||||
#
|
||||
# * The WPF original used [System.Windows.Forms.SaveFileDialog] (R13 forbids
|
||||
# WinForms in UI/Avalonia) and built [System.Windows.Controls.*] objects
|
||||
# directly. Both are routed through the Avalonia helpers in
|
||||
# IntuneManagerExtensionHooks.ps1 (Add-AvaloniaExportPropertyCheckbox,
|
||||
# Add-AvaloniaDetailsButton, Show-AvaloniaScriptEditor) which use
|
||||
# [Avalonia.Controls.*] + Host::SaveFilePicker.
|
||||
|
||||
##############################################
|
||||
# Helpers (file-private functions invoked from inside the ScriptMethods).
|
||||
# Defined first so the `${function:X}` captures below see live refs before
|
||||
# the SBs build their closures.
|
||||
##############################################
|
||||
|
||||
function Invoke-DownloadScriptAvalonia
|
||||
{
|
||||
param($ScriptPolicy)
|
||||
|
||||
if (-not $ScriptPolicy) { return }
|
||||
if ($ScriptPolicy.IsFullObject -eq $false) { [void]$ScriptPolicy.Get() }
|
||||
if (-not $ScriptPolicy.JsonObject.scriptContent) { return }
|
||||
|
||||
Write-Status ""
|
||||
|
||||
$picked = (Get-AvaloniaHost)::SaveFilePicker(
|
||||
$script:Window,
|
||||
'Save script',
|
||||
[string]$ScriptPolicy.JsonObject.FileName,
|
||||
$null,
|
||||
'All files',
|
||||
'*.*')
|
||||
if (-not $picked) { return }
|
||||
|
||||
Save-IntuneScriptContent $ScriptPolicy $picked | Out-Null
|
||||
}
|
||||
|
||||
function Invoke-EditScriptAvalonia
|
||||
{
|
||||
param($ScriptPolicy)
|
||||
|
||||
if (-not $ScriptPolicy) { return }
|
||||
if ($ScriptPolicy.IsFullObject -eq $false) { [void]$ScriptPolicy.Get() }
|
||||
if (-not $ScriptPolicy.JsonObject.scriptContent) { return }
|
||||
|
||||
$scriptText = [System.Text.Encoding]::UTF8.GetString(
|
||||
[System.Convert]::FromBase64String($ScriptPolicy.JsonObject.scriptContent))
|
||||
|
||||
Show-AvaloniaScriptEditor `
|
||||
-Title "Edit: $($ScriptPolicy.JsonObject.displayName)" `
|
||||
-InitialText $scriptText `
|
||||
-OnSave {
|
||||
param([string]$NewText)
|
||||
|
||||
$pre = [System.Text.Encoding]::UTF8.GetPreamble()
|
||||
$utfBom = [System.Text.Encoding]::UTF8.GetString($pre)
|
||||
if ($NewText.StartsWith($utfBom)) { $NewText = $NewText.Remove(0, $utfBom.Length) }
|
||||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($NewText)
|
||||
$encodedText = [Convert]::ToBase64String($bytes)
|
||||
|
||||
if ($ScriptPolicy.JsonObject.scriptContent -eq $encodedText) { return }
|
||||
|
||||
$confirm = $script:UIProvider.ShowMessageBox("Are you sure you want to update the script?`n`nObject:`n$($ScriptPolicy.Name)", "Update script?", "YesNo", "Warning")
|
||||
if ($confirm -ne "Yes") { return }
|
||||
|
||||
Write-Status "Update $($ScriptPolicy.Name)"
|
||||
$cloned = $ScriptPolicy.Clone()
|
||||
$cloned.JsonObject.scriptContent = $encodedText
|
||||
Remove-GraphPropertiesForImport $cloned $cloned.JsonObject
|
||||
foreach ($prop in $ScriptPolicy.PolicyType.PropertiesToRemoveForUpdate) {
|
||||
Remove-Property $cloned.JsonObject $prop
|
||||
}
|
||||
Remove-Property $cloned.JsonObject "Assignments"
|
||||
Remove-Property $cloned.JsonObject "isAssigned"
|
||||
|
||||
if ($ScriptPolicy.Set($null, $cloned.JsonString, "PATCH", $null)) {
|
||||
Write-Log "Script saved successfully"
|
||||
} else {
|
||||
Write-Log "Failed to save script" 3
|
||||
}
|
||||
Write-Status ""
|
||||
}.GetNewClosure()
|
||||
}
|
||||
|
||||
##############################################
|
||||
# Captures (see [[scriptmethod-loses-module-scope]])
|
||||
##############################################
|
||||
|
||||
$sbAddCheckbox = ${function:Add-AvaloniaExportPropertyCheckbox}
|
||||
$sbAddDetailsBtn = ${function:Add-AvaloniaDetailsButton}
|
||||
$sbSetCache = ${function:Set-CacheObject}
|
||||
$sbWriteLogError = ${function:Write-LogError}
|
||||
$sbInvokeDownloadScript = ${function:Invoke-DownloadScriptAvalonia}
|
||||
$sbInvokeEditScript = ${function:Invoke-EditScriptAvalonia}
|
||||
|
||||
##############################################
|
||||
# ScriptBlocks (built AFTER captures are in scope so GetNewClosure picks them up)
|
||||
##############################################
|
||||
|
||||
$SBAddScriptUIExportExtension = {
|
||||
param($Form)
|
||||
|
||||
if (-not $Form) { return }
|
||||
|
||||
$chk = & $sbAddCheckbox -Form $Form `
|
||||
-CheckboxName 'chkExportScript' `
|
||||
-LabelText 'Export script' `
|
||||
-ToolTip 'Export the script associated with selected profiles' `
|
||||
-InitialChecked $true
|
||||
if (-not $chk) { return }
|
||||
|
||||
& $sbSetCache "ExportScripts" $chk.IsChecked
|
||||
|
||||
$chk.add_IsCheckedChanged({
|
||||
param($s, $e)
|
||||
& $sbSetCache "ExportScripts" $s.IsChecked
|
||||
}.GetNewClosure())
|
||||
}.GetNewClosure()
|
||||
|
||||
$SBAddScriptUIDetailsExtension = {
|
||||
param($Form)
|
||||
|
||||
if (-not $Form) { return }
|
||||
|
||||
$onDownload = {
|
||||
param($s, $e)
|
||||
$row = $script:dgIntuneManagerObjects.SelectedItem
|
||||
$sel = if ($row) { $row.Source } else { $null }
|
||||
if (-not $sel) { return }
|
||||
try { & $sbInvokeDownloadScript $sel }
|
||||
catch { & $sbWriteLogError "Download script failed" $_.Exception }
|
||||
}.GetNewClosure()
|
||||
|
||||
$onEdit = {
|
||||
param($s, $e)
|
||||
$row = $script:dgIntuneManagerObjects.SelectedItem
|
||||
$sel = if ($row) { $row.Source } else { $null }
|
||||
if (-not $sel) { return }
|
||||
try { & $sbInvokeEditScript $sel }
|
||||
catch { & $sbWriteLogError "Edit script failed" $_.Exception }
|
||||
}.GetNewClosure()
|
||||
|
||||
& $sbAddDetailsBtn -Form $Form -Name 'btnDownload' -Content 'Download' `
|
||||
-InsertIndex 0 -OnClick $onDownload | Out-Null
|
||||
& $sbAddDetailsBtn -Form $Form -Name 'btnEdit' -Content 'Edit' `
|
||||
-InsertIndex 1 -OnClick $onEdit | Out-Null
|
||||
}.GetNewClosure()
|
||||
|
||||
##############################################
|
||||
# Init
|
||||
##############################################
|
||||
|
||||
function Invoke-InitializeScriptUIExtensions
|
||||
{
|
||||
$typeClasses = @()
|
||||
|
||||
Get-SubClasses "ScriptTypeBase" | ForEach-Object {
|
||||
try {
|
||||
$tmpClass = Get-SingletonObject $_.Name
|
||||
if ($null -ne $tmpClass.PolicyGroup) {
|
||||
$typeClasses += $tmpClass
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
foreach ($typeClass in $typeClasses) {
|
||||
Add-ObjectMethod $typeClass "AddUIExportExtensions" $SBAddScriptUIExportExtension
|
||||
Add-ObjectMethod $typeClass "AddUIDetailsExtension" $SBAddScriptUIDetailsExtension
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-InitializeScriptUIExtensions
|
||||
@@ -0,0 +1,17 @@
|
||||
#ImportOrder 6
|
||||
|
||||
# CLR-typed row for the ADMX listBox element editor (Setting Properties
|
||||
# dialog). Was previously a `ListValue` class defined via Invoke-Expression
|
||||
# inside Set-ADMXElementsPanel - a type created that way is not resolvable
|
||||
# from the module's event handlers at click time, which killed the list
|
||||
# editor's Add button. A real class file loads with the module and is
|
||||
# visible everywhere (and, being CLR-typed, binds in the DataGrid - see
|
||||
# [[avalonia-binding-needs-clr-types]]).
|
||||
|
||||
class ADMXListValueItem
|
||||
{
|
||||
[string]$Key = ''
|
||||
[string]$Value = ''
|
||||
|
||||
ADMXListValueItem() {}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#ImportOrder 4
|
||||
|
||||
# CLR-typed shape for rows in the About dialog's loaded-modules ListBox.
|
||||
# See [[avalonia-binding-needs-clr-types]] — Avalonia's binder does not
|
||||
# special-case PSObject NoteProperties, so DataTemplate items must be real
|
||||
# CLR types with real properties.
|
||||
|
||||
class AboutModuleEntry
|
||||
{
|
||||
[string]$Name
|
||||
[string]$Version
|
||||
[string]$Type
|
||||
|
||||
AboutModuleEntry() {}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
#ImportOrder 60
|
||||
#
|
||||
# Avalonia concrete subclass of UIProvider. Each method captures the
|
||||
# corresponding free function from CoreUIAvalonia.ps1 (and friends) at construction
|
||||
# time via ${function:X}, then invokes the captured scriptblock with `&`.
|
||||
# This is defensive: PS native class methods *may* see module-scoped free
|
||||
# functions, but the documented workaround for ScriptMethod scriptblocks
|
||||
# (see [[scriptmethod-loses-module-scope]]) is the capture pattern. Until a
|
||||
# smoke test confirms direct calls work, we capture explicitly.
|
||||
#
|
||||
# Folder picking is exposed through the provider even though the implementation
|
||||
# delegates to Avalonia's StorageProvider host wrapper.
|
||||
|
||||
class AvaloniaUIProvider : UIProvider {
|
||||
|
||||
#region Modal / dialog
|
||||
|
||||
[void] ShowMessageBox([string]$Text) {
|
||||
$f = ${function:Show-MessageBox}
|
||||
& $f $Text | Out-Null
|
||||
}
|
||||
|
||||
[void] ShowMessageBox([string]$Text, [string]$Caption) {
|
||||
$f = ${function:Show-MessageBox}
|
||||
& $f $Text $Caption | Out-Null
|
||||
}
|
||||
|
||||
[object] ShowMessageBox([string]$Text, [string]$Caption, [string]$Button, [string]$Icon) {
|
||||
$f = ${function:Show-MessageBox}
|
||||
return (& $f $Text $Caption $Button $Icon)
|
||||
}
|
||||
|
||||
[void] ShowModalForm([string]$FormTitle, [object]$FormObject) {
|
||||
$f = ${function:Show-ModalForm}
|
||||
& $f $FormTitle $FormObject
|
||||
}
|
||||
|
||||
[void] ShowModalForm([string]$FormTitle, [object]$FormObject, [bool]$HideButtons) {
|
||||
$f = ${function:Show-ModalForm}
|
||||
if ($HideButtons) { & $f $FormTitle $FormObject -HideButtons }
|
||||
else { & $f $FormTitle $FormObject }
|
||||
}
|
||||
|
||||
[void] ShowModalObject() {
|
||||
$f = ${function:Show-ModalObject}
|
||||
& $f
|
||||
}
|
||||
|
||||
[void] ShowModalObject([object]$Obj) {
|
||||
$f = ${function:Show-ModalObject}
|
||||
& $f $Obj
|
||||
}
|
||||
|
||||
[void] CloseTopModalObject() {
|
||||
$f = ${function:Close-TopModalObject}
|
||||
& $f
|
||||
}
|
||||
|
||||
[string] ShowInputDialog([string]$FormTitle, [string]$FormText, [string]$DefaultValue) {
|
||||
$f = ${function:Show-InputDialog}
|
||||
return (& $f $FormTitle $FormText $DefaultValue)
|
||||
}
|
||||
|
||||
[void] ShowAboutDialog() {
|
||||
$f = ${function:Show-AboutDialog}
|
||||
& $f
|
||||
}
|
||||
|
||||
[bool] RequestUIConfirmation([string]$Message, [string]$Caption) {
|
||||
$f = ${function:Request-UIConfirmation}
|
||||
return [bool](& $f $Message $Caption)
|
||||
}
|
||||
|
||||
[void] ShowPopup([object]$Popup) {
|
||||
$f = ${function:Show-Popup}
|
||||
& $f $Popup
|
||||
}
|
||||
|
||||
[void] HidePopup() {
|
||||
$f = ${function:Hide-Popup}
|
||||
& $f
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region File / folder pickers
|
||||
|
||||
[string] ShowFolderPicker([string]$Description) {
|
||||
$f = ${function:Get-AvaloniaHost}
|
||||
$hostType = & $f
|
||||
return $hostType::OpenFolderPicker($script:Window, $Description)
|
||||
}
|
||||
|
||||
[string] ShowFolderPicker([string]$Path, [string]$Description) {
|
||||
$f = ${function:Get-AvaloniaHost}
|
||||
$hostType = & $f
|
||||
return $hostType::OpenFolderPicker($script:Window, $Description, $Path)
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region XAML helpers
|
||||
|
||||
[object] GetXamlObject([string]$FileName) {
|
||||
$f = ${function:Get-XamlObject}
|
||||
return (& $f $FileName)
|
||||
}
|
||||
|
||||
[object] GetXamlObject([string]$FileName, [bool]$AddVariables) {
|
||||
$f = ${function:Get-XamlObject}
|
||||
if ($AddVariables) { return (& $f $FileName -AddVariables) }
|
||||
else { return (& $f $FileName) }
|
||||
}
|
||||
|
||||
[object] GetXamlObject([string]$FileName, [bool]$AddVariables, [bool]$AddStyles) {
|
||||
$f = ${function:Get-XamlObject}
|
||||
$h = @{}
|
||||
if ($AddVariables) { $h.AddVariables = $true }
|
||||
if ($AddStyles) { $h.AddStyles = $true }
|
||||
return (& $f $FileName @h)
|
||||
}
|
||||
|
||||
[void] AddXamlEvent([object]$XamlObj, [string]$ControlName, [string]$EventName, [scriptblock]$Handler) {
|
||||
$f = ${function:Add-XamlEvent}
|
||||
& $f $XamlObj $ControlName $EventName $Handler
|
||||
}
|
||||
|
||||
[void] AddXamlVariables([object]$XamlObj, [object]$Scope) {
|
||||
$f = ${function:Add-XamlVariables}
|
||||
& $f $XamlObj $Scope
|
||||
}
|
||||
|
||||
[void] SetXamlProperty([object]$XamlObj, [string]$ControlName, [string]$PropertyName, [object]$Value) {
|
||||
$f = ${function:Set-XamlProperty}
|
||||
& $f $XamlObj $ControlName $PropertyName $Value
|
||||
}
|
||||
|
||||
[object] GetXamlProperty([object]$XamlObj, [string]$ControlName, [string]$PropertyName) {
|
||||
$f = ${function:Get-XamlProperty}
|
||||
return (& $f $XamlObj $ControlName $PropertyName)
|
||||
}
|
||||
|
||||
[void] SetControlVisible([object]$XamlObj, [string]$ControlName, [bool]$Visible) {
|
||||
$this.SetXamlProperty($XamlObj, $ControlName, "IsVisible", $Visible)
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Main window / chrome
|
||||
|
||||
[void] ShowMainWindow([object]$View) {
|
||||
$f = ${function:Show-MainWindow}
|
||||
& $f $View
|
||||
}
|
||||
|
||||
[void] SetMainTitle() {
|
||||
$f = ${function:Set-MainTitle}
|
||||
& $f
|
||||
}
|
||||
|
||||
[object] GetMainWindow() {
|
||||
$f = ${function:Get-MainWindow}
|
||||
return (& $f)
|
||||
}
|
||||
|
||||
[void] SetAppTheme([string]$ThemeName) {
|
||||
$f = ${function:Set-AppTheme}
|
||||
& $f $ThemeName
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Threading / status
|
||||
|
||||
[void] UpdateUIStatus([string]$Text) {
|
||||
$f = ${function:Update-UIStatus}
|
||||
& $f -Text $Text | Out-Null
|
||||
}
|
||||
|
||||
[bool] UpdateUIStatus([System.Collections.IDictionary]$Params) {
|
||||
$f = ${function:Update-UIStatus}
|
||||
$h = @{}
|
||||
foreach ($k in $Params.Keys) { $h[$k] = $Params[$k] }
|
||||
return [bool](& $f @h)
|
||||
}
|
||||
|
||||
[void] InvokeUIMessagePump() {
|
||||
$f = ${function:Invoke-UIMessagePump}
|
||||
& $f
|
||||
}
|
||||
|
||||
[void] SetClipboardText([string]$Text) {
|
||||
$f = ${function:Get-AvaloniaHost}
|
||||
$hostType = & $f
|
||||
$hostType::SetClipboardText($script:Window, $Text)
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Auth chrome
|
||||
|
||||
[void] ShowAuthenticationInfo() {
|
||||
$f = ${function:Show-AuthenticationInfo}
|
||||
& $f
|
||||
}
|
||||
|
||||
[void] SetEnvironmentInfo() {
|
||||
$f = ${function:Set-EnvironmentInfo}
|
||||
& $f
|
||||
}
|
||||
|
||||
[void] SetEnvironmentInfo([string]$TenantName) {
|
||||
$f = ${function:Set-EnvironmentInfo}
|
||||
& $f $TenantName
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
#ImportOrder 10
|
||||
|
||||
# CLR row shape for Show-GraphBulkAssignmentsForm's dgBulkAssignObjects
|
||||
# DataGrid (the left-hand "Objects to update" grid). Mirrors
|
||||
# [[BulkScopeTagRowItem]] / [[BulkExportRowItem]] — Group/API mode means
|
||||
# exactly one of ObjectGroup / ObjectType is populated, the other is $null.
|
||||
# Avalonia's binder ignores PSObject NoteProperties, so PSCustomObject would
|
||||
# render Selected / Title blank — see [[avalonia-binding-needs-clr-types]].
|
||||
|
||||
class BulkAssignRowItem
|
||||
{
|
||||
[bool] $Selected = $true
|
||||
[string] $Title
|
||||
[object] $ObjectGroup
|
||||
[object] $ObjectType
|
||||
|
||||
BulkAssignRowItem() {}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
#ImportOrder 10
|
||||
|
||||
# CLR row shape for the dgBulkAssignList DataGrid in Show-GraphBulkAssignmentsForm.
|
||||
# The WPF original uses PSCustomObject here; Avalonia's binder needs real CLR
|
||||
# properties (see [[avalonia-binding-needs-clr-types]]).
|
||||
#
|
||||
# Set-GraphBulkAssignments accesses these by name (.TargetType, .GroupId,
|
||||
# .Intent, .Settings as [Hashtable]) so the public command consumes class
|
||||
# instances the same way it consumes PSCustomObject — confirmed by reading
|
||||
# Public/Set-GraphBulkAssignments.ps1.
|
||||
#
|
||||
# Settings is keyed by Graph settings type name (e.g.
|
||||
# "win32LobAppAssignmentSettings"). Absent key = no override for that type.
|
||||
# SettingsDisplay is a derived count string ("(none)" / "N platform(s)") that
|
||||
# the App settings... dialog refreshes on save.
|
||||
|
||||
class BulkAssignmentRowItem
|
||||
{
|
||||
[string] $TargetType
|
||||
[string] $TargetTypeDisplay
|
||||
[string] $GroupId
|
||||
[string] $GroupName
|
||||
[string] $FilterId
|
||||
[string] $FilterName
|
||||
[string] $FilterType = "include"
|
||||
[string] $FilterDisplay = ""
|
||||
[string] $Intent = "required"
|
||||
[Hashtable] $Settings = @{}
|
||||
[string] $SettingsDisplay = "(none)"
|
||||
|
||||
BulkAssignmentRowItem() {}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#ImportOrder 10
|
||||
|
||||
# CLR row shape for Show-GraphBulkCompareForm's dgObjectsToCompare. Same
|
||||
# reason as [[BulkExportRowItem]] / [[avalonia-binding-needs-clr-types]] —
|
||||
# Avalonia's binder ignores PSObject NoteProperties. Default Selected is
|
||||
# true (matches WPF original — every object type pre-selected for a
|
||||
# bulk compare run).
|
||||
|
||||
class BulkCompareRowItem
|
||||
{
|
||||
[bool] $Selected = $true
|
||||
[string] $Title
|
||||
[object] $ObjectGroup
|
||||
|
||||
BulkCompareRowItem() {}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#ImportOrder 10
|
||||
|
||||
# CLR row shape for Show-GraphBulkCopyForm's dgBulkCopyObjects. Same reason
|
||||
# as [[BulkDeleteRowItem]] / [[avalonia-binding-needs-clr-types]] — Avalonia's
|
||||
# binder ignores PSObject NoteProperties. Default Selected is true (matches
|
||||
# the original Bulk Copy where every type was pre-checked).
|
||||
|
||||
class BulkCopyRowItem
|
||||
{
|
||||
[bool] $Selected = $true
|
||||
[string] $Title
|
||||
[object] $ObjectType
|
||||
|
||||
BulkCopyRowItem() {}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#ImportOrder 10
|
||||
|
||||
# CLR row shape for Show-GraphBulkDeleteForm's dgBulkDeleteObjects. Same
|
||||
# reason as [[BulkExportRowItem]] / [[avalonia-binding-needs-clr-types]] —
|
||||
# Avalonia's binder ignores PSObject NoteProperties. Default Selected is
|
||||
# false (matches WPF; bulk delete is destructive and shouldn't pre-select).
|
||||
|
||||
class BulkDeleteRowItem
|
||||
{
|
||||
[bool] $Selected = $false
|
||||
[string] $Title
|
||||
[object] $ObjectGroup
|
||||
|
||||
BulkDeleteRowItem() {}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
#ImportOrder 10
|
||||
|
||||
# CLR row shape for Show-GraphBulkDocumentationForm's dgObjectsToDocument.
|
||||
# Same reason as [[BulkExportRowItem]] / [[avalonia-binding-needs-clr-types]] —
|
||||
# Avalonia's binder ignores PSObject NoteProperties, so Selected and Title
|
||||
# would render blank if we used PSCustomObject. ObjectGroup / ObjectType
|
||||
# hold references to the underlying IntuneGroup / IntuneType / policy so the
|
||||
# click handler can pass the appropriate selection into Start-GraphBulkDocumentation.
|
||||
|
||||
class BulkDocumentationRowItem
|
||||
{
|
||||
[bool] $Selected = $true
|
||||
[string] $Title
|
||||
[object] $ObjectGroup
|
||||
[object] $ObjectType
|
||||
[object] $PolicyObject
|
||||
|
||||
BulkDocumentationRowItem() {}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#ImportOrder 10
|
||||
|
||||
# CLR row shape for Show-GraphBulkExportForm's dgObjectsToExport. WPF used
|
||||
# [PSCustomObject]@{...} but Avalonia's binder ignores PSObject NoteProperties
|
||||
# (see [[avalonia-binding-needs-clr-types]]) — Selected and Title would render
|
||||
# blank. ObjectGroup / ObjectType keep references to the underlying
|
||||
# IntuneGroup / IntuneType so Get-BulkExportSelectedIds can pull .Id off them.
|
||||
|
||||
class BulkExportRowItem
|
||||
{
|
||||
[bool] $Selected = $true
|
||||
[string] $Title
|
||||
[object] $ObjectGroup
|
||||
[object] $ObjectType
|
||||
|
||||
BulkExportRowItem() {}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#ImportOrder 10
|
||||
|
||||
# CLR row shape for Show-GraphBulkImportForm's dgObjectsToImport. Same reason
|
||||
# as [[BulkExportRowItem]] / [[avalonia-binding-needs-clr-types]] — Avalonia's
|
||||
# binder ignores PSObject NoteProperties, so Selected and Title would render
|
||||
# blank if we used [PSCustomObject]@{...} like the WPF original. ObjectGroup
|
||||
# keeps a reference to the underlying IntuneGroup so the import handler can
|
||||
# pull .PolicyTypes off it.
|
||||
|
||||
class BulkImportRowItem
|
||||
{
|
||||
[bool] $Selected = $true
|
||||
[string] $Title
|
||||
[object] $ObjectGroup
|
||||
|
||||
BulkImportRowItem() {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
#ImportOrder 10
|
||||
|
||||
# CLR row shape for Show-GraphBulkScopeTagForm's dgBulkScopeTagObjects.
|
||||
# Same reasoning as [[BulkExportRowItem]] / [[avalonia-binding-needs-clr-types]] —
|
||||
# Avalonia's binder ignores PSObject NoteProperties, so Selected / Title would
|
||||
# render blank if we used PSCustomObject. Mirrors BulkExportRowItem because
|
||||
# this form also offers Group / API mode (one of ObjectGroup / ObjectType is
|
||||
# populated, the other is $null).
|
||||
|
||||
class BulkScopeTagRowItem
|
||||
{
|
||||
[bool] $Selected = $true
|
||||
[string] $Title
|
||||
[object] $ObjectGroup
|
||||
[object] $ObjectType
|
||||
|
||||
BulkScopeTagRowItem() {}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#ImportOrder 9
|
||||
|
||||
# CLR-typed row shape for the Cached Objects DataGrid (CachedObjects.axaml).
|
||||
# Update-CachedObjectsView builds these from $script:cacheObjects (a hashtable
|
||||
# of PSCustomObject in non-UI code) — Avalonia's binder needs CLR types here
|
||||
# rather than the raw cache entries.
|
||||
|
||||
class CacheRowItem
|
||||
{
|
||||
[string] $Name
|
||||
[object] $Tags
|
||||
[string] $TagsText
|
||||
[object] $Value
|
||||
[string] $ValueText
|
||||
[bool] $Persistent
|
||||
[datetime] $TimeOut
|
||||
[long] $Size
|
||||
[string] $SizeText
|
||||
|
||||
CacheRowItem() {}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
#ImportOrder 11
|
||||
|
||||
# CLR-typed row shape for the Compare DataGrid. Compare-PolicyObjects
|
||||
# returns PSCustomObject rows, which Avalonia's reflection-based binder
|
||||
# can't traverse (see [[avalonia-binding-needs-clr-types]]). Project each
|
||||
# into one of these before assigning DataGrid.ItemsSource.
|
||||
#
|
||||
# Match is nullable bool (e.g. ignored core properties produce $null).
|
||||
# Slice 3d3 uses it to drive row foreground color.
|
||||
#
|
||||
# MatchGlyph is the precomputed tri-state glyph the "Match" DataGrid column
|
||||
# binds to (checkmark / ballot-X / em-dash), mirroring the WPF CompareForm.xaml
|
||||
# DataTrigger chain. Avalonia has no WPF-style per-value DataTrigger, so the
|
||||
# glyph is computed at projection time instead of in XAML.
|
||||
|
||||
class CompareResultRowItem
|
||||
{
|
||||
[string] $PropertyName
|
||||
[string] $Category
|
||||
[string] $SubCategory
|
||||
[string] $Object1Value
|
||||
[string] $Object2Value
|
||||
[object] $Match
|
||||
[string] $MatchGlyph
|
||||
|
||||
CompareResultRowItem() {}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
#ImportOrder 10
|
||||
|
||||
# CLR-typed shape for items inside the Copy dialog's scope-tag ListBox
|
||||
# controls. Get-GraphPolicies / Get-CacheObject return PSCustomObject tags
|
||||
# whose NoteProperties don't bind through Avalonia's reflection-based
|
||||
# ItemTemplate (see [[avalonia-binding-needs-clr-types]]). The Copy dialog
|
||||
# projects each PSCustomObject tag into one of these before assigning to
|
||||
# the ObservableCollection bound to the ListBox.
|
||||
|
||||
class CopyDialogScopeTagItem
|
||||
{
|
||||
[string] $Id
|
||||
[string] $Name
|
||||
|
||||
CopyDialogScopeTagItem() {}
|
||||
|
||||
# Backstop only - the control selects the field via DisplayMemberBinding.
|
||||
[string] ToString() { return [string]$this.Name }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
#ImportOrder 10
|
||||
|
||||
# CLR-typed item shape for the Copy dialog's destination-tenant ComboBox.
|
||||
# Get-TokenInfo returns PSObjects whose NoteProperties don't bind through
|
||||
# Avalonia's reflection-based ItemTemplate (see [[avalonia-binding-needs-clr-types]]).
|
||||
# Project the fields we actually need into this class; the original token
|
||||
# stays on .Source for the Copy call.
|
||||
|
||||
class CopyDialogTenantItem
|
||||
{
|
||||
[string] $TenantNameEx
|
||||
[int] $Id
|
||||
[bool] $IsDefault
|
||||
[object] $Source
|
||||
|
||||
CopyDialogTenantItem() {}
|
||||
|
||||
# Backstop only - the control selects the field via DisplayMemberBinding.
|
||||
[string] ToString() { return [string]$this.TenantNameEx }
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
#ImportOrder 5
|
||||
|
||||
# Avalonia counterparts of the three debug ViewObjects in
|
||||
# UI/WPF/Classes/CoreUIBaseClassesWPF.ps1 (LogViewObject, CachedObjectsViewObject,
|
||||
# GraphCallsViewObject). Get-SubClasses("ViewObjectBase") in Show-MainWindow
|
||||
# auto-discovers these and Add-ViewObject registers each one — same flow as
|
||||
# the existing PreviewViewObject sandbox view.
|
||||
#
|
||||
# All three set _HideMenu=$true: per Show-View, that hides the in-view item
|
||||
# menu (these panels host their own DataGrid rather than a row of view items).
|
||||
# AddToMenu stays at the default $true so they appear in the left nav under
|
||||
# the Views section.
|
||||
|
||||
class LogViewObject : ViewObjectBase
|
||||
{
|
||||
LogViewObject() : base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._ID = "CoreLog"
|
||||
$this._Title = "Log"
|
||||
$this._Description = "View log items"
|
||||
$this._HideMenu = $true
|
||||
}
|
||||
|
||||
[Object] GetViewPanel()
|
||||
{
|
||||
if ($null -eq $this._ViewPanel) {
|
||||
$this._ViewPanel = Get-LogViewPanel
|
||||
}
|
||||
return $this._ViewPanel
|
||||
}
|
||||
|
||||
[Object[]] GetViewItems() { return $null }
|
||||
|
||||
Authenticate() {}
|
||||
|
||||
[Object[]] OnItemChanged($SelectedItem) { return $null }
|
||||
|
||||
OnDeactivating($NewActiveView) {}
|
||||
|
||||
OnActivating($PreviousActiveView) {}
|
||||
|
||||
OnActivated()
|
||||
{
|
||||
# Re-bind log items each activation so entries logged while the view
|
||||
# was inactive show up. ItemsSource is reassigned (not appended) so
|
||||
# the DataGrid picks up the latest snapshot.
|
||||
if ($this._ViewPanel) {
|
||||
Update-LogView -ViewPanel $this._ViewPanel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CachedObjectsViewObject : ViewObjectBase
|
||||
{
|
||||
CachedObjectsViewObject() : base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._ID = "CoreCachedObjects"
|
||||
$this._Title = "Cached Objects"
|
||||
$this._Description = "View cached items"
|
||||
$this._HideMenu = $true
|
||||
}
|
||||
|
||||
[Object] GetViewPanel()
|
||||
{
|
||||
if ($null -eq $this._ViewPanel) {
|
||||
$this._ViewPanel = Get-CachedObjectsViewPanel
|
||||
}
|
||||
return $this._ViewPanel
|
||||
}
|
||||
|
||||
[Object[]] GetViewItems() { return $null }
|
||||
|
||||
Authenticate() {}
|
||||
|
||||
[Object[]] OnItemChanged($SelectedItem) { return $null }
|
||||
|
||||
OnDeactivating($NewActiveView) {}
|
||||
|
||||
OnActivating($PreviousActiveView) {}
|
||||
|
||||
OnActivated()
|
||||
{
|
||||
if ($this._ViewPanel) {
|
||||
Update-CachedObjectsView -ViewPanel $this._ViewPanel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class GraphCallsViewObject : ViewObjectBase
|
||||
{
|
||||
GraphCallsViewObject() : base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._ID = "GraphCalls"
|
||||
$this._Title = "Graph Calls"
|
||||
$this._Description = "View all calls to Microsoft Graph"
|
||||
$this._HideMenu = $true
|
||||
}
|
||||
|
||||
[Object] GetViewPanel()
|
||||
{
|
||||
if ($null -eq $this._ViewPanel) {
|
||||
$this._ViewPanel = Get-GraphCallsViewPanel
|
||||
}
|
||||
return $this._ViewPanel
|
||||
}
|
||||
|
||||
[Object[]] GetViewItems() { return $null }
|
||||
|
||||
Authenticate() {}
|
||||
|
||||
[Object[]] OnItemChanged($SelectedItem) { return $null }
|
||||
|
||||
OnDeactivating($NewActiveView) {}
|
||||
|
||||
OnActivating($PreviousActiveView) {}
|
||||
|
||||
OnActivated()
|
||||
{
|
||||
if ($this._ViewPanel) {
|
||||
Update-GraphCallsView -ViewPanel $this._ViewPanel
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#ImportOrder 10
|
||||
|
||||
# CLR row shape for the Object Details "Columns" tab — used by both
|
||||
# lstBasicProperties and lstObjectProperties. WPF sets DisplayMemberPath="Name"
|
||||
# on a [PSCustomObject]@{ Name; Value; Source }, but Avalonia has no
|
||||
# DisplayMemberPath and its binder doesn't reflect into PSCustomObject
|
||||
# NoteProperties (see [[avalonia-binding-needs-clr-types]]) — so we project
|
||||
# into this typed shape and wire an inline ItemTemplate.
|
||||
|
||||
class DetailsPropertyItem
|
||||
{
|
||||
[string] $Name
|
||||
[object] $Value
|
||||
[string] $Source
|
||||
|
||||
DetailsPropertyItem() {}
|
||||
|
||||
[string] ToString()
|
||||
{
|
||||
return [string]$this.Name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#ImportOrder 10
|
||||
|
||||
# CLR-typed row for the Permissions dialog's DataGrid
|
||||
# (Extensions/EffectivePermissionsUIAvalonia.ps1). Get-GraphEffectivePermissions
|
||||
# returns PSCustomObjects, which the Avalonia binder renders blank, so the
|
||||
# dialog converts each row to an instance of this class.
|
||||
|
||||
# Property declaration order is the column order (AutoGenerateColumns).
|
||||
class EffectivePermissionRowItem
|
||||
{
|
||||
[string] $Type
|
||||
[string] $Category
|
||||
[string] $Required
|
||||
[string] $Token
|
||||
[string] $IntuneRole
|
||||
[string] $Effective
|
||||
[string] $Result
|
||||
[string] $Reason
|
||||
|
||||
EffectivePermissionRowItem() {}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#ImportOrder 8
|
||||
|
||||
# CLR-typed row shape for the nested batch-request DataGrid in
|
||||
# GraphCallsPanel.axaml. Lives one level below GraphCallRowItem and is bound
|
||||
# via {Binding SelectedItem.BatchRequests, ElementName=dgGraphCalls}.
|
||||
#
|
||||
# ImportOrder 8 (loads before GraphCallRowItem at 9) so the
|
||||
# List[GraphBatchRequestRowItem] type reference on GraphCallRowItem resolves.
|
||||
|
||||
class GraphBatchRequestRowItem
|
||||
{
|
||||
[string] $Id
|
||||
[double] $KB
|
||||
[int] $ObjectCount
|
||||
[int] $PageCount
|
||||
[string] $StatusCode
|
||||
[string] $Method
|
||||
[string] $URL
|
||||
|
||||
GraphBatchRequestRowItem() {}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
#ImportOrder 9
|
||||
|
||||
# CLR-typed row shape for the Graph Calls DataGrid (GraphCallsPanel.axaml).
|
||||
# Internal/MSGraph.ps1 stores entries in $script:AllGraphCalls as
|
||||
# PSCustomObject; Update-GraphCallsView projects them into these so the
|
||||
# Avalonia binder can resolve column paths and the BatchRequests sub-grid.
|
||||
#
|
||||
# BatchRequests is exposed as List[GraphBatchRequestRowItem] so the nested
|
||||
# DataGrid (bound via SelectedItem.BatchRequests) gets a typed enumerable.
|
||||
|
||||
class GraphCallRowItem
|
||||
{
|
||||
[string] $Provider
|
||||
[datetime] $Time
|
||||
[double] $Duration
|
||||
[double] $KB
|
||||
[int] $ObjectCount
|
||||
[int] $PageCount
|
||||
[string] $StatusCode
|
||||
[string] $BatchErrorSummary
|
||||
[string] $Method
|
||||
[string] $URL
|
||||
[bool] $IsBatch
|
||||
[System.Collections.Generic.List[GraphBatchRequestRowItem]] $BatchRequests
|
||||
|
||||
GraphCallRowItem() {
|
||||
$this.BatchRequests = [System.Collections.Generic.List[GraphBatchRequestRowItem]]::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
#ImportOrder 8
|
||||
|
||||
# CLR-typed row shape for the Intune Assignments DataGrid. Get-IntuneAssignmentRow
|
||||
# in Internal/IntuneAssignments.ps1 returns [PSCustomObject]; Avalonia's binder
|
||||
# can't traverse those (see [[avalonia-binding-needs-clr-types]]), so the
|
||||
# Avalonia tool converts each PSCustomObject into one of these before assigning
|
||||
# ItemsSource. Properties mirror what the DataGrid columns + filter + CSV
|
||||
# export read.
|
||||
|
||||
class IntuneAssignmentRowItem
|
||||
{
|
||||
[string] $Name
|
||||
[string] $Type
|
||||
[int] $AssignmentCount
|
||||
[bool] $HasFilters
|
||||
[string] $IncludedString
|
||||
[string] $ExcludedString
|
||||
[string] $IncludedFilterString
|
||||
[string] $ExcludedFilterString
|
||||
[string] $Id
|
||||
[object] $Object
|
||||
[object[]] $Included
|
||||
[object[]] $Excluded
|
||||
[object[]] $IncludedFilters
|
||||
[object[]] $ExcludedFilters
|
||||
|
||||
IntuneAssignmentRowItem() {}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
#ImportOrder 9
|
||||
|
||||
# CLR-typed row shape for the Intune Filter Usage DataGrid. Get-IntuneFilterUsageData
|
||||
# returns [PSCustomObject] rows; Avalonia binder can't traverse those, so
|
||||
# Start-IntuneFilterUsageLoad converts each into one of these before assigning
|
||||
# DataGrid.ItemsSource.
|
||||
|
||||
class IntuneFilterUsageRowItem
|
||||
{
|
||||
[string] $FilterName
|
||||
[string] $Platform
|
||||
[string] $FilterType
|
||||
[string] $PolicyName
|
||||
[string] $PayloadType
|
||||
[string] $Mode
|
||||
[string] $GroupId
|
||||
[string] $GroupName
|
||||
|
||||
IntuneFilterUsageRowItem() {}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
#ImportOrder 10
|
||||
|
||||
# CLR-typed row shape for the Import dialog's dgObjectsToImport DataGrid.
|
||||
# Get-PoliciesFromFolder returns IntunePolicyBase / PSCustomObject hybrids
|
||||
# whose nested-property bindings (Object.Name, Object.PolicyName, etc.)
|
||||
# don't survive Avalonia's reflection-based binder
|
||||
# (see [[avalonia-binding-needs-clr-types]]). Show-IntuneManagerImportForm
|
||||
# projects each policy into one of these before assigning ItemsSource.
|
||||
# Source/ImportTarget remain on .Source / .ImportTarget so the import
|
||||
# pass can recover the originals.
|
||||
|
||||
class IntuneImportRowItem
|
||||
{
|
||||
[bool] $Selected = $true
|
||||
[string] $ObjectName
|
||||
[string] $PolicyType
|
||||
[string] $PolicyBase
|
||||
[string] $Platform
|
||||
[string] $FileName
|
||||
[string] $ImportAction
|
||||
[string] $ImportMatch
|
||||
[string] $ImportMatchStrategy
|
||||
[object] $Source # underlying IntunePolicyBase (the .Object the WPF code reads)
|
||||
[object] $ImportTarget # existing policy resolved by Resolve-IntuneImportUpdateTarget
|
||||
|
||||
IntuneImportRowItem() {}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#ImportOrder 10
|
||||
|
||||
# CLR-typed row shape for the Intune Manager DataGrid. The WPF implementation
|
||||
# wraps each IntunePolicyBase via [PSCustomObject]$policy + Add-Member; that
|
||||
# survives WPF's PSObject special-case binding but renders empty in Avalonia
|
||||
# (see [[avalonia-binding-needs-clr-types]]). Show-SelectedGraphPolicies
|
||||
# projects the canonical view properties onto this class before binding.
|
||||
#
|
||||
# Source / JsonObject / TokenId mirror the fields the bulk forms read off
|
||||
# the row directly. Custom column overrides (per-type `ObjectColumns`
|
||||
# setting) reach paths beyond these canonical fields and bind through
|
||||
# [IntuneManagement.AvaloniaHost.IntunePolicyPathConverter] (declared in
|
||||
# Bootstrap/AvaloniaHost.cs, compiled at runtime) against `Source` so
|
||||
# PSObject NoteProperties on the underlying IntunePolicyBase resolve.
|
||||
|
||||
class IntuneObjectRowItem
|
||||
{
|
||||
[bool] $IsSelected = $false
|
||||
[string] $Name
|
||||
[string] $PolicyName
|
||||
[string] $Platform
|
||||
[string] $Description
|
||||
[string] $LastModified
|
||||
[string] $Created
|
||||
[string] $ID
|
||||
[string] $ScopeTags
|
||||
|
||||
[object] $JsonObject
|
||||
[object] $Source
|
||||
[int] $TokenId
|
||||
|
||||
IntuneObjectRowItem() {}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#ImportOrder 7
|
||||
|
||||
# Verbatim port of UI/WPF/Classes/IntuneToolsViewObject.ps1. The class shape is
|
||||
# unchanged; the difference between WPF and Avalonia lives in the matching
|
||||
# Extensions/IntuneToolsUI.ps1 (panel + items + tool placeholders).
|
||||
|
||||
class IntuneToolsViewObject : ViewObjectBase
|
||||
{
|
||||
IntuneToolsViewObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._ID = "IntuneTools"
|
||||
$this._Title = "Intune Tools"
|
||||
$this._Description = "Tools that operate on Intune objects, such as listing assignments across all object types."
|
||||
$this._AddToMenu = $true
|
||||
$this._ExpandInViewsMenu = $true
|
||||
}
|
||||
|
||||
[Object]GetViewPanel()
|
||||
{
|
||||
if($null -eq $this._ViewPanel)
|
||||
{
|
||||
$this._ViewPanel = Get-IntuneToolsViewPanel
|
||||
}
|
||||
return $this._ViewPanel
|
||||
}
|
||||
|
||||
[Object[]]GetViewItems()
|
||||
{
|
||||
return @(Get-IntuneToolsViewItems)
|
||||
}
|
||||
|
||||
Authenticate()
|
||||
{
|
||||
$active = $null
|
||||
try { $active = Get-AuthProvider } catch { }
|
||||
if($active) { $active.RefreshAmbientSession() }
|
||||
}
|
||||
|
||||
[Object[]]OnItemChanged($SelectedItem)
|
||||
{
|
||||
return (Invoke-IntuneToolsActivateItem $SelectedItem)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
#ImportOrder 150
|
||||
|
||||
# Avalonia port of UI/WPF/Classes/IntuneUIGeneric.ps1.
|
||||
#
|
||||
# The legacy "Intune Manager" view — main object editor / list / bulk host. In
|
||||
# WPF this is the landing view (`Show-View "IntuneManagement"`); we mirror that
|
||||
# here. AddToMenu=$false so it doesn't show up in the Views menu — it's added
|
||||
# explicitly by Invoke-IntuneUIAppInitialized.
|
||||
#
|
||||
# Method bodies delegate to functions in Extensions/IntuneManagerUI.ps1
|
||||
# (mirrors the WPF split). Most are still stubbed pending the data-load and
|
||||
# DataGrid column ports — see CLAUDE.md "Pending work".
|
||||
|
||||
class IntuneViewObject : ViewObjectBase
|
||||
{
|
||||
Hidden [Object[]]$_Items = @()
|
||||
|
||||
IntuneViewObject() : Base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._ID = "IntuneManagement"
|
||||
$this._Title = "Intune Manager"
|
||||
$this._Description = "Manages Intune environments. This view can be used for copying objects in an Intune environment. It can also be used for backing up an entire Intune environment and cloning the Intune environment into another tenant."
|
||||
$this._AddToMenu = $false
|
||||
}
|
||||
|
||||
[Object]GetViewPanel()
|
||||
{
|
||||
if ($null -eq $this._ViewPanel) {
|
||||
$this._ViewPanel = Get-IntuneManagementViewPanel
|
||||
}
|
||||
return $this._ViewPanel
|
||||
}
|
||||
|
||||
[Object[]]GetViewItems()
|
||||
{
|
||||
return @(Get-IntuneViewItems)
|
||||
}
|
||||
|
||||
Authenticate()
|
||||
{
|
||||
# Match WPF: silent-only at activation. Interactive auth happens via the
|
||||
# Sign-in button. Gated to MSAL so MgGraph mode isn't hijacked.
|
||||
$active = $null
|
||||
try { $active = Get-AuthProvider } catch { }
|
||||
if ($active) { $active.RefreshAmbientSession() }
|
||||
}
|
||||
|
||||
[Object[]]OnItemChanged($SelectedItem)
|
||||
{
|
||||
return (Invoke-IntuneActivateObject $SelectedItem)
|
||||
}
|
||||
|
||||
OnDeactivating($NewActiveView)
|
||||
{
|
||||
Invoke-IntuneDeactivatingView $NewActiveView
|
||||
}
|
||||
|
||||
OnActivating($PreviousActiveView)
|
||||
{
|
||||
Invoke-IntuneActivatingView $PreviousActiveView
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#ImportOrder 9
|
||||
|
||||
# CLR-typed row shape for the Log DataGrid (LogInfo.axaml). The non-UI
|
||||
# Internal/Core.ps1 keeps log entries as PSCustomObject in
|
||||
# $script:LogItems; Avalonia's binder can't traverse PSCustomObject inside
|
||||
# DataTemplates, so Get-LogViewPanel projects each entry into one of these
|
||||
# at refresh time.
|
||||
#
|
||||
# RowForeground is computed at projection time so the DataGrid row style
|
||||
# can bind directly to it (Type==2 -> orange, Type==3 -> red, default -> null).
|
||||
|
||||
class LogRowItem
|
||||
{
|
||||
[int] $ID
|
||||
[datetime] $DateTime
|
||||
[int] $Type
|
||||
[string] $TypeText
|
||||
[string] $Text
|
||||
[object] $RowForeground
|
||||
|
||||
LogRowItem() {}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#ImportOrder 10
|
||||
|
||||
# CLR row shape for Avalonia ComboBoxes that need DisplayMemberBinding.
|
||||
#
|
||||
# Avalonia's data binder reflects on the runtime CLR type, not on PSObject
|
||||
# ETS NoteProperties — so a ComboBox with ItemsSource = PSCustomObject[]
|
||||
# and DisplayMemberBinding="{Binding Name}" silently renders blank cells
|
||||
# because PSCustomObject has no real .Name property (see
|
||||
# [[avalonia-binding-needs-clr-types]]).
|
||||
#
|
||||
# Wrap items in this class to get real CLR Name / Value / EnglishName /
|
||||
# Source properties that the Avalonia binder + PowerShell readers (e.g.
|
||||
# Get-AvaloniaDocumentationOptions reading SelectedItem.Value) both see.
|
||||
# Source keeps the original PSCustomObject so callers that need extra
|
||||
# fields (e.g. language metadata) can pull them back.
|
||||
|
||||
class NameValueComboItem
|
||||
{
|
||||
[string] $Name
|
||||
[string] $EnglishName
|
||||
[object] $Value
|
||||
[object] $Source
|
||||
|
||||
NameValueComboItem() {}
|
||||
|
||||
# Backstop only. Each control picks its own field via DisplayMemberBinding
|
||||
# (13 combos bind Name, the language combo binds EnglishName), so this
|
||||
# exists purely so a control that was never given a binding degrades to
|
||||
# readable text instead of rendering "NameValueComboItem".
|
||||
[string] ToString() { return [string]$this.Name }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#ImportOrder 10
|
||||
|
||||
# CLR row shape for Show-ObjectPickerDialog's dgPickerObjects DataGrid.
|
||||
# Avalonia's binder can't reach PSObject NoteProperties (see
|
||||
# [[avalonia-binding-needs-clr-types]]), so each policy fetched from
|
||||
# Get-GraphPolicies is projected into one of these. Source carries the
|
||||
# original PSCustomObject so callers receive it back via SelectedItem.Source.
|
||||
# Currently the dialog is called with a "Name" + "PolicyType.Title" column
|
||||
# pair — mapped to the Name / PolicyTypeTitle properties below. Add a new
|
||||
# property + binding alias in Show-ObjectPickerDialog when a future caller
|
||||
# needs another column.
|
||||
|
||||
class PickerObjectRow
|
||||
{
|
||||
[string] $Name
|
||||
[string] $PolicyTypeTitle
|
||||
[string] $Description
|
||||
[object] $Source
|
||||
|
||||
PickerObjectRow() {}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
#ImportOrder 5
|
||||
|
||||
# Sandbox view: the only ViewObjectBase subclass that loads under the Avalonia
|
||||
# backend (the WPF subclasses live in UI/WPF/Classes/ and aren't touched here). It
|
||||
# exists to exercise the discover -> register -> Show-View flow end-to-end while
|
||||
# the real views are ported one at a time.
|
||||
|
||||
class PreviewViewObject : ViewObjectBase
|
||||
{
|
||||
Hidden [Object]$_BodyText = $null
|
||||
|
||||
PreviewViewObject() : base()
|
||||
{
|
||||
$this.Init()
|
||||
}
|
||||
|
||||
Hidden Init()
|
||||
{
|
||||
$this._ID = "AvaloniaPreview"
|
||||
$this._Title = "Avalonia Preview"
|
||||
$this._Description = "Sandbox view to verify the Avalonia view-system port."
|
||||
}
|
||||
|
||||
[Object]GetViewPanel()
|
||||
{
|
||||
# Built programmatically — no .axaml needed for a placeholder. Avalonia
|
||||
# types are available because Initialize-AvaloniaRuntime runs before
|
||||
# Show-View ever calls this.
|
||||
if($null -eq $this._ViewPanel)
|
||||
{
|
||||
$panel = New-Object Avalonia.Controls.StackPanel
|
||||
$panel.Margin = '20'
|
||||
$panel.Spacing = 12
|
||||
|
||||
$title = New-Object Avalonia.Controls.TextBlock
|
||||
$title.Text = "Avalonia preview view"
|
||||
$title.FontSize = 22
|
||||
$title.FontWeight = 'SemiBold'
|
||||
|
||||
$body = New-Object Avalonia.Controls.TextBlock
|
||||
$body.Text = "Pick an item from the left nav - its title will appear here."
|
||||
$body.TextWrapping = 'Wrap'
|
||||
|
||||
$panel.Children.Add($title) | Out-Null
|
||||
$panel.Children.Add($body) | Out-Null
|
||||
|
||||
$this._BodyText = $body
|
||||
$this._ViewPanel = $panel
|
||||
}
|
||||
return $this._ViewPanel
|
||||
}
|
||||
|
||||
[Object[]]OnItemChanged($SelectedItem)
|
||||
{
|
||||
if ($null -ne $this._BodyText -and $null -ne $SelectedItem) {
|
||||
$this._BodyText.Text = "Selected: $($SelectedItem.Title) (Id=$($SelectedItem.Id))"
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
[Object[]]GetViewItems()
|
||||
{
|
||||
return @(
|
||||
[ViewMenuItem]@{ Id = 'demo-1'; Title = 'First demo item'; MenuLabel = 'First demo item' }
|
||||
[ViewMenuItem]@{ Id = 'demo-2'; Title = 'Second demo item'; MenuLabel = 'Second demo item' }
|
||||
[ViewMenuItem]@{ Id = 'demo-3'; Title = 'Third demo item'; MenuLabel = 'Third demo item' }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
#ImportOrder 6
|
||||
|
||||
# CLR-typed shape for items inside the Settings dialog's ComboBox controls
|
||||
# (Type="List" settings). Existing setting registrations pass PSCustomObject
|
||||
# arrays in -ItemsSource with .Name / .Value properties — those won't bind in
|
||||
# Avalonia (see [[avalonia-binding-needs-clr-types]]). Add-SettingComboBox
|
||||
# converts each PSCustomObject into one of these before assigning ItemsSource.
|
||||
|
||||
class SettingsListItem
|
||||
{
|
||||
[string]$Name
|
||||
[object]$Value
|
||||
|
||||
SettingsListItem() {}
|
||||
|
||||
[string] ToString()
|
||||
{
|
||||
return [string]$this.Name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#ImportOrder 10
|
||||
|
||||
# CLR-typed Name/Value pair shape used by Show-MSALDecodedToken's DataGrid.
|
||||
# The WPF version builds an array of PSCustomObjects with Name/Value; that
|
||||
# would silently render blank in Avalonia (binder can't traverse NoteProperty),
|
||||
# so the Avalonia tool converts to instances of this class.
|
||||
|
||||
class TokenInfoRow
|
||||
{
|
||||
[string] $Name
|
||||
[object] $Value
|
||||
|
||||
TokenInfoRow() {}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#ImportOrder 3
|
||||
|
||||
# CLR-backed shape for items rendered in the left-nav ListBox under the
|
||||
# Avalonia backend. Avalonia's binder reflects against real CLR properties
|
||||
# and does NOT special-case PSObject's NoteProperty bag the way WPF does, so
|
||||
# binding to a [PSCustomObject] would silently resolve to null. This class
|
||||
# exists to give those bindings real properties to find.
|
||||
#
|
||||
# Mirrors the WPF view-item shape just enough for the existing
|
||||
# DataTemplate ({Binding MenuLabel} + {Binding IconImage}) plus the few
|
||||
# fields Show-View / OnItemChanged read.
|
||||
|
||||
class ViewMenuItem
|
||||
{
|
||||
[string]$Id
|
||||
[string]$Title
|
||||
[string]$MenuLabel
|
||||
[string]$Category
|
||||
[string]$Description
|
||||
# Permission state, stamped from the underlying type/group by
|
||||
# Get-IntuneViewItems. Drives the row colour via the Classes bindings in
|
||||
# MainWindow.axaml ("Limited" = orange, "None" = red); AccessInfo is the
|
||||
# tooltip breakdown. Held as a string because Avalonia's Classes binding
|
||||
# compares against one.
|
||||
[string]$AccessType
|
||||
# [object], not [string]: an empty tooltip is not the same as no tooltip.
|
||||
# Avalonia shows a blank popup for ToolTip.Tip="" but nothing for $null, and
|
||||
# a [string] property would coerce $null to "".
|
||||
[object]$AccessInfo
|
||||
# Avalonia's Classes bindings take booleans and it has no DataTrigger, so the
|
||||
# string above cannot drive the style directly. Set alongside AccessType by
|
||||
# Get-IntuneViewItems.
|
||||
[bool]$IsAccessLimited = $false
|
||||
[bool]$IsAccessNone = $false
|
||||
[Object]$IconImage
|
||||
[Object]$Tag
|
||||
# IsHeader=true marks a category-header row inserted by Show-ViewMenu when
|
||||
# rendering grouped left-nav menus. Avalonia's ListBox has no GroupStyle
|
||||
# equivalent, so we fake the WPF Expander headers with non-selectable rows
|
||||
# that share the same DataTemplate (template branches on IsHeader).
|
||||
[bool]$IsHeader = $false
|
||||
|
||||
ViewMenuItem() {}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$NoUI,
|
||||
[ValidateSet('Default','Light','Dark')]
|
||||
[string]$ThemeVariant,
|
||||
# Authentication provider for this session only (MSAL, OAuth, MgGraph, ...),
|
||||
# overriding the saved "Active authentication provider" setting without
|
||||
# changing it. Surfaced as IM_AUTH_PROVIDER for the module; forwarded as an
|
||||
# argument through the macOS main-thread and Windows STA re-launches.
|
||||
[string]$Provider
|
||||
)
|
||||
|
||||
if ($PSVersionTable.PSEdition -ne 'Core') {
|
||||
Write-Error "The Avalonia backend requires PowerShell 7+. Launch with pwsh.exe."
|
||||
return
|
||||
}
|
||||
|
||||
if ($Provider) {
|
||||
$env:IM_AUTH_PROVIDER = $Provider
|
||||
}
|
||||
else {
|
||||
# Same cleanup as IM_THEME_VARIANT below: a previous -Provider launch from
|
||||
# this shell must not silently stick to later plain launches.
|
||||
Remove-Item Env:IM_AUTH_PROVIDER -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Cocoa only allows the GUI on the process main thread, and a normal pwsh
|
||||
# pipeline is not that thread. Start-Avalonia.command engages the main-thread
|
||||
# startup hook up front; when this script is run directly (pwsh -File ...) and
|
||||
# the hook is absent, re-launch this same pwsh with the hook so the script runs
|
||||
# on the main thread. See UI/Avalonia/Bootstrap/MainThreadHook/StartupHook.cs.
|
||||
if ($IsMacOS -and -not $NoUI -and -not ('IntuneManagement.MainThreadHook.MainThread' -as [type])) {
|
||||
$root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
|
||||
$hook = Join-Path $root 'Bin/MainThreadHook/IntuneManagement.MainThreadHook.dll'
|
||||
if (-not (Test-Path -LiteralPath $hook)) { throw "Main-thread hook missing: $hook. Re-download the application or rebuild it with UI/Avalonia/Bootstrap/Publish-MainThreadHook.ps1." }
|
||||
$pwsh = (Get-Process -Id $PID).Path
|
||||
$env:DOTNET_STARTUP_HOOKS = if ($env:DOTNET_STARTUP_HOOKS) { $hook + [IO.Path]::PathSeparator + $env:DOTNET_STARTUP_HOOKS } else { $hook }
|
||||
$env:IM_MAIN_THREAD_HOOK = '1'
|
||||
try {
|
||||
$argList = @('-NoLogo', '-NoProfile', '-File', $PSCommandPath)
|
||||
if ($ThemeVariant) { $argList += @('-ThemeVariant', $ThemeVariant) }
|
||||
if ($Provider) { $argList += @('-Provider', $Provider) }
|
||||
& $pwsh @argList
|
||||
if ($LASTEXITCODE -ne 0) { throw "Main-thread launch failed ($LASTEXITCODE). See the error above." }
|
||||
}
|
||||
finally {
|
||||
Remove-Item Env:DOTNET_STARTUP_HOOKS, Env:IM_MAIN_THREAD_HOOK -ErrorAction SilentlyContinue
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
# Surface -ThemeVariant as the env var Initialize-AvaloniaRuntime reads. Setting
|
||||
# it before the STA self-relaunch makes it inherited by the child pwsh process.
|
||||
if ($ThemeVariant) {
|
||||
$env:IM_THEME_VARIANT = $ThemeVariant
|
||||
}
|
||||
else {
|
||||
# The env var outranks the AppTheme setting, and a previous -ThemeVariant
|
||||
# launch leaves it behind in this shell - without this cleanup every later
|
||||
# plain launch from the same terminal silently kept the old variant and
|
||||
# the Settings theme appeared to have no effect.
|
||||
Remove-Item Env:IM_THEME_VARIANT -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Avalonia hosts on the calling thread. Win32 platform features (clipboard,
|
||||
# drag/drop, OLE) want STA. PowerShell 7 defaults to MTA, so re-launch self
|
||||
# with -STA when needed. Detect the re-launch via $env:IM_AVALONIA_STA so we
|
||||
# don't recurse. Apartment state is a Win32/OLE concept — pwsh rejects -STA on
|
||||
# Linux/macOS, where Avalonia uses X11/Cocoa backends that don't need it, so
|
||||
# only attempt the relaunch on Windows.
|
||||
if ($IsWindows -and -not $env:IM_AVALONIA_STA -and [System.Threading.Thread]::CurrentThread.GetApartmentState() -ne 'STA') {
|
||||
$env:IM_AVALONIA_STA = '1'
|
||||
$argList = @('-NoLogo', '-NoProfile', '-STA', '-File', $PSCommandPath)
|
||||
if ($NoUI) { $argList += '-NoUI' }
|
||||
if ($ThemeVariant) { $argList += @('-ThemeVariant', $ThemeVariant) }
|
||||
if ($Provider) { $argList += @('-Provider', $Provider) }
|
||||
& pwsh @argList
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
$env:IM_UI_BACKEND = 'Avalonia'
|
||||
|
||||
# Linux: Avalonia's file dialogs use xdg-desktop-portal (the native, correctly
|
||||
# sized picker) only when it can reach the session bus. xrdp/XFCE sessions often
|
||||
# leave XDG_RUNTIME_DIR (and sometimes DBUS_SESSION_BUS_ADDRESS) unset, which
|
||||
# forces Avalonia's oversized managed dialog and also breaks "open in browser".
|
||||
# Point them at the standard runtime dir / bus when they exist so the native
|
||||
# picker (and Open-ExternalUri) work.
|
||||
if (-not $IsWindows) {
|
||||
try {
|
||||
$uid = (& id -u).Trim()
|
||||
if ([string]::IsNullOrEmpty($env:XDG_RUNTIME_DIR) -and (Test-Path "/run/user/$uid")) {
|
||||
$env:XDG_RUNTIME_DIR = "/run/user/$uid"
|
||||
}
|
||||
if ([string]::IsNullOrEmpty($env:DBUS_SESSION_BUS_ADDRESS) -and $env:XDG_RUNTIME_DIR -and (Test-Path "$($env:XDG_RUNTIME_DIR)/bus")) {
|
||||
$env:DBUS_SESSION_BUS_ADDRESS = "unix:path=$($env:XDG_RUNTIME_DIR)/bus"
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
$projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
|
||||
$avaloniaBin = Join-Path $projectRoot 'Bin/Avalonia'
|
||||
|
||||
if (-not (Test-Path $avaloniaBin)) {
|
||||
Write-Host "Avalonia binaries missing. Running Bootstrap/Restore-AvaloniaBinaries.ps1..." -ForegroundColor Yellow
|
||||
& (Join-Path $PSScriptRoot 'Bootstrap/Restore-AvaloniaBinaries.ps1')
|
||||
}
|
||||
|
||||
Import-Module (Join-Path $projectRoot 'IntuneManagement.psd1') -Force -ErrorAction Stop
|
||||
|
||||
if (-not $NoUI) {
|
||||
Show-IMMainWindow -View 'IntuneManagement'
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<ResourceDictionary xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!--
|
||||
Theme brushes — a single file, two variants, automatic selection.
|
||||
|
||||
Avalonia walks ResourceDictionary.ThemeDictionaries based on the active
|
||||
Application.RequestedThemeVariant (set by AvaloniaHost.Initialize via
|
||||
the ThemeVariant setting / IM_THEME_VARIANT env var). This is the
|
||||
idiomatic replacement for the WPF "load Default.xaml or Dark.xaml"
|
||||
pattern: same key, different value per variant, no per-load swap.
|
||||
-->
|
||||
|
||||
<ResourceDictionary.ThemeDictionaries>
|
||||
|
||||
<!--
|
||||
Light palette, refreshed 2026-08-23 to sit closer to Windows 11.
|
||||
Keep in step with UI/WPF/Themes/Light.xaml - the two backends are meant
|
||||
to look the same, and that file carries the same reasoning.
|
||||
|
||||
Headline change is TitleBackgroundColor: it was #FF9B9B9B, a heavy
|
||||
mid-grey that read as a Windows 7 toolbar. It cannot simply go white,
|
||||
though - it backs the title strips, which must stay distinct from the
|
||||
window - so it becomes a light grey rather than disappearing.
|
||||
-->
|
||||
<ResourceDictionary x:Key="Light">
|
||||
<!-- Title strips. Dark text sits on this, so it stays well above mid-grey. -->
|
||||
<SolidColorBrush x:Key="TitleBackgroundColor" Color="#FFECECEC"/>
|
||||
<SolidColorBrush x:Key="TitleForegroundColor" Color="#FF1A1A1A"/>
|
||||
<!-- One step darker than the title strip so a header still reads as a
|
||||
header when the two are adjacent; both were #FF9B9B9B before, which
|
||||
stopped working once the title strip lightened. -->
|
||||
<SolidColorBrush x:Key="SettingsSectionBackgroundColor" Color="#FFE0E0E0"/>
|
||||
<!-- Near-black, matching Windows 11 text; pure black is harsh on white. -->
|
||||
<SolidColorBrush x:Key="TextColor" Color="#FF1A1A1A"/>
|
||||
<!-- Reads as selected rather than merely grey. Long-standing Explorer
|
||||
selection tint, so it stays recognisable whatever the accent colour. -->
|
||||
<SolidColorBrush x:Key="SelectedRowBackgroundColor" Color="#FFE5F1FB"/>
|
||||
<SolidColorBrush x:Key="MismatchColor" Color="#FFC42B1C"/>
|
||||
<!-- Left-nav permission marking. WPF hardcodes the literals Red and
|
||||
Orange in MainWindow.xaml; these are theme keys instead so the
|
||||
dark variant is not an unreadable dark-on-dark. Both clear 4.5:1
|
||||
against their variant's window background. -->
|
||||
<SolidColorBrush x:Key="AccessNoneColor" Color="#FFC42B1C"/>
|
||||
<SolidColorBrush x:Key="AccessLimitedColor" Color="#FFB25000"/>
|
||||
|
||||
<SolidColorBrush x:Key="WindowBackgroundColor" Color="#FFFFFFFF"/>
|
||||
<!-- A light layer over the white window, not a distinct grey slab. -->
|
||||
<SolidColorBrush x:Key="PanelBackgroundColor" Color="#FFF3F3F3"/>
|
||||
<SolidColorBrush x:Key="ControlBackgroundColor" Color="#FFFFFFFF"/>
|
||||
<!-- Hairline separators; #FFCCCCCC read as a drawn box. -->
|
||||
<SolidColorBrush x:Key="BorderColor" Color="#FFE1E1E1"/>
|
||||
<SolidColorBrush x:Key="DisabledForegroundColor" Color="#FF9A9A9A"/>
|
||||
|
||||
<!-- Hyperlink colour, matching UI/WPF/Themes/Light.xaml's Hyperlink style. -->
|
||||
<SolidColorBrush x:Key="LinkColor" Color="#FF0066CC"/>
|
||||
<SolidColorBrush x:Key="ButtonHoverBackground" Color="#FFEAEAEA"/>
|
||||
<SolidColorBrush x:Key="ButtonPressedBackground" Color="#FFDCDCDC"/>
|
||||
|
||||
<!-- DataGrid package resources: align row selection with the app
|
||||
palette (SelectedRowBackgroundColor) instead of the Fluent
|
||||
accent-with-opacity default. -->
|
||||
<SolidColorBrush x:Key="DataGridRowSelectedBackgroundBrush" Color="#FFE5F1FB"/>
|
||||
<SolidColorBrush x:Key="DataGridRowSelectedHoveredBackgroundBrush" Color="#FFE5F1FB"/>
|
||||
<SolidColorBrush x:Key="DataGridRowSelectedUnfocusedBackgroundBrush" Color="#FFE5F1FB"/>
|
||||
<SolidColorBrush x:Key="DataGridRowSelectedHoveredUnfocusedBackgroundBrush" Color="#FFE5F1FB"/>
|
||||
<x:Double x:Key="DataGridRowSelectedBackgroundOpacity">1</x:Double>
|
||||
<x:Double x:Key="DataGridRowSelectedHoveredBackgroundOpacity">1</x:Double>
|
||||
<x:Double x:Key="DataGridRowSelectedUnfocusedBackgroundOpacity">1</x:Double>
|
||||
<x:Double x:Key="DataGridRowSelectedHoveredUnfocusedBackgroundOpacity">1</x:Double>
|
||||
|
||||
<!-- CheckBox checked state: the WPF reference theme draws a neutral
|
||||
box (ControlBackground fill, Border stroke, TextColor glyph) -
|
||||
no accent blue. Mirror it by overriding the Fluent resources. -->
|
||||
<SolidColorBrush x:Key="CheckBoxCheckBackgroundFillChecked" Color="#FFFFFFFF"/>
|
||||
<SolidColorBrush x:Key="CheckBoxCheckBackgroundFillCheckedPointerOver" Color="#FFFFFFFF"/>
|
||||
<SolidColorBrush x:Key="CheckBoxCheckBackgroundFillCheckedPressed" Color="#FFFFFFFF"/>
|
||||
<SolidColorBrush x:Key="CheckBoxCheckBackgroundStrokeChecked" Color="#FFE1E1E1"/>
|
||||
<SolidColorBrush x:Key="CheckBoxCheckBackgroundStrokeCheckedPointerOver" Color="#FF1A1A1A"/>
|
||||
<SolidColorBrush x:Key="CheckBoxCheckBackgroundStrokeCheckedPressed" Color="#FF1A1A1A"/>
|
||||
<SolidColorBrush x:Key="CheckBoxCheckGlyphForegroundChecked" Color="#FF1A1A1A"/>
|
||||
<SolidColorBrush x:Key="CheckBoxCheckGlyphForegroundCheckedPointerOver" Color="#FF1A1A1A"/>
|
||||
<SolidColorBrush x:Key="CheckBoxCheckGlyphForegroundCheckedPressed" Color="#FF1A1A1A"/>
|
||||
|
||||
<!-- RadioButton checked state: same WPF-neutral treatment as the
|
||||
CheckBox above (no accent blue). -->
|
||||
<SolidColorBrush x:Key="RadioButtonOuterEllipseCheckedFill" Color="#FFFFFFFF"/>
|
||||
<SolidColorBrush x:Key="RadioButtonOuterEllipseCheckedFillPointerOver" Color="#FFFFFFFF"/>
|
||||
<SolidColorBrush x:Key="RadioButtonOuterEllipseCheckedFillPressed" Color="#FFFFFFFF"/>
|
||||
<SolidColorBrush x:Key="RadioButtonOuterEllipseCheckedStroke" Color="#FFE1E1E1"/>
|
||||
<SolidColorBrush x:Key="RadioButtonOuterEllipseCheckedStrokePointerOver" Color="#FF1A1A1A"/>
|
||||
<SolidColorBrush x:Key="RadioButtonOuterEllipseCheckedStrokePressed" Color="#FF1A1A1A"/>
|
||||
<SolidColorBrush x:Key="RadioButtonCheckGlyphFill" Color="#FF1A1A1A"/>
|
||||
<SolidColorBrush x:Key="RadioButtonCheckGlyphFillPointerOver" Color="#FF1A1A1A"/>
|
||||
<SolidColorBrush x:Key="RadioButtonCheckGlyphFillPressed" Color="#FF1A1A1A"/>
|
||||
<SolidColorBrush x:Key="RadioButtonCheckGlyphStroke" Color="#FF1A1A1A"/>
|
||||
</ResourceDictionary>
|
||||
|
||||
<ResourceDictionary x:Key="Dark">
|
||||
<SolidColorBrush x:Key="TitleBackgroundColor" Color="#FF1E1E1E"/>
|
||||
<SolidColorBrush x:Key="TitleForegroundColor" Color="#FFCCCCCC"/>
|
||||
<!-- Settings-dialog section headers only. NOT TitleBackgroundColor: that
|
||||
doubles as the title-bar background (= WindowBackgroundColor), which
|
||||
made section headers invisible in Dark. -->
|
||||
<SolidColorBrush x:Key="SettingsSectionBackgroundColor" Color="#FF3F3F3F"/>
|
||||
<SolidColorBrush x:Key="TextColor" Color="#FFEEEEEE"/>
|
||||
<SolidColorBrush x:Key="SelectedRowBackgroundColor" Color="#FF555555"/>
|
||||
<SolidColorBrush x:Key="MismatchColor" Color="#FFFF7B72"/>
|
||||
<!-- Light-on-dark counterparts of the access marking. The Light
|
||||
variant's #FFC42B1C / #FFB25000 would be near-invisible here. -->
|
||||
<SolidColorBrush x:Key="AccessNoneColor" Color="#FFFF7B72"/>
|
||||
<SolidColorBrush x:Key="AccessLimitedColor" Color="#FFFFA657"/>
|
||||
|
||||
<SolidColorBrush x:Key="WindowBackgroundColor" Color="#FF1E1E1E"/>
|
||||
<SolidColorBrush x:Key="PanelBackgroundColor" Color="#FF252526"/>
|
||||
<SolidColorBrush x:Key="ControlBackgroundColor" Color="#FF2D2D2D"/>
|
||||
<SolidColorBrush x:Key="BorderColor" Color="#FF555555"/>
|
||||
<SolidColorBrush x:Key="DisabledForegroundColor" Color="#FF777777"/>
|
||||
|
||||
<!-- Hyperlink colour, matching UI/WPF/Themes/Dark.xaml's Hyperlink style. -->
|
||||
<SolidColorBrush x:Key="LinkColor" Color="#FF6CB3F5"/>
|
||||
<SolidColorBrush x:Key="ButtonHoverBackground" Color="#FF555555"/>
|
||||
<SolidColorBrush x:Key="ButtonPressedBackground" Color="#FF666666"/>
|
||||
|
||||
<!-- DataGrid package resources: align row selection with the app
|
||||
palette (SelectedRowBackgroundColor) instead of the Fluent
|
||||
accent-with-opacity default. -->
|
||||
<SolidColorBrush x:Key="DataGridRowSelectedBackgroundBrush" Color="#FF555555"/>
|
||||
<SolidColorBrush x:Key="DataGridRowSelectedHoveredBackgroundBrush" Color="#FF555555"/>
|
||||
<SolidColorBrush x:Key="DataGridRowSelectedUnfocusedBackgroundBrush" Color="#FF555555"/>
|
||||
<SolidColorBrush x:Key="DataGridRowSelectedHoveredUnfocusedBackgroundBrush" Color="#FF555555"/>
|
||||
<x:Double x:Key="DataGridRowSelectedBackgroundOpacity">1</x:Double>
|
||||
<x:Double x:Key="DataGridRowSelectedHoveredBackgroundOpacity">1</x:Double>
|
||||
<x:Double x:Key="DataGridRowSelectedUnfocusedBackgroundOpacity">1</x:Double>
|
||||
<x:Double x:Key="DataGridRowSelectedHoveredUnfocusedBackgroundOpacity">1</x:Double>
|
||||
|
||||
<!-- CheckBox checked state: the WPF reference theme draws a neutral
|
||||
box (ControlBackground fill, Border stroke, TextColor glyph) -
|
||||
no accent blue. Mirror it by overriding the Fluent resources. -->
|
||||
<SolidColorBrush x:Key="CheckBoxCheckBackgroundFillChecked" Color="#FF2D2D2D"/>
|
||||
<SolidColorBrush x:Key="CheckBoxCheckBackgroundFillCheckedPointerOver" Color="#FF2D2D2D"/>
|
||||
<SolidColorBrush x:Key="CheckBoxCheckBackgroundFillCheckedPressed" Color="#FF2D2D2D"/>
|
||||
<SolidColorBrush x:Key="CheckBoxCheckBackgroundStrokeChecked" Color="#FF555555"/>
|
||||
<SolidColorBrush x:Key="CheckBoxCheckBackgroundStrokeCheckedPointerOver" Color="#FFEEEEEE"/>
|
||||
<SolidColorBrush x:Key="CheckBoxCheckBackgroundStrokeCheckedPressed" Color="#FFEEEEEE"/>
|
||||
<SolidColorBrush x:Key="CheckBoxCheckGlyphForegroundChecked" Color="#FFEEEEEE"/>
|
||||
<SolidColorBrush x:Key="CheckBoxCheckGlyphForegroundCheckedPointerOver" Color="#FFEEEEEE"/>
|
||||
<SolidColorBrush x:Key="CheckBoxCheckGlyphForegroundCheckedPressed" Color="#FFEEEEEE"/>
|
||||
|
||||
<!-- RadioButton checked state: same WPF-neutral treatment as the
|
||||
CheckBox above (no accent blue). -->
|
||||
<SolidColorBrush x:Key="RadioButtonOuterEllipseCheckedFill" Color="#FF2D2D2D"/>
|
||||
<SolidColorBrush x:Key="RadioButtonOuterEllipseCheckedFillPointerOver" Color="#FF2D2D2D"/>
|
||||
<SolidColorBrush x:Key="RadioButtonOuterEllipseCheckedFillPressed" Color="#FF2D2D2D"/>
|
||||
<SolidColorBrush x:Key="RadioButtonOuterEllipseCheckedStroke" Color="#FF555555"/>
|
||||
<SolidColorBrush x:Key="RadioButtonOuterEllipseCheckedStrokePointerOver" Color="#FFEEEEEE"/>
|
||||
<SolidColorBrush x:Key="RadioButtonOuterEllipseCheckedStrokePressed" Color="#FFEEEEEE"/>
|
||||
<SolidColorBrush x:Key="RadioButtonCheckGlyphFill" Color="#FFEEEEEE"/>
|
||||
<SolidColorBrush x:Key="RadioButtonCheckGlyphFillPointerOver" Color="#FFEEEEEE"/>
|
||||
<SolidColorBrush x:Key="RadioButtonCheckGlyphFillPressed" Color="#FFEEEEEE"/>
|
||||
<SolidColorBrush x:Key="RadioButtonCheckGlyphStroke" Color="#FFEEEEEE"/>
|
||||
</ResourceDictionary>
|
||||
|
||||
</ResourceDictionary.ThemeDictionaries>
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,14 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<!--
|
||||
Intentionally empty.
|
||||
|
||||
Theme brushes for both Light and Dark live in Brushes.axaml under
|
||||
ResourceDictionary.ThemeDictionaries. Avalonia auto-selects the right
|
||||
variant based on Application.RequestedThemeVariant — no separate
|
||||
Dark.axaml load needed.
|
||||
|
||||
Kept as a placeholder for any dark-only style overrides that aren't
|
||||
expressible as a brush swap (rare).
|
||||
-->
|
||||
</Styles>
|
||||
@@ -0,0 +1,238 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!--
|
||||
Avalonia port of UI/WPF/Themes/Default.xaml.
|
||||
|
||||
Translation rules:
|
||||
WPF Avalonia
|
||||
=========================================================================
|
||||
Style TargetType="Button" Style Selector="Button"
|
||||
Trigger IsMouseOver=True Style Selector="Button:pointerover"
|
||||
Trigger IsPressed=True Style Selector="Button:pressed"
|
||||
Trigger IsEnabled=False Style Selector="Button:disabled"
|
||||
Trigger IsChecked=True Style Selector="CheckBox:checked"
|
||||
Trigger IsSelected=True Style Selector="TabItem:selected"
|
||||
StaticResource Foo DynamicResource Foo (theme-swap safe)
|
||||
|
||||
Deferred from the original (FluentTheme covers them with reasonable defaults; we
|
||||
can re-template later if visual fidelity needs to match the legacy WPF look):
|
||||
- ComboBox / CheckBox / RadioButton / Button / MenuItem / ScrollBar templates
|
||||
- DataGrid styling (Avalonia.Controls.DataGrid is a separate package)
|
||||
- ListView (WPF-only; Avalonia uses ListBox)
|
||||
- Hyperlink (different model in Avalonia)
|
||||
- PersonPicture* keys (UWP-specific, not used here)
|
||||
|
||||
Brushes live in Brushes.axaml (ThemeDictionaries), loaded into
|
||||
Application.Resources.MergedDictionaries before this file. DynamicResource
|
||||
lookups on the keys below resolve through the ThemeDictionary that matches
|
||||
Application.RequestedThemeVariant.
|
||||
-->
|
||||
|
||||
<!-- Window -->
|
||||
<Style Selector="Window">
|
||||
<Setter Property="Background" Value="{DynamicResource WindowBackgroundColor}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextColor}"/>
|
||||
</Style>
|
||||
|
||||
<!-- Text-only controls -->
|
||||
<Style Selector="TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextColor}"/>
|
||||
</Style>
|
||||
<Style Selector="Label">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextColor}"/>
|
||||
</Style>
|
||||
|
||||
<!-- Inputs -->
|
||||
<Style Selector="TextBox">
|
||||
<Setter Property="Background" Value="{DynamicResource ControlBackgroundColor}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextColor}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderColor}"/>
|
||||
<Setter Property="CaretBrush" Value="{DynamicResource TextColor}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="2,1"/>
|
||||
<!-- Fluent's 32px MinHeight towers over the 12pt text (the WPF app's
|
||||
inputs hug the font); 24 keeps a little breathing room. -->
|
||||
<Setter Property="MinHeight" Value="24"/>
|
||||
</Style>
|
||||
|
||||
<!-- Avalonia has no PasswordBox; TextBox PasswordChar="*" is the equivalent. -->
|
||||
|
||||
<!-- TabControl: WPF draws a border around the tab CONTENT area; Avalonia's
|
||||
Fluent template has none, and BorderBrush/Thickness on the control
|
||||
itself do not reach it. Target the template's selected-content host
|
||||
directly - ContentPresenter supports border properties. -->
|
||||
<Style Selector="TabControl /template/ ContentPresenter#PART_SelectedContentHost">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderColor}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="8"/>
|
||||
</Style>
|
||||
|
||||
<!-- DataGrid chrome. The DataGrid package ships Fluent defaults that
|
||||
ignore the app palette: headers get the panel color. Do NOT change
|
||||
DataGridColumnHeader Padding here - a symmetric override starved the
|
||||
narrow checkbox-column headers of content width and the select-all
|
||||
checkbox vanished. Checkbox-column header alignment is handled at
|
||||
the column (Margin 0,0,12,0 on the header CheckBox compensates the
|
||||
Fluent left-only header padding). Selection colors live in
|
||||
Brushes.axaml. -->
|
||||
<Style Selector="DataGrid">
|
||||
<Setter Property="Background" Value="{DynamicResource ControlBackgroundColor}"/>
|
||||
<!-- WPF sets RowBackground / AlternatingRowBackground and both grid-line
|
||||
brushes; Fluent ships none of it, so rows lost their zebra striping
|
||||
and the grid lost its rules. Avalonia's DataGrid has NO
|
||||
AlternatingRowBackground property (verified), so the striping is
|
||||
done with the nth-child selector below. -->
|
||||
<Setter Property="RowBackground" Value="{DynamicResource ControlBackgroundColor}"/>
|
||||
<Setter Property="GridLinesVisibility" Value="All"/>
|
||||
<Setter Property="HorizontalGridLinesBrush" Value="{DynamicResource BorderColor}"/>
|
||||
<Setter Property="VerticalGridLinesBrush" Value="{DynamicResource BorderColor}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderColor}"/>
|
||||
</Style>
|
||||
<!-- Zebra striping: WPF's AlternatingRowBackground equivalent. -->
|
||||
<Style Selector="DataGridRow:nth-child(2n)">
|
||||
<Setter Property="Background" Value="{DynamicResource PanelBackgroundColor}"/>
|
||||
</Style>
|
||||
<Style Selector="DataGridColumnHeader">
|
||||
<Setter Property="Background" Value="{DynamicResource PanelBackgroundColor}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextColor}"/>
|
||||
</Style>
|
||||
|
||||
<!-- ComboBox + ComboBoxItem: colour overrides only; FluentTheme provides the template. -->
|
||||
<Style Selector="ComboBox">
|
||||
<Setter Property="Background" Value="{DynamicResource ControlBackgroundColor}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextColor}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderColor}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="4,2"/>
|
||||
</Style>
|
||||
<Style Selector="ComboBoxItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextColor}"/>
|
||||
<Setter Property="Padding" Value="4,2"/>
|
||||
</Style>
|
||||
<Style Selector="ComboBoxItem:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource SelectedRowBackgroundColor}"/>
|
||||
</Style>
|
||||
|
||||
<!-- CheckBox / RadioButton: colour-only; FluentTheme handles glyph + states.
|
||||
Background must stay Transparent: on these controls it paints the whole
|
||||
root (a slab behind glyph AND label), not the glyph - the glyph's fill
|
||||
comes from the Fluent template's own resources. A solid value here drew
|
||||
a ControlBackgroundColor box around every radio/checkbox that sat on a
|
||||
window or panel surface. -->
|
||||
<Style Selector="CheckBox">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextColor}"/>
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
</Style>
|
||||
<Style Selector="RadioButton">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextColor}"/>
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
</Style>
|
||||
|
||||
<!-- Button: keep the legacy hover/press tones; lean on FluentTheme for the rest. -->
|
||||
<Style Selector="Button">
|
||||
<Setter Property="Background" Value="{DynamicResource ControlBackgroundColor}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextColor}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderColor}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="5,2"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center"/>
|
||||
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||
</Style>
|
||||
<Style Selector="Button:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource ButtonHoverBackground}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderColor}"/>
|
||||
</Style>
|
||||
<Style Selector="Button:pressed /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource ButtonPressedBackground}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderColor}"/>
|
||||
</Style>
|
||||
<Style Selector="Button:disabled">
|
||||
<Setter Property="Opacity" Value="0.5"/>
|
||||
</Style>
|
||||
|
||||
<!-- Lists -->
|
||||
<Style Selector="ListBox">
|
||||
<Setter Property="Background" Value="{DynamicResource ControlBackgroundColor}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextColor}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderColor}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextColor}"/>
|
||||
<Setter Property="Padding" Value="6,2"/>
|
||||
<Setter Property="MinHeight" Value="0"/>
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource ButtonHoverBackground}"/>
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource SelectedRowBackgroundColor}"/>
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:selected">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextColor}"/>
|
||||
</Style>
|
||||
|
||||
<!-- Menus -->
|
||||
<Style Selector="Menu">
|
||||
<Setter Property="Background" Value="{DynamicResource WindowBackgroundColor}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextColor}"/>
|
||||
</Style>
|
||||
<Style Selector="MenuItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextColor}"/>
|
||||
<Setter Property="Padding" Value="6,3"/>
|
||||
</Style>
|
||||
<Style Selector="MenuItem:disabled">
|
||||
<Setter Property="Foreground" Value="{DynamicResource DisabledForegroundColor}"/>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Separator">
|
||||
<Setter Property="Background" Value="{DynamicResource BorderColor}"/>
|
||||
<Setter Property="Margin" Value="0,2"/>
|
||||
<Setter Property="Height" Value="1"/>
|
||||
</Style>
|
||||
|
||||
<Style Selector="ContextMenu">
|
||||
<Setter Property="Background" Value="{DynamicResource ControlBackgroundColor}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextColor}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderColor}"/>
|
||||
</Style>
|
||||
|
||||
<!-- Tabs -->
|
||||
<Style Selector="TabControl">
|
||||
<Setter Property="Background" Value="{DynamicResource WindowBackgroundColor}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextColor}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderColor}"/>
|
||||
</Style>
|
||||
<Style Selector="TabItem">
|
||||
<Setter Property="Background" Value="{DynamicResource PanelBackgroundColor}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextColor}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderColor}"/>
|
||||
<Setter Property="Padding" Value="8,3"/>
|
||||
<Setter Property="FontSize" Value="14"/>
|
||||
<Setter Property="MinHeight" Value="0"/>
|
||||
</Style>
|
||||
<Style Selector="TabItem:selected">
|
||||
<Setter Property="Background" Value="{DynamicResource ControlBackgroundColor}"/>
|
||||
</Style>
|
||||
<Style Selector="TabItem:pointerover">
|
||||
<Setter Property="Background" Value="{DynamicResource ButtonHoverBackground}"/>
|
||||
</Style>
|
||||
|
||||
<!-- Containers -->
|
||||
<Style Selector="ScrollViewer">
|
||||
<Setter Property="Background" Value="{DynamicResource WindowBackgroundColor}"/>
|
||||
</Style>
|
||||
<!-- GroupBox: not part of core Avalonia. Common substitute is HeaderedContentControl
|
||||
or a Border with a label; we'll handle it per-screen if/when ported screens use it. -->
|
||||
|
||||
<Style Selector="ToolTip">
|
||||
<Setter Property="Background" Value="{DynamicResource ControlBackgroundColor}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextColor}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderColor}"/>
|
||||
</Style>
|
||||
|
||||
</Styles>
|
||||
@@ -0,0 +1,139 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!--
|
||||
Avalonia port of UI/WPF/Themes/Styles.xaml.
|
||||
|
||||
Paradigm shift: Avalonia has no x:Key on Style; named styles are addressed
|
||||
by class instead. Consumers move from
|
||||
Style="{StaticResource HoverUnderlineStyle}"
|
||||
to
|
||||
Classes="hover-underline"
|
||||
and the selector becomes
|
||||
Style Selector="TextBlock.hover-underline"
|
||||
|
||||
Naming convention: the original PascalCase keys translate to kebab-case
|
||||
classes (HoverUnderlineStyle -> hover-underline, LinkButton -> link, etc.)
|
||||
per Avalonia community conventions.
|
||||
|
||||
Deferred (will reimplement when a screen actually needs them):
|
||||
- ErrorControl ContentControl with Tag-based DataTrigger
|
||||
- ErrorBorder HasErrors-bound DataTrigger
|
||||
- DataGridRow requires the Avalonia.Controls.DataGrid package
|
||||
-->
|
||||
|
||||
<!--
|
||||
info-icon: the Avalonia stand-in for WPF's InfoIcon (UI/WPF/Themes/Styles.xaml),
|
||||
which is a 12x12 Rectangle filled with a DrawingBrush of three GeometryDrawings.
|
||||
A glyph instead of a vector port: U+24D8 CIRCLED LATIN SMALL LETTER I scales with
|
||||
the font, inherits the surrounding Foreground so it themes for free, and needs no
|
||||
Drawing translation. It is the same glyph the settings dialog already renders
|
||||
(Add-SettingsItem in Extensions/CoreUIAvalonia.ps1), so the two agree.
|
||||
|
||||
Consumers put the description on the SURROUNDING StackPanel rather than on the
|
||||
glyph, so hovering the label shows it too - Avalonia's dropped InfoIcons had
|
||||
moved these tips onto the Label, and this keeps that behaviour while restoring
|
||||
the visible marker, without repeating the description string twice:
|
||||
|
||||
<StackPanel Orientation="Horizontal" ToolTip.Tip="...">
|
||||
<Label Content="Export root"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
|
||||
The glyph is written as a numeric entity so the markup stays ASCII - it is also
|
||||
embedded in .ps1 here-strings, which ARE covered by the ANSI/CP1252 gate in
|
||||
Tests/Static.Tests.ps1 (.axaml files are not, but keeping one spelling avoids
|
||||
a trap when this is copied into PowerShell).
|
||||
-->
|
||||
<Style Selector="TextBlock.info-icon">
|
||||
<Setter Property="Text" Value="ⓘ"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="Margin" Value="4,0,0,0"/>
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
</Style>
|
||||
|
||||
<!-- TextBlock named styles -->
|
||||
<Style Selector="TextBlock.hover-underline:pointerover">
|
||||
<Setter Property="TextDecorations" Value="Underline"/>
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.hover-underline-bg:pointerover">
|
||||
<Setter Property="TextDecorations" Value="Underline"/>
|
||||
<Setter Property="Background" Value="LightGray"/>
|
||||
</Style>
|
||||
|
||||
<!-- LinkButton: button styled as a text link.
|
||||
Templated to a TextBlock (Foreground from button), with hover underline + bg
|
||||
and disabled colour driven by DisabledForegroundColor.
|
||||
|
||||
Avalonia note: TextDecorations on Button is not honoured by ContentPresenter
|
||||
alone; the templated TextBlock owns the decoration. -->
|
||||
<Style Selector="Button.link">
|
||||
<!-- WPF paints Hyperlinks blue; body-text colour made links invisible
|
||||
as links. LinkColor is per-variant in Brushes.axaml. -->
|
||||
<Setter Property="Foreground" Value="{DynamicResource LinkColor}"/>
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Padding" Value="0"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left"/>
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<TextBlock Name="PART_Text"
|
||||
Text="{TemplateBinding Content}"
|
||||
Foreground="{TemplateBinding Foreground}"
|
||||
Background="{TemplateBinding Background}"/>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="Button.link:pointerover /template/ TextBlock#PART_Text">
|
||||
<Setter Property="TextDecorations" Value="Underline"/>
|
||||
<Setter Property="Background" Value="{DynamicResource SelectedRowBackgroundColor}"/>
|
||||
</Style>
|
||||
<Style Selector="Button.link:disabled">
|
||||
<Setter Property="Foreground" Value="{DynamicResource DisabledForegroundColor}"/>
|
||||
<Setter Property="Cursor" Value="Arrow"/>
|
||||
</Style>
|
||||
|
||||
<!-- ContentButton: bare button — just the content with hover background. -->
|
||||
<Style Selector="Button.content">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextColor}"/>
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Padding" Value="0"/>
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<ContentPresenter Name="PART_Content"
|
||||
Background="{TemplateBinding Background}"
|
||||
Content="{TemplateBinding Content}"/>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="Button.content:pointerover /template/ ContentPresenter#PART_Content">
|
||||
<Setter Property="Background" Value="{DynamicResource SelectedRowBackgroundColor}"/>
|
||||
</Style>
|
||||
|
||||
<!-- ListBoxItem default: hover/selected highlights.
|
||||
Default.axaml already styles ListBoxItem:selected; this adds :pointerover
|
||||
and matches the legacy padding. Targets all ListBoxItems (no class), same
|
||||
as the original Styles.xaml. -->
|
||||
<Style Selector="ListBoxItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextColor}"/>
|
||||
<Setter Property="Padding" Value="2"/>
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource SelectedRowBackgroundColor}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource SelectedRowBackgroundColor}"/>
|
||||
</Style>
|
||||
|
||||
<!-- Intune Manager grid, ObjectListFirstLineOnly setting (WPF parity): the
|
||||
firstLine class is toggled by Add-IntuneManagerDataGridColumns. Nested
|
||||
^ selector so the rule follows the class on the grid, not on the cell. -->
|
||||
<Style Selector="DataGrid.firstLine">
|
||||
<Style Selector="^ DataGridCell TextBlock">
|
||||
<Setter Property="MaxLines" Value="1"/>
|
||||
<Setter Property="TextTrimming" Value="CharacterEllipsis"/>
|
||||
</Style>
|
||||
</Style>
|
||||
|
||||
</Styles>
|
||||
@@ -0,0 +1,72 @@
|
||||
<Border xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Background="{DynamicResource WindowBackgroundColor}"
|
||||
TextElement.Foreground="{DynamicResource TextColor}">
|
||||
<!--
|
||||
Avalonia port of UI/WPF/XAML/AboutDialog.xaml.
|
||||
|
||||
WPF -> Avalonia translations:
|
||||
- <Hyperlink> has no Avalonia equivalent. Replaced with an inline
|
||||
Button using Classes="link" (style defined in Themes/Styles.axaml).
|
||||
Show-AboutDialog wires the Click handler to launch the URL.
|
||||
- Grid.IsSharedSizeScope / SharedSizeGroup don't exist in Avalonia.
|
||||
Dropped — module list still reads cleanly with a fixed first column
|
||||
and natural sizing for the rest. Revisit if module names get long
|
||||
enough to misalign in practice.
|
||||
-->
|
||||
<Grid Name="grdAbout" Margin="10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Name="txtTitle" FontWeight="Bold" Margin="0,5,0,0"/>
|
||||
|
||||
<TextBlock Grid.Row="1" Text="(c) 2022 Mikael Karlsson - MIT License" Margin="0,5,0,0"/>
|
||||
|
||||
<StackPanel Grid.Row="2" Margin="0,5,0,0">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="See "/>
|
||||
<Button Name="linkSource" Classes="link" Content="GitHub"/>
|
||||
<TextBlock Text=" for more information."/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="Like the tool? "/>
|
||||
<Button Name="linkCoffee" Classes="link" Content="Buy me a coffee"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Name="txtViewTitle" Grid.Column="1" Margin="20,5,0,0"/>
|
||||
|
||||
<TextBlock Name="txtViewDescription" Grid.Row="1" Grid.Column="1" Grid.RowSpan="3"
|
||||
VerticalAlignment="Top" Margin="20,5,0,0" TextWrapping="Wrap"/>
|
||||
|
||||
<TextBlock Grid.Row="3" Text="Loaded modules:" Margin="0,10,0,0"/>
|
||||
|
||||
<ListBox Name="lstModules" SelectionMode="Single" Grid.Row="4" Grid.ColumnSpan="2"
|
||||
MinHeight="200" MinWidth="500" Margin="0,5,0,5">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="200"/>
|
||||
<ColumnDefinition Width="120"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="{Binding Name}" Grid.Column="0"/>
|
||||
<TextBlock Text="{Binding Version}" Grid.Column="1" Margin="10,0,0,0"/>
|
||||
<TextBlock Text="{Binding Type}" Grid.Column="2" Margin="10,0,0,0"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -0,0 +1,160 @@
|
||||
<Grid xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Margin="5">
|
||||
<!--
|
||||
Avalonia port of UI/WPF/XAML/BulkAssignmentsForm.xaml. Hosted via
|
||||
Show-ModalForm — Apply / Close live in this Grid and call back into
|
||||
Show-GraphBulkAssignmentsForm.
|
||||
|
||||
Same WPF -> Avalonia translations as the other bulk forms:
|
||||
- Grid host (modal-form host owns the chrome).
|
||||
- SharedSizeGroup dropped — explicit ColumnDefinition Auto/* widths.
|
||||
- InfoIcon Rectangles are rendered as the info-icon glyph (Themes/Styles.axaml);
|
||||
the description sits on the wrapping StackPanel so hovering the label shows it too.
|
||||
- DataGrid columns inlined; chkSelectAll toolbar checkbox in place
|
||||
of WPF's bound header checkbox (Avalonia DataGridCheckBoxColumn
|
||||
has no bindable header — same trick as Slice 3e/4a/4b/4c/4d).
|
||||
- DynamicResource ControlBackgroundColor / TextColor dropped — theme
|
||||
drives DataGrid / TextBlock backgrounds.
|
||||
-->
|
||||
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Top property block: Action / Filter / List-by -->
|
||||
<Grid Grid.Row="0">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Row="0" Grid.Column="0" Orientation="Horizontal" Margin="0,0,5,0" VerticalAlignment="Center" ToolTip.Tip="Add: union of current and selected. Replace: overwrite all current assignments. Remove: subtract selected from current.">
|
||||
<Label Content="Action"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="0" Grid.Column="1" Orientation="Horizontal"
|
||||
VerticalAlignment="Center" Margin="0,5,0,5">
|
||||
<RadioButton Name="rbBulkAssignAdd" Content="Add" GroupName="BulkAssignAction" IsChecked="True" VerticalAlignment="Center"/>
|
||||
<RadioButton Name="rbBulkAssignReplace" Content="Replace" GroupName="BulkAssignAction" Margin="15,0,0,0" VerticalAlignment="Center"/>
|
||||
<RadioButton Name="rbBulkAssignRemove" Content="Remove" GroupName="BulkAssignAction" Margin="15,0,0,0" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="1" Grid.Column="0" Orientation="Horizontal" Margin="0,0,5,0" VerticalAlignment="Center" ToolTip.Tip="Regex (or literal substring) matched against each policy's Name. Leave empty to target every listed policy.">
|
||||
<Label Content="Name filter"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<TextBox Grid.Row="1" Grid.Column="1" Name="txtBulkAssignNameFilter"
|
||||
Margin="0,5,0,5"/>
|
||||
|
||||
<StackPanel Grid.Row="2" Grid.Column="0" Orientation="Horizontal" Margin="0,0,5,0" VerticalAlignment="Center" ToolTip.Tip="Group: pick whole categories (Configuration, Compliance, ...). API: pick individual policy types. Apps and Health Scripts are supported — configure per-policy fields via the App settings... button.">
|
||||
<Label Content="List by"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="2" Grid.Column="1" Orientation="Horizontal"
|
||||
VerticalAlignment="Center" Margin="0,5,0,5">
|
||||
<RadioButton Name="rbBulkAssignViewGroup" Content="Group" GroupName="BulkAssignView" IsChecked="True" VerticalAlignment="Center"/>
|
||||
<RadioButton Name="rbBulkAssignViewType" Content="API" GroupName="BulkAssignView" Margin="15,0,0,0" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Middle: object DataGrid (left) + assignment list DataGrid + buttons (right) -->
|
||||
<Grid Grid.Row="1" Margin="0,5,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="5"/>
|
||||
<ColumnDefinition Width="2*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Grid Grid.Column="0">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,3" ToolTip.Tip="Select the policy types / groups to update">
|
||||
<Label Content="Objects to update"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<DataGrid CanUserResizeColumns="True" Grid.Row="1" Name="dgBulkAssignObjects"
|
||||
AutoGenerateColumns="False"
|
||||
CanUserReorderColumns="True"
|
||||
CanUserSortColumns="True"
|
||||
HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTemplateColumn Width="Auto" CanUserSort="False">
|
||||
<DataGridTemplateColumn.Header>
|
||||
<CheckBox IsChecked="True"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center"
|
||||
ToolTip.Tip="Select/deselect all listed items"/>
|
||||
</DataGridTemplateColumn.Header>
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<CheckBox IsChecked="{Binding Selected, Mode=TwoWay}"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center" Margin="12,0,0,0"/>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
<DataGridTextColumn Header="Object type" Binding="{Binding Title}" IsReadOnly="True" Width="*"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Column="2">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,3" ToolTip.Tip="Targets that will be added / replaced / removed. Double-click a row to open settings.">
|
||||
<Label Content="Assignments to apply"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<DataGrid CanUserResizeColumns="True" Grid.Row="1" Name="dgBulkAssignList"
|
||||
AutoGenerateColumns="False"
|
||||
SelectionMode="Single"
|
||||
CanUserReorderColumns="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Target type" Binding="{Binding TargetTypeDisplay}" IsReadOnly="True" Width="Auto"/>
|
||||
<DataGridTextColumn Header="Group" Binding="{Binding GroupName}" IsReadOnly="True" Width="2*"/>
|
||||
<DataGridTextColumn Header="Intent" Binding="{Binding Intent}" IsReadOnly="True" Width="Auto"/>
|
||||
<DataGridTextColumn Header="Filter" Binding="{Binding FilterDisplay}" IsReadOnly="True" Width="*"/>
|
||||
<DataGridTextColumn Header="App settings" Binding="{Binding SettingsDisplay}" IsReadOnly="True" Width="Auto"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,5,0,0">
|
||||
<Button Name="btnBulkAssignAddGroup" Content="Add group..." MinWidth="120" Margin="0,0,5,0"
|
||||
ToolTip.Tip="Pick an AAD group to include or exclude"/>
|
||||
<Button Name="btnBulkAssignAddAllDev" Content="All devices" MinWidth="100" Margin="0,0,5,0"
|
||||
ToolTip.Tip="Add the All Devices virtual target"/>
|
||||
<Button Name="btnBulkAssignAddAllUsers" Content="All users" MinWidth="100" Margin="0,0,5,0"
|
||||
ToolTip.Tip="Add the All Users virtual target"/>
|
||||
<Button Name="btnBulkAssignSettings" Content="App settings..." MinWidth="120" Margin="0,0,5,0"
|
||||
ToolTip.Tip="Configure per-platform app assignment settings for the selected row"/>
|
||||
<Button Name="btnBulkAssignRemoveSel" Content="Remove selected" MinWidth="120"
|
||||
ToolTip.Tip="Remove the highlighted row from the list above"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<!-- Bottom: status + Apply / Close -->
|
||||
<Grid Grid.Row="2" Margin="0,5,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Name="txtBulkAssignStatus" VerticalAlignment="Center"
|
||||
Opacity="0.8"/>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button Name="btnBulkAssignApply" Content="Apply" Width="100" Margin="5,0,0,0"/>
|
||||
<Button Name="btnBulkAssignClose" Content="Close" Width="100" Margin="5,0,0,0"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@@ -0,0 +1,75 @@
|
||||
<Grid xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Margin="5" MinWidth="500" MinHeight="400">
|
||||
<!--
|
||||
Avalonia port of UI/WPF/XAML/BulkAssignmentsGroupPicker.xaml. Hosted via
|
||||
Show-ModalForm — stacks over the Bulk Assignments form via grdModal.
|
||||
|
||||
The WPF original used DisplayMemberPath / SelectedValuePath on the
|
||||
ComboBoxes; in Avalonia those map to ItemTemplate (assigned in code
|
||||
via DataTemplates is finicky for runtime PSCustomObject) so we use
|
||||
DisplayMemberBinding for the ListBox and assign ComboBox items as
|
||||
plain string lists where possible. cbGroupFilter is a list of
|
||||
PSCustomObject with Id/Name — Avalonia binds to the .Name property
|
||||
via ItemTemplate (set in code) and we read .Id back manually on OK.
|
||||
cbGroupIntent is bound the same way.
|
||||
-->
|
||||
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Label Grid.Row="0" Grid.Column="0" Content="Search:" Margin="0,0,5,0"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBox Grid.Row="0" Grid.Column="1" Name="txtGroupSearch" Margin="0,3,5,3"/>
|
||||
<Button Grid.Row="0" Grid.Column="2" Name="btnGroupSearch" Content="Search" MinWidth="80"/>
|
||||
|
||||
<ListBox Grid.Row="1" Grid.ColumnSpan="3" Name="lstGroupResults"
|
||||
Margin="0,5,0,5" SelectionMode="Single"/>
|
||||
|
||||
<Label Grid.Row="2" Grid.Column="0" Content="Direction:" Margin="0,0,5,0"
|
||||
VerticalAlignment="Center"/>
|
||||
<StackPanel Grid.Row="2" Grid.Column="1" Grid.ColumnSpan="2" Orientation="Horizontal"
|
||||
VerticalAlignment="Center" Margin="0,5,0,5">
|
||||
<RadioButton Name="rbGroupInclude" Content="Include" GroupName="GroupPickerDir" IsChecked="True" VerticalAlignment="Center"/>
|
||||
<RadioButton Name="rbGroupExclude" Content="Exclude" GroupName="GroupPickerDir" Margin="15,0,0,0" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<Label Grid.Row="3" Grid.Column="0" Content="Filter:" Margin="0,0,5,0"
|
||||
VerticalAlignment="Center"/>
|
||||
<ComboBox Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2" Name="cbGroupFilter"
|
||||
Margin="0,3,0,3" HorizontalAlignment="Stretch"/>
|
||||
|
||||
<Label Grid.Row="4" Grid.Column="0" Content="Filter mode:" Margin="0,0,5,0"
|
||||
VerticalAlignment="Center"/>
|
||||
<StackPanel Grid.Row="4" Grid.Column="1" Grid.ColumnSpan="2" Orientation="Horizontal"
|
||||
VerticalAlignment="Center" Margin="0,5,0,5">
|
||||
<RadioButton Name="rbGroupFilterInclude" Content="Include" GroupName="GroupPickerFilterDir" IsChecked="True" VerticalAlignment="Center"/>
|
||||
<RadioButton Name="rbGroupFilterExclude" Content="Exclude" GroupName="GroupPickerFilterDir" Margin="15,0,0,0" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="5" Grid.Column="0" Orientation="Horizontal" Margin="0,0,5,0" VerticalAlignment="Center" ToolTip.Tip="Only applies when the target policy is an app (mobile app assignment). Ignored for Device Configuration, Compliance, scripts, etc.">
|
||||
<Label Content="App intent:"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<ComboBox Grid.Row="5" Grid.Column="1" Grid.ColumnSpan="2" Name="cbGroupIntent"
|
||||
Margin="0,3,0,3" HorizontalAlignment="Stretch"
|
||||
ToolTip.Tip="Required = auto-install. Available = visible in Company Portal. Uninstall = auto-remove. Available without enrollment = web/CP without MDM enrollment. Not available = hidden."/>
|
||||
|
||||
<StackPanel Grid.Row="6" Grid.ColumnSpan="3" Orientation="Horizontal"
|
||||
HorizontalAlignment="Right" Margin="0,8,0,0">
|
||||
<Button Name="btnGroupPickerOK" Content="Add" MinWidth="100" Margin="0,0,5,0"/>
|
||||
<Button Name="btnGroupPickerCancel" Content="Cancel" MinWidth="100"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
@@ -0,0 +1,427 @@
|
||||
<Grid xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Margin="5" MinWidth="640" MinHeight="500">
|
||||
<!--
|
||||
Avalonia port of UI/WPF/XAML/BulkAssignmentsSettings.xaml. Hosted via
|
||||
Show-ModalForm — stacks over the Bulk Assignments form via grdModal.
|
||||
|
||||
Per-platform app assignment settings. 13 TabItems, each containing a
|
||||
ScrollViewer + StackPanel with an Apply CheckBox (FontWeight=SemiBold)
|
||||
and a Grid of fields whose IsEnabled is bound to the Apply checkbox.
|
||||
|
||||
WPF -> Avalonia translations:
|
||||
- SharedSizeGroup dropped — explicit ColumnDefinition Auto/* widths.
|
||||
- DynamicResource ControlBackgroundColor / TextColor dropped — theme
|
||||
drives backgrounds.
|
||||
- Binding ElementName=...,Path=IsChecked still works for IsEnabled.
|
||||
- ToolTip="..." -> ToolTip.Tip="...".
|
||||
-->
|
||||
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Margin="0,0,0,6" TextWrapping="Wrap">
|
||||
Per-platform app assignment settings. Tick "Apply these settings" on a tab to include it in
|
||||
the POST for matching apps; leave it unticked to let Graph defaults stand.
|
||||
</TextBlock>
|
||||
|
||||
<TabControl Grid.Row="1" Name="tcBulkAssignSettings">
|
||||
|
||||
<!-- ======================== iOS LOB ======================== -->
|
||||
<TabItem Header="iOS LOB" Name="tabIosLob">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Margin="8">
|
||||
<CheckBox Name="chkApplyIosLob" Content="Apply these settings to iOS LOB apps" FontWeight="SemiBold" Margin="0,0,0,8"/>
|
||||
<Grid IsEnabled="{Binding ElementName=chkApplyIosLob, Path=IsChecked}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="180"/><ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Label Grid.Row="0" Grid.Column="0" Content="Is removable:"/>
|
||||
<CheckBox Grid.Row="0" Grid.Column="1" Name="chkIosLob_IsRemovable" VerticalAlignment="Center" Margin="0,5,0,5"/>
|
||||
<Label Grid.Row="1" Grid.Column="0" Content="Prevent backup:"/>
|
||||
<CheckBox Grid.Row="1" Grid.Column="1" Name="chkIosLob_PreventBackup" VerticalAlignment="Center" Margin="0,5,0,5"/>
|
||||
<Label Grid.Row="2" Grid.Column="0" Content="Uninstall on removal:"/>
|
||||
<CheckBox Grid.Row="2" Grid.Column="1" Name="chkIosLob_Uninstall" VerticalAlignment="Center" Margin="0,5,0,5"/>
|
||||
<Label Grid.Row="3" Grid.Column="0" Content="VPN config id:"/>
|
||||
<TextBox Grid.Row="3" Grid.Column="1" Name="txtIosLob_VpnId" Margin="0,5,0,5"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- ======================== iOS Store ======================== -->
|
||||
<TabItem Header="iOS Store" Name="tabIosStore">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Margin="8">
|
||||
<CheckBox Name="chkApplyIosStore" Content="Apply these settings to iOS Store apps" FontWeight="SemiBold" Margin="0,0,0,8"/>
|
||||
<Grid IsEnabled="{Binding ElementName=chkApplyIosStore, Path=IsChecked}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="180"/><ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Label Grid.Row="0" Grid.Column="0" Content="Is removable:"/>
|
||||
<CheckBox Grid.Row="0" Grid.Column="1" Name="chkIosStore_IsRemovable" VerticalAlignment="Center" Margin="0,5,0,5"/>
|
||||
<Label Grid.Row="1" Grid.Column="0" Content="Prevent backup:"/>
|
||||
<CheckBox Grid.Row="1" Grid.Column="1" Name="chkIosStore_PreventBackup" VerticalAlignment="Center" Margin="0,5,0,5"/>
|
||||
<Label Grid.Row="2" Grid.Column="0" Content="Uninstall on removal:"/>
|
||||
<CheckBox Grid.Row="2" Grid.Column="1" Name="chkIosStore_Uninstall" VerticalAlignment="Center" Margin="0,5,0,5"/>
|
||||
<Label Grid.Row="3" Grid.Column="0" Content="VPN config id:"/>
|
||||
<TextBox Grid.Row="3" Grid.Column="1" Name="txtIosStore_VpnId" Margin="0,5,0,5"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- ======================== iOS VPP ======================== -->
|
||||
<TabItem Header="iOS VPP" Name="tabIosVpp">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Margin="8">
|
||||
<CheckBox Name="chkApplyIosVpp" Content="Apply these settings to iOS VPP apps" FontWeight="SemiBold" Margin="0,0,0,8"/>
|
||||
<Grid IsEnabled="{Binding ElementName=chkApplyIosVpp, Path=IsChecked}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="180"/><ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Label Grid.Row="0" Grid.Column="0" Content="Use device licensing:"/>
|
||||
<CheckBox Grid.Row="0" Grid.Column="1" Name="chkIosVpp_DeviceLicensing" VerticalAlignment="Center" Margin="0,5,0,5"/>
|
||||
<Label Grid.Row="1" Grid.Column="0" Content="Is removable:"/>
|
||||
<CheckBox Grid.Row="1" Grid.Column="1" Name="chkIosVpp_IsRemovable" VerticalAlignment="Center" Margin="0,5,0,5"/>
|
||||
<Label Grid.Row="2" Grid.Column="0" Content="Prevent backup:"/>
|
||||
<CheckBox Grid.Row="2" Grid.Column="1" Name="chkIosVpp_PreventBackup" VerticalAlignment="Center" Margin="0,5,0,5"/>
|
||||
<Label Grid.Row="3" Grid.Column="0" Content="Prevent auto-update:"/>
|
||||
<CheckBox Grid.Row="3" Grid.Column="1" Name="chkIosVpp_PreventAutoUpdate" VerticalAlignment="Center" Margin="0,5,0,5"/>
|
||||
<Label Grid.Row="4" Grid.Column="0" Content="Uninstall on removal:"/>
|
||||
<CheckBox Grid.Row="4" Grid.Column="1" Name="chkIosVpp_Uninstall" VerticalAlignment="Center" Margin="0,5,0,5"/>
|
||||
<Label Grid.Row="5" Grid.Column="0" Content="VPN config id:"/>
|
||||
<TextBox Grid.Row="5" Grid.Column="1" Name="txtIosVpp_VpnId" Margin="0,5,0,5"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- ======================== iOS DDM LOB ======================== -->
|
||||
<TabItem Header="iOS DDM" Name="tabIosDdm">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Margin="8">
|
||||
<CheckBox Name="chkApplyIosDdm" Content="Apply these settings to iOS DDM LOB apps" FontWeight="SemiBold" Margin="0,0,0,8"/>
|
||||
<Grid IsEnabled="{Binding ElementName=chkApplyIosDdm, Path=IsChecked}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="180"/><ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Label Grid.Row="0" Grid.Column="0" Content="Associated domains:"/>
|
||||
<TextBox Grid.Row="0" Grid.Column="1" Name="txtIosDdm_Domains" Margin="0,5,0,5" Height="50"
|
||||
AcceptsReturn="True" TextWrapping="Wrap"
|
||||
ToolTip.Tip="One domain per line."/>
|
||||
<Label Grid.Row="1" Grid.Column="0" Content="Direct download:"/>
|
||||
<CheckBox Grid.Row="1" Grid.Column="1" Name="chkIosDdm_DirectDownload" VerticalAlignment="Center" Margin="0,5,0,5"/>
|
||||
<Label Grid.Row="2" Grid.Column="0" Content="Prevent backup:"/>
|
||||
<CheckBox Grid.Row="2" Grid.Column="1" Name="chkIosDdm_PreventBackup" VerticalAlignment="Center" Margin="0,5,0,5"/>
|
||||
<Label Grid.Row="3" Grid.Column="0" Content="Tap-to-pay screen lock:"/>
|
||||
<CheckBox Grid.Row="3" Grid.Column="1" Name="chkIosDdm_TapToPay" VerticalAlignment="Center" Margin="0,5,0,5"/>
|
||||
<Label Grid.Row="4" Grid.Column="0" Content="VPN config id:"/>
|
||||
<TextBox Grid.Row="4" Grid.Column="1" Name="txtIosDdm_VpnId" Margin="0,5,0,5"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- ======================== Android Managed Store ======================== -->
|
||||
<TabItem Header="Android Store" Name="tabAndroidStore">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Margin="8">
|
||||
<CheckBox Name="chkApplyAndroidStore" Content="Apply these settings to Android Managed Store apps" FontWeight="SemiBold" Margin="0,0,0,8"/>
|
||||
<Grid IsEnabled="{Binding ElementName=chkApplyAndroidStore, Path=IsChecked}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="180"/><ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Label Grid.Row="0" Grid.Column="0" Content="Auto-update mode:"/>
|
||||
<ComboBox Grid.Row="0" Grid.Column="1" Name="cbAndroidStore_AutoUpdate" Margin="0,5,0,5" Width="200" HorizontalAlignment="Left"/>
|
||||
<Label Grid.Row="1" Grid.Column="0" Content="Track IDs:"/>
|
||||
<TextBox Grid.Row="1" Grid.Column="1" Name="txtAndroidStore_Tracks" Margin="0,5,0,5" Height="50"
|
||||
AcceptsReturn="True" TextWrapping="Wrap"
|
||||
ToolTip.Tip="One track id per line. Leave empty for default production track."/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- ======================== macOS LOB ======================== -->
|
||||
<TabItem Header="macOS LOB" Name="tabMacLob">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Margin="8">
|
||||
<CheckBox Name="chkApplyMacLob" Content="Apply these settings to macOS LOB apps" FontWeight="SemiBold" Margin="0,0,0,8"/>
|
||||
<Grid IsEnabled="{Binding ElementName=chkApplyMacLob, Path=IsChecked}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="180"/><ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Label Grid.Row="0" Grid.Column="0" Content="Uninstall on removal:"/>
|
||||
<CheckBox Grid.Row="0" Grid.Column="1" Name="chkMacLob_Uninstall" VerticalAlignment="Center" Margin="0,5,0,5"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- ======================== macOS VPP ======================== -->
|
||||
<TabItem Header="macOS VPP" Name="tabMacVpp">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Margin="8">
|
||||
<CheckBox Name="chkApplyMacVpp" Content="Apply these settings to macOS VPP apps" FontWeight="SemiBold" Margin="0,0,0,8"/>
|
||||
<Grid IsEnabled="{Binding ElementName=chkApplyMacVpp, Path=IsChecked}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="180"/><ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Label Grid.Row="0" Grid.Column="0" Content="Use device licensing:"/>
|
||||
<CheckBox Grid.Row="0" Grid.Column="1" Name="chkMacVpp_DeviceLicensing" VerticalAlignment="Center" Margin="0,5,0,5"/>
|
||||
<Label Grid.Row="1" Grid.Column="0" Content="Prevent backup:"/>
|
||||
<CheckBox Grid.Row="1" Grid.Column="1" Name="chkMacVpp_PreventBackup" VerticalAlignment="Center" Margin="0,5,0,5"/>
|
||||
<Label Grid.Row="2" Grid.Column="0" Content="Prevent auto-update:"/>
|
||||
<CheckBox Grid.Row="2" Grid.Column="1" Name="chkMacVpp_PreventAutoUpdate" VerticalAlignment="Center" Margin="0,5,0,5"/>
|
||||
<Label Grid.Row="3" Grid.Column="0" Content="Uninstall on removal:"/>
|
||||
<CheckBox Grid.Row="3" Grid.Column="1" Name="chkMacVpp_Uninstall" VerticalAlignment="Center" Margin="0,5,0,5"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- ======================== Microsoft Store for Business ======================== -->
|
||||
<TabItem Header="MS Store" Name="tabMsStore">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Margin="8">
|
||||
<CheckBox Name="chkApplyMsStore" Content="Apply these settings to Microsoft Store for Business apps" FontWeight="SemiBold" Margin="0,0,0,8"/>
|
||||
<Grid IsEnabled="{Binding ElementName=chkApplyMsStore, Path=IsChecked}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="180"/><ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Label Grid.Row="0" Grid.Column="0" Content="Device context:"/>
|
||||
<CheckBox Grid.Row="0" Grid.Column="1" Name="chkMsStore_DeviceContext" VerticalAlignment="Center" Margin="0,5,0,5"
|
||||
ToolTip.Tip="True = install in device context (all users on device). False = per-user."/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- ======================== Windows AppX ======================== -->
|
||||
<TabItem Header="Windows AppX" Name="tabWinAppx">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Margin="8">
|
||||
<CheckBox Name="chkApplyWinAppx" Content="Apply these settings to Windows AppX apps" FontWeight="SemiBold" Margin="0,0,0,8"/>
|
||||
<Grid IsEnabled="{Binding ElementName=chkApplyWinAppx, Path=IsChecked}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="180"/><ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Label Grid.Row="0" Grid.Column="0" Content="Device context:"/>
|
||||
<CheckBox Grid.Row="0" Grid.Column="1" Name="chkWinAppx_DeviceContext" VerticalAlignment="Center" Margin="0,5,0,5"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- ======================== Windows Universal AppX ======================== -->
|
||||
<TabItem Header="Windows UAP" Name="tabWinUap">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Margin="8">
|
||||
<CheckBox Name="chkApplyWinUap" Content="Apply these settings to Windows Universal AppX apps" FontWeight="SemiBold" Margin="0,0,0,8"/>
|
||||
<Grid IsEnabled="{Binding ElementName=chkApplyWinUap, Path=IsChecked}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="180"/><ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Label Grid.Row="0" Grid.Column="0" Content="Device context:"/>
|
||||
<CheckBox Grid.Row="0" Grid.Column="1" Name="chkWinUap_DeviceContext" VerticalAlignment="Center" Margin="0,5,0,5"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- ======================== Win32 LOB ======================== -->
|
||||
<TabItem Header="Win32 LOB" Name="tabWin32">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Margin="8">
|
||||
<CheckBox Name="chkApplyWin32" Content="Apply these settings to Win32 LOB / Win32 Catalog apps" FontWeight="SemiBold" Margin="0,0,0,8"/>
|
||||
<Grid IsEnabled="{Binding ElementName=chkApplyWin32, Path=IsChecked}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="180"/><ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Label Grid.Row="0" Grid.Column="0" Content="Delivery optimization:"/>
|
||||
<ComboBox Grid.Row="0" Grid.Column="1" Name="cbWin32_DeliveryOpt" Margin="0,5,0,5" Width="200" HorizontalAlignment="Left"/>
|
||||
<Label Grid.Row="1" Grid.Column="0" Content="Notifications:"/>
|
||||
<ComboBox Grid.Row="1" Grid.Column="1" Name="cbWin32_Notifications" Margin="0,5,0,5" Width="200" HorizontalAlignment="Left"/>
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.ColumnSpan="2" Text="Auto-update settings" FontWeight="SemiBold" Margin="0,8,0,4"/>
|
||||
<Label Grid.Row="3" Grid.Column="0" Content="Superseded apps:"/>
|
||||
<ComboBox Grid.Row="3" Grid.Column="1" Name="cbWin32_Superseded" Margin="0,5,0,5" Width="200" HorizontalAlignment="Left"/>
|
||||
|
||||
<TextBlock Grid.Row="4" Grid.ColumnSpan="2" Text="Install time settings" FontWeight="SemiBold" Margin="0,8,0,4"/>
|
||||
<Label Grid.Row="5" Grid.Column="0" Content="Use local time:"/>
|
||||
<CheckBox Grid.Row="5" Grid.Column="1" Name="chkWin32_UseLocalTime" VerticalAlignment="Center" Margin="0,5,0,5"/>
|
||||
<Label Grid.Row="6" Grid.Column="0" Content="Start (ISO):"/>
|
||||
<TextBox Grid.Row="6" Grid.Column="1" Name="txtWin32_StartTime" Margin="0,5,0,5"
|
||||
ToolTip.Tip="ISO8601 datetime, e.g. 2026-06-01T10:00:00Z. Leave empty for no start time."/>
|
||||
<Label Grid.Row="7" Grid.Column="0" Content="Deadline (ISO):"/>
|
||||
<TextBox Grid.Row="7" Grid.Column="1" Name="txtWin32_DeadlineTime" Margin="0,5,0,5"/>
|
||||
|
||||
<TextBlock Grid.Row="8" Grid.ColumnSpan="2" Text="Restart settings (Required intent only)" FontWeight="SemiBold" Margin="0,8,0,4"/>
|
||||
<Label Grid.Row="9" Grid.Column="0" Content="Grace period (min):"/>
|
||||
<TextBox Grid.Row="9" Grid.Column="1" Name="txtWin32_GracePeriod" Margin="0,5,0,5" Width="120" HorizontalAlignment="Left"/>
|
||||
<Label Grid.Row="10" Grid.Column="0" Content="Countdown (min):"/>
|
||||
<TextBox Grid.Row="10" Grid.Column="1" Name="txtWin32_Countdown" Margin="0,5,0,5" Width="120" HorizontalAlignment="Left"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- ======================== winGet ======================== -->
|
||||
<TabItem Header="winGet" Name="tabWinGet">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Margin="8">
|
||||
<CheckBox Name="chkApplyWinGet" Content="Apply these settings to winGet apps" FontWeight="SemiBold" Margin="0,0,0,8"/>
|
||||
<Grid IsEnabled="{Binding ElementName=chkApplyWinGet, Path=IsChecked}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="180"/><ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Label Grid.Row="0" Grid.Column="0" Content="Notifications:"/>
|
||||
<ComboBox Grid.Row="0" Grid.Column="1" Name="cbWinGet_Notifications" Margin="0,5,0,5" Width="200" HorizontalAlignment="Left"/>
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.ColumnSpan="2" Text="Install time settings (deadline required when enabled)" FontWeight="SemiBold" Margin="0,8,0,4"/>
|
||||
<Label Grid.Row="2" Grid.Column="0" Content="Use local time:"/>
|
||||
<CheckBox Grid.Row="2" Grid.Column="1" Name="chkWinGet_UseLocalTime" VerticalAlignment="Center" Margin="0,5,0,5"/>
|
||||
<Label Grid.Row="3" Grid.Column="0" Content="Deadline (ISO):"/>
|
||||
<TextBox Grid.Row="3" Grid.Column="1" Name="txtWinGet_DeadlineTime" Margin="0,5,0,5"/>
|
||||
|
||||
<TextBlock Grid.Row="4" Grid.ColumnSpan="2" Text="Restart settings (Required intent only)" FontWeight="SemiBold" Margin="0,8,0,4"/>
|
||||
<Label Grid.Row="5" Grid.Column="0" Content="Grace period (min):"/>
|
||||
<TextBox Grid.Row="5" Grid.Column="1" Name="txtWinGet_GracePeriod" Margin="0,5,0,5" Width="120" HorizontalAlignment="Left"/>
|
||||
<Label Grid.Row="6" Grid.Column="0" Content="Countdown (min):"/>
|
||||
<TextBox Grid.Row="6" Grid.Column="1" Name="txtWinGet_Countdown" Margin="0,5,0,5" Width="120" HorizontalAlignment="Left"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- ======================== Health Script ======================== -->
|
||||
<TabItem Header="Health Script" Name="tabHealthScript">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Margin="8">
|
||||
<CheckBox Name="chkApplyHealthScript" Content="Apply these settings to Health Script (Proactive Remediation) assignments" FontWeight="SemiBold" Margin="0,0,0,8"/>
|
||||
<Grid IsEnabled="{Binding ElementName=chkApplyHealthScript, Path=IsChecked}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="180"/><ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Label Grid.Row="0" Grid.Column="0" Content="Run remediation:"/>
|
||||
<CheckBox Grid.Row="0" Grid.Column="1" Name="chkScript_RunRemediation" VerticalAlignment="Center" Margin="0,5,0,5"
|
||||
ToolTip.Tip="True = run the remediation script when detection finds an issue. False = detection only."/>
|
||||
|
||||
<Label Grid.Row="1" Grid.Column="0" Content="Schedule type:"/>
|
||||
<ComboBox Grid.Row="1" Grid.Column="1" Name="cbScript_ScheduleType" Margin="0,5,0,5" Width="200" HorizontalAlignment="Left"/>
|
||||
|
||||
<Label Grid.Row="2" Grid.Column="0" Content="Interval:"/>
|
||||
<StackPanel Grid.Row="2" Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBox Name="txtScript_Interval" Margin="0,5,5,5" Width="80"
|
||||
ToolTip.Tip="Hourly: every N hours. Daily: every N days. Once: ignored."/>
|
||||
<TextBlock Text="(hours / days / once)" VerticalAlignment="Center" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
|
||||
<Label Grid.Row="3" Grid.Column="0" Content="Time (HH:MM):"/>
|
||||
<TextBox Grid.Row="3" Grid.Column="1" Name="txtScript_Time" Margin="0,5,0,5" Width="100" HorizontalAlignment="Left"
|
||||
ToolTip.Tip="24-hour HH:MM. Ignored when schedule type is Hourly."/>
|
||||
|
||||
<Label Grid.Row="4" Grid.Column="0" Content="Use UTC:"/>
|
||||
<CheckBox Grid.Row="4" Grid.Column="1" Name="chkScript_UseUtc" VerticalAlignment="Center" Margin="0,5,0,5"
|
||||
ToolTip.Tip="Off = device local time. Ignored when schedule type is Hourly."/>
|
||||
|
||||
<Label Grid.Row="5" Grid.Column="0" Content="Date (YYYY-MM-DD):"/>
|
||||
<TextBox Grid.Row="5" Grid.Column="1" Name="txtScript_Date" Margin="0,5,0,5" Width="150" HorizontalAlignment="Left"
|
||||
ToolTip.Tip="Required for Once schedule type. Ignored otherwise."/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- ======================== Windows AutoUpdate Catalog ======================== -->
|
||||
<TabItem Header="Win AutoUpdate" Name="tabWinAutoUpdate">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Margin="8">
|
||||
<CheckBox Name="chkApplyWinAutoUpdate" Content="Apply these settings to Windows AutoUpdate Catalog apps" FontWeight="SemiBold" Margin="0,0,0,8"/>
|
||||
<Grid IsEnabled="{Binding ElementName=chkApplyWinAutoUpdate, Path=IsChecked}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="180"/><ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Label Grid.Row="0" Grid.Column="0" Content="Delivery optimization:"/>
|
||||
<ComboBox Grid.Row="0" Grid.Column="1" Name="cbWinAutoUpdate_DeliveryOpt" Margin="0,5,0,5" Width="200" HorizontalAlignment="Left"/>
|
||||
<Label Grid.Row="1" Grid.Column="0" Content="Notification type:"/>
|
||||
<ComboBox Grid.Row="1" Grid.Column="1" Name="cbWinAutoUpdate_Notifications" Margin="0,5,0,5" Width="200" HorizontalAlignment="Left"/>
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.ColumnSpan="2" Text="Install time settings" FontWeight="SemiBold" Margin="0,8,0,4"/>
|
||||
<Label Grid.Row="3" Grid.Column="0" Content="Use local time:"/>
|
||||
<CheckBox Grid.Row="3" Grid.Column="1" Name="chkWinAutoUpdate_UseLocalTime" VerticalAlignment="Center" Margin="0,5,0,5"/>
|
||||
<Label Grid.Row="4" Grid.Column="0" Content="Start (ISO):"/>
|
||||
<TextBox Grid.Row="4" Grid.Column="1" Name="txtWinAutoUpdate_StartTime" Margin="0,5,0,5"/>
|
||||
<Label Grid.Row="5" Grid.Column="0" Content="Deadline (ISO):"/>
|
||||
<TextBox Grid.Row="5" Grid.Column="1" Name="txtWinAutoUpdate_DeadlineTime" Margin="0,5,0,5"/>
|
||||
|
||||
<TextBlock Grid.Row="6" Grid.ColumnSpan="2" Text="Restart settings (Required intent only)" FontWeight="SemiBold" Margin="0,8,0,4"/>
|
||||
<Label Grid.Row="7" Grid.Column="0" Content="Grace period (min):"/>
|
||||
<TextBox Grid.Row="7" Grid.Column="1" Name="txtWinAutoUpdate_GracePeriod" Margin="0,5,0,5" Width="120" HorizontalAlignment="Left"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,8,0,0">
|
||||
<Button Name="btnBulkAssignSettingsOK" Content="OK" Width="100" Margin="0,0,5,0"/>
|
||||
<Button Name="btnBulkAssignSettingsCancel" Content="Cancel" Width="100"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
@@ -0,0 +1,183 @@
|
||||
<Grid xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Margin="5" MinWidth="700" MinHeight="500">
|
||||
<!--
|
||||
Avalonia port of UI/WPF/XAML/BulkCompare.xaml. Hosted via Show-ModalForm
|
||||
with -HideButtons; Compare / Close live inside this Grid and call
|
||||
back into Show-GraphBulkCompareForm.
|
||||
|
||||
WPF -> Avalonia translations applied:
|
||||
- Grid.IsSharedSizeScope / SharedSizeGroup="TitleColumn" dropped —
|
||||
explicit Auto/* widths used in each child Grid.
|
||||
- InfoIcon Rectangles are rendered as the info-icon glyph (Themes/Styles.axaml);
|
||||
the description sits on the wrapping StackPanel so hovering the label shows it too.
|
||||
- DisplayMemberPath / SelectedValuePath dropped on the ComboBoxes —
|
||||
the ItemTemplate is set in code via LoadXaml and the SelectedItem
|
||||
is mapped back to the original PSCustomObject through a hashtable
|
||||
(same pattern as Slice 3d1's CompareForm).
|
||||
- DataGrid columns inlined; chkSelectAll toolbar checkbox replaces
|
||||
WPF's bound header checkbox (Avalonia DataGridCheckBoxColumn has
|
||||
no bindable header — same trick as Slice 3e/4a/4b/4c/4d/4e).
|
||||
- DynamicResource ControlBackgroundColor dropped — theme drives the
|
||||
DataGrid background.
|
||||
-->
|
||||
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Provider picker -->
|
||||
<Grid Grid.Row="0">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Row="0" Grid.Column="0" Orientation="Horizontal" Margin="0,0,5,0" VerticalAlignment="Center" ToolTip.Tip="Specify the objects to compare e.g. compare exported files with existing objects or two existing objects based on name">
|
||||
<Label Content="Compare object"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<ComboBox Grid.Row="0" Grid.Column="1" Name="cbCompareProvider" DisplayMemberBinding="{Binding Name}"
|
||||
Margin="0,5,0,0" MinWidth="250" HorizontalAlignment="Stretch"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Provider-specific options -->
|
||||
<ContentControl Grid.Row="1" Name="ccContentProviderOptions" Margin="0,5,0,0"/>
|
||||
|
||||
<!-- Output config + objects-to-compare DataGrid -->
|
||||
<Grid Grid.Row="2" Margin="0,5,0,0">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Row="0" Grid.Column="0" Orientation="Horizontal" Margin="0,0,5,0" VerticalAlignment="Center" ToolTip.Tip="Specifies how the export csv should be saved. One file per ObjectType or one file for all objects.">
|
||||
<Label Content="Save as"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<ComboBox Grid.Row="0" Grid.Column="1" Name="cbCompareSave" DisplayMemberBinding="{Binding Name}"
|
||||
Margin="0,5,0,0" MinWidth="250" HorizontalAlignment="Left"/>
|
||||
|
||||
<StackPanel Grid.Row="1" Grid.Column="0" Orientation="Horizontal" Margin="0,0,5,0" VerticalAlignment="Center" ToolTip.Tip="Specify the output file format for compare results">
|
||||
<Label Content="Output format"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<ComboBox Grid.Row="1" Grid.Column="1" Name="cbCompareOutputFormat" DisplayMemberBinding="{Binding Name}"
|
||||
Margin="0,5,0,0" MinWidth="250" HorizontalAlignment="Left"/>
|
||||
|
||||
<StackPanel Grid.Row="2" Grid.Column="0" Orientation="Horizontal" Margin="0,0,5,0" VerticalAlignment="Center" ToolTip.Tip="Specify how objects should be compared">
|
||||
<Label Content="Comparison Type"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<ComboBox Grid.Row="2" Grid.Column="1" Name="cbCompareType" DisplayMemberBinding="{Binding Name}"
|
||||
Margin="0,5,0,0" MinWidth="250" HorizontalAlignment="Left"/>
|
||||
|
||||
<StackPanel Grid.Row="3" Grid.Column="0" Orientation="Horizontal" Margin="0,0,5,0" VerticalAlignment="Center" ToolTip.Tip="Specify the character to use for separating properties in the CSV file">
|
||||
<Label Content="CSV Delimiter"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<ComboBox Grid.Row="3" Grid.Column="1" Name="cbCompareCSVDelimiter"
|
||||
Margin="0,5,0,0" MinWidth="250" HorizontalAlignment="Left"/>
|
||||
|
||||
<StackPanel Grid.Row="4" Grid.Column="0" Orientation="Horizontal" Margin="0,0,5,0" VerticalAlignment="Center" ToolTip.Tip="Specify the character to use for separating values for properties with more than one value. New line will cause a new line in the exported CSV">
|
||||
<Label Content="Multi-Value Delimiter"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<ComboBox Grid.Row="4" Grid.Column="1" Name="cbCompareMultiValueDelimiter" DisplayMemberBinding="{Binding Name}"
|
||||
Margin="0,5,0,0" MinWidth="250" HorizontalAlignment="Left"/>
|
||||
|
||||
<StackPanel Grid.Row="5" Grid.Column="0" Orientation="Horizontal" Margin="0,0,5,0" VerticalAlignment="Center" ToolTip.Tip="Skip mismatch coloring for displayName, description, id, createdDateTime, lastModifiedDateTime and version. These will almost always differ between two objects.">
|
||||
<Label Content="Ignore core properties"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<CheckBox Grid.Row="5" Grid.Column="1" Name="chkIgnoreCoreProperties"
|
||||
Margin="0,5,0,0" HorizontalAlignment="Left" VerticalAlignment="Center"/>
|
||||
|
||||
<StackPanel Grid.Row="6" Grid.Column="0" Orientation="Horizontal" Margin="0,0,5,0" VerticalAlignment="Center" ToolTip.Tip="Skip comparing assignments.">
|
||||
<Label Content="Skip Assignments"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<CheckBox Grid.Row="6" Grid.Column="1" Name="chkSkipCompareAssignments"
|
||||
Margin="0,5,0,0" HorizontalAlignment="Left" VerticalAlignment="Center"/>
|
||||
|
||||
<StackPanel Grid.Row="7" Grid.Column="0" Orientation="Horizontal" Margin="0,0,5,0" VerticalAlignment="Center" ToolTip.Tip="Skip policies that only exist on the compare side (e.g. in Intune but never exported).">
|
||||
<Label Content="Skip Missing Source Policies"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<CheckBox Grid.Row="7" Grid.Column="1" Name="chkSkipMissingSourcePolicies"
|
||||
Margin="0,5,0,0" HorizontalAlignment="Left" VerticalAlignment="Center"/>
|
||||
|
||||
<StackPanel Grid.Row="8" Grid.Column="0" Orientation="Horizontal" Margin="0,0,5,0" VerticalAlignment="Center" ToolTip.Tip="Skip policies whose counterpart is missing (e.g. exported file with no matching object in Intune).">
|
||||
<Label Content="Skip Missing Destination Policies"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<CheckBox Grid.Row="8" Grid.Column="1" Name="chkSkipMissingDestinationPolicies"
|
||||
Margin="0,5,0,0" HorizontalAlignment="Left" VerticalAlignment="Center"/>
|
||||
|
||||
<Grid Grid.Row="9" Grid.ColumnSpan="2" Name="grdObjectsToCompareSection" Margin="0,5,0,0">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,3" ToolTip.Tip="Select the object types that should be compared">
|
||||
<Label Content="Objects to compare"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<DataGrid CanUserResizeColumns="True" Grid.Row="1" Name="dgObjectsToCompare"
|
||||
AutoGenerateColumns="False"
|
||||
CanUserSortColumns="True"
|
||||
HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTemplateColumn Width="Auto" CanUserSort="False">
|
||||
<DataGridTemplateColumn.Header>
|
||||
<CheckBox IsChecked="True"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center"
|
||||
ToolTip.Tip="Select/deselect all listed items"/>
|
||||
</DataGridTemplateColumn.Header>
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<CheckBox IsChecked="{Binding Selected, Mode=TwoWay}"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center" Margin="12,0,0,0"/>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
<DataGridTextColumn Header="Object type" Binding="{Binding Title}" IsReadOnly="True" Width="*"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<!-- Status + Compare / Close -->
|
||||
<Grid Grid.Row="3" Margin="0,5,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Name="txtBulkCompareStatus" VerticalAlignment="Center"
|
||||
Opacity="0.8"/>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button Name="btnStartCompare" Content="Compare" Width="100" Margin="5,0,0,0"/>
|
||||
<Button Name="btnClose" Content="Close" Width="100" Margin="5,0,0,0"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@@ -0,0 +1,72 @@
|
||||
<Grid xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Margin="5">
|
||||
<!--
|
||||
Avalonia port of UI/WPF/XAML/BulkCopy.xaml (Bulk Copy, originally from
|
||||
the old project's Copy extension). Hosted via Show-ModalForm.
|
||||
|
||||
WPF -> Avalonia translations applied (same set as BulkDeleteForm):
|
||||
- SharedSizeGroup dropped - explicit ColumnDefinition widths.
|
||||
- InfoIcon Rectangles are rendered as the info-icon glyph (Themes/Styles.axaml);
|
||||
the description sits on the wrapping StackPanel so hovering the label shows it too.
|
||||
- Inline DataGrid columns with a select-all header CheckBox.
|
||||
-->
|
||||
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Row="0" Grid.Column="0" Orientation="Horizontal" Margin="0,0,5,0" VerticalAlignment="Center" ToolTip.Tip="Specify the pattern of the source objects e.g. Test -">
|
||||
<Label Content="Source object name pattern"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<TextBox Name="txtCopyFromPattern" Grid.Row="0" Grid.Column="1" Margin="0,5,0,0"/>
|
||||
|
||||
<StackPanel Grid.Row="1" Grid.Column="0" Orientation="Horizontal" Margin="0,0,5,0" VerticalAlignment="Center" ToolTip.Tip="Specify the pattern of the new object e.g. Prod -">
|
||||
<Label Content="Copy object name pattern"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<TextBox Name="txtCopyToPattern" Grid.Row="1" Grid.Column="1" Margin="0,5,0,0"/>
|
||||
|
||||
<StackPanel Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="2" Orientation="Horizontal" Margin="0,5,5,0" VerticalAlignment="Top" ToolTip.Tip="Select the object types that should be copied">
|
||||
<Label Content="Objects to copy"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
|
||||
<DataGrid CanUserResizeColumns="True" Grid.Row="3" Grid.Column="0" Grid.ColumnSpan="2" Name="dgBulkCopyObjects"
|
||||
AutoGenerateColumns="False"
|
||||
CanUserReorderColumns="True"
|
||||
CanUserSortColumns="True"
|
||||
Margin="0,0,0,5">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTemplateColumn Width="Auto" CanUserSort="False">
|
||||
<DataGridTemplateColumn.Header>
|
||||
<CheckBox IsChecked="True"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center"
|
||||
ToolTip.Tip="Select/deselect all listed items"/>
|
||||
</DataGridTemplateColumn.Header>
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<CheckBox IsChecked="{Binding Selected, Mode=TwoWay}"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center" Margin="12,0,0,0"/>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
<DataGridTextColumn Header="Object type" Binding="{Binding Title}" IsReadOnly="True" Width="*"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
|
||||
<StackPanel Grid.Row="4" Grid.Column="0" Grid.ColumnSpan="2"
|
||||
Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button Name="btnStartCopy" Content="Copy" Width="100" Margin="5,0,0,0"/>
|
||||
<Button Name="btnClose" Content="Close" Width="100" Margin="5,0,0,0"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
@@ -0,0 +1,70 @@
|
||||
<Grid xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Margin="5">
|
||||
<!--
|
||||
Avalonia port of UI/WPF/XAML/BulkDeleteForm.xaml. Hosted via Show-ModalForm
|
||||
— Delete / Close live in this Grid and call back into
|
||||
Show-GraphBulkDeleteForm.
|
||||
|
||||
WPF -> Avalonia translations applied (same set as BulkExport/BulkImport):
|
||||
- Window/UserControl host -> Grid (modal-form host owns the chrome).
|
||||
- SharedSizeGroup dropped — explicit ColumnDefinition Width="Auto"/"*".
|
||||
- InfoIcon Rectangles are rendered as the info-icon glyph (Themes/Styles.axaml);
|
||||
the description sits on the wrapping StackPanel so hovering the label shows it too.
|
||||
- Inline DataGrid columns (no Get-GridCheckboxColumn header trick —
|
||||
chkSelectAll toolbar checkbox replaces it, default unchecked
|
||||
because bulk-delete rows are unselected by default).
|
||||
-->
|
||||
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Row="0" Grid.Column="0" Orientation="Horizontal" Margin="0,0,5,0" VerticalAlignment="Center" ToolTip.Tip="Specify name filter for the objects to delete">
|
||||
<Label Content="Name filter"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<TextBox Name="txtDeleteNameFilter" Grid.Row="0" Grid.Column="1"
|
||||
Margin="0,5,0,0"/>
|
||||
|
||||
<StackPanel Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="2" Orientation="Horizontal" Margin="0,5,5,0" VerticalAlignment="Top" ToolTip.Tip="All objects of the selected types will be deleted">
|
||||
<Label Content="Objects to delete"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
|
||||
<DataGrid CanUserResizeColumns="True" Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="2" Name="dgBulkDeleteObjects"
|
||||
AutoGenerateColumns="False"
|
||||
CanUserReorderColumns="True"
|
||||
CanUserSortColumns="True"
|
||||
Margin="0,0,0,5">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTemplateColumn Width="Auto" CanUserSort="False">
|
||||
<DataGridTemplateColumn.Header>
|
||||
<CheckBox IsChecked="False"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center"
|
||||
ToolTip.Tip="Select/deselect all listed items"/>
|
||||
</DataGridTemplateColumn.Header>
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<CheckBox IsChecked="{Binding Selected, Mode=TwoWay}"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center" Margin="12,0,0,0"/>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
<DataGridTextColumn Header="Object type" Binding="{Binding Title}" IsReadOnly="True" Width="*"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
|
||||
<StackPanel Name="spDeleteSubMenu" Grid.Row="3" Grid.Column="0" Grid.ColumnSpan="2"
|
||||
Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button Name="btnDelete" Content="Delete" Width="100" Margin="5,0,0,0"/>
|
||||
<Button Name="btnClose" Content="Close" Width="100" Margin="5,0,0,0"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,176 @@
|
||||
<Grid xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Margin="5">
|
||||
<!--
|
||||
Avalonia port of UI/WPF/XAML/BulkExportForm.xaml. Hosted via Show-ModalForm
|
||||
— Save / Export / Close live in this Grid and call back into
|
||||
Show-GraphBulkExportForm.
|
||||
|
||||
WPF -> Avalonia translations applied:
|
||||
- Window/UserControl host -> Grid (modal-form host owns the chrome).
|
||||
- Grid.IsSharedSizeScope / SharedSizeGroup dropped — single-grid
|
||||
ColumnDefinition Width="Auto"/"*" keeps rows aligned.
|
||||
- InfoIcon Rectangles are rendered as the info-icon glyph (Themes/Styles.axaml);
|
||||
the description sits on the wrapping StackPanel so hovering the label shows it too.
|
||||
- {Binding ExportFolder, Mode=TwoWay} et al replaced by
|
||||
imperative property assignment in Show-GraphBulkExportForm —
|
||||
IntuneManagerExportSettings has no INotifyPropertyChanged so
|
||||
two-way bindings would render but never round-trip. Same
|
||||
convention as ExportForm.axaml / ImportForm.axaml.
|
||||
- chkAddObjectType is forced IsChecked=true and IsEnabled=false
|
||||
(matches WPF — bulk export always pushes a per-type subfolder).
|
||||
- DataGrid Columns are declared inline — WPF used
|
||||
Get-GridCheckboxColumn (header-checkbox toggle-all) which has
|
||||
no Avalonia bindable equivalent. The "Select all" toolbar
|
||||
checkbox under spExportSubMenu replaces it (same convention
|
||||
as ImportForm.axaml).
|
||||
- DataGrid Background DynamicResource binding dropped — Avalonia
|
||||
theme keys differ; default works.
|
||||
- Per-type AddUIExportExtensions (per-policy-type checkboxes)
|
||||
NOT wired — same constraint as Slice 3c.
|
||||
-->
|
||||
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Grid Name="grdExportProperties">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Row="0" Grid.Column="0" Orientation="Horizontal" Margin="0,0,5,0" VerticalAlignment="Center" ToolTip.Tip="The root folder where exported files will be stored">
|
||||
<Label Content="Export root"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<Grid Grid.Row="0" Grid.Column="1">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="5"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox Name="txtExportPath" Grid.Column="0"/>
|
||||
<Button Name="browseExportPath" Grid.Column="2" Content="..."
|
||||
Padding="5,0,5,0" Width="50"
|
||||
ToolTip.Tip="Browse for folder"/>
|
||||
</Grid>
|
||||
|
||||
<StackPanel Grid.Row="1" Grid.Column="0" Orientation="Horizontal" Margin="0,5,5,0" VerticalAlignment="Center" ToolTip.Tip="Specify name filter for the objects to export">
|
||||
<Label Content="Name filter"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<TextBox Name="txtExportNameFilter" Grid.Row="1" Grid.Column="1"
|
||||
Margin="0,5,0,0"/>
|
||||
|
||||
<StackPanel Grid.Row="2" Grid.Column="0" Orientation="Horizontal" Margin="0,5,5,0" VerticalAlignment="Center" ToolTip.Tip="This will export all objects to a sub-directory of the export path with name based on object type">
|
||||
<Label Content="Add object name to path"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<CheckBox Name="chkAddObjectType" Grid.Row="2" Grid.Column="1"
|
||||
Margin="0,5,0,0" VerticalAlignment="Center"
|
||||
IsChecked="True" IsEnabled="False"/>
|
||||
|
||||
<StackPanel Grid.Row="3" Grid.Column="0" Orientation="Horizontal" Margin="0,5,5,0" VerticalAlignment="Center" ToolTip.Tip="Export object assignments">
|
||||
<Label Content="Export Assignments"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<CheckBox Name="chkExportAssignments" Grid.Row="3" Grid.Column="1"
|
||||
Margin="0,5,0,0" VerticalAlignment="Center"/>
|
||||
|
||||
<StackPanel Grid.Row="4" Grid.Column="0" Orientation="Horizontal" Margin="0,5,5,0" VerticalAlignment="Center" ToolTip.Tip="This will add the company name in Azure to the export path">
|
||||
<Label Content="Add company name to path"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<CheckBox Name="chkAddCompanyName" Grid.Row="4" Grid.Column="1"
|
||||
Margin="0,5,0,0" VerticalAlignment="Center"/>
|
||||
|
||||
<StackPanel Grid.Row="5" Grid.Column="0" Orientation="Horizontal" Margin="0,5,5,0" VerticalAlignment="Center" ToolTip.Tip="Depth of group-membership recursion. 1 (default) exports only the group directly assigned to the policy. 2 also exports groups that are direct members of the assigned group. Higher values mean more Graph calls and may hit throttling on large group trees.">
|
||||
<Label Content="Export nested group levels"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<TextBox Name="txtNestedGroupLevels" Grid.Row="5" Grid.Column="1"
|
||||
Margin="0,5,0,0" Width="60" HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<StackPanel Grid.Row="6" Grid.Column="0" Orientation="Horizontal" Margin="0,5,5,0" VerticalAlignment="Center" ToolTip.Tip="Group: pick whole categories (Configuration, Compliance, ...). API: pick individual policy types.">
|
||||
<Label Content="List by"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="6" Grid.Column="1" Orientation="Horizontal"
|
||||
Margin="0,5,0,0" VerticalAlignment="Center">
|
||||
<RadioButton Name="rbBulkExportViewGroup" Content="Group"
|
||||
GroupName="BulkExportView" IsChecked="True"
|
||||
VerticalAlignment="Center"/>
|
||||
<RadioButton Name="rbBulkExportViewType" Content="API"
|
||||
GroupName="BulkExportView" Margin="15,0,0,0"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="1" Margin="0,5,0,0">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Row="0" Grid.Column="0" Orientation="Horizontal" Margin="0,5,5,0" VerticalAlignment="Center" ToolTip.Tip="Select the object types that should be exported">
|
||||
<Label Content="Objects to export"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
|
||||
<DataGrid CanUserResizeColumns="True" Grid.Row="1" Grid.Column="1" Name="dgObjectsToExport"
|
||||
AutoGenerateColumns="False"
|
||||
CanUserReorderColumns="True"
|
||||
CanUserSortColumns="True"
|
||||
Margin="0,0,0,5">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTemplateColumn Width="Auto" CanUserSort="False">
|
||||
<DataGridTemplateColumn.Header>
|
||||
<CheckBox IsChecked="True"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center"
|
||||
ToolTip.Tip="Select/deselect all listed items"/>
|
||||
</DataGridTemplateColumn.Header>
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<CheckBox IsChecked="{Binding Selected, Mode=TwoWay}"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center" Margin="12,0,0,0"/>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
<DataGridTextColumn Header="Object type" Binding="{Binding Title}" IsReadOnly="True" Width="*"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="2">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Name="spExportSubMenu" Grid.Column="1" Orientation="Horizontal"
|
||||
HorizontalAlignment="Right">
|
||||
<Button Name="btnExport" Content="Export" Width="100" Margin="5,0,0,0"/>
|
||||
<Button Name="btnClose" Content="Close" Width="100" Margin="5,0,0,0"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@@ -0,0 +1,188 @@
|
||||
<Grid xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Margin="5">
|
||||
<!--
|
||||
Avalonia port of UI/WPF/XAML/BulkImportForm.xaml. Hosted via Show-ModalForm
|
||||
— Save / Import / Close live in this Grid and call back into
|
||||
Show-GraphBulkImportForm.
|
||||
|
||||
WPF -> Avalonia translations applied (same set as BulkExportForm.axaml):
|
||||
- Window/UserControl host -> Grid (modal-form host owns the chrome).
|
||||
- Grid.IsSharedSizeScope / SharedSizeGroup dropped — single grid
|
||||
ColumnDefinition Width="Auto"/"*" keeps rows aligned.
|
||||
- InfoIcon Rectangles are rendered as the info-icon glyph (Themes/Styles.axaml);
|
||||
the description sits on the wrapping StackPanel so hovering the label shows it too.
|
||||
- chkAddObjectType is forced IsChecked=true and IsEnabled=false
|
||||
(matches WPF — bulk import always reads per-type subfolders).
|
||||
- cbImportType + lblImportType are visible by default here (WPF
|
||||
XAML had them Collapsed and the PS code flipped them visible —
|
||||
we just start visible).
|
||||
- DataGrid Columns are declared inline. WPF's
|
||||
Get-GridCheckboxColumn header-checkbox toggle-all has no
|
||||
Avalonia bindable equivalent — the chkSelectAll toolbar
|
||||
checkbox under spImportSubMenu replaces it (same convention
|
||||
as ImportForm.axaml / BulkExportForm.axaml).
|
||||
- Per-type AddUIImportExtensions (per-policy-type checkboxes)
|
||||
NOT wired — same constraint as Slice 3e / 4a.
|
||||
- DataContext two-way bindings dropped — IntuneManagerImportSettings
|
||||
has no INotifyPropertyChanged so two-way bindings would render
|
||||
but never round-trip. Same imperative round-trip as the rest
|
||||
of the port.
|
||||
-->
|
||||
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Grid Name="grdImportProperties">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Row="0" Grid.Column="0" Orientation="Horizontal" Margin="0,0,5,0" VerticalAlignment="Center" ToolTip.Tip="The root folder where exported files are stored">
|
||||
<Label Content="Import root"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<Grid Grid.Row="0" Grid.Column="1">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="5"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox Name="txtImportPath" Grid.Column="0"/>
|
||||
<Button Name="browseImportPath" Grid.Column="2" Content="..."
|
||||
Padding="5,0,5,0" Width="50"
|
||||
ToolTip.Tip="Browse for folder"/>
|
||||
</Grid>
|
||||
|
||||
<StackPanel Grid.Row="1" Grid.Column="0" Orientation="Horizontal" Margin="0,5,5,0" VerticalAlignment="Center" ToolTip.Tip="Specify name filter for the objects to import">
|
||||
<Label Content="Name filter"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<TextBox Name="txtImportNameFilter" Grid.Row="1" Grid.Column="1"
|
||||
Margin="0,5,0,0"/>
|
||||
|
||||
<StackPanel Grid.Row="2" Grid.Column="0" Orientation="Horizontal" Margin="0,5,5,0" VerticalAlignment="Center" ToolTip.Tip="This contains information about the exported environment e.g. Groups, ScopeTags etc. Note: This is only used when importing objects from a different tenant">
|
||||
<Label Content="Migration Table"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<TextBlock Name="lblMigrationTableInfo" Grid.Row="2" Grid.Column="1"
|
||||
Margin="0,5,0,0" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||
|
||||
<StackPanel Grid.Row="3" Grid.Column="0" Orientation="Horizontal" Margin="0,5,5,0" VerticalAlignment="Center" ToolTip.Tip="This will import objects from a sub-directory of the import path with name based on object type">
|
||||
<Label Content="Add object name to path"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<CheckBox Name="chkAddObjectType" Grid.Row="3" Grid.Column="1"
|
||||
Margin="0,5,0,0" VerticalAlignment="Center"
|
||||
IsChecked="True" IsEnabled="False"/>
|
||||
|
||||
<StackPanel Grid.Row="4" Grid.Column="0" Orientation="Horizontal" Margin="0,5,5,0" VerticalAlignment="Center" ToolTip.Tip="This will import ScopeTags. The ScopeTags must exist in the target environment before they can be assigned during import of an object">
|
||||
<Label Content="Assign Scope (Tags)"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<CheckBox Name="chkImportScopes" Grid.Row="4" Grid.Column="1"
|
||||
Margin="0,5,0,0" VerticalAlignment="Center"
|
||||
IsChecked="True"/>
|
||||
|
||||
<StackPanel Grid.Row="5" Grid.Column="0" Orientation="Horizontal" Margin="0,5,5,0" VerticalAlignment="Center" ToolTip.Tip="Import object assignments. Note: This will create groups that don't exist in the target environment">
|
||||
<Label Content="Import Assignments"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<CheckBox Name="chkImportAssignments" Grid.Row="5" Grid.Column="1"
|
||||
Margin="0,5,0,0" VerticalAlignment="Center"
|
||||
IsChecked="True"/>
|
||||
|
||||
<StackPanel Grid.Row="6" Grid.Column="0" Orientation="Horizontal" Margin="0,5,5,0" VerticalAlignment="Center" ToolTip.Tip="Replaces IDs of dependency objects e.g. App Config references Applications. Increases import time but makes sure objects are imported correctly. Note: Referenced objects must exist!">
|
||||
<Label Content="Replace Dependency IDs"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<CheckBox Name="chkReplaceDependencyIDs" Grid.Row="6" Grid.Column="1"
|
||||
Margin="0,5,0,0" VerticalAlignment="Center"
|
||||
IsChecked="True"/>
|
||||
|
||||
<Label Grid.Row="7" Grid.Column="0" Content="Import Type" Margin="0,5,5,0"
|
||||
VerticalAlignment="Center">
|
||||
<ToolTip.Tip>
|
||||
<TextBlock>
|
||||
Specify how files are imported.
|
||||
Always import: always try to import the object (no detection of existing objects, default).
|
||||
Skip if object exists: skip import if there is an existing object with the same name and type.
|
||||
Replace: if an object is detected it will be deleted and the assignments copied to the new imported object.
|
||||
Replace with assignments: if an object is detected it will be deleted and the assignments come from the import file.
|
||||
Update: if an object is detected, settings will be replaced from the import file.
|
||||
</TextBlock>
|
||||
</ToolTip.Tip>
|
||||
</Label>
|
||||
<ComboBox Name="cbImportType" DisplayMemberBinding="{Binding Name}" Grid.Row="7" Grid.Column="1"
|
||||
Margin="0,5,0,0" MinWidth="250" HorizontalAlignment="Left"/>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="1" Margin="0,5,0,0">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Row="0" Grid.Column="0" Orientation="Horizontal" Margin="0,5,5,0" VerticalAlignment="Center" ToolTip.Tip="Select the object types that should be imported">
|
||||
<Label Content="Objects to import"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
|
||||
<DataGrid CanUserResizeColumns="True" Grid.Row="1" Grid.Column="1" Name="dgObjectsToImport"
|
||||
AutoGenerateColumns="False"
|
||||
CanUserReorderColumns="True"
|
||||
CanUserSortColumns="True"
|
||||
Margin="0,0,0,5">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTemplateColumn Width="Auto" CanUserSort="False">
|
||||
<DataGridTemplateColumn.Header>
|
||||
<CheckBox IsChecked="True"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center"
|
||||
ToolTip.Tip="Select/deselect all listed items"/>
|
||||
</DataGridTemplateColumn.Header>
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<CheckBox IsChecked="{Binding Selected, Mode=TwoWay}"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center" Margin="12,0,0,0"/>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
<DataGridTextColumn Header="Object type" Binding="{Binding Title}" IsReadOnly="True" Width="*"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="2">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Name="spImportSubMenu" Grid.Column="1" Orientation="Horizontal"
|
||||
HorizontalAlignment="Right">
|
||||
<Button Name="btnImport" Content="Import" Width="100" Margin="5,0,0,0"/>
|
||||
<Button Name="btnClose" Content="Close" Width="100" Margin="5,0,0,0"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@@ -0,0 +1,164 @@
|
||||
<Grid xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Margin="5">
|
||||
<!--
|
||||
Avalonia port of UI/WPF/XAML/BulkScopeTagForm.xaml. Hosted via Show-ModalForm
|
||||
— Apply / Close live in this Grid and call back into
|
||||
Show-GraphBulkScopeTagForm.
|
||||
|
||||
WPF -> Avalonia translations (same set as BulkExport/BulkImport/BulkDelete):
|
||||
- Window/UserControl host -> Grid (modal-form host owns the chrome).
|
||||
- SharedSizeGroup dropped — explicit ColumnDefinition Width="Auto"/"*".
|
||||
- InfoIcon Rectangles are rendered as the info-icon glyph (Themes/Styles.axaml);
|
||||
the description sits on the wrapping StackPanel so hovering the label shows it too.
|
||||
- DataGrid columns inlined (no Get-GridCheckboxColumn header trick —
|
||||
chkSelectAll toolbar checkbox replaces it; default checked since
|
||||
scope-tag rows pre-select per the WPF original).
|
||||
- DynamicResource brushes (ControlBackgroundColor / TextColor) dropped —
|
||||
Avalonia theme drives ListBox / TextBlock backgrounds.
|
||||
-->
|
||||
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Top property block: Action / Cleanup / Filter / List-by -->
|
||||
<Grid Grid.Row="0">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Row="0" Grid.Column="0" Orientation="Horizontal" Margin="0,0,5,0" VerticalAlignment="Center" ToolTip.Tip="Add: union of current and selected. Replace: overwrite. Remove: subtract selected from current.">
|
||||
<Label Content="Action"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="0" Grid.Column="1" Orientation="Horizontal"
|
||||
VerticalAlignment="Center" Margin="0,5,0,5">
|
||||
<RadioButton Name="rbBulkScopeTagAdd" Content="Add" GroupName="BulkScopeTagAction" IsChecked="True" VerticalAlignment="Center"/>
|
||||
<RadioButton Name="rbBulkScopeTagReplace" Content="Replace" GroupName="BulkScopeTagAction" Margin="15,0,0,0" VerticalAlignment="Center"/>
|
||||
<RadioButton Name="rbBulkScopeTagRemove" Content="Remove" GroupName="BulkScopeTagAction" Margin="15,0,0,0" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="1" Grid.Column="0" Orientation="Horizontal" Margin="0,0,5,0" VerticalAlignment="Center" ToolTip.Tip="In addition to the chosen action, strip any tag ids from each policy that no longer resolve to a real scope tag in the tenant. Can also be used standalone (Add with no tags selected).">
|
||||
<Label Content="Cleanup orphans"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<CheckBox Grid.Row="1" Grid.Column="1" Name="chkBulkScopeTagCleanupOrphans"
|
||||
VerticalAlignment="Center" Margin="0,5,0,5"/>
|
||||
|
||||
<StackPanel Grid.Row="2" Grid.Column="0" Orientation="Horizontal" Margin="0,0,5,0" VerticalAlignment="Center" ToolTip.Tip="Regex (or literal substring) matched against each policy's Name. Leave empty to target every listed policy.">
|
||||
<Label Content="Name filter"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<TextBox Grid.Row="2" Grid.Column="1" Name="txtBulkScopeTagNameFilter"
|
||||
Margin="0,5,0,5"/>
|
||||
|
||||
<StackPanel Grid.Row="3" Grid.Column="0" Orientation="Horizontal" Margin="0,0,5,0" VerticalAlignment="Center" ToolTip.Tip="Group: pick whole categories (Configuration, Compliance, ...). API: pick individual policy types.">
|
||||
<Label Content="List by"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="3" Grid.Column="1" Orientation="Horizontal"
|
||||
VerticalAlignment="Center" Margin="0,5,0,5">
|
||||
<RadioButton Name="rbBulkScopeTagViewGroup" Content="Group" GroupName="BulkScopeTagView" IsChecked="True" VerticalAlignment="Center"/>
|
||||
<RadioButton Name="rbBulkScopeTagViewType" Content="API" GroupName="BulkScopeTagView" Margin="15,0,0,0" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Middle: object DataGrid (left) + dual-list scope-tag picker (right) -->
|
||||
<Grid Grid.Row="1" Margin="0,5,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="5"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Grid Grid.Column="0">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,3" ToolTip.Tip="Select the policy types / groups that should be updated">
|
||||
<Label Content="Objects to update"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<DataGrid CanUserResizeColumns="True" Grid.Row="1" Name="dgBulkScopeTagObjects"
|
||||
AutoGenerateColumns="False"
|
||||
CanUserReorderColumns="True"
|
||||
CanUserSortColumns="True"
|
||||
HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTemplateColumn Width="Auto" CanUserSort="False">
|
||||
<DataGridTemplateColumn.Header>
|
||||
<CheckBox IsChecked="True"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center"
|
||||
ToolTip.Tip="Select/deselect all listed items"/>
|
||||
</DataGridTemplateColumn.Header>
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<CheckBox IsChecked="{Binding Selected, Mode=TwoWay}"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center" Margin="12,0,0,0"/>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
<DataGridTextColumn Header="Object type" Binding="{Binding Title}" IsReadOnly="True" Width="*"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Column="2">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,3" ToolTip.Tip="Double-click to add to the Selected list">
|
||||
<Label Content="Available scope tags"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<ListBox Grid.Row="1" Name="lstBulkScopeTagAvailable"
|
||||
DisplayMemberBinding="{Binding Name}"
|
||||
SelectionMode="Multiple"/>
|
||||
<Grid Grid.Row="2" Margin="0,3,0,3">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Label Content="Selected scope tags" VerticalAlignment="Center"
|
||||
ToolTip.Tip="Tags to use with the chosen action. Double-click a row to remove it."/>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="5">
|
||||
<Button Name="btnBulkScopeTagAddTag" Content="Add ↓" MinWidth="80"
|
||||
ToolTip.Tip="Move the highlighted available tags to the Selected list"/>
|
||||
<Button Name="btnBulkScopeTagRemoveTag" Content="Remove ↑" MinWidth="80"
|
||||
ToolTip.Tip="Move the highlighted selected tags back to the Available list"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<ListBox Grid.Row="3" Name="lstBulkScopeTagSelected"
|
||||
DisplayMemberBinding="{Binding Name}"
|
||||
SelectionMode="Multiple"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<!-- Bottom: status + Select-all toggle + Apply / Close -->
|
||||
<Grid Grid.Row="2" Margin="0,5,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Name="txtBulkScopeTagStatus" VerticalAlignment="Center"
|
||||
Opacity="0.8"/>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button Name="btnBulkScopeTagApply" Content="Apply" Width="100" Margin="5,0,0,0"/>
|
||||
<Button Name="btnBulkScopeTagClose" Content="Close" Width="100" Margin="5,0,0,0"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@@ -0,0 +1,95 @@
|
||||
<Grid xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Margin="5">
|
||||
|
||||
<!--
|
||||
Avalonia port of UI/WPF/XAML/CachedObjects.xaml. Top stats bar +
|
||||
Refresh button live in a Border header; rows render in a DataGrid
|
||||
bound through Update-CachedObjectsView (CacheRowItem CLR class —
|
||||
the binder can't reach PSCustomObject NoteProperties).
|
||||
|
||||
Selected-row detail pane uses DataContext binding to dgCachedObjects
|
||||
SelectedItem; Avalonia uses the #element-name shorthand.
|
||||
-->
|
||||
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="4*" MinHeight="300"/>
|
||||
<RowDefinition Height="3"/>
|
||||
<RowDefinition Height="1*" MinHeight="100"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Border Grid.Row="0" BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
|
||||
Background="{DynamicResource PanelBackgroundColor}" Padding="8" Margin="0,0,0,5">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal">
|
||||
<TextBlock Text="Entries: " FontWeight="Bold" Margin="0,0,2,0"/>
|
||||
<TextBlock x:Name="txtCacheEntries" Text="0" Margin="0,0,16,0"/>
|
||||
<TextBlock Text="Hits: " FontWeight="Bold" Margin="0,0,2,0"/>
|
||||
<TextBlock x:Name="txtCacheHits" Text="0" Margin="0,0,16,0"/>
|
||||
<TextBlock Text="Misses: " FontWeight="Bold" Margin="0,0,2,0"/>
|
||||
<TextBlock x:Name="txtCacheMisses" Text="0" Margin="0,0,16,0"/>
|
||||
<TextBlock Text="Total size: " FontWeight="Bold" Margin="0,0,2,0"/>
|
||||
<TextBlock x:Name="txtCacheBytes" Text="0 B" Margin="0,0,16,0"
|
||||
ToolTip.Tip="Approximate in-memory size of all cached values (UTF-8 JSON serialization, compressed)."/>
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="1" x:Name="btnRefreshCachedObjects" Content="Refresh"
|
||||
Padding="8,2" Margin="5,0,0,0" VerticalAlignment="Center"
|
||||
ToolTip.Tip="Recompute cache totals and reload the list"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<DataGrid Grid.Row="1" x:Name="dgCachedObjects"
|
||||
AutoGenerateColumns="False"
|
||||
SelectionMode="Single"
|
||||
IsReadOnly="True"
|
||||
CanUserReorderColumns="True"
|
||||
CanUserResizeColumns="True"
|
||||
CanUserSortColumns="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Name" Binding="{Binding Name}" Width="Auto"/>
|
||||
<DataGridTextColumn Header="Tags" Binding="{Binding TagsText}" Width="Auto"/>
|
||||
<DataGridTextColumn Header="Size" Binding="{Binding SizeText}" Width="Auto"/>
|
||||
<DataGridTextColumn Header="Persistent" Binding="{Binding Persistent}" Width="Auto"/>
|
||||
<DataGridTextColumn Header="TimeOut" Binding="{Binding TimeOut, StringFormat=\{0:HH:mm.ss.fff\}}" Width="Auto"/>
|
||||
<DataGridTemplateColumn Header="Value" Width="*">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding ValueText}" TextWrapping="NoWrap"/>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
|
||||
<GridSplitter Grid.Row="2" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"/>
|
||||
|
||||
<Grid Grid.Row="3" DataContext="{Binding #dgCachedObjects.SelectedItem}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="5"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<TextBlock Text="Name" Grid.Row="0" Grid.Column="0"/>
|
||||
<TextBlock Text="{Binding Name}" Grid.Row="0" Grid.Column="2"/>
|
||||
<Button x:Name="btnClearCachedObject" Content="Clear" Grid.Row="0" Grid.Column="3"
|
||||
Margin="0,5,5,5" Width="100"
|
||||
ToolTip.Tip="Clear the selected object from cache"/>
|
||||
|
||||
<TextBox Grid.Row="1" Grid.ColumnSpan="99" x:Name="txtCacheObjectInfo"
|
||||
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
|
||||
IsReadOnly="True" AcceptsReturn="True" TextWrapping="Wrap"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@@ -0,0 +1,43 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Choose Cloud"
|
||||
SizeToContent="WidthAndHeight"
|
||||
CanResize="False"
|
||||
ShowInTaskbar="False"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource WindowBackgroundColor}">
|
||||
|
||||
<!--
|
||||
Avalonia port of UI/WPF/XAML/CloudPickerMenu.xaml. Translation notes:
|
||||
- Window.Resources merging Default.xaml dropped — Avalonia loads themes
|
||||
globally via Application.Resources.MergedDictionaries.
|
||||
- SharedSizeGroup dropped (no Avalonia equivalent).
|
||||
- InfoIcon rectangle dropped.
|
||||
- FocusManager.FocusedElement binding dropped; Avalonia uses Focus()
|
||||
imperatively if needed.
|
||||
- ComboBox DisplayMemberPath / SelectedValuePath dropped — the PS code
|
||||
assigns an ItemTemplate and reads SelectedItem.Value directly.
|
||||
-->
|
||||
|
||||
<Grid Width="380" Margin="10">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Label Content="Cloud" Margin="0,5,5,0" VerticalAlignment="Center"
|
||||
ToolTip.Tip="Select the Microsoft cloud to sign in to: Public (Global), US Government (GCC High), US Government (DoD), or China (Vianet)."/>
|
||||
<ComboBox x:Name="cbCloud" DisplayMemberBinding="{Binding Name}" Grid.Column="1" Margin="0,5,5,0" HorizontalAlignment="Stretch"
|
||||
ToolTip.Tip="Select the Microsoft cloud to sign in to: Public (Global), US Government (GCC High), US Government (DoD), or China (Vianet)."/>
|
||||
|
||||
<StackPanel Grid.Row="1" Grid.ColumnSpan="2" Orientation="Horizontal"
|
||||
HorizontalAlignment="Right" Margin="0,10,0,0">
|
||||
<Button x:Name="btnLogin" Content="Sign in" Width="100" Margin="5,0,0,0" IsDefault="True"/>
|
||||
<Button x:Name="btnCancel" Content="Cancel" Width="100" Margin="5,0,0,0" IsCancel="True"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,36 @@
|
||||
<Grid xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<!--
|
||||
Avalonia port of UI/WPF/XAML/CompareExportOptions.xaml. Used by both
|
||||
CompareExportFilesProvider ("export") and CompareIntuneWithExportProvider
|
||||
("IntuneWithExport") — shape is identical: name filter + export root
|
||||
path with browse button. SharedSizeGroup dropped; InfoIcon Rectangles are
|
||||
rendered as the info-icon glyph (Themes/Styles.axaml), with the description
|
||||
on the wrapping StackPanel so hovering the label shows it too.
|
||||
-->
|
||||
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Row="0" Grid.Column="0" Orientation="Horizontal" Margin="0,0,5,0" VerticalAlignment="Center" ToolTip.Tip="Specify name filter for the objects to compare">
|
||||
<Label Content="Name filter"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<TextBox Grid.Row="0" Grid.Column="1" Grid.ColumnSpan="2" Name="txtCompareNameFilter"
|
||||
Margin="0,5,0,0"/>
|
||||
|
||||
<StackPanel Grid.Row="1" Grid.Column="0" Orientation="Horizontal" Margin="0,0,5,0" VerticalAlignment="Center" ToolTip.Tip="The root folder where exported files are stored. This sould be the Company Name folder if it was included in the export.">
|
||||
<Label Content="Export root"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<TextBox Grid.Row="1" Grid.Column="1" Name="txtExportPath" Margin="0,5,5,0"/>
|
||||
<Button Grid.Row="1" Grid.Column="2" Name="browseExportPath" Content="..." Margin="0,5,0,0"
|
||||
Padding="5,2,5,2" Width="50" ToolTip.Tip="Browse for folder"/>
|
||||
</Grid>
|
||||
@@ -0,0 +1,44 @@
|
||||
<Grid xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<!--
|
||||
Avalonia port of UI/WPF/XAML/CompareExportedFilesOptions.xaml. Used by
|
||||
CompareExportedFoldersProvider ("exportedFolders"). SharedSizeGroup
|
||||
dropped; InfoIcon Rectangles are rendered as the info-icon glyph
|
||||
(Themes/Styles.axaml), with the description on the wrapping StackPanel
|
||||
so hovering the label shows it too.
|
||||
-->
|
||||
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Row="0" Grid.Column="0" Orientation="Horizontal" Margin="0,0,5,0" VerticalAlignment="Center" ToolTip.Tip="Specify name filter for the objects to compare">
|
||||
<Label Content="Name filter"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<TextBox Grid.Row="0" Grid.Column="1" Grid.ColumnSpan="2" Name="txtCompareNameFilter"
|
||||
Margin="0,5,0,0"/>
|
||||
|
||||
<StackPanel Grid.Row="1" Grid.Column="0" Orientation="Horizontal" Margin="0,0,5,0" VerticalAlignment="Center" ToolTip.Tip="The root folder where exported files are stored. This sould be the Company Name folder if it was included in the export.">
|
||||
<Label Content="Source root"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<TextBox Grid.Row="1" Grid.Column="1" Name="txtExportPathSource" Margin="0,5,5,0"/>
|
||||
<Button Grid.Row="1" Grid.Column="2" Name="browseExportPathSource" Content="..." Margin="0,5,0,0"
|
||||
Padding="5,2,5,2" Width="50" ToolTip.Tip="Browse for folder"/>
|
||||
|
||||
<StackPanel Grid.Row="2" Grid.Column="0" Orientation="Horizontal" Margin="0,0,5,0" VerticalAlignment="Center" ToolTip.Tip="The folder with exported object that should be compared with the source">
|
||||
<Label Content="Compare root"/>
|
||||
<TextBlock Classes="info-icon"/>
|
||||
</StackPanel>
|
||||
<TextBox Grid.Row="2" Grid.Column="1" Name="txtExportPathCompare" Margin="0,5,5,0"/>
|
||||
<Button Grid.Row="2" Grid.Column="2" Name="browseExportPathCompare" Content="..." Margin="0,5,0,0"
|
||||
Padding="5,2,5,2" Width="50" ToolTip.Tip="Browse for folder"/>
|
||||
</Grid>
|
||||
@@ -0,0 +1,16 @@
|
||||
<Grid xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<!--
|
||||
Avalonia port of UI/WPF/XAML/CompareFileOptions.xaml. Used as
|
||||
ccCompareInputOptions.Content when the user picks "File" in
|
||||
cbCompareInput. Slice 3d2.
|
||||
-->
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="5"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox Name="txtCompareFile" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="2" Name="browseCompareObject" Padding="5,2,5,2" Width="50"
|
||||
Content="..." ToolTip.Tip="Browse for file"/>
|
||||
</Grid>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user