Runtime/Invoke-OSDStageDesktopSettings.ps1
|
[CmdletBinding()] param([string]$ConfigPath = (Join-Path $PSScriptRoot 'config.json')) function ConvertTo-OSDStageDesktopPins { param([Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Pins) $seen = @{} foreach ($pin in $Pins) { if ($null -eq $pin -or ($pin -isnot [System.Collections.IDictionary] -and $pin -isnot [pscustomobject])) { throw 'Each pin must be an object with exactly one app ID or link.' } $names = @(if ($pin -is [System.Collections.IDictionary]) { $pin.Keys } else { $pin.PSObject.Properties.Name }) if ($names.Count -ne 1 -or $names[0] -cnotin @('desktopAppId','packagedAppId','desktopAppLink')) { throw 'Use exactly one of desktopAppId, packagedAppId or desktopAppLink per pin.' } $name = [string]$names[0] $value = $pin.$name if ($value -isnot [string] -or [string]::IsNullOrWhiteSpace($value) -or $value.Length -gt 2048 -or $value -ne $value.Trim() -or $value -match '[\x00-\x1F]') { throw 'Pin values must be nonempty literal strings without control characters.' } if ($name -eq 'desktopAppLink') { if ($value -notmatch '^(?:[A-Za-z]:\\|%(?:ALLUSERSPROFILE|ProgramData|APPDATA|LOCALAPPDATA|ProgramFiles|ProgramFiles\(x86\)|SystemRoot|windir)%\\).+\.lnk$' -or $value -match '["<>|*?]' -or $value -match '(^|\\)\.\.(\\|$)') { throw 'A desktopAppLink must be a local absolute .lnk path, optionally rooted in a supported Windows environment variable.' } } elseif ($value -match '[\\/"<>|*?\s]') { throw 'Use an app ID, not a display name, executable path or command.' } $identity = $name + ':' + $value if ($seen.ContainsKey($identity)) { throw 'Duplicate pin.' } $seen[$identity] = $true [pscustomobject]@{$name=$value} } } function Assert-OSDStageDesktopOperation { param([Parameter(Mandatory)]$Operation) switch ([string]$Operation.Kind) { { $_ -in @('StartPins','TaskbarPins') } { if ($null -eq $Operation.Pins -or $Operation.Pins -isnot [System.Collections.IList]) { throw 'Pins must be an array, which may be empty.' } $null = @(ConvertTo-OSDStageDesktopPins -Pins @($Operation.Pins)) } default { throw 'Unsupported desktop setting.' } } } function Assert-OSDStageDesktopConfiguration { param([Parameter(Mandatory)]$Configuration) $id = [guid]::Empty if ([int]$Configuration.SchemaVersion -ne 2 -or -not [guid]::TryParse([string]$Configuration.DeploymentId,[ref]$id) -or [string]::IsNullOrWhiteSpace([string]$Configuration.ResultPath) -or @($Configuration.Operations).Count -eq 0) { throw 'Invalid DesktopSettings configuration.' } $seen = @{} foreach ($op in @($Configuration.Operations)) { Assert-OSDStageDesktopOperation -Operation $op if ($seen.ContainsKey([string]$op.Kind)) { throw 'Duplicate desktop setting.' } $seen[[string]$op.Kind]=$true } } function ConvertTo-OSDStageTaskbarXml { param([Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Pins) $items = @(ConvertTo-OSDStageDesktopPins -Pins $Pins) $builder = [Text.StringBuilder]::new() $settings = [Xml.XmlWriterSettings]::new() $settings.OmitXmlDeclaration=$true $writer = [Xml.XmlWriter]::Create($builder,$settings) $layoutNs='http://schemas.microsoft.com/Start/2014/LayoutModification' $fullNs='http://schemas.microsoft.com/Start/2014/FullDefaultLayout' $barNs='http://schemas.microsoft.com/Start/2014/TaskbarLayout' try { $writer.WriteStartElement('LayoutModificationTemplate',$layoutNs) $writer.WriteAttributeString('Version','1') $writer.WriteStartElement('CustomTaskbarLayoutCollection',$layoutNs) $writer.WriteAttributeString('PinListPlacement','Replace') $writer.WriteStartElement('defaultlayout','TaskbarLayout',$fullNs) $writer.WriteStartElement('taskbar','TaskbarPinList',$barNs) if ($items.Count -eq 0) { # Microsoft's empty replacement sentinel is internal, not a caller-supplied link. $writer.WriteStartElement('taskbar','DesktopApp',$barNs) $writer.WriteAttributeString('DesktopApplicationLinkPath','#leaveempty') $writer.WriteEndElement() } foreach ($pin in $items) { $name=@($pin.PSObject.Properties.Name)[0] $element=if ($name -eq 'packagedAppId') { 'UWA' } else { 'DesktopApp' } $attribute=@{packagedAppId='AppUserModelID';desktopAppId='DesktopApplicationID';desktopAppLink='DesktopApplicationLinkPath'}[$name] $writer.WriteStartElement('taskbar',$element,$barNs) $writer.WriteAttributeString($attribute,[string]$pin.$name) $writer.WriteAttributeString('PinGeneration','1') $writer.WriteEndElement() } $writer.WriteEndElement();$writer.WriteEndElement();$writer.WriteEndElement();$writer.WriteEndElement() $writer.Flush() $builder.ToString() } finally { $writer.Dispose() } } function Assert-OSDStageStartPinsPlatform { param([int]$Build, [int]$Revision, [string]$InstallationType, [string]$EditionId) if ($InstallationType -ne 'Client' -or $Build -lt 26100 -or ($Build -eq 26100 -and $Revision -lt 4770) -or $EditionId -notmatch '^(Professional|Enterprise|Education|IoTEnterprise)') { throw 'One-time Start pins require Windows 11 Pro, Enterprise, Education or IoT Enterprise, build 26100.4770 or later.' } } function Get-OSDStageStartPinsPolicy { param([string]$WindowsDirectory = $env:SystemRoot) # Read the target Windows ADMX, so the policy is never confused with the CSP JSON value. $path=Join-Path $WindowsDirectory 'PolicyDefinitions\StartMenu.admx' $settings=[Xml.XmlReaderSettings]::new();$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null $reader=[Xml.XmlReader]::Create($path,$settings) try { $doc=[Xml.XmlDocument]::new();$doc.XmlResolver=$null;$doc.Load($reader) } finally { $reader.Dispose() } $policy=$doc.SelectSingleNode("//*[local-name()='policy' and @name='ConfigureStartPins']") $text=if ($policy) { $policy.SelectSingleNode("./*[local-name()='elements']/*[local-name()='text']") } $enabled=if ($policy) { $policy.SelectSingleNode("./*[local-name()='enabledValue']/*[local-name()='decimal']") } if (-not $policy -or $policy.GetAttribute('key') -ine 'Software\Policies\Microsoft\Windows\Explorer' -or $policy.GetAttribute('valueName') -ne 'ConfigureStartPins' -or $policy.GetAttribute('class') -ne 'Both' -or -not $text -or $text.GetAttribute('valueName') -ne 'ConfigureStartPinsJSON' -or -not $enabled -or $enabled.GetAttribute('value') -ne '1') { throw 'The target StartMenu.admx does not contain the supported Configure Start Pins file policy.' } [pscustomobject]@{Key='SOFTWARE\Policies\Microsoft\Windows\Explorer';EnabledName='ConfigureStartPins';FileName='ConfigureStartPinsJSON'} } function Assert-OSDStageTaskbarPinsPlatform { param([int]$Build, [int]$Revision, [string]$InstallationType, [string]$EditionId) # Use the same supported baseline as Start applyOnce; it includes the PinGeneration update. if ($InstallationType -ne 'Client' -or $Build -lt 26100 -or ($Build -eq 26100 -and $Revision -lt 4770) -or $EditionId -notmatch '^(Professional|Enterprise|Education|IoTEnterprise)') { throw 'User-editable taskbar pins require Windows 11 Pro, Enterprise, Education or IoT Enterprise, build 26100.4770 or later.' } } function Get-OSDStageTaskbarPinsPolicy { param([Parameter(Mandatory)][string]$WindowsDirectory) $path=Join-Path $WindowsDirectory 'PolicyDefinitions\StartMenu.admx' $settings=[Xml.XmlReaderSettings]::new();$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null $reader=[Xml.XmlReader]::Create($path,$settings) try { $doc=[Xml.XmlDocument]::new();$doc.XmlResolver=$null;$doc.Load($reader) } finally { $reader.Dispose() } $policy=$doc.SelectSingleNode("//*[local-name()='policy' and @name='LockedStartLayout']") $text=if ($policy) { $policy.SelectSingleNode("./*[local-name()='elements']/*[local-name()='text' and @valueName='StartLayoutFile']") } $enabledNode=if ($policy) { $policy.SelectSingleNode("./*[local-name()='enabledValue']") } $enabled=if ($policy) { $policy.SelectSingleNode("./*[local-name()='enabledValue']/*[local-name()='decimal']") } if (-not $policy -or $policy.GetAttribute('key') -ine 'Software\Policies\Microsoft\Windows\Explorer' -or $policy.GetAttribute('valueName') -ne 'LockedStartLayout' -or $policy.GetAttribute('class') -ne 'Both' -or -not $text -or ($enabledNode -and (-not $enabled -or $enabled.GetAttribute('value') -ne '1'))) { throw 'The target StartMenu.admx does not contain the supported Start Layout file policy for taskbar pins.' } # Windows omits enabledValue here; the named policy uses the implicit DWORD 1. $fileKind=if ($text.GetAttribute('expandable') -in @('true','1')) { [Microsoft.Win32.RegistryValueKind]::ExpandString } else { [Microsoft.Win32.RegistryValueKind]::String } # Relative to the already-mounted SOFTWARE hive, never the host registry. [pscustomobject]@{Key='Policies\Microsoft\Windows\Explorer';EnabledName='LockedStartLayout';FileName='StartLayoutFile';FileKind=$fileKind} } function Set-OSDStageStartPinsConfiguration { param([Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Pins) $cv=Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction Stop Assert-OSDStageStartPinsPlatform -Build ([int]$cv.CurrentBuildNumber) -Revision ([int]$cv.UBR) -InstallationType $cv.InstallationType -EditionId $cv.EditionID $mapping=Get-OSDStageStartPinsPolicy $json=([ordered]@{applyOnce=$true;pinnedList=@(ConvertTo-OSDStageDesktopPins -Pins $Pins)} | ConvertTo-Json -Depth 10) $directory=Join-Path $env:ProgramData 'OSDStage\Configuration' $path=Join-Path $directory 'StartPins.json' $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64) $key=$null;$oldBytes=$null;$written=$false;$old=@{} try { $key=$base.CreateSubKey($mapping.Key) foreach ($name in @($mapping.EnabledName,$mapping.FileName)) { if ($key.GetValueNames() -contains $name) { $old[$name]=@{Value=$key.GetValue($name,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames);Kind=$key.GetValueKind($name)} } } if ($old.Count -gt 0 -and ($key.GetValue($mapping.FileName) -ne $path -or $key.GetValue($mapping.EnabledName) -ne 1)) { throw 'An existing Start pins policy belongs to another configuration. Nothing was replaced.' } if (Test-Path -LiteralPath $path) { if ($old.Count -ne 2) { throw 'An unowned StartPins.json already exists.' } $oldBytes=[IO.File]::ReadAllBytes($path) } New-Item -ItemType Directory -Path $directory -Force -ErrorAction Stop | Out-Null [IO.File]::WriteAllText($path,$json,[Text.UTF8Encoding]::new($false));$written=$true $key.SetValue($mapping.FileName,$path,[Microsoft.Win32.RegistryValueKind]::String) $key.SetValue($mapping.EnabledName,1,[Microsoft.Win32.RegistryValueKind]::DWord) [pscustomobject]@{Outcome='ConfiguredForFirstSignIn';Path=$path;PinCount=$Pins.Count;ApplyOnce=$true} } catch { if ($written) { if ($null -ne $oldBytes) { [IO.File]::WriteAllBytes($path,$oldBytes) } else { [IO.File]::Delete($path) } foreach ($name in @($mapping.EnabledName,$mapping.FileName)) { if ($old.ContainsKey($name)) { $key.SetValue($name,$old[$name].Value,$old[$name].Kind) } else { $key.DeleteValue($name,$false) } } } throw } finally { if ($key) { $key.Dispose() };$base.Dispose() } } function Invoke-OSDStageDesktopConfiguration { param([Parameter(Mandatory)]$Configuration) Assert-OSDStageDesktopConfiguration -Configuration $Configuration $items=@(foreach ($operation in @($Configuration.Operations)) { $item=[ordered]@{Kind=[string]$operation.Kind;Status='Failed';Detail=$null;Error=$null} try { switch ([string]$operation.Kind) { 'StartPins' { $item.Detail=Set-OSDStageStartPinsConfiguration -Pins @($operation.Pins) } 'TaskbarPins' { $item.Detail=[pscustomobject]@{Outcome='PreparedOffline';PinCount=@($operation.Pins).Count;Mode='Replace';PinGeneration=1;Note='Taskbar file policy was prepared in WinPE for first sign-in. Windows retains the policy; PinGeneration permits user unpinning. This is not proof of the rendered user layout.'} } } $item.Status='Succeeded' } catch { $item.Error=$_.Exception.Message } [pscustomobject]$item }) $failed=@($items | Where-Object Status -eq 'Failed').Count [pscustomobject]@{ SchemaVersion=2;DeploymentId=[string]$Configuration.DeploymentId;Status=if ($failed) {'Failed'} else {'Succeeded'} OperationCount=$items.Count;SuccessfulOperationCount=($items.Count-$failed);FailedOperationCount=$failed RestartNeeded=$false;RebootRequested=$false;Items=$items } } if ($MyInvocation.InvocationName -eq '.') { return } $ErrorActionPreference='Stop' try { if (Test-Path -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\MiniNT') { throw 'Desktop settings runtime requires full Windows.' } if (-not [Environment]::Is64BitProcess) { throw 'Desktop settings runtime requires native 64-bit PowerShell.' } $configuration=Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop $record=Invoke-OSDStageDesktopConfiguration -Configuration $configuration $resultPath=[Environment]::ExpandEnvironmentVariables([string]$configuration.ResultPath) New-Item -ItemType Directory -Path (Split-Path $resultPath -Parent) -Force | Out-Null $record | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $resultPath -Encoding UTF8 if ($record.Status -eq 'Failed') { exit 1 } exit 0 } catch { Write-Error $_.Exception.Message -ErrorAction Continue;exit 1 } |