Core enhancements: - Expanded default export/import scope to ~45 object types including DeviceManagementIntents - Added -AllPages pagination support across Graph queries for large tenants - Invoke-GraphRequest now throws on 4xx/5xx instead of silently returning null - Added macOS Keychain fallback for secret retrieval in headless auth flow - Added NameSearchPattern/NameReplacePattern mutation support through export/import forms New toolkit scripts: - Bulk-AppAssignment.ps1: bulk-assign apps to groups/All Users/All Devices - Bulk-AssignmentManager.ps1: add/remove assignments for any policy type with correct @odata.type - Backup-Restore-Assignments.ps1: JSON backup with cross-tenant group resolution - Export-AssignmentsToCsv.ps1: CSV/Markdown documentation output - Bulk-RenamePolicies.ps1: regex search/replace and prefix mutations - Bulk-DeviceOperations.ps1: delete/retire/wipe/lock/sync with -WhatIf safeguards - Start-IntuneManagementTui.ps1: interactive terminal UI for headless operations - Create-IntuneManagementApp.ps1: helper for app registration setup Updated existing scripts: - Export-Policies.ps1 / Import-Policies.ps1: wired mutation params through - Start-HeadlessIntune.ps1: integrated TUI and new parameter forwarding
1051 lines
25 KiB
PowerShell
1051 lines
25 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Headless runtime helpers for macOS Intune Management.
|
|
|
|
.DESCRIPTION
|
|
This module provides the non-UI runtime used by the CLI entrypoints.
|
|
#>
|
|
|
|
# Microsoft.Graph.Authentication registers an alias Invoke-GraphRequest -> Invoke-MgGraphRequest.
|
|
# Remove it so our local function in MSGraph.psm1 is used instead.
|
|
if (Get-Alias Invoke-GraphRequest -ErrorAction SilentlyContinue)
|
|
{
|
|
Remove-Item Alias:\Invoke-GraphRequest -Force -ErrorAction SilentlyContinue
|
|
}
|
|
|
|
function Get-ModuleVersion
|
|
{
|
|
'4.0.0'
|
|
}
|
|
|
|
function Test-IsWindowsPlatform
|
|
{
|
|
[Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT
|
|
}
|
|
|
|
function Invoke-AppDoEvents
|
|
{
|
|
}
|
|
|
|
function Expand-FileName
|
|
{
|
|
param([string]$Path)
|
|
if(-not $Path) { return $Path }
|
|
$expanded = [Environment]::ExpandEnvironmentVariables($Path)
|
|
if($expanded -like "~/*" -or $expanded -eq "~")
|
|
{
|
|
$expanded = $expanded -replace "^~", $HOME
|
|
}
|
|
return $expanded
|
|
}
|
|
|
|
function Remove-InvalidFileNameChars
|
|
{
|
|
param([string]$Name)
|
|
if([string]::IsNullOrEmpty($Name)) { return $Name }
|
|
$invalid = [IO.Path]::GetInvalidFileNameChars()
|
|
foreach($char in $invalid)
|
|
{
|
|
$Name = $Name.Replace($char, '_')
|
|
}
|
|
# Also replace path separator if present (relevant on Unix)
|
|
$Name = $Name.Replace('/', '_')
|
|
$Name
|
|
}
|
|
|
|
function Remove-Property
|
|
{
|
|
param($Object, [string]$PropertyName)
|
|
if(-not $Object -or [string]::IsNullOrEmpty($PropertyName)) { return }
|
|
if($Object.PSObject.Properties[$PropertyName])
|
|
{
|
|
$Object.PSObject.Properties.Remove($PropertyName)
|
|
}
|
|
}
|
|
|
|
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 = "<![LOG[$Text]LOG]!><time=""$timeStr"" date=""$dateStr"" component=""$component"" context="""" type=""$Type"" thread=""$PID"" file=""$component"">"
|
|
|
|
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"
|
|
)
|
|
|
|
$control = [PSCustomObject]@{
|
|
Name = $Name
|
|
Type = $Type
|
|
Focusable = ($Type -ne "DataGrid")
|
|
Visibility = "Visible"
|
|
IsEnabled = $true
|
|
Parent = $null
|
|
DataContext = $null
|
|
}
|
|
|
|
switch($Type)
|
|
{
|
|
"TextBox" {
|
|
$control | Add-Member -NotePropertyName Text -NotePropertyValue ""
|
|
}
|
|
"CheckBox" {
|
|
$control | Add-Member -NotePropertyName IsChecked -NotePropertyValue $false
|
|
}
|
|
"ComboBox" {
|
|
$control | Add-Member -NotePropertyName SelectedValue -NotePropertyValue $null
|
|
$control | Add-Member -NotePropertyName SelectedIndex -NotePropertyValue -1
|
|
$control | Add-Member -NotePropertyName SelectedItem -NotePropertyValue $null
|
|
$control | Add-Member -NotePropertyName SelectedItems -NotePropertyValue @()
|
|
$control | Add-Member -NotePropertyName ItemsSource -NotePropertyValue @()
|
|
$control | Add-Member -NotePropertyName Items -NotePropertyValue @()
|
|
}
|
|
"Label" {
|
|
$control | Add-Member -NotePropertyName Content -NotePropertyValue ""
|
|
}
|
|
"DataGrid" {
|
|
$control | Add-Member -NotePropertyName ItemsSource -NotePropertyValue @()
|
|
$control | Add-Member -NotePropertyName Columns -NotePropertyValue @()
|
|
$control | Add-Member -NotePropertyName SelectedItems -NotePropertyValue @()
|
|
$control | Add-Member -NotePropertyName SelectedItem -NotePropertyValue $null
|
|
}
|
|
}
|
|
|
|
$control
|
|
}
|
|
|
|
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 *
|