#Set-StrictMode -Version Latest $script:AppRootFolder = $PSScriptRoot $script:SessionID = [Guid]::NewGuid() $PSModuleAutoloadingPreference = "none" # Unblock-File clears the NTFS mark-of-the-web (Zone.Identifier) stream, which # only exists on Windows. On Linux/macOS the cmdlet has nothing to do and errors. # $IsWindows is defined only on PowerShell Core; Windows PowerShell 5.1 is always # Windows (Desktop edition), so treat that as Windows too. $script:IsWindowsOS = $IsWindows -or $PSVersionTable.PSEdition -eq 'Desktop' # Windows PowerShell 5.1 does not load System.Net.Http into the runspace by # default, but class files (AuthenticationMgGraph) reference its types at class # compile time. PS7 has it loaded already; the call is a cheap no-op there. try { Add-Type -AssemblyName System.Net.Http -ErrorAction Stop } catch { } $dependencyFiles = @() $ImportOrderStr = "#ImportOrder " $useDotSoure = $true $loadedFiles = @() $preloadFiles = @() # WPF is the default backend on Windows. Off Windows it can't load, so default to # the headless 'None' backend unless the caller explicitly opts into a UI (e.g. # the cross-platform Avalonia backend) via IM_UI_BACKEND. $script:UIBackend = if ($env:IM_UI_BACKEND) { $env:IM_UI_BACKEND } elseif ($script:IsWindowsOS) { 'WPF' } else { 'None' } # 'None' runs the module headless (CLI / automation): no UI files are scanned or # loaded and no UI provider is instantiated. Useful on Linux/macOS where the WPF # backend isn't available. $script:AppUIRootFolder = if ($script:UIBackend -ieq 'None') { $null } elseif ($script:UIBackend -ieq 'Avalonia') { "$script:AppRootFolder/UI/Avalonia" } else { "$script:AppRootFolder/UI/WPF" } $script:AppUIFolderExists = $script:AppUIRootFolder -and [IO.Directory]::Exists($script:AppUIRootFolder) $preloadFiles += "$script:AppRootFolder/Internal/Core.ps1" if ($script:AppUIFolderExists) { # Glob so provider-suffixed names match (CoreUIWPF.ps1 / CoreUIAvalonia.ps1) # alongside the legacy CoreUI.ps1 during the rename window. $preloadFiles += @(Get-ChildItem -Path "$script:AppUIRootFolder/Extensions" ` -Filter 'CoreUI*.ps1' -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName }) } foreach ($preloadFile in $preloadFiles) { $file = [IO.FileInfo]$preloadFile if ($file.Exists) { Write-Host "Import file $($file.Name)" if ($script:IsWindowsOS) { Unblock-File -Path $file.FullName } if ($useDotSoure) { . $file.FullName } else { $ExecutionContext.InvokeCommand.InvokeScript($false, ([scriptblock]::Create([IO.File]::ReadAllText($File.FullName))), $null, $null) } $loadedFiles += $File.FullName } else { if ($file.DirectoryName.StartsWith($Script:AppUIRootFolder, "CurrentCultureIgnoreCase")) { Write-Warning "UI file $($file.Name) not found. Ignoring" } else { # A missing core preload (Core.ps1) is fatal: everything after this # loop calls functions defined there. Throw so module import fails # with one clear error instead of a cascade of "function not found". throw "Preload file '$($file.FullName)' does not exist" } } } # Everything from here on (Write-Log, Set-CacheObject, Get-CacheObject, ...) is # defined in Internal/Core.ps1, which the preload loop above is responsible for # loading first. Make that dependency explicit: if the preload didn't deliver # the primitives (Core renamed/reordered out of the preload set, a function # removed), fail with one clear error instead of a cascade of "command not # found" through the rest of module init. foreach ($coreFn in @('Write-Log', 'Set-CacheObject', 'Get-CacheObject', 'Get-SettingValue')) { if (-not (Get-Command $coreFn -ErrorAction SilentlyContinue)) { throw "Core preload did not define '$coreFn'. Internal/Core.ps1 must be preloaded (and define its primitives) before module initialization continues." } } Write-Log "#####################################################################################" Write-Log "Application started" Write-Log "#####################################################################################" Write-Log "PowerShell version: $($PSVersionTable.PSVersion.ToString())" if($PSVersionTable.BuildVersion) { Write-Log "PowerShell build: $($PSVersionTable.BuildVersion.ToString())" } if($PSVersionTable.CLRVersion) { Write-Log "PowerShell CLR: $($PSVersionTable.CLRVersion.ToString())" } Write-Log "PowerShell edition: $($PSVersionTable.PSEdition)" # The detailed OS name/build live in the Windows registry; other platforms fall # through to the OSVersion string. if ($script:IsWindowsOS) { try { $osName = Get-ItemPropertyValue "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion" -Name "ProductName" -ErrorAction Stop $patchLevel = Get-ItemPropertyValue "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion" -Name "UBR" -ErrorAction Stop $ver = [Version]::new([Environment]::OSVersion.Version.Major, [Environment]::OSVersion.Version.Minor, [Environment]::OSVersion.Version.Build, $patchLevel) Write-Log "OS: $osName $ver" } catch { Write-Log "OS version: $([environment]::OSVersion.VersionString)" } } else { Write-Log "OS version: $([environment]::OSVersion.VersionString)" } $script:WPFNS = "xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation' xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'" Set-CacheObject "ScriptRoot" $script:AppRootFolder -Persistent # Registry | Json | Memory. Resolved at the top of Internal/Core.ps1 (it has to be # known before the first Write-Log); this only publishes it to the cache for # callers that report the active backend. Set-CacheObject "SettingType" $script:SettingsStoreMode -Persistent Set-CacheObject "MainAppStarted" $false Set-CacheObject "MainUIStarted" $false Set-CacheObject "ShowUI" $false -Persistent $allClasses = @() $allClasses += (Get-ChildItem -path "$script:AppRootFolder/Classes" -Filter "*.ps1") # Shared UI base classes (e.g. UIProvider) live under UI\Classes and are # loaded ahead of the per-provider subclasses so #ImportOrder can place the # base before its subclass. $allClasses += (Get-ChildItem -path "$script:AppRootFolder/UI/Classes" -Filter "*.ps1" -ErrorAction SilentlyContinue) if ($script:AppUIFolderExists) { $allClasses += (Get-ChildItem -path "$script:AppUIRootFolder/Classes" -Filter "*.ps1" -ErrorAction SilentlyContinue) } foreach ($file in $allClasses) { $fileContent = Get-Content -LiteralPath $file.FullName -Encoding UTF8 $tmpPriority = ($fileContent -like "$($ImportOrderStr)*") $ImportOrder = 0 if ($tmpPriority) { try { $ImportOrder = [int]$tmpPriority.Substring($ImportOrderStr.Length).Trim() } catch { $ImportOrder = 0 } } Remove-Variable "tmpPriority" if ($ImportOrder -eq 0) { if ($fileContent -like "Class * :*Attribute*") { $ImportOrder = 10 } elseif ($fileContent -like "class *" -and $fileContent -notlike "class * : *") { $ImportOrder = 20 } elseif ($fileContent -like "class *") { $ImportOrder = 50 } else { $ImportOrder = 100 } } $dependencyFiles += [PSCustomObject]@{ FileInfo = $file Order = $ImportOrder } } # Primary sort: #ImportOrder (explicit marker, else the derived default - # 10 attribute class / 20 base class / 50 derived class / 100 non-class). # Secondary sort: FullName, so files sharing the SAME Order load in a stable, # deterministic order instead of relying on filesystem enumeration order. foreach ($dependencyFile in ($dependencyFiles | Sort-Object -Property Order, @{ Expression = { $_.FileInfo.FullName } })) { Write-Host "Import dependency $($dependencyFile.FileInfo.Name) based on order $($dependencyFile.Order)" if ($script:IsWindowsOS) { Unblock-File -Path $dependencyFile.FileInfo.Fullname } . $dependencyFile.FileInfo.Fullname $loadedFiles += $dependencyFile.FileInfo.Fullname } $allPSFiles = @() $allPSFiles += Get-ChildItem -path "$script:AppRootFolder/Internal" -Filter "*.ps1" -ErrorAction SilentlyContinue if ($script:AppUIFolderExists) { # Extensions BEFORE ClassExtensions: ClassExtensions captures helper # functions (Add-AvaloniaExportPropertyCheckbox, Add-AvaloniaDetailsButton, ...) # from Extensions/IntuneManagerExtensionHooks.ps1 via `${function:X}` at # module-load time. If Extensions loads second, the captures get $null # and ScriptMethod invocations fail with "expression after & not valid" # (Avalonia Export form per-policy-type hooks fail this way). $allPSFiles += Get-ChildItem -path "$script:AppUIRootFolder/Extensions" -Filter "*.ps1" -ErrorAction SilentlyContinue $allPSFiles += Get-ChildItem -path "$script:AppUIRootFolder/ClassExtensions" -Filter "*.ps1" -ErrorAction SilentlyContinue } $allPSFiles += Get-ChildItem -path "$script:AppRootFolder/Public" -Filter "*.ps1" -ErrorAction SilentlyContinue foreach ($file in $allPSFiles) { if ($loadedFiles -contains $file.FullName) { Write-Verbose "File $($file.Name) already loaded"; continue; } Write-Host "Import file $($file.Name)" if ($script:IsWindowsOS) { Unblock-File -Path $file.FullName } if ($useDotSoure) { . $file.FullName } else { $ExecutionContext.InvokeCommand.InvokeScript($false, ([scriptblock]::Create([IO.File]::ReadAllText($File.FileName))), $null, $null) } $loadedFiles += $File.FullName } # GetFolderPath(LocalApplicationData) is cross-platform: %LOCALAPPDATA% on Windows, # $XDG_DATA_HOME / ~/.local/share on Linux/macOS. The %LOCALAPPDATA% env var only # exists on Windows, so ExpandEnvironmentVariables left it literal elsewhere. $script:AppDataFolder = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) "IntuneManagement" if ([IO.Directory]::Exists($script:AppDataFolder) -eq $false) { [IO.Directory]::CreateDirectory($script:AppDataFolder) | Out-Null } Remove-Variable "loadedFiles" Remove-Variable "useDotSoure" # Concrete UI provider for the active backend. Subclasses are loaded by the # Classes scan above (UI\Classes\UIProvider.ps1 + provider folder's # *UIProvider.ps1). $script:UIProvider stays $null when the UI folder isn't # present (headless / CLI use) so non-UI callers can guard with `if ($script:UIProvider)`. $script:UIProvider = $null if ($script:AppUIFolderExists) { try { if ($script:UIBackend -ieq 'Avalonia') { $script:UIProvider = [AvaloniaUIProvider]::new() } else { $script:UIProvider = [WPFUIProvider]::new() } } catch { Write-LogError "Failed to instantiate UI provider for backend '$script:UIBackend'" $_.Exception } } # Module-scope alias of the provider. # # Every Show-* function opens with `$ui = $script:UIProvider` and then uses # `$ui.X()` - including inside event handlers. On the Avalonia backend those # handlers are re-bound to the module by ConvertTo-AvaloniaEventScriptBlock, # which DROPS function-local captures, so `$ui` was $null at click time and # the handler threw before doing anything (this silently killed Save buttons, # Browse buttons, Close/Escape and whole dialogs). # # An unqualified `$ui` inside a module-bound scriptblock falls back to module # scope, so defining it here makes that entrenched idiom safe everywhere. It # is the SAME object as $script:UIProvider - never assign anything else to a # local named $ui. $script:ui = $script:UIProvider # Load the settings backend before the AppInitialized event so event handlers # read persisted values. On non-Windows this also ensures the JSON settings store # is active before any Get-SettingStoreValue call, so the (Windows-only) registry branch is # never reached. Registry mode is initialized lazily inside Start-CoreApp, and # Memory mode needs no initialization - Core.ps1 created its settings object at # dot-source time. if ($script:SettingsStoreMode -eq "Json") { Initialize-JsonSettings } Invoke-AppEvent "AppInitialized" Start-CoreApp Invoke-AppEvent "AppStarted"