IntuneManagement 4.0.0-beta1

This commit is contained in:
Mikael Karlsson
2026-09-23 19:13:09 +10:00
commit 7869619510
892 changed files with 577109 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
# Experimental-platform notice - the DECISION half.
#
# macOS and Linux run the same engine as Windows through the Avalonia backend, but
# the macOS path has never been executed on a Mac. Users get told that once, with a
# "do not show again" opt-out, rather than discovering it from a stack trace.
#
# Why here and not in UI/<backend>/: UI/Classes/ loads for BOTH backends, so the
# gate is testable headlessly on Windows (Tests/ExperimentalPlatformNotice.Tests.ps1)
# even though the dialog itself only ever renders under Avalonia. The rendering half
# lives in UI/Avalonia/Extensions/ExperimentalPlatformNoticeAvalonia.ps1.
#
# Why AppInitialized: UI/Classes/ dot-sources before Internal/, so the settings
# sections do not exist at load time. See the header of UICommonSettings.ps1.
$script:ExperimentalPlatformNoticeKey = "HideExperimentalPlatformNotice"
function Add-ExperimentalPlatformSettings
{
# Idempotent: the notice calls this defensively if it runs before AppInitialized
# has fired, and a second Add-SettingsObject would put a duplicate row in the
# Settings dialog.
foreach($section in (Get-SettingsSections)) {
if($section.Values | Where-Object Key -eq $script:ExperimentalPlatformNoticeKey) { return }
}
Add-SettingsObject -Title "Hide experimental platform notice" -Key $script:ExperimentalPlatformNoticeKey -Type "Boolean" `
-Description "Stop showing the startup notice that macOS and Linux support is experimental. Clear this to see it again." `
-DefaultValue $false -Section "General"
}
Add-AppEventHandler "AppInitialized" "Add-ExperimentalPlatformSettings"
function Test-ShouldShowExperimentalPlatformNotice
{
# The opt-out wins everywhere, including under the forced override - otherwise a
# developer who ticks the box on Windows cannot get the dialog back without
# editing the settings store by hand.
if((Get-SettingValue $script:ExperimentalPlatformNoticeKey) -eq $true) { return $false }
# IM_EXPERIMENTAL_NOTICE=1 forces the dialog on Windows so it can be reviewed on
# the machine it is developed on; it never renders there otherwise.
if($script:IsWindowsOS -and $env:IM_EXPERIMENTAL_NOTICE -ne '1') { return $false }
return $true
}
+115
View File
@@ -0,0 +1,115 @@
# Shared UI settings registration - single source for settings that are consumed
# ONLY by UI code but apply to BOTH backends (WPF + Avalonia).
#
# Why here: these keys used to be registered twice (once per backend, kept in sync by
# hand) or in engine files despite having no engine consumer. UI/Classes/ loads for
# every backend (and headless - harmless: the Settings dialog never renders there,
# and registration keeps Get-SettingValue resolving the right SubPath everywhere).
#
# Why AppInitialized: UI/Classes/ dot-sources BEFORE Internal/, so the non-General
# sections (IntuneManager / GraphGeneral / IntuneTools / MSAL) do not exist yet at
# load time. All sections exist by AppInitialized, and both Settings dialogs read
# the registry at render time, long after that. (Same pattern as the IntuneManagerUI
# extensions' registrations.)
#
# Rule of thumb: a setting consumed by Internal/Classes/Public code must be
# registered in an engine file instead (see GraphPageSize in Internal/IntuneManager.ps1
# for the cautionary tale) - this file is only for UI-consumed settings.
function Add-CommonUISettings
{
# ---- General section (UI appearance / behavior) ----
# Default is not a palette of its own - it resolves to Light or Dark from the
# Windows app theme at apply time (see UI/Classes/UIThemeCommon.ps1).
$themeList = @(
[PSCustomObject]@{ Name = "Default (follow Windows)"; Value = "Default" },
[PSCustomObject]@{ Name = "Light"; Value = "Light" },
[PSCustomObject]@{ Name = "Dark"; Value = "Dark" }
)
Add-SettingsObject -Title "Theme" -Key "AppTheme" -Type "List" `
-ItemsSource $themeList `
-Description "Application color theme. Default follows the Windows app theme." `
-DefaultValue "Default" -Section "General"
Add-SettingsObject -Title "Hide No-access items" -Key "HideNoAccess" -Type "Boolean" `
-Description "Remove items from the menu if object permissions is missing. Default is to mark them with red" `
-DefaultValue $false -Section "General"
Add-SettingsObject -Title "Environment name" -Key "EnvironmentText" -Type "Text" `
-Description "Label shown as a badge in the toolbar (e.g. Production, Lab). Leave empty to hide." `
-Section "General"
# Guarded: System.Drawing may be unavailable in a headless PS7 session; the badge
# color picker then just offers the empty entry (the dialog never renders anyway).
$colorsList = @([PSCustomObject]@{ Name = ""; Value = "" })
if("System.Drawing.Color" -as [type]) {
foreach($color in ([System.Drawing.Color].GetProperties() | Where-Object { $_.PropertyType -eq [System.Drawing.Color] } | Sort-Object -Property Name | Select-Object Name).Name) {
$colorsList += [PSCustomObject]@{ Name = $color; Value = $color }
}
}
Add-SettingsObject -Title "Environment color" -Key "EnvironmentColor" -Type "List" -ItemsSource $colorsList `
-Description "Background color of the environment badge. Text color is auto-calculated for contrast." `
-Section "General"
Add-SettingsObject -Title "Show tenant name" -Key "MenuShowOrganizationName" -Type "Boolean" -DefaultValue $true `
-Description "Adds the organization name next to the login info on the menu bar" `
-Section "General"
# ---- Intune section (list behavior) ----
Add-SettingsObject -Title "Get all pages" -Key "GetAllPages" -Type "Boolean" `
-Description "Get all pages when getting items in the UI. Note: This can take long time in environments with lots of policies and apps." `
-DefaultValue $true -SubPath "IntuneManager" -Section "IntuneManager"
Add-SettingsObject -Title "Single-line values in object list" -Key "ObjectListFirstLineOnly" -Type "Boolean" `
-Description "Show only the first line of multi-line values (e.g. store app descriptions) in the object list, so every row is one line high. Hover a cell for the full text. Takes effect when the list is next loaded." `
-DefaultValue $true -SubPath "IntuneManager" -Section "IntuneManager"
Add-SettingsObject -Title "Max characters per cell" -Key "ObjectListMaxCellLength" -Type "Int" `
-Description "With single-line values on, cut a cell's text at this many characters so one long description cannot push the other columns out of view. Hover the cell for the full text; sorting and the filter still use the whole value. 0 = no limit." `
-DefaultValue 50 -SubPath "IntuneManager" -Section "IntuneManager"
$viewMenuTypes = @(
[PSCustomObject]@{ Name = "Group"; Value = "Group" },
[PSCustomObject]@{ Name = "Type (API)"; Value = "Type" }
)
Add-SettingsObject -Title "Menu Object Type" -Key "ObjectViewType" -Type "List" -ItemsSource $viewMenuTypes `
-Description "Specify object type for the menu. Group: Groups items together like the portal. Type - Single item based on API. Some APIs are split into multiple menu items." `
-DefaultValue "Group" -SubPath "IntuneManager" -Section "IntuneManager"
# ---- MS Graph General section (UI action gating) ----
Add-SettingsObject -Title "Refresh Objects after copy" -Key "RefreshObjectsAfterCopy" -Type "Boolean" `
-Description "Reload the object list after copying an object" -DefaultValue $true `
-SubPath "IntuneManager" -Section "GraphGeneral"
Add-SettingsObject -Title "Show Delete button" -Key "AllowDelete" -Type "Boolean" `
-Description "Allow deleting individual objectes" -DefaultValue $false `
-SubPath "IntuneManager" -Section "GraphGeneral"
Add-SettingsObject -Title "Show Bulk Delete" -Key "AllowBulkDelete" -Type "Boolean" `
-Description "Allow using bulk delete to delete all objects of selected types" -DefaultValue $true `
-SubPath "IntuneManager" -Section "GraphGeneral"
# ---- Intune Tools section ----
Add-SettingsObject -Title "Format OMA-URI Settings" -Key "FormatOMAURI" -Type "Boolean" `
-Description "Automatically clean up XML formatting in OMA-URI and ADMX registry policies for consistent output" -DefaultValue $false `
-SubPath "IntuneManager" -Section "IntuneTools"
# ---- MSAL section (picker sort order) ----
Add-SettingsObject -Title "Sort Account List" -Key "SortAccountList" -Type "Boolean" -DefaultValue $false `
-Description "Sort the list of cached accounts based on user name. Updated at restart or account change" `
-Section "MSAL"
Add-SettingsObject -Title "Sort Tenant List" -Key "SortTenantList" -Type "Boolean" -DefaultValue $false `
-Description "Sort the list of available tenants based on Tenant name. Updated at restart or account change" `
-Section "MSAL"
}
Add-AppEventHandler "AppInitialized" "Add-CommonUISettings"
+188
View File
@@ -0,0 +1,188 @@
#ImportOrder 30
#
# Abstract base class for the per-backend UI provider. Concrete subclasses
# live in UI/WPF/Classes/WPFUIProvider.ps1 and UI/Avalonia/Classes/AvaloniaUIProvider.ps1.
# The loader instantiates exactly one of them into $script:UIProvider after
# all classes are loaded but before AppInitialized fires (see
# IntuneManagement.psm1).
#
# Methods are virtual-by-throw: every method on this base raises
# "<name> not implemented by <typename>" so a missing override surfaces at
# the call site rather than silently no-op'ing. Subclasses override every
# method they support; cross-boundary callers (Internal/Public) only invoke
# methods that both backends implement.
#
# Parameter types are deliberately loose ([object]) for surfaces where the
# concrete shape differs between backends — e.g. ShowModalForm receives a
# WPF FrameworkElement under WPF and an Avalonia Control under Avalonia.
class UIProvider {
#region Modal / dialog
[void] ShowMessageBox([string]$Text) {
throw "ShowMessageBox(text) not implemented by $($this.GetType().Name)"
}
[void] ShowMessageBox([string]$Text, [string]$Caption) {
throw "ShowMessageBox not implemented by $($this.GetType().Name)"
}
[object] ShowMessageBox([string]$Text, [string]$Caption, [string]$Button, [string]$Icon) {
throw "ShowMessageBox(button,icon) not implemented by $($this.GetType().Name)"
}
[void] ShowModalForm([string]$FormTitle, [object]$FormObject) {
throw "ShowModalForm not implemented by $($this.GetType().Name)"
}
[void] ShowModalForm([string]$FormTitle, [object]$FormObject, [bool]$HideButtons) {
throw "ShowModalForm(hideButtons) not implemented by $($this.GetType().Name)"
}
[void] ShowModalObject() {
throw "ShowModalObject() not implemented by $($this.GetType().Name)"
}
[void] ShowModalObject([object]$Obj) {
throw "ShowModalObject not implemented by $($this.GetType().Name)"
}
[void] CloseTopModalObject() {
throw "CloseTopModalObject not implemented by $($this.GetType().Name)"
}
[string] ShowInputDialog([string]$FormTitle, [string]$FormText, [string]$DefaultValue) {
throw "ShowInputDialog not implemented by $($this.GetType().Name)"
}
[void] ShowAboutDialog() {
throw "ShowAboutDialog not implemented by $($this.GetType().Name)"
}
[bool] RequestUIConfirmation([string]$Message, [string]$Caption) {
throw "RequestUIConfirmation not implemented by $($this.GetType().Name)"
}
[void] ShowPopup([object]$Popup) {
throw "ShowPopup not implemented by $($this.GetType().Name)"
}
[void] HidePopup() {
throw "HidePopup not implemented by $($this.GetType().Name)"
}
#endregion
#region File / folder pickers
[string] ShowFolderPicker([string]$Description) {
throw "ShowFolderPicker not implemented by $($this.GetType().Name)"
}
[string] ShowFolderPicker([string]$Path, [string]$Description) {
throw "ShowFolderPicker(path,description) not implemented by $($this.GetType().Name)"
}
#endregion
#region XAML helpers
[object] GetXamlObject([string]$FileName) {
throw "GetXamlObject not implemented by $($this.GetType().Name)"
}
[object] GetXamlObject([string]$FileName, [bool]$AddVariables) {
throw "GetXamlObject(addVariables) not implemented by $($this.GetType().Name)"
}
[object] GetXamlObject([string]$FileName, [bool]$AddVariables, [bool]$AddStyles) {
throw "GetXamlObject(addVariables,addStyles) not implemented by $($this.GetType().Name)"
}
[void] AddXamlEvent([object]$XamlObj, [string]$ControlName, [string]$EventName, [scriptblock]$Handler) {
throw "AddXamlEvent not implemented by $($this.GetType().Name)"
}
[void] AddXamlVariables([object]$XamlObj, [object]$Scope) {
throw "AddXamlVariables not implemented by $($this.GetType().Name)"
}
[void] SetXamlProperty([object]$XamlObj, [string]$ControlName, [string]$PropertyName, [object]$Value) {
throw "SetXamlProperty not implemented by $($this.GetType().Name)"
}
[object] GetXamlProperty([object]$XamlObj, [string]$ControlName, [string]$PropertyName) {
throw "GetXamlProperty not implemented by $($this.GetType().Name)"
}
# Show or hide a named control. $Visible is the LOGICAL intent (shown/hidden);
# each backend maps it to its own framework property: WPF sets Visibility to
# "Visible"/"Collapsed", Avalonia sets the IsVisible bool. Callers must use this
# method (not the underlying property) so code stays backend-agnostic.
[void] SetControlVisible([object]$XamlObj, [string]$ControlName, [bool]$Visible) {
throw "SetControlVisible not implemented by $($this.GetType().Name)"
}
#endregion
#region Main window / chrome
[void] ShowMainWindow([object]$View) {
throw "ShowMainWindow not implemented by $($this.GetType().Name)"
}
[void] SetMainTitle() {
throw "SetMainTitle not implemented by $($this.GetType().Name)"
}
[object] GetMainWindow() {
throw "GetMainWindow not implemented by $($this.GetType().Name)"
}
[void] SetAppTheme([string]$ThemeName) {
throw "SetAppTheme not implemented by $($this.GetType().Name)"
}
#endregion
#region Threading / status
[void] UpdateUIStatus([string]$Text) {
throw "UpdateUIStatus not implemented by $($this.GetType().Name)"
}
[bool] UpdateUIStatus([System.Collections.IDictionary]$Params) {
throw "UpdateUIStatus(IDictionary) not implemented by $($this.GetType().Name)"
}
[void] InvokeUIMessagePump() {
throw "InvokeUIMessagePump not implemented by $($this.GetType().Name)"
}
# Copy text to the system clipboard. Backends implement this over their own
# framework clipboard (WPF System.Windows.Clipboard / Avalonia TopLevel.Clipboard)
# rather than PowerShell's Set-Clipboard, which is a silent no-op on a bare
# Linux box without xclip/xsel/wl-copy.
[void] SetClipboardText([string]$Text) {
throw "SetClipboardText not implemented by $($this.GetType().Name)"
}
#endregion
#region Auth chrome
[void] ShowAuthenticationInfo() {
throw "ShowAuthenticationInfo not implemented by $($this.GetType().Name)"
}
[void] SetEnvironmentInfo() {
throw "SetEnvironmentInfo() not implemented by $($this.GetType().Name)"
}
[void] SetEnvironmentInfo([string]$TenantName) {
throw "SetEnvironmentInfo not implemented by $($this.GetType().Name)"
}
#endregion
}
+139
View File
@@ -0,0 +1,139 @@
# Theme resolution shared by both UI backends.
#
# The AppTheme setting has three values:
# Default - follow the OS app theme, and keep following it
# Light - always light
# Dark - always dark
#
# "Default" is not a theme of its own: it resolves to Light or Dark at the point
# of use. Both backends call Resolve-AppTheme and then apply the concrete result
# their own way - WPF merges Themes/<name>.xaml, Avalonia sets
# Application.RequestedThemeVariant - so this file stays free of toolkit types
# (R12) and neither backend reimplements the lookup (R10).
# Platform flags, mirroring $script:IsWindowsOS in IntuneManagement.psm1.
# $IsMacOS / $IsLinux exist only on PowerShell Core; on 5.1 they read as $null,
# which is correct because 5.1 is Windows-only. Captured once at load so tests
# can override them in module scope to exercise a branch off-platform.
$script:IsMacOSPlatform = [bool]$IsMacOS
$script:IsLinuxPlatform = [bool]$IsLinux
# The two external theme readers sit behind thin wrappers on purpose: Pester
# cannot mock a native command that does not exist on the machine running the
# test, so `defaults` is unmockable on Linux/Windows CI and `gsettings` on
# Windows/macOS. Wrapping them makes both branches of Get-SystemAppTheme
# testable everywhere. They are the only lines here that touch the OS.
function Get-MacOSInterfaceStyle {
[CmdletBinding()]
param()
& defaults read -g AppleInterfaceStyle 2>$null
}
function Get-LinuxDesktopSetting {
[CmdletBinding()]
param([Parameter(Mandatory = $true)][string]$Key)
& gsettings get org.gnome.desktop.interface $Key 2>$null
}
# The OS app theme, as 'Light' or 'Dark'.
#
# Every platform falls back to Light when its theme cannot be read. Light is the
# historical default for this app, so an unreadable setting changes nothing.
function Get-SystemAppTheme {
[CmdletBinding()]
param()
if ($script:IsWindowsOS) {
# Personalize\AppsUseLightTheme is the *app* theme; SystemUsesLightTheme
# is the separate taskbar/Start setting and is deliberately not used - a
# user who runs light apps on a dark taskbar wants light apps.
try {
$key = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize'
$value = (Get-ItemProperty -Path $key -Name 'AppsUseLightTheme' -ErrorAction Stop).AppsUseLightTheme
if ($null -ne $value -and [int]$value -eq 0) { return 'Dark' }
return 'Light'
}
catch {
Write-LogDebug "Windows app theme not readable, assuming Light: $($_.Exception.Message)"
return 'Light'
}
}
if ($script:IsMacOSPlatform) {
# 'defaults read -g AppleInterfaceStyle' prints 'Dark' in dark mode. In
# light mode the key does not exist at all, so the command exits non-zero
# and prints "does not exist" to stderr. That failure IS the light answer,
# not an error - hence the catch returning Light rather than logging loudly.
# The try/catch is required: on PS 7.4+ a non-zero native exit throws when
# $ErrorActionPreference is Stop.
try {
$style = Get-MacOSInterfaceStyle
if ($style -and (($style -join '').Trim() -eq 'Dark')) { return 'Dark' }
}
catch {
Write-LogDebug "macOS AppleInterfaceStyle not set, assuming Light"
}
return 'Light'
}
if ($script:IsLinuxPlatform) {
# There is no cross-desktop standard. GNOME 42+ exposes color-scheme
# ('prefer-dark' / 'prefer-light' / 'default'); 'default' means the user
# expressed no preference, so it is Light here. Older GNOME and several
# derivatives only carry gtk-theme, whose name ends in '-dark' by
# convention (Adwaita-dark, Yaru-dark). Anything else stays Light.
# gsettings values come back single-quoted, hence the Trim.
try {
$scheme = Get-LinuxDesktopSetting 'color-scheme'
if ($scheme) {
$scheme = ($scheme -join '').Trim().Trim("'")
if ($scheme -eq 'prefer-dark') { return 'Dark' }
if ($scheme -eq 'prefer-light') { return 'Light' }
}
$gtkTheme = Get-LinuxDesktopSetting 'gtk-theme'
if ($gtkTheme) {
$gtkTheme = ($gtkTheme -join '').Trim().Trim("'")
if ($gtkTheme -match '-dark$') { return 'Dark' }
}
}
catch {
Write-LogDebug "Linux desktop theme not readable (no gsettings?), assuming Light"
}
return 'Light'
}
return 'Light'
}
# Turn an AppTheme setting value into the concrete theme to apply.
#
# Pass nothing to read the current setting. Unknown values resolve as Default
# rather than throwing, so a hand-edited settings file cannot leave the app
# unthemed.
function Resolve-AppTheme {
[CmdletBinding()]
param([string]$ThemeName)
if (-not $PSBoundParameters.ContainsKey('ThemeName') -or [string]::IsNullOrWhiteSpace($ThemeName)) {
$ThemeName = Get-SettingValue 'AppTheme'
}
switch ($ThemeName) {
'Light' { return 'Light' }
'Dark' { return 'Dark' }
default { return (Get-SystemAppTheme) }
}
}
# True when the setting says "follow the OS" - the backends use this to decide
# whether an OS theme change should be reacted to.
function Test-AppThemeFollowsSystem {
[CmdletBinding()]
param([string]$ThemeName)
if (-not $PSBoundParameters.ContainsKey('ThemeName') -or [string]::IsNullOrWhiteSpace($ThemeName)) {
$ThemeName = Get-SettingValue 'AppTheme'
}
return ($ThemeName -notin @('Light', 'Dark'))
}