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