Public/Builder.ps1
|
# Builder functions: used by Show-AutopilotBrandingBuilder (UI) and New-AutopilotBrandingPackage (headless) to # - discover the configuration tasks (cmdlets) and their parameters from the module's Tasks folder # - load, complete, validate and save JSON configuration files # - convert a legacy (v3) Config.xml file into a JSON configuration # - generate an output folder containing the module, the assets, and a runner script (AutopilotBranding.ps1) # - package the output folder as an Intune Win32 app (.intunewin) # # Parameter conventions used by the builder: # - Parameters whose name ends in "Path" are local files and parameters whose name ends in "Folder" are local # folders. They are copied into the Assets folder of the output and passed to the cmdlet using a path relative # to the runner script. Relative paths in the JSON file are relative to the JSON file's folder. # - [string[]] parameters are edited one value per line. # - [bool] and [switch] parameters are shown as check boxes; [ValidateSet()] parameters as drop-down lists. $script:ConfigurationVersion = 1 function Get-ABDefaultModulePath { <# .SYNOPSIS Returns the folder of the loaded AutopilotBranding module (the module that is copied into the output). #> [CmdletBinding()] [OutputType([string])] param () $script:ABModuleRoot } function Get-ABDefaultConfigurationPath { <# .SYNOPSIS Returns the path of the default configuration file: AutopilotBranding.json in the current folder. .DESCRIPTION The file doesn't need to exist. Create a project (configuration plus assets) with New-AutopilotBrandingProject. #> [CmdletBinding()] [OutputType([string])] param () Join-Path (Get-Location).ProviderPath 'AutopilotBranding.json' } function Get-ABTemplatePath { <# .SYNOPSIS Returns the folder of the default project template included in the module. .DESCRIPTION The template contains the default configuration (AutopilotBranding.json) and the sample files it uses (Assets). New-AutopilotBrandingProject copies it to a project folder; don't edit the template itself. #> [CmdletBinding()] [OutputType([string])] param () Join-Path $script:ABModuleRoot 'Template' } function Get-ABTaskDefinition { <# .SYNOPSIS Returns the configuration tasks (cmdlets) of the AutopilotBranding module and their parameters, in run order. .DESCRIPTION The task files (Tasks\<order>-<CmdletName>.ps1) are parsed - the module doesn't need to be imported - and the comment-based help and param() block of each cmdlet are used to describe the task and its parameters. .PARAMETER ModulePath Folder containing the AutopilotBranding module source. .PARAMETER Name Only return the task(s) with these names. #> [CmdletBinding()] param ( [string] $ModulePath = (Get-ABDefaultModulePath), [string[]] $Name ) $taskFolder = Join-Path $ModulePath 'Tasks' if (-not (Test-Path $taskFolder)) { throw "Task folder not found: $taskFolder" } foreach ($file in (Get-ChildItem -Path $taskFolder -Filter '*.ps1' | Sort-Object Name)) { $order = 0 $functionName = $file.BaseName if ($file.BaseName -match '^(\d+)-(.+)$') { $order = [int]$Matches[1] $functionName = $Matches[2] } if ($Name -and $Name -notcontains $functionName) { continue } $tokens = $null $errors = $null $ast = [System.Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$tokens, [ref]$errors) if ($errors -and $errors.Count -gt 0) { Write-Warning "Parse errors in $($file.Name): $($errors[0].Message)" } $function = $ast.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $functionName }, $true) if (-not $function) { Write-Warning "Function $functionName not found in $($file.Name), skipping" continue } $help = $function.GetHelpContent() $synopsis = '' $description = '' $parameterHelp = @{} if ($help) { if ($help.Synopsis) { $synopsis = ($help.Synopsis.Trim() -replace '\s*\r?\n\s*', ' ') } if ($help.Description) { $description = ($help.Description.Trim() -replace '\s*\r?\n\s*', ' ') } foreach ($key in $help.Parameters.Keys) { $parameterHelp[$key.ToUpperInvariant()] = ($help.Parameters[$key].Trim() -replace '\s*\r?\n\s*', ' ') } } $parameters = New-Object System.Collections.Generic.List[object] $paramBlock = $function.Body.ParamBlock if ($paramBlock) { foreach ($p in $paramBlock.Parameters) { $paramName = $p.Name.VariablePath.UserPath $mandatory = $false $validateSet = $null foreach ($attribute in $p.Attributes) { if ($attribute -isnot [System.Management.Automation.Language.AttributeAst]) { continue } switch ($attribute.TypeName.Name) { 'Parameter' { foreach ($named in $attribute.NamedArguments) { if ($named.ArgumentName -eq 'Mandatory') { $mandatory = $named.ExpressionOmitted -or [bool](Get-ABAstValue $named.Argument) } } } 'ValidateSet' { $validateSet = @($attribute.PositionalArguments | ForEach-Object { [string](Get-ABAstValue $_) }) } } } $staticType = $p.StaticType if ($staticType -eq [switch]) { $kind = 'Switch' } elseif ($staticType -eq [bool]) { $kind = 'Boolean' } elseif ($staticType -eq [string[]]) { $kind = 'StringArray' } elseif ($staticType -eq [int] -or $staticType -eq [long]) { $kind = 'Integer' } else { $kind = 'String' } $assetKind = $null if ($kind -eq 'String') { if ($paramName -like '*Path') { $assetKind = 'File' } elseif ($paramName -like '*Folder') { $assetKind = 'Folder' } } switch ($kind) { 'Switch' { $default = $false } 'Boolean' { $default = Get-ABAstValue $p.DefaultValue if ($null -eq $default) { $default = $false } $default = [bool]$default } 'StringArray' { $default = Get-ABAstValue $p.DefaultValue if ($null -eq $default) { $default = @() } else { $default = [string[]]@($default) } } 'Integer' { $default = Get-ABAstValue $p.DefaultValue if ($null -ne $default) { $default = [int]$default } } default { $default = Get-ABAstValue $p.DefaultValue if ($null -eq $default) { $default = '' } $default = [string]$default } } $helpText = '' if ($parameterHelp.ContainsKey($paramName.ToUpperInvariant())) { $helpText = $parameterHelp[$paramName.ToUpperInvariant()] } $parameters.Add([pscustomobject]@{ Name = $paramName Kind = $kind TypeName = $staticType.Name Mandatory = [bool]$mandatory ValidateSet = $validateSet DefaultValue = $default AssetKind = $assetKind HelpText = $helpText }) } } [pscustomobject]@{ Name = $functionName Order = $order FileName = $file.Name Synopsis = $synopsis Description = $description Parameters = $parameters.ToArray() } } } function ConvertTo-ABParameterValue { <# .SYNOPSIS Coerces a configuration value to the type expected by a task parameter. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] $Definition, [AllowNull()] $Value ) switch ($Definition.Kind) { { $_ -in 'Switch', 'Boolean' } { if ($null -eq $Value) { return [bool]$Definition.DefaultValue } if ($Value -is [string]) { return ($Value -eq 'true' -or $Value -eq '1' -or $Value -eq '$true') } return [bool]$Value } 'StringArray' { if ($null -eq $Value) { return , [string[]]@() } $items = @(foreach ($item in @($Value)) { if ($null -ne $item) { foreach ($line in ("$item" -split '\r?\n')) { $line.Trim() } } }) | Where-Object { $_ -ne '' } $items = @($items) return , [string[]]$items } 'Integer' { if ($null -eq $Value -or "$Value" -eq '') { return $null } return [int]$Value } default { if ($null -eq $Value) { return '' } return [string]$Value } } } function New-ABConfiguration { <# .SYNOPSIS Creates a new configuration with every task enabled and every parameter set to its default value. #> [CmdletBinding()] param ( [object[]] $TaskDefinitions = @(Get-ABTaskDefinition) ) $configuration = [ordered]@{ Version = $script:ConfigurationVersion Description = 'Autopilot Branding configuration' Tasks = [ordered]@{} } foreach ($task in $TaskDefinitions) { $configuration.Tasks[$task.Name] = [ordered]@{ Enabled = $true; Parameters = [ordered]@{} } } Complete-ABConfiguration -Configuration $configuration -TaskDefinitions $TaskDefinitions } function Complete-ABConfiguration { <# .SYNOPSIS Makes sure a configuration contains an entry for every task and a value for every parameter. .DESCRIPTION Tasks that aren't in the configuration (e.g. tasks added in a newer version of the module) are added disabled. Parameters without a value get their default value. Values are coerced to the parameter types. Tasks and parameters that are unknown are kept (and reported by Test-ABConfiguration). The tasks are reordered to match the module's run order. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [System.Collections.IDictionary] $Configuration, [object[]] $TaskDefinitions = @(Get-ABTaskDefinition) ) if (-not $Configuration.Contains('Version')) { $Configuration['Version'] = $script:ConfigurationVersion } if (-not $Configuration.Contains('Tasks') -or $null -eq $Configuration['Tasks']) { $Configuration['Tasks'] = [ordered]@{} } $existing = $Configuration['Tasks'] $tasks = [ordered]@{} foreach ($definition in $TaskDefinitions) { if ($existing.Contains($definition.Name) -and $null -ne $existing[$definition.Name]) { $entry = $existing[$definition.Name] } else { $entry = [ordered]@{ Enabled = $false; Parameters = [ordered]@{} } } if (-not $entry.Contains('Enabled')) { $entry['Enabled'] = $true } $entry['Enabled'] = [bool]$entry['Enabled'] if (-not $entry.Contains('Parameters') -or $null -eq $entry['Parameters']) { $entry['Parameters'] = [ordered]@{} } $parameters = [ordered]@{} foreach ($p in $definition.Parameters) { if ($entry['Parameters'].Contains($p.Name)) { $value = $entry['Parameters'][$p.Name] } else { $value = $p.DefaultValue } $parameters[$p.Name] = ConvertTo-ABParameterValue -Definition $p -Value $value } # Keep unknown parameters so nothing is silently lost foreach ($key in @($entry['Parameters'].Keys)) { if (-not $parameters.Contains($key)) { $parameters[$key] = $entry['Parameters'][$key] } } $entry['Parameters'] = $parameters $tasks[$definition.Name] = $entry } foreach ($key in @($existing.Keys)) { if (-not $tasks.Contains($key)) { $tasks[$key] = $existing[$key] } } $Configuration['Tasks'] = $tasks return $Configuration } function Read-ABConfiguration { <# .SYNOPSIS Reads a JSON configuration file and completes it with default values. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $Path, [object[]] $TaskDefinitions = @(Get-ABTaskDefinition) ) $json = Get-Content -Path $Path -Raw -Encoding UTF8 $configuration = ConvertTo-ABOrderedHashtable (ConvertFrom-Json -InputObject $json) if ($configuration -isnot [System.Collections.IDictionary]) { throw "Invalid configuration file: $Path" } Complete-ABConfiguration -Configuration $configuration -TaskDefinitions $TaskDefinitions } function Save-ABConfiguration { <# .SYNOPSIS Saves a configuration as a JSON file. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [System.Collections.IDictionary] $Configuration, [Parameter(Mandatory = $true)] [string] $Path ) $folder = Split-Path -Path $Path -Parent if ($folder -and -not (Test-Path $folder)) { New-Item -Path $folder -ItemType Directory -Force | Out-Null } $json = ConvertTo-Json -InputObject $Configuration -Depth 10 # Windows PowerShell 5.1 escapes some characters and uses wide indentation; both are valid JSON. $fullPath = ConvertTo-ABFullPath $Path [System.IO.File]::WriteAllText($fullPath, $json, (New-Object System.Text.UTF8Encoding($false))) } function Resolve-ABAssetPath { <# .SYNOPSIS Resolves a file or folder path from a configuration (relative paths are relative to BasePath). #> [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory = $true)] [string] $Path, [Parameter(Mandatory = $true)] [string] $BasePath ) $expanded = [Environment]::ExpandEnvironmentVariables($Path) if ([System.IO.Path]::IsPathRooted($expanded)) { return ConvertTo-ABFullPath $expanded } return ConvertTo-ABFullPath (Join-Path $BasePath $expanded) } function Get-ABRelativePath { <# .SYNOPSIS Returns Path relative to BasePath if it is inside BasePath, otherwise the full path. #> [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory = $true)] [string] $Path, [Parameter(Mandatory = $true)] [string] $BasePath ) $full = ConvertTo-ABFullPath $Path $base = (ConvertTo-ABFullPath $BasePath).TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar if ($full.StartsWith($base, [System.StringComparison]::OrdinalIgnoreCase)) { return $full.Substring($base.Length) } return $full } function Test-ABConfiguration { <# .SYNOPSIS Validates a configuration and returns a list of problems (Severity = Error or Warning). #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [System.Collections.IDictionary] $Configuration, [Parameter(Mandatory = $true)] [string] $BasePath, [object[]] $TaskDefinitions = @(Get-ABTaskDefinition) ) $definitions = @{} foreach ($d in $TaskDefinitions) { $definitions[$d.Name] = $d } foreach ($taskName in $Configuration.Tasks.Keys) { $entry = $Configuration.Tasks[$taskName] if (-not $definitions.ContainsKey($taskName)) { [pscustomobject]@{ Severity = 'Warning'; Task = $taskName; Parameter = $null; Message = 'Unknown task, it will be ignored.' } continue } if (-not $entry.Enabled) { continue } $definition = $definitions[$taskName] foreach ($key in $entry.Parameters.Keys) { if (-not ($definition.Parameters | Where-Object { $_.Name -eq $key })) { [pscustomobject]@{ Severity = 'Warning'; Task = $taskName; Parameter = $key; Message = 'Unknown parameter, it will be ignored.' } } } foreach ($p in $definition.Parameters) { $value = $entry.Parameters[$p.Name] $isEmpty = ($null -eq $value) -or ($value -is [string] -and $value.Trim() -eq '') -or ($value -is [array] -and $value.Count -eq 0) if ($p.Mandatory -and $isEmpty) { [pscustomobject]@{ Severity = 'Error'; Task = $taskName; Parameter = $p.Name; Message = 'A value is required.' } continue } if ($p.ValidateSet -and -not $isEmpty -and $p.ValidateSet -notcontains $value) { [pscustomobject]@{ Severity = 'Error'; Task = $taskName; Parameter = $p.Name; Message = "Value must be one of: $($p.ValidateSet -join ', ')" } } if ($p.AssetKind -and -not $isEmpty) { $resolved = Resolve-ABAssetPath -Path $value -BasePath $BasePath if ($p.AssetKind -eq 'File' -and -not (Test-Path -LiteralPath $resolved -PathType Leaf)) { [pscustomobject]@{ Severity = 'Error'; Task = $taskName; Parameter = $p.Name; Message = "File not found: $resolved" } } elseif ($p.AssetKind -eq 'Folder' -and -not (Test-Path -LiteralPath $resolved -PathType Container)) { [pscustomobject]@{ Severity = 'Error'; Task = $taskName; Parameter = $p.Name; Message = "Folder not found: $resolved" } } } } } } function ConvertTo-ABLiteral { <# .SYNOPSIS Converts a value into PowerShell source code. #> [CmdletBinding()] [OutputType([string])] param ( [AllowNull()] $Value, [string] $Indent = '' ) if ($null -eq $Value) { return '$null' } if ($Value -is [bool] -or $Value -is [switch]) { if ([bool]$Value) { return '$true' } else { return '$false' } } if ($Value -is [int] -or $Value -is [long] -or $Value -is [double] -or $Value -is [decimal]) { return ([System.Convert]::ToString($Value, [System.Globalization.CultureInfo]::InvariantCulture)) } if ($Value -is [array]) { if ($Value.Count -eq 0) { return '@()' } if ($Value.Count -eq 1) { return "@($(ConvertTo-ABLiteral -Value $Value[0]))" } $lines = foreach ($item in $Value) { "$Indent`t$(ConvertTo-ABLiteral -Value $item)" } return "@(`r`n$($lines -join "`r`n")`r`n$Indent)" } return "'" + ([string]$Value).Replace("'", "''") + "'" } function Get-ABRunnerScript { <# .SYNOPSIS Returns the runner script (AutopilotBranding.ps1) that goes into every package. .DESCRIPTION The runner is the same for every configuration: it loads the module next to it and calls Invoke-AutopilotBranding with the AutopilotBranding.json next to it. The configuration decides which tasks run with which parameters. #> [CmdletBinding()] [OutputType([string])] param () [System.IO.File]::ReadAllText((Join-Path $script:ABModuleRoot 'Runner\AutopilotBranding.ps1')) } function New-ABOutput { <# .SYNOPSIS Generates an output folder containing the module, the assets, the configuration and the runner script. .DESCRIPTION The runner script is the same for every configuration (see Get-ABRunnerScript): on the device it runs the tasks in the packaged AutopilotBranding.json, whose file and folder parameters refer to the output's Assets folder. The output folder can be packaged as an Intune Win32 app with New-ABIntuneWinPackage; the install command is powershell.exe -noprofile -executionpolicy bypass -file .\AutopilotBranding.ps1 .PARAMETER Configuration Configuration (see Read-ABConfiguration). .PARAMETER BasePath Folder that relative file and folder paths in the configuration are relative to (normally the JSON file's folder). .PARAMETER OutputPath Output folder. It is emptied first if -Force is specified. .PARAMETER ModulePath AutopilotBranding module source folder. .PARAMETER RunnerName File name of the runner script. #> [CmdletBinding(SupportsShouldProcess = $true)] param ( [Parameter(Mandatory = $true)] [System.Collections.IDictionary] $Configuration, [Parameter(Mandatory = $true)] [string] $BasePath, [Parameter(Mandatory = $true)] [string] $OutputPath, [string] $ModulePath = (Get-ABDefaultModulePath), [string] $RunnerName = 'AutopilotBranding.ps1', [switch] $Force ) $definitions = @(Get-ABTaskDefinition -ModulePath $ModulePath) $Configuration = Complete-ABConfiguration -Configuration $Configuration -TaskDefinitions $definitions $problems = @(Test-ABConfiguration -Configuration $Configuration -BasePath $BasePath -TaskDefinitions $definitions) foreach ($warning in ($problems | Where-Object { $_.Severity -eq 'Warning' })) { Write-Warning "$($warning.Task) $($warning.Parameter): $($warning.Message)" } $errors = @($problems | Where-Object { $_.Severity -eq 'Error' }) if ($errors.Count -gt 0) { $text = ($errors | ForEach-Object { " $($_.Task) -$($_.Parameter): $($_.Message)" }) -join [Environment]::NewLine throw "The configuration has errors:$([Environment]::NewLine)$text" } $OutputPath = ConvertTo-ABFullPath $OutputPath if (Test-Path $OutputPath) { $existing = @(Get-ChildItem -Path $OutputPath -Force) if ($existing.Count -gt 0) { if (-not $Force) { throw "Output folder $OutputPath is not empty; specify -Force to replace its contents." } if ($PSCmdlet.ShouldProcess($OutputPath, 'Remove existing contents')) { $existing | Remove-Item -Recurse -Force } } } else { New-Item -Path $OutputPath -ItemType Directory -Force | Out-Null } # Copy the module $moduleDest = Join-Path $OutputPath 'AutopilotBranding' Copy-Item -Path $ModulePath -Destination $moduleDest -Recurse -Force # The project template (sample assets) isn't needed on the device $templateCopy = Join-Path $moduleDest 'Template' if (Test-Path -LiteralPath $templateCopy) { Remove-Item -LiteralPath $templateCopy -Recurse -Force } # Copy the assets used by enabled tasks $assetRoot = Join-Path $OutputPath 'Assets' New-Item -Path $assetRoot -ItemType Directory -Force | Out-Null $assetMap = [ordered]@{} $sourceToName = @{} $usedNames = @{} foreach ($definition in $definitions) { $entry = $Configuration.Tasks[$definition.Name] if (-not $entry.Enabled) { continue } foreach ($p in ($definition.Parameters | Where-Object { $_.AssetKind })) { $value = $entry.Parameters[$p.Name] if ([string]::IsNullOrWhiteSpace($value)) { continue } $source = Resolve-ABAssetPath -Path $value -BasePath $BasePath $sourceKey = $source.ToLowerInvariant() if (-not $sourceToName.ContainsKey($sourceKey)) { $leaf = Split-Path -Path $source -Leaf $name = $leaf $i = 2 while ($usedNames.ContainsKey($name.ToLowerInvariant())) { $name = '{0}_{1}{2}' -f [System.IO.Path]::GetFileNameWithoutExtension($leaf), $i, [System.IO.Path]::GetExtension($leaf) $i++ } $usedNames[$name.ToLowerInvariant()] = $true $sourceToName[$sourceKey] = $name $destination = Join-Path $assetRoot $name if ($p.AssetKind -eq 'Folder') { Copy-Item -LiteralPath $source -Destination $destination -Recurse -Force } else { Copy-Item -LiteralPath $source -Destination $destination -Force } } $assetMap["$($definition.Name)|$($p.Name)"] = $sourceToName[$sourceKey] } } # Save the configuration used, with asset paths pointing at the copied files. File and folder parameters of # disabled tasks are cleared: their files aren't in the package, and the package must not refer to anything outside it. $outputConfiguration = ConvertTo-ABOrderedHashtable (ConvertFrom-Json -InputObject (ConvertTo-Json -InputObject $Configuration -Depth 10)) foreach ($definition in $definitions) { if (-not $outputConfiguration.Tasks.Contains($definition.Name)) { continue } foreach ($p in ($definition.Parameters | Where-Object { $_.AssetKind })) { $key = "$($definition.Name)|$($p.Name)" if ($assetMap.Contains($key)) { $outputConfiguration.Tasks[$definition.Name].Parameters[$p.Name] = "Assets\$($assetMap[$key])" } elseif ($outputConfiguration.Tasks[$definition.Name].Parameters.Contains($p.Name)) { $outputConfiguration.Tasks[$definition.Name].Parameters[$p.Name] = '' } } } Save-ABConfiguration -Configuration $outputConfiguration -Path (Join-Path $OutputPath 'AutopilotBranding.json') # The runner script (the same for every configuration) $runnerPath = Join-Path $OutputPath $RunnerName Copy-Item -LiteralPath (Join-Path $ModulePath 'Runner\AutopilotBranding.ps1') -Destination $runnerPath -Force # Make sure everything the package refers to is inside it $problems = @(Test-ABOutputSelfContained -OutputPath $OutputPath -RunnerName $RunnerName -TaskDefinitions $definitions) foreach ($warning in ($problems | Where-Object { $_.Severity -eq 'Warning' })) { Write-Warning $warning.Message } $errors = @($problems | Where-Object { $_.Severity -eq 'Error' }) if ($errors.Count -gt 0) { throw "The output isn't self-contained:$([Environment]::NewLine)$(($errors | ForEach-Object { ' ' + $_.Message }) -join [Environment]::NewLine)" } [pscustomobject]@{ OutputPath = $OutputPath RunnerScript = $runnerPath EnabledTasks = @($definitions | Where-Object { $Configuration.Tasks[$_.Name].Enabled } | ForEach-Object { $_.Name }) Assets = @($sourceToName.Values) } } function Test-ABOutputSelfContained { <# .SYNOPSIS Checks that a generated output folder (the content of the .intunewin package) only refers to files inside it. .DESCRIPTION Returns a list of problems (Severity = Error or Warning, Message): - Error: a file or folder parameter in the output's AutopilotBranding.json that is a full path, goes outside the output folder (..), or doesn't exist in the output folder. - Warning: another text value of an enabled task that looks like a full path on the build machine (for example C:\Users\...), which won't exist on the device. Device paths such as C:\Windows aren't reported. - Warning: a runner script that differs from the module's. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $OutputPath, [string] $RunnerName = 'AutopilotBranding.ps1', [object[]] $TaskDefinitions ) $OutputPath = ConvertTo-ABFullPath $OutputPath if (-not $TaskDefinitions) { $TaskDefinitions = @(Get-ABTaskDefinition -ModulePath (Join-Path $OutputPath 'AutopilotBranding')) } # The packaged configuration $devicePrefixes = '^[A-Za-z]:\\(Windows|Program Files|Program Files \(x86\)|ProgramData)(\\|$)' $configPath = Join-Path $OutputPath 'AutopilotBranding.json' if (Test-Path -LiteralPath $configPath) { $configuration = Read-ABConfiguration -Path $configPath -TaskDefinitions $TaskDefinitions foreach ($definition in $TaskDefinitions) { $entry = $configuration.Tasks[$definition.Name] foreach ($p in ($definition.Parameters | Where-Object { $_.AssetKind })) { $value = [string]$entry.Parameters[$p.Name] if ([string]::IsNullOrWhiteSpace($value)) { continue } $where = "$($definition.Name) -$($p.Name) ($value)" if ([System.IO.Path]::IsPathRooted($value) -or $value -match '^%|^\$') { [pscustomobject]@{ Severity = 'Error'; Message = "$($where): not a path relative to the package" } } elseif (($value -split '[\\/]') -contains '..') { [pscustomobject]@{ Severity = 'Error'; Message = "$($where): goes outside the package" } } elseif (-not (Test-Path -LiteralPath (Join-Path $OutputPath $value))) { [pscustomobject]@{ Severity = 'Error'; Message = "$($where): not found in the package" } } } # Other text values that look like paths on the build computer (device paths such as C:\Windows are fine) if (-not $entry.Enabled) { continue } foreach ($p in ($definition.Parameters | Where-Object { -not $_.AssetKind -and $_.Kind -in 'String', 'StringArray' })) { foreach ($text in @($entry.Parameters[$p.Name])) { if (($text -match '^[A-Za-z]:\\' -or $text -match '^\\\\') -and $text -notmatch $devicePrefixes) { [pscustomobject]@{ Severity = 'Warning'; Message = "$($definition.Name) -$($p.Name): '$text' looks like a path on this computer; it won't be in the package." } } } } } } # The runner script $runnerPath = Join-Path $OutputPath $RunnerName if (-not (Test-Path -LiteralPath $runnerPath)) { [pscustomobject]@{ Severity = 'Error'; Message = "Runner script not found: $runnerPath" } return } $expected = Join-Path $script:ABModuleRoot 'Runner\AutopilotBranding.ps1' if ((Test-Path -LiteralPath $expected) -and -not (Test-ABSameContent -A $runnerPath -B $expected)) { [pscustomobject]@{ Severity = 'Warning'; Message = "The runner script differs from the module's ($expected)." } } } function New-ABIntuneWinPackage { <# .SYNOPSIS Packages an output folder (see New-ABOutput) as an Intune Win32 app (.intunewin file). .DESCRIPTION Uses New-IntuneWinPackage from the SvRooij.ContentPrep.Cmdlet module, which is installed for the current user from the PowerShell Gallery if needed (along with the NuGet package provider). The .intunewin file is named after the setup file, e.g. AutopilotBranding.intunewin; an existing file with that name is replaced. .PARAMETER SourcePath Folder to package (the generated output folder). .PARAMETER DestinationPath Folder where the .intunewin file is created. It must not be inside SourcePath. .PARAMETER SetupFile Setup file inside SourcePath (the runner script). .OUTPUTS The full path of the .intunewin file. #> [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory = $true)] [string] $SourcePath, [Parameter(Mandatory = $true)] [string] $DestinationPath, [string] $SetupFile = 'AutopilotBranding.ps1' ) $SourcePath = ConvertTo-ABFullPath $SourcePath $DestinationPath = ConvertTo-ABFullPath $DestinationPath if (-not (Test-Path -LiteralPath (Join-Path $SourcePath $SetupFile) -PathType Leaf)) { throw "Setup file $SetupFile not found in $SourcePath" } $sourcePrefix = $SourcePath.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar if ($DestinationPath.Equals($SourcePath.TrimEnd('\', '/'), [System.StringComparison]::OrdinalIgnoreCase) -or $DestinationPath.StartsWith($sourcePrefix, [System.StringComparison]::OrdinalIgnoreCase)) { throw "The package destination ($DestinationPath) must not be inside the folder being packaged ($SourcePath)." } # Make sure the NuGet provider and the content prep module are available if (-not (Get-Module -ListAvailable -Name SvRooij.ContentPrep.Cmdlet | Where-Object { $_.Version -ge [version]'0.4.0' })) { $minimumNuGet = [version]'2.8.5.201' $provider = Get-PackageProvider -Name NuGet -ListAvailable -ErrorAction SilentlyContinue | Sort-Object Version -Descending | Select-Object -First 1 if (-not $provider -or $provider.Version -lt $minimumNuGet) { Write-Verbose "Installing NuGet package provider $minimumNuGet or later" Install-PackageProvider -Name NuGet -MinimumVersion $minimumNuGet -Force | Out-Null } Write-Verbose 'Installing SvRooij.ContentPrep.Cmdlet module' Install-Module SvRooij.ContentPrep.Cmdlet -MinimumVersion 0.4.0 -Scope CurrentUser -Force } Import-Module SvRooij.ContentPrep.Cmdlet -MinimumVersion 0.4.0 -ErrorAction Stop if (-not (Test-Path -LiteralPath $DestinationPath)) { New-Item -Path $DestinationPath -ItemType Directory -Force | Out-Null } $packagePath = Join-Path $DestinationPath ([System.IO.Path]::GetFileNameWithoutExtension($SetupFile) + '.intunewin') if (Test-Path -LiteralPath $packagePath) { Remove-Item -LiteralPath $packagePath -Force } New-IntuneWinPackage -SourcePath $SourcePath -SetupFile $SetupFile -DestinationPath $DestinationPath -ErrorAction Stop | Out-Null if (-not (Test-Path -LiteralPath $packagePath)) { throw "The package was not created: $packagePath" } return $packagePath } function ConvertFrom-ABLegacyConfig { <# .SYNOPSIS Converts a v3 Config.xml file (and the files next to it) into a JSON configuration. .DESCRIPTION File and folder parameters point at the files in the legacy folder (Layout.xml, start2.bin, Autopilot.theme, ...). Save the result with Save-ABConfiguration; you may want to copy the files next to the JSON file and use relative paths. .PARAMETER Path Path of the legacy Config.xml file. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $Path, [object[]] $TaskDefinitions = @(Get-ABTaskDefinition) ) $folder = Split-Path -Path (ConvertTo-ABFullPath $Path) -Parent [xml]$xml = Get-Content -Path $Path -Raw $c = $xml.Config function Get-Value([string]$name) { $node = $c.SelectSingleNode($name) if ($node) { return $node.InnerText.Trim() } return '' } function Get-List([string]$parent, [string]$child) { $nodes = $c.SelectNodes("$parent/$child") return , [string[]]@($nodes | ForEach-Object { $_.InnerText.Trim() } | Where-Object { $_ }) } function Test-NotSkipped([string]$name) { return (Get-Value $name) -ine 'true' } function Get-File([string]$name) { if (-not $name) { return '' } $candidate = Join-Path $folder $name if (Test-Path -LiteralPath $candidate) { return $candidate } return '' } $removeApps = Get-List 'RemoveApps' 'App' $winget = Get-List 'WinGetInstall' 'Id' $addFeatures = Get-List 'AddFeatures' 'Feature' $disableFeatures = Get-List 'DisableOptionalFeatures' 'Feature' $removeCapabilities = Get-List 'RemoveCapability' 'Capability' $oem = $c.SelectSingleNode('OEMInfo') function Get-OEM([string]$name) { if ($oem -and $oem.SelectSingleNode($name)) { return $oem.SelectSingleNode($name).InnerText.Trim() } return '' } $languageSettings = $c.SelectSingleNode('LanguageSettings') function Get-LanguageSetting([string]$name) { if ($languageSettings -and $languageSettings.SelectSingleNode($name)) { return $languageSettings.SelectSingleNode($name).InnerText.Trim() } return '' } $geoId = 0 [void][int]::TryParse((Get-LanguageSetting 'GeoId'), [ref]$geoId) $tasks = [ordered]@{ 'Rename-ABComputer' = @{ Enabled = [bool](Get-Value 'ComputerName'); Parameters = @{ NewName = (Get-Value 'ComputerName') } } 'Set-ABStartLayout' = @{ Enabled = (Test-NotSkipped 'SkipStartLayout'); Parameters = @{ LayoutXmlPath = (Get-File 'Layout.xml'); Start2BinPath = (Get-File 'start2.bin'); SettingsDatPath = (Get-File 'settings.dat') } } 'Set-ABTaskbarLayout' = @{ Enabled = (Test-NotSkipped 'SkipTaskbarLayout'); Parameters = @{ TaskbarLayoutPath = (Get-File 'TaskbarLayoutModification.xml') } } 'Set-ABTheme' = @{ Enabled = (Test-NotSkipped 'SkipTheme'); Parameters = @{ ThemePath = (Get-File 'Autopilot.theme'); WallpaperPath = (Get-File 'Autopilot.jpg') } } 'Set-ABLockScreen' = @{ Enabled = (Test-NotSkipped 'SkipLockScreen'); Parameters = @{ ImagePath = (Get-File 'AutopilotLock.jpg') } } 'Disable-ABStartOnFirstLogon' = @{ Enabled = $true; Parameters = @{} } 'Disable-ABDesktopSpotlight' = @{ Enabled = $true; Parameters = @{} } 'Set-ABStartAlignment' = @{ Enabled = (Test-NotSkipped 'SkipLeftAlignStart'); Parameters = @{ Alignment = 'Left' } } 'Hide-ABWidgets' = @{ Enabled = (Test-NotSkipped 'SkipHideWidgets'); Parameters = @{} } 'Disable-ABWidgets' = @{ Enabled = (Test-NotSkipped 'SkipDisableWidgets'); Parameters = @{} } 'Set-ABSearchBoxMode' = @{ Enabled = (Test-NotSkipped 'SkipSetSearchIcon'); Parameters = @{ Mode = 'IconOnly' } } 'Set-ABTimeZone' = @{ Enabled = $true; Parameters = @{ TimeZone = (Get-Value 'TimeZone') } } 'Remove-ABProvisionedApps' = @{ Enabled = ($removeApps.Count -gt 0); Parameters = @{ AppNames = $removeApps } } 'Disable-ABCopilotPWA' = @{ Enabled = (Test-NotSkipped 'SkipRemoveCopilotPWA'); Parameters = @{} } 'Install-ABOneDrive' = @{ Enabled = [bool](Get-Value 'OneDriveSetup'); Parameters = @{ Url = (Get-Value 'OneDriveSetup'); ArmUrl = (Get-Value 'OneDriveARMSetup') } } 'Disable-ABEdgeDesktopShortcut' = @{ Enabled = $true; Parameters = @{} } 'Update-ABEdge' = @{ Enabled = (Test-NotSkipped 'SkipEdgeUpdate'); Parameters = @{ Url = (Get-Value 'EdgeBusinessMSI'); ArmUrl = (Get-Value 'EdgeBusinessARM') } } 'Remove-ABEdgeBookmarks' = @{ Enabled = $true; Parameters = @{} } 'Add-ABLanguagePacks' = @{ Enabled = [bool](Get-ChildItem (Join-Path $folder 'LPs') -Filter *.cab -ErrorAction SilentlyContinue); Parameters = @{ LanguagePackFolder = (Get-File 'LPs') } } 'Set-ABLanguage' = @{ Enabled = [bool](Get-Value 'Language'); Parameters = @{ LanguageXmlPath = (Get-File (Get-Value 'Language')) } } 'Set-ABLanguageSettings' = @{ Enabled = [bool](Get-LanguageSetting 'Language'); Parameters = @{ Language = (Get-LanguageSetting 'Language'); Locale = (Get-LanguageSetting 'Locale'); GeoId = $geoId } } 'Disable-ABOptionalFeatures' = @{ Enabled = ($disableFeatures.Count -gt 0); Parameters = @{ FeatureNames = $disableFeatures } } 'Remove-ABWindowsCapabilities' = @{ Enabled = ($removeCapabilities.Count -gt 0); Parameters = @{ CapabilityNames = $removeCapabilities } } 'Add-ABWindowsCapabilities' = @{ Enabled = ($addFeatures.Count -gt 0); Parameters = @{ CapabilityNames = $addFeatures } } 'Set-ABDefaultApps' = @{ Enabled = [bool](Get-Value 'DefaultApps'); Parameters = @{ AssociationsXmlPath = (Get-File (Get-Value 'DefaultApps')) } } 'Set-ABRegisteredOwner' = @{ Enabled = [bool](Get-Value 'RegisteredOwner'); Parameters = @{ Owner = (Get-Value 'RegisteredOwner'); Organization = (Get-Value 'RegisteredOrganization') } } 'Set-ABOEMInfo' = @{ Enabled = [bool]$oem; Parameters = @{ Manufacturer = (Get-OEM 'Manufacturer'); Model = (Get-OEM 'Model'); SupportPhone = (Get-OEM 'SupportPhone'); SupportHours = (Get-OEM 'SupportHours'); SupportURL = (Get-OEM 'SupportURL'); LogoPath = (Get-File (Get-OEM 'Logo')) } } 'Set-ABEnterpriseEdition' = @{ Enabled = (Test-NotSkipped 'SkipEnterpriseGVLK'); Parameters = @{} } 'Enable-ABUEV' = @{ Enabled = (Test-NotSkipped 'SkipUEV'); Parameters = @{ TemplateFolder = (Get-File 'UEV') } } 'Disable-ABNetworkLocationFlyout' = @{ Enabled = $true; Parameters = @{} } 'Disable-ABAutoInstalledApps' = @{ Enabled = (Test-NotSkipped 'SkipAutoinstallingApps'); Parameters = @{} } 'Install-ABWinGetPackages' = @{ Enabled = ((Test-NotSkipped 'SkipWinGet') -and $winget.Count -gt 0); Parameters = @{ PackageIds = $winget } } 'Set-ABChromeConfig' = @{ Enabled = (Test-NotSkipped 'SkipChromeConfig'); Parameters = @{ InitialPreferencesPath = (Get-File 'initial_preferences') } } 'Disable-ABAPv2Pages' = @{ Enabled = (Test-NotSkipped 'SkipAPv2'); Parameters = @{} } 'Update-ABApps' = @{ Enabled = (Test-NotSkipped 'SkipUpdates'); Parameters = @{} } 'Disable-ABFirstLogonAnimation' = @{ Enabled = (Test-NotSkipped 'SkipShowDesktopFaster'); Parameters = @{} } 'Remove-ABOffice' = @{ Enabled = (Test-NotSkipped 'SkipRemoveOffice'); Parameters = @{} } } $configuration = [ordered]@{ Version = $script:ConfigurationVersion Description = "Converted from $Path" Tasks = [ordered]@{} } foreach ($name in $tasks.Keys) { $parameters = [ordered]@{} foreach ($key in $tasks[$name].Parameters.Keys) { $value = $tasks[$name].Parameters[$key] # Don't override the cmdlet defaults with empty URLs if (($value -is [string]) -and $value -eq '' -and $key -like '*Url') { continue } $parameters[$key] = $value } $configuration.Tasks[$name] = [ordered]@{ Enabled = [bool]$tasks[$name].Enabled; Parameters = $parameters } } Complete-ABConfiguration -Configuration $configuration -TaskDefinitions $TaskDefinitions } |