<# .SYNOPSIS Headless runtime helpers for macOS Intune Management. .DESCRIPTION This module provides the non-UI runtime used by the CLI entrypoints. #> function Get-ModuleVersion { '4.0.0' } function Test-IsWindowsPlatform { [Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT } function Invoke-AppDoEvents { } function Start-CoreApp { param($View) $global:hideUI = $true $global:useDefaultFolderDialog = $false $global:WindowsAPICodePackLoaded = $false $global:loadedModules = @() $global:viewObjects = @() $global:AppRootFolder = $PSScriptRoot $global:modulesPath = Join-Path $PSScriptRoot "Extensions" Add-DefaultSettings if(-not $global:UseJSonSettings) { $global:UseJSonSettings = $true } Initialize-JsonSettings Initialize-Settings Write-Log "#####################################################################################" Write-Log "Application started" Write-Log "#####################################################################################" Write-Log "PowerShell version: $($PSVersionTable.PSVersion)" Write-Log "PowerShell edition: $($PSVersionTable.PSEdition)" Write-Log "OS version: $([Environment]::OSVersion.VersionString)" if(-not (Test-Path $global:modulesPath)) { Write-Log "Extensions folder $($global:modulesPath) not found. Aborting..." 3 return } Import-AllModules $global:currentViewObject = $null $global:FirstTimeRunning = $false $global:MainAppStarted = $true Invoke-ModuleFunction "Invoke-InitializeModule" if($View) { $global:currentViewObject = $global:viewObjects | Where-Object { $_.ViewInfo.Id -eq $View } | Select-Object -First 1 } if(-not $global:currentViewObject) { $global:currentViewObject = $global:viewObjects | Select-Object -First 1 } if(-not $global:SilentBatchFile) { Write-Log "SilentBatchFile must be specified" 3 return } $silentFile = [IO.FileInfo]$global:SilentBatchFile if(-not $silentFile.Exists) { Write-Log "SilentBatchFile $($global:SilentBatchFile) not found" 3 return } Invoke-ModuleFunction "Invoke-InitSilentBatchJob" Start-RunSilentBatchJob } function Start-RunSilentBatchJob { try { $settingsObj = ConvertFrom-Json (Get-Content -Path $global:SilentBatchFile -Raw -ErrorAction Stop) Invoke-ModuleFunction "Invoke-SilentBatchJob" $settingsObj } catch { Write-LogError "Failed to trigger silent batch job." $_.Exception } } function Import-AllModules { foreach($file in (Get-Item -Path (Join-Path $global:modulesPath "*.psm1"))) { $module = Import-Module $file.FullName -PassThru -Force -Global -ErrorAction Stop $global:loadedModules += $module Write-Host "Module $($module.Name) loaded successfully" } } function Write-Log { param($Text, $Type = 1) if($script:logFailed -eq $true) { return } if(-not $global:logFile) { $defaultLogFile = Join-Path (Get-CloudApiDataFolder) "IntuneManagement.log" $global:logFile = Get-SettingValue "LogFile" $defaultLogFile } if(-not $global:logFileMaxSize) { [Int64]$global:logFileMaxSize = Get-SettingValue "LogFileSize" 1024 $global:logFileMaxSize *= 1kb } if($null -eq $global:logOutputError) { $global:logOutputError = Get-SettingValue "LogOutputError" $true } try { $logPath = [IO.Path]::GetDirectoryName($global:logFile) if($logPath -and -not (Test-Path $logPath)) { New-Item -ItemType Directory -Path $logPath -Force -ErrorAction Stop | Out-Null } } catch { $script:logFailed = $true return } $date = Get-Date $component = [IO.Path]::GetFileNameWithoutExtension($PSCommandPath) if(-not $component) { $component = "IntuneManagement" } $timeStr = "{0:HH}:{0:mm}:{0:ss}.000+000" -f $date $dateStr = "{0:MM}-{0:dd}-{0:yyyy}" -f $date $logOut = "" switch($Type) { 2 { Write-Warning $Text; $typeText = "Warning" } 3 { if($global:logOutputError -ne $false) { $host.UI.WriteErrorLine($Text) } else { Write-Warning $Text } $typeText = "Error" } default { Write-Host $Text; $typeText = "Info" } } if(-not $script:LogItems) { $script:LogItems = [System.Collections.ObjectModel.ObservableCollection[object]]::new() } $script:LogItems.Add([PSCustomObject]@{ ID = ($script:LogItems.Count + 1) DateTime = $date Type = $Type TypeText = $typeText Text = $Text }) try { $logFileInfo = [IO.FileInfo]$global:logFile if($logFileInfo.Exists -and $logFileInfo.Length -gt $global:logFileMaxSize) { $bakFile = Join-Path $logFileInfo.DirectoryName "$($logFileInfo.BaseName).lo_" if(Test-Path $bakFile) { Remove-Item -LiteralPath $bakFile -Force -ErrorAction SilentlyContinue } $logFileInfo.MoveTo($bakFile) } Out-File -FilePath $global:logFile -Append -Encoding ascii -InputObject $logOut } catch { } } function Write-LogDebug { param($Text, $Type = 1) if($global:Debug) { Write-Log ("Debug: " + $Text) $Type } } function Write-LogError { param($Text, $Exception) if($Text -and $Exception.Message) { $Text += " Exception: $($Exception.Message)" } Write-Log $Text 3 if((Get-SettingValue "ShowStackTrace" $false) -eq $true -and $Exception) { Write-Log "Stack trace:`n$($Exception.StackTrace)" Write-Log "Script stack trace:`n$($Exception.ScriptStackTrace)" } } function Write-Status { param($Text, [switch]$SkipLog, [switch]$Block, [switch]$Force) if(-not $Text) { $global:BlockStatusUpdates = $false return } if($global:BlockStatusUpdates -and $Force -ne $true) { return } if($Block) { $global:BlockStatusUpdates = $true } if($SkipLog -ne $true) { Write-Log $Text } } function Set-XamlProperty { param($Parent, $ControlName, $PropertyName, $Value) if(-not $Parent) { return } try { $control = $Parent if($ControlName) { $control = $Parent.FindName($ControlName) } if(-not $control) { return } if($control.PSObject.Properties.Name -contains $PropertyName) { $control.$PropertyName = $Value } else { $control | Add-Member -NotePropertyName $PropertyName -NotePropertyValue $Value -Force } } catch { Write-LogError "Failed to set property value. Control: $ControlName. Property: $PropertyName." $_.Exception } } function Get-XamlProperty { param($Parent, $ControlName, $PropertyName, $DefaultValue) if(-not $Parent) { return $DefaultValue } try { $control = $Parent if($ControlName) { $control = $Parent.FindName($ControlName) } if(-not $control) { return $DefaultValue } if($control.PSObject.Properties.Name -contains $PropertyName) { return (?? $control.$PropertyName $DefaultValue) } } catch { Write-LogError "Failed to get property value. Control: $ControlName. Property: $PropertyName." $_.Exception } $DefaultValue } function Set-BatchProperties { param($SettingsObj, $Form, [switch]$SkipMissingControlWarning) if(-not $SettingsObj -or -not $Form) { return } foreach($prop in $SettingsObj) { if($prop.Type -eq "Custom") { continue } $obj = $Form.FindName($prop.Name) if(-not $obj) { if($SkipMissingControlWarning -ne $true) { Write-Log "No setting for $($prop.Name) found" 2 } continue } if($prop.Value -is [string] -and [string]::IsNullOrEmpty($prop.Value)) { continue } try { if($obj.PSObject.Properties.Name -contains "IsChecked") { $obj.IsChecked = $prop.Value -eq $true } elseif($obj.PSObject.Properties.Name -contains "Text") { $obj.Text = $prop.Value } elseif($obj.PSObject.Properties.Name -contains "SelectedValue") { $obj.SelectedValue = $prop.Value } elseif($obj.PSObject.Properties.Name -contains "ItemsSource") { $obj.ItemsSource = $prop.Value } else { Write-Log "Unsupported object type for silent batch job: $($obj.GetType().FullName)" 3 } } catch { Write-LogError "Failed to set batch job property for $($prop.Name)" $_.Exception } } } function New-HeadlessControl { param( [string]$Name, [ValidateSet("TextBox","CheckBox","ComboBox","Label","DataGrid")] [string]$Type = "TextBox" ) [PSCustomObject]@{ Name = $Name Type = $Type Focusable = ($Type -ne "DataGrid") Visibility = "Visible" IsEnabled = $true Text = "" IsChecked = $false SelectedValue = $null SelectedIndex = -1 SelectedItem = $null SelectedItems = @() ItemsSource = @() Items = @() Columns = @() Content = "" Parent = $null DataContext = $null } } function New-HeadlessForm { param($Controls) $form = [PSCustomObject]@{ Controls = @{} } foreach($control in $Controls) { $control.Parent = $form $form.Controls[$control.Name] = $control } $form | Add-Member -MemberType ScriptMethod -Name FindName -Value { param($Name) $this.Controls[$Name] } $form | Add-Member -MemberType ScriptMethod -Name RegisterName -Value { param($Name, $Control) $Control.Parent = $this $this.Controls[$Name] = $Control } $form } function Initialize-Window { param($XamlFile) throw "UI support has been removed from this project." } function Show-ModalForm { param($FormTitle, $Form, [switch]$HideButtons) } function Show-ModalObject { } function Get-Folder { param($CurrentFolder, $Description) if($CurrentFolder -and (Test-Path $CurrentFolder)) { return $CurrentFolder } throw "Folder prompts are not supported in the headless runtime." } function Get-CloudApiDataFolder { if(Test-IsWindowsPlatform) { if($env:LOCALAPPDATA) { return (Join-Path $env:LOCALAPPDATA "macOS_IntuneManagement") } return (Join-Path $env:USERPROFILE "AppData/Local/macOS_IntuneManagement") } if($IsMacOS) { return (Join-Path $HOME "Library/Application Support/macOS_IntuneManagement") } return (Join-Path $HOME ".local/share/macOS_IntuneManagement") } function Initialize-Settings { param([switch]$Updated) $global:Debug = Get-SettingValue "Debug" $false $global:logFile = $null $global:logFileMaxSize = $null $global:logOutputError = $null $script:proxyURI = $null if($Updated -eq $true) { Invoke-ModuleFunction "Invoke-SettingsUpdated" } } function Initialize-JsonSettings { if(-not $global:JSonSettingFile) { $global:JSonSettingFile = Join-Path (Get-CloudApiDataFolder) "Settings.json" } try { $settingsDir = Split-Path -Parent $global:JSonSettingFile if($settingsDir -and -not (Test-Path $settingsDir)) { New-Item -ItemType Directory -Path $settingsDir -Force -ErrorAction Stop | Out-Null } if(-not (Test-Path $global:JSonSettingFile)) { @{} | ConvertTo-Json | Out-File -LiteralPath $global:JSonSettingFile -Force -Encoding utf8 } $raw = Get-Content -Path $global:JSonSettingFile -Raw -ErrorAction Stop if([string]::IsNullOrWhiteSpace($raw)) { $raw = "{}" } $global:JsonSettingsObj = ConvertFrom-Json $raw -AsHashtable Write-Host "Use json settings file: $($global:JSonSettingFile)" } catch { Clear-JsonSettingsValues Write-LogError "Failed to read json setting file $($global:JSonSettingFile)." $_.Exception } } function Clear-JsonSettingsValues { $global:JsonSettingsObj = @{} $global:JSonSettingFile = $null $global:UseJSonSettings = $false } function Get-JsonSettingsNode { param( [string]$SubPath, [switch]$Create ) if($null -eq $global:JsonSettingsObj) { $global:JsonSettingsObj = @{} } $node = $global:JsonSettingsObj $parts = @() if($SubPath) { $parts = $SubPath.TrimEnd(@('/','\')).Split(@('/','\'), [System.StringSplitOptions]::RemoveEmptyEntries) } foreach($part in $parts) { if(-not $node.ContainsKey($part)) { if(-not $Create) { return $null } $node[$part] = @{} } elseif($node[$part] -isnot [System.Collections.IDictionary]) { if(-not $Create) { return $null } $node[$part] = @{} } $node = $node[$part] } $node } function Save-Setting { param($SubPath = "", $Key = "", $Value, $Type = "String") if(-not $Key) { return } try { $parent = Get-JsonSettingsNode -SubPath $SubPath -Create if($null -eq $Value) { $parent.Remove($Key) | Out-Null } else { switch($Type) { "String" { $Value = $Value.ToString() } "DWord" { $Value = [int]$Value } } $parent[$Key] = $Value } $global:JsonSettingsObj | ConvertTo-Json -Depth 30 | Out-File -LiteralPath $global:JSonSettingFile -Force -Encoding utf8 } catch { Write-LogError "Failed to save json setting value $Key" $_.Exception } } function Get-Setting { param($SubPath = "", $Key = "", $DefaultValue) if(-not $Key) { return } try { $parent = Get-JsonSettingsNode -SubPath $SubPath if($parent -and $parent.ContainsKey($Key)) { $value = $parent[$Key] if($null -ne $value) { return $value } } } catch { Write-LogError "Failed to read json setting value $Key" $_.Exception } $DefaultValue } function Add-DefaultSettings { $global:appSettingSections = @() $global:appSettingSections += [PSCustomObject]@{ Title = "General" Id = "General" Values = @() } Add-SettingsObject ([PSCustomObject]@{ Title = "Log file" Key = "LogFile" Type = "File" DefaultValue = (Join-Path (Get-CloudApiDataFolder) "IntuneManagement.log") }) "General" Add-SettingsObject ([PSCustomObject]@{ Title = "Max log file size" Key = "LogFileSize" Type = "Int" DefaultValue = 1024 }) "General" Add-SettingsObject ([PSCustomObject]@{ Title = "Add errors to PowerShell output" Key = "LogOutputError" Type = "Boolean" DefaultValue = $true }) "General" Add-SettingsObject ([PSCustomObject]@{ Title = "Show stack trace" Key = "ShowStackTrace" Type = "Boolean" DefaultValue = $false }) "General" Add-SettingsObject ([PSCustomObject]@{ Title = "Debug" Key = "Debug" Type = "Boolean" DefaultValue = $false }) "General" Add-SettingsObject ([PSCustomObject]@{ Title = "Proxy URI" Key = "ProxyURI" Type = "String" DefaultValue = "" }) "General" } function Add-SettingsObject { param($Obj, $Section) $targetSection = $global:appSettingSections | Where-Object Id -eq $Section if(-not $targetSection) { Write-Log "Could not find section $Section" 3 return } if(-not ($Obj.PSObject.Properties.Name -contains "SubPath")) { $Obj | Add-Member -NotePropertyName "SubPath" -NotePropertyValue "" } $targetSection.Values += $Obj } function Get-SettingValue { param($Key, $DefaultValue, [switch]$GlobalOnly, [switch]$TenantOnly, $TenantID) $settingObj = $null foreach($section in $global:appSettingSections) { $settingObj = $section.Values | Where-Object Key -eq $Key | Select-Object -First 1 if($settingObj) { break } } if($null -eq $DefaultValue -and $settingObj) { $DefaultValue = $settingObj.DefaultValue } if(-not $TenantID -and $global:Organization) { $TenantID = $global:Organization.Id } $value = $null if($settingObj) { if($GlobalOnly -ne $true -and $TenantID) { $tenantPath = if($settingObj.SubPath) { Join-Path $TenantID $settingObj.SubPath } else { $TenantID } $value = Get-Setting $tenantPath $settingObj.Key } if($null -eq $value -and $TenantOnly -ne $true) { $value = Get-Setting $settingObj.SubPath $settingObj.Key $DefaultValue } } else { $value = Get-Setting "" $Key $DefaultValue } if($settingObj) { switch($settingObj.Type) { "Boolean" { $value = ($value -eq $true -or $value -eq "true") } "Int" { try { $value = [int]$value } catch { $value = $DefaultValue } } } if($settingObj.PSObject.Properties.Name -contains "Value") { $settingObj.Value = $value } else { $settingObj | Add-Member -NotePropertyName "Value" -NotePropertyValue $value } } $value } function Add-ViewObject { param($ViewObject) $global:viewObjects += [PSCustomObject]@{ ViewInfo = $ViewObject ViewItems = @() } } function Add-ViewItem { param($ViewItem) $viewObject = $global:viewObjects | Where-Object { $_.ViewInfo.Id -eq $ViewItem.ViewID } | Select-Object -First 1 if(-not $viewObject) { Write-Log "Could not find menu with id $($ViewItem.ViewID). Item $($ViewItem.Title) not added" 2 return } if(-not ($ViewItem.PSObject.Properties.Name -contains "ImportOrder")) { $ViewItem | Add-Member -NotePropertyName "ImportOrder" -NotePropertyValue 1000 } foreach($scope in @($ViewItem.Permissons)) { if($scope -and $viewObject.ViewInfo.Permissions -notcontains $scope) { $viewObject.ViewInfo.Permissions += $scope } } $viewObject.ViewItems += $ViewItem } function Invoke-ModuleFunction { param($Function, $Arguments = $null) Write-Log "Trigger function $Function" $params = @{} if($null -ne $Arguments) { $params.ArgumentList = $Arguments } foreach($module in $global:loadedModules) { $cmd = $module.ExportedFunctions[$Function] if($cmd) { Write-Log "Trigger $Function in $($module.Name)" Invoke-Command -ScriptBlock $cmd.ScriptBlock @params } } } function Invoke-Coalesce ($Value, $Default) { if($null -eq $Value) { return $Default } if($Value -is [string] -and [string]::IsNullOrEmpty($Value)) { return $Default } $Value } function Invoke-IfTrue ($Expression, $ValueIfTrue, $ValueIfFalse) { if($Expression) { return $ValueIfTrue } $ValueIfFalse } function Get-JWTtoken { param($Token) if(-not $Token) { return } if(-not $Token.StartsWith("eyJ")) { Write-Log "Invalid JWT token" 3 return } $parts = $Token.Split(".") if($parts.Count -lt 2) { Write-Log "Invalid token" 3 return } $header = $parts[0].Replace('-', '+').Replace('_', '/') while($header.Length % 4) { $header += "=" } $payload = $parts[1].Replace('-', '+').Replace('_', '/') while($payload.Length % 4) { $payload += "=" } [PSCustomObject]@{ Header = ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($header)) | ConvertFrom-Json) Payload = ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($payload)) | ConvertFrom-Json) } } function Get-ProxyURI { if($null -eq $script:proxyURI) { $script:proxyURI = Get-SettingValue "ProxyURI" } if($null -eq $script:proxyURI) { $script:proxyURI = "" } $script:proxyURI } function Start-DownloadFile { param($SourceURL, $TargetFile) Write-Log "Download file from $SourceURL" if(-not $SourceURL -or -not $TargetFile) { return } $proxyURI = Get-ProxyURI $webClient = [System.Net.WebClient]::new() $webClient.Encoding = [System.Text.Encoding]::UTF8 if($proxyURI) { $webClient.Proxy = [System.Net.WebProxy]::new($proxyURI) } try { Write-Status "Download file: $SourceURL" $webClient.DownloadFile($SourceURL, $TargetFile) Write-Log "File downloaded to $TargetFile" } catch { Write-LogError "Failed to download file" $_.Exception } finally { $webClient.Dispose() } } function Get-ASCIIBytes { param($String) $bytes = [System.Text.Encoding]::ASCII.GetBytes($String) if($bytes[0] -eq 0x2b -and $bytes[1] -eq 0x2f -and $bytes[2] -eq 0x76) { return [Text.Encoding]::UTF7.GetBytes($String) } elseif($bytes[0] -eq 0xff -and $bytes[1] -eq 0xfe) { return [Text.Encoding]::Unicode.GetBytes($String) } elseif($bytes[0] -eq 0xfe -and $bytes[1] -eq 0xff) { return [Text.Encoding]::BigEndianUnicode.GetBytes($String) } elseif($bytes[0] -eq 0x00 -and $bytes[1] -eq 0x00 -and $bytes[2] -eq 0xfe -and $bytes[3] -eq 0xff) { return [Text.Encoding]::UTF32.GetBytes($String) } elseif($bytes[0] -eq 0xef -and $bytes[1] -eq 0xbb -and $bytes[2] -eq 0xbf) { return [Text.Encoding]::UTF8.GetBytes($String) } $bytes } function Get-GUIDs { param($Text) $regExpGuid = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" $uniqueGuids = [System.Collections.Generic.HashSet[string]]::new() [regex]::Matches($Text, $regExpGuid) | ForEach-Object { $uniqueGuids.Add($_.Value) | Out-Null } $uniqueGuids } New-Alias -Name ?? -Value Invoke-Coalesce New-Alias -Name ?: -Value Invoke-IfTrue Export-ModuleMember -Alias * -Function *