Runtime/Invoke-OSDStagePreInstall.ps1
|
[CmdletBinding()] param( [Parameter(Mandatory)][string]$StagedPath, [ValidateRange(1,120)][int]$OfficeRefreshTimeoutMinutes = 20 ) $ErrorActionPreference = 'Stop' $sleepRequest = $null $sleepLog = $null . (Join-Path $PSScriptRoot 'Enter-OSDStageCacheLock.ps1') . (Join-Path $PSScriptRoot 'Test-OSDStageOfficeCacheCompatibility.ps1') $cacheLock = $null . (Join-Path $PSScriptRoot 'Restore-OSDStageCacheGeneration.ps1') . (Join-Path $PSScriptRoot 'OSDStagePayloadFile.ps1') . (Join-Path $PSScriptRoot 'OSDStage7Zip.ps1') . (Join-Path $PSScriptRoot 'OSDStageVisualStudioCode.ps1') . (Join-Path $PSScriptRoot 'OSDStagePrerequisite.ps1') . (Join-Path $PSScriptRoot 'Invoke-OSDStageBuiltInCacheTransaction.ps1') . (Join-Path $PSScriptRoot 'Assert-OSDStageTransactionSpace.ps1') Add-Type -AssemblyName System.Net.Http -ErrorAction Stop $manifestPath = Join-Path $StagedPath 'DeviceManifest.json' $logPath = [Environment]::ExpandEnvironmentVariables('%ProgramData%\OSDStage\Logs\Runtime.log') $logDirectory = Split-Path -Path $logPath -Parent $preInstallStopwatch = [System.Diagnostics.Stopwatch]::StartNew() $preInstallStartedAtUtc = (Get-Date).ToUniversalTime().ToString('o') $resultPath = [IO.Path]::GetFullPath([Environment]::ExpandEnvironmentVariables('%ProgramData%\OSDStage\Results\Latest.json')) $preInstallResultPath = Join-Path (Split-Path -Path $resultPath -Parent) 'PreInstall.json' $manifest = $null $builtInApps = @() $usbRoot = $null $networkAvailable = $null $appResults = [System.Collections.Generic.List[object]]::new() function Write-PreInstallLog { param([string]$Event,[ValidateSet('Info','Warning','Error')][string]$Level='Info',[string]$Message,[hashtable]$Data) New-Item -ItemType Directory -Path $logDirectory -Force | Out-Null # Runtime.log is shared by PreInstall and the runner. Apply the same # 1 MB + 3 archives retention policy in both entry points. if (Test-Path -LiteralPath $logPath -PathType Leaf) { if ((Get-Item -LiteralPath $logPath).Length -ge 1MB) { for ($i = 2; $i -ge 1; $i--) { $source = "$logPath.$i" $target = "$logPath.$($i + 1)" if (Test-Path -LiteralPath $source -PathType Leaf) { Move-Item -LiteralPath $source -Destination $target -Force } } Move-Item -LiteralPath $logPath -Destination "$logPath.1" -Force if (Test-Path -LiteralPath "$logPath.4") { Remove-Item -LiteralPath "$logPath.4" -Force } } } $type = if ($Level -eq 'Warning') { 2 } elseif ($Level -eq 'Error') { 3 } else { 1 } $details = @() if ($Data) { foreach ($key in @($Data.Keys | Sort-Object)) { $value=$Data[$key]; if ($value -is [System.Collections.IEnumerable] -and $value -isnot [string]) { $value=@($value)-join ',' }; $details += ('{0}={1}' -f $key,$value) } } $msg = if ($Message) { "[$Event] $Message" } else { "[$Event]" } if ($details.Count -gt 0) { $msg += ' | ' + ($details -join '; ') } $msg = $msg -replace '\]LOG\]!>', ']LOG]! >' $msg = $msg -replace '[\r\n\x00-\x1F]+', ' ' $now=(Get-Date).ToUniversalTime(); $time=$now.ToString('HH:mm:ss.fff') + '+000'; $date=$now.ToString('MM-dd-yyyy'); $thread=[System.Threading.Thread]::CurrentThread.ManagedThreadId $line='<![LOG[{0}]LOG]!><time="{1}" date="{2}" component="PreInstall" context="" type="{3}" thread="{4}" file="">' -f $msg,$time,$date,$type,$thread Add-Content -LiteralPath $logPath -Value $line -Encoding UTF8 } function Stop-PreInstallProcessTree { param( [Parameter(Mandatory)][System.Diagnostics.Process]$Process, [ValidateRange(1,30)][int]$TimeoutSeconds = 5 ) try { $Process.Refresh() } catch {} if ($Process.HasExited) { return $true } # setup.exe can leave Office acquisition child processes behind. Use the # Windows process-tree terminator, but bound that helper as well so the # PreInstall timeout can never turn into another unbounded wait. $taskKillPath = Join-Path $env:SystemRoot 'System32\taskkill.exe' if (Test-Path -LiteralPath $taskKillPath -PathType Leaf) { try { $taskKill = Start-Process -FilePath $taskKillPath -ArgumentList @('/PID', $Process.Id, '/T', '/F') -WindowStyle Hidden -PassThru if (-not $taskKill.WaitForExit($TimeoutSeconds * 1000)) { try { $taskKill.Kill() } catch {} } } catch {} } try { $Process.Refresh() } catch {} if (-not $Process.HasExited) { try { $Process.Kill() } catch {} } try { [void]$Process.WaitForExit($TimeoutSeconds * 1000) } catch {} try { $Process.Refresh() } catch {} return [bool]$Process.HasExited } function Write-PreInstallResult { param( [Parameter(Mandatory)][ValidateSet('Succeeded','CompletedWithErrors','Failed')][string]$Status, [string]$FailureType, [string]$ErrorMessage ) $directory = Split-Path -Path $preInstallResultPath -Parent New-Item -ItemType Directory -Path $directory -Force | Out-Null $temporaryPath = "$preInstallResultPath.$([guid]::NewGuid().ToString('N')).tmp" $successful = @($appResults | Where-Object { $_.Status -eq 'Succeeded' }).Count $failed = @($appResults | Where-Object { $_.Status -eq 'Failed' }).Count $record = [ordered]@{ SchemaVersion = '2.0' DeploymentId = if ($manifest -and $manifest.DeploymentId) { [string]$manifest.DeploymentId } else { $null } Phase = 'PreInstall' Status = $Status StartedAtUtc = $preInstallStartedAtUtc CompletedAtUtc = (Get-Date).ToUniversalTime().ToString('o') DurationSeconds = [math]::Round($preInstallStopwatch.Elapsed.TotalSeconds, 1) BuiltInPackageCount = @($builtInApps).Count SuccessfulBuiltInPackageCount = $successful FailedBuiltInPackageCount = $failed CacheUsed = [bool]$usbRoot CachePath = $usbRoot FallbackPackageCount = @($appResults | Where-Object { $_.AcquisitionOutcome -eq 'FallbackCacheUsed' }).Count OfflineCachePackageCount = @($appResults | Where-Object { $_.AcquisitionOutcome -eq 'OfflineCacheUsed' }).Count HasAcquisitionWarnings = @($appResults | Where-Object { $_.AcquisitionOutcome -in @('FallbackCacheUsed','OfflineCacheUsed') }).Count -gt 0 NetworkAvailable = $networkAvailable Packages = @($appResults | ForEach-Object { $_ }) FailureType = $FailureType Error = $ErrorMessage } try { $record | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $temporaryPath -Encoding UTF8 -ErrorAction Stop Move-Item -LiteralPath $temporaryPath -Destination $preInstallResultPath -Force -ErrorAction Stop } finally { if (Test-Path -LiteralPath $temporaryPath -PathType Leaf) { Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue } } } function Get-PreInstallFailureType { param([Parameter(Mandatory)][System.Management.Automation.ErrorRecord]$ErrorRecord) if ($ErrorRecord.Exception.Data.Contains('OSDStageCacheLockFailure')) { return 'CacheLockUnavailable' } $message = $ErrorRecord.Exception.Message if ($ErrorRecord.Exception -is [System.Security.SecurityException]) { return 'TrustValidationFailed' } if ($ErrorRecord.Exception -is [System.IO.InvalidDataException]) { return 'ManifestInvalid' } if ($message -match 'Insufficient free space') { return 'InsufficientDiskSpace' } if ($message -match 'no network|not reachable') { return 'NetworkUnavailable' } if ($message -match 'timeout|exceeded') { return 'Timeout' } if ($message -match 'no staged|no .*cached|missing') { return 'CacheOrSourceMissing' } if ($message -match 'metadata|HTTP|response|download|acquisition') { return 'RemoteAcquisition' } 'PreInstallError' } function Set-PreInstallAcquisitionResult { param( [Parameter(Mandatory)][hashtable]$Result, [Parameter(Mandatory)][ValidateSet('Downloaded','CacheReused','OfflineCacheUsed','FallbackCacheUsed')][string]$Outcome, [System.Management.Automation.ErrorRecord]$RefreshError, [string]$CacheDecisionReason ) $Result.Outcome = $Outcome # Current primary-content acquisition, not a version comparison or an earlier cache sync. # Helper-only downloads (for example the Teams bootstrapper) do not set this flag. $Result.PackageUpdated = ($Outcome -eq 'Downloaded') if ($CacheDecisionReason) { $Result.CacheDecisionReason = $CacheDecisionReason } # Offline/fallback outcomes supersede any earlier reason for attempting a refresh. if ($Outcome -eq 'OfflineCacheUsed') { $Result.CacheDecisionReason = 'No network was available; existing staged content passed the applicable fallback checks. Currency was not checked.' } elseif ($Outcome -eq 'FallbackCacheUsed') { $Result.CacheDecisionReason = 'Refresh failed; existing staged content passed the applicable fallback checks. Currency was not confirmed.' if ($RefreshError) { $Result.CacheDecisionReason += ' ' + $RefreshError.Exception.Message } } if ($RefreshError) { $Result.RefreshFailureType = Get-PreInstallFailureType -ErrorRecord $RefreshError $Result.RefreshError = $RefreshError.Exception.Message } } function Get-PreInstallHttpCacheReason { param( [AllowNull()][AllowEmptyString()][string]$PackagePath, $CacheInfo, [Parameter(Mandatory)]$Remote, [Parameter(Mandatory)][bool]$MetadataMatches ) # Explain the existing HTTP comparison; do not add a freshness or integrity check. if ($MetadataMatches) { return 'The existing primary payload matches the vendor HTTP metadata.' } if (-not $PackagePath -or -not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) { return 'The primary payload is missing from the selected cache location.' } if (-not $CacheInfo) { return 'CacheInfo.json is missing or unreadable; cache currency cannot be established.' } if (($Remote.ETag -and $CacheInfo.RemoteETag) -or ($Remote.LastModified -and $CacheInfo.RemoteLastModified -and $Remote.ContentLength -and $CacheInfo.RemoteContentLength)) { return 'Vendor HTTP metadata differs from the recorded cache metadata; this does not necessarily mean a newer app version.' } return 'Comparable vendor/cache HTTP metadata is unavailable; cache currency cannot be established.' } function Test-InternetEndpoint { param([Parameter(Mandatory)][string]$Uri,[int]$TimeoutSec=10) try { Invoke-WebRequest -Uri $Uri -Method Head -UseBasicParsing -TimeoutSec $TimeoutSec -ErrorAction Stop | Out-Null; return $true } catch { return $false } } function Get-OfficeCacheVersion { param([Parameter(Mandatory)][string]$OfficeRoot) $dataPath=Join-Path $OfficeRoot 'Office\Data' if (-not (Test-Path -LiteralPath $dataPath -PathType Container)) { return $null } $versions=@(Get-ChildItem -LiteralPath $dataPath -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '^\d+\.\d+\.\d+\.\d+$' } | ForEach-Object { try { [pscustomobject]@{Name=$_.Name;Version=[version]$_.Name} } catch{} } | Sort-Object Version -Descending) if ($versions.Count -gt 0) { return $versions[0].Name } return $null } function Test-OfficeCacheGeneration { param( [Parameter(Mandatory)][string]$OfficeRoot, [Parameter(Mandatory)][string]$ExpectedVersion, [ValidateSet('x64','x86')][string]$Architecture='x64' ) $dataPath=Join-Path $OfficeRoot 'Office\Data';$prefix=if($Architecture -eq 'x86'){'v32'}else{'v64'} $versions=@(Get-ChildItem -LiteralPath $dataPath -Directory -ErrorAction SilentlyContinue|Where-Object{$_.Name -match '^\d+\.\d+\.\d+\.\d+$'}) if($versions.Count -ne 1){throw "Expected exactly one Office version directory; found $($versions.Count)."} if($versions[0].Name -ne $ExpectedVersion){throw "Office cache version '$($versions[0].Name)' does not match '$ExpectedVersion'."} if(@(Get-ChildItem -LiteralPath $versions[0].FullName -File -Recurse -ErrorAction Stop).Count -eq 0){throw 'The active Office version directory contains no files.'} $sharedCab=Join-Path $dataPath "$prefix.cab";$versionCab=Join-Path $dataPath ("{0}_{1}.cab" -f $prefix,$ExpectedVersion) if(-not(Test-Path -LiteralPath $sharedCab -PathType Leaf)){throw "$prefix.cab is missing."} if(-not(Test-Path -LiteralPath $versionCab -PathType Leaf)){throw "The version-specific $prefix CAB is missing."} $sharedHash=(Get-FileHash -LiteralPath $sharedCab -Algorithm SHA256).Hash;$versionHash=(Get-FileHash -LiteralPath $versionCab -Algorithm SHA256).Hash if($sharedHash -ne $versionHash){throw "$prefix.cab does not match the active version-specific CAB."} $true } function Set-OfficeDownloadSourcePath { param([Parameter(Mandatory)][string]$SourcePath,[Parameter(Mandatory)][string]$DestinationPath) [xml]$xml=Get-Content -LiteralPath $SourcePath -Raw -Encoding UTF8 $add=$xml.Configuration.Add if(-not $add){throw 'Office configuration does not contain an Add element.'} $add.SetAttribute('SourcePath',(Split-Path -Path $DestinationPath -Parent)) $settings=[System.Xml.XmlWriterSettings]::new();$settings.Indent=$true;$settings.Encoding=[System.Text.UTF8Encoding]::new($false) $writer=[System.Xml.XmlWriter]::Create($DestinationPath,$settings);try{$xml.Save($writer)}finally{$writer.Dispose()} } function Get-MicrosoftTeamsPackageVersion { param([Parameter(Mandatory)][string]$Path) Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue $archive=[System.IO.Compression.ZipFile]::OpenRead($Path) try { $entry=$archive.Entries | Where-Object { $_.FullName -eq 'AppxManifest.xml' } | Select-Object -First 1 if (-not $entry) { throw 'AppxManifest.xml was not found in the Teams MSIX.' } $reader=New-Object System.IO.StreamReader($entry.Open()) try { [xml]$m=$reader.ReadToEnd() } finally { $reader.Dispose() } return [string]$m.Package.Identity.Version } finally { $archive.Dispose() } } function Get-RemoteMetadata { param([Parameter(Mandatory)][string]$Uri,[int]$TimeoutSec=15) $handler=[System.Net.Http.HttpClientHandler]::new(); $handler.AllowAutoRedirect=$true $client=[System.Net.Http.HttpClient]::new($handler); $client.Timeout=[TimeSpan]::FromSeconds($TimeoutSec) try { $request=[System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Head,$Uri) $response=$client.SendAsync($request).GetAwaiter().GetResult() try { $response.EnsureSuccessStatusCode() | Out-Null [pscustomobject]@{ ETag=if($response.Headers.ETag){[string]$response.Headers.ETag.Tag}else{$null} LastModified=if($response.Content.Headers.LastModified){$response.Content.Headers.LastModified.UtcDateTime.ToString('o')}else{$null} ContentLength=if($response.Content.Headers.ContentLength){[int64]$response.Content.Headers.ContentLength}else{$null} FinalUri=[string]$response.RequestMessage.RequestUri.AbsoluteUri } } finally { $response.Dispose(); $request.Dispose() } } finally { $client.Dispose(); $handler.Dispose() } } function Save-PreInstallDownload { [CmdletBinding(DefaultParameterSetName='Exact')] param( [Parameter(Mandatory)][string]$Uri, [Parameter(Mandatory,ParameterSetName='Exact')][string]$DestinationPath, [Parameter(Mandatory,ParameterSetName='Vendor')][string]$DestinationDirectory, [Parameter(Mandatory,ParameterSetName='Vendor')][string]$ExpectedExtension, [Parameter(Mandatory)][string]$Id, [Parameter(Mandatory)][string]$Description, [int]$TimeoutMinutes = 30 ) $directory = if ($PSCmdlet.ParameterSetName -eq 'Vendor') { $DestinationDirectory } else { Split-Path -Path $DestinationPath -Parent } $extension = if ($PSCmdlet.ParameterSetName -eq 'Vendor') { $ExpectedExtension } else { [IO.Path]::GetExtension($DestinationPath) } New-Item -ItemType Directory -Path $directory -Force -ErrorAction Stop | Out-Null $tempPath = Join-Path $directory ('.osdstage-download-' + [guid]::NewGuid().ToString('N')) $handler = [Net.Http.HttpClientHandler]::new(); $handler.AllowAutoRedirect = $true $client = [Net.Http.HttpClient]::new($handler); $client.Timeout = [TimeSpan]::FromMinutes($TimeoutMinutes) $response = $null; $source = $null; $target = $null $watch = [Diagnostics.Stopwatch]::StartNew() Write-PreInstallLog -Event 'DownloadStart' -Message "Downloading $Description." -Data @{Id=$Id;Uri=$Uri;Destination=$directory} try { $response = $client.GetAsync($Uri,[Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult() $response.EnsureSuccessStatusCode() | Out-Null $name = Get-OSDStageDownloadFileName -Response $response -ExpectedExtension $extension if ($PSCmdlet.ParameterSetName -eq 'Exact' -and $name -cne (Split-Path -Path $DestinationPath -Leaf)) { throw [IO.InvalidDataException]::new('Vendor response filename differs from the requested exact filename.') } $DestinationPath = Join-Path $directory $name $source = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() $target = [IO.FileStream]::new($tempPath,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) $buffer = New-Object byte[] (1024 * 1024) [long]$downloaded = 0 while (($read=$source.Read($buffer,0,$buffer.Length)) -gt 0) { $target.Write($buffer,0,$read); $downloaded += $read } $target.Dispose(); $target = $null $expectedBytes = $response.Content.Headers.ContentLength if ($downloaded -le 0 -or ($null -ne $expectedBytes -and $downloaded -ne $expectedBytes)) { throw [IO.InvalidDataException]::new('Downloaded payload is empty or differs from Content-Length.') } Move-Item -LiteralPath $tempPath -Destination $DestinationPath -Force -ErrorAction Stop $watch.Stop() Write-PreInstallLog -Event 'DownloadComplete' -Message "$Description download completed." -Data @{ Id=$Id;Bytes=$downloaded;SizeMB=[math]::Round($downloaded/1MB,1) DurationSeconds=[math]::Round($watch.Elapsed.TotalSeconds,1) AverageMBps=[math]::Round(($downloaded/1MB)/[math]::Max(0.001,$watch.Elapsed.TotalSeconds),1) Destination=$DestinationPath;PayloadFileName=$name } Get-Item -LiteralPath $DestinationPath -ErrorAction Stop } finally { if ($target) { $target.Dispose() }; if ($source) { $source.Dispose() }; if ($response) { $response.Dispose() } $client.Dispose(); $handler.Dispose(); $watch.Stop() if (Test-Path -LiteralPath $tempPath) { Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue } } } function Copy-DirectoryReplace { param([Parameter(Mandatory)][string]$Source,[Parameter(Mandatory)][string]$Destination) New-Item -ItemType Directory -Path (Split-Path $Destination -Parent) -Force | Out-Null $temp="$Destination.refresh" if(Test-Path -LiteralPath $temp){Remove-Item -LiteralPath $temp -Recurse -Force} Copy-Item -LiteralPath $Source -Destination $temp -Recurse -Force if(Test-Path -LiteralPath $Destination){Remove-Item -LiteralPath $Destination -Recurse -Force} Move-Item -LiteralPath $temp -Destination $Destination -Force } # Avoid PowerShell's self-copy error when the OSDCloud USB cache is absent. # In that case the acquisition directory is already the local staged directory. function Copy-OfficeConfigurationToCache { [CmdletBinding()] param( [Parameter(Mandatory)][string]$LocalConfig, [Parameter(Mandatory)][string]$AcquireConfig ) $sourcePath = [System.IO.Path]::GetFullPath($LocalConfig) $destinationPath = [System.IO.Path]::GetFullPath($AcquireConfig) if ([string]::Equals($sourcePath, $destinationPath, [System.StringComparison]::OrdinalIgnoreCase)) { return } Copy-Item -LiteralPath $LocalConfig -Destination $AcquireConfig -Force -ErrorAction Stop } function Test-LocalOfficeSource { param([string]$Root, [Parameter(Mandatory)][string]$ConfigurationPath, [hashtable]$Decision) try{ $cacheInfoPath=Join-Path $Root 'CacheInfo.json' if(-not(Test-Path -LiteralPath $cacheInfoPath -PathType Leaf)){ if ($null -ne $Decision) { $Decision.CacheDecisionReason = 'The checked Office location has no recorded generation; CacheInfo.json is missing.' } return $false } $cacheInfo=Get-Content -LiteralPath $cacheInfoPath -Raw -Encoding UTF8|ConvertFrom-Json Test-OfficeCacheGeneration -OfficeRoot $Root -ExpectedVersion ([string]$cacheInfo.Version) -Architecture $(if([string]$cacheInfo.Architecture -eq 'x86'){'x86'}else{'x64'})|Out-Null $compatibility = Test-OSDStageOfficeCacheCompatibility -OfficeRoot $Root -ConfigurationPath $ConfigurationPath if ($null -ne $Decision) { $Decision.CacheDecisionReason = if ($compatibility.Compatible) { 'The Office generation and requested configuration passed validation.' } else { 'The Office cache is incompatible with the requested configuration. ' + ($compatibility.Reasons -join ' ') } } if (-not $compatibility.Compatible) { Write-PreInstallLog -Event 'OfficeCacheIncompatible' -Level Warning -Message 'Office fallback does not match the requested configuration.' -Data @{ Id='Microsoft365Apps'; Reasons=($compatibility.Reasons -join ' ') } } return $compatibility.Compatible }catch{ if ($null -ne $Decision) { $Decision.CacheDecisionReason = 'Office generation validation failed. ' + $_.Exception.Message } return $false } } function Copy-OfficeCachePayload { param([string]$SourceRoot, [string]$DestinationRoot, [string]$ConfigurationPath) $compatibility = Test-OSDStageOfficeCacheCompatibility -OfficeRoot $SourceRoot -ConfigurationPath $ConfigurationPath if (-not $compatibility.Compatible) { throw ('Office cache cannot satisfy this deployment. ' + ($compatibility.Reasons -join ' ')) } # Never copy the cache XML over the selected deployment configuration. Copy-Item -LiteralPath (Join-Path $SourceRoot 'setup.exe') -Destination (Join-Path $DestinationRoot 'setup.exe') -Force Copy-DirectoryReplace -Source (Join-Path $SourceRoot 'Office') -Destination (Join-Path $DestinationRoot 'Office') $info = Get-Content -LiteralPath (Join-Path $SourceRoot 'CacheInfo.json') -Raw -Encoding UTF8 | ConvertFrom-Json $info | Add-Member -NotePropertyName PayloadProfile -NotePropertyValue $compatibility.CachedProfile -Force $info | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath (Join-Path $DestinationRoot 'CacheInfo.json') -Encoding UTF8 } function Test-LocalMicrosoftTeamsSource { param([string]$Root) return ((Test-Path -LiteralPath (Join-Path $Root 'teamsbootstrapper.exe') -PathType Leaf) -and (Test-OSDStageBuiltInPayload -Root $Root -ExpectedExtension '.msix')) } function Test-LocalAdobeAcrobatUnifiedSource { param([string]$Root) return (Test-OSDStageBuiltInPayload -Root $Root -ExpectedExtension '.zip') } function Test-LocalMsiSource { param([string]$Root) return (Test-OSDStageBuiltInPayload -Root $Root -ExpectedExtension '.msi') } function Restore-PreInstallBuiltInCache { param([Parameter(Mandatory)][string]$Root, [Parameter(Mandatory)][string]$Id) $recovery = Restore-OSDStageCacheGeneration -Root $Root -Validate { param($candidate) Assert-OSDStageRecoverableBuiltInGeneration -Root $candidate -Id $Id -SignatureValidator { param($appId, $file) Assert-PreInstallSignedPayload -Id $appId -Path $file } -OfficeValidator { param($officeRoot, $version, $architecture) Test-OfficeCacheGeneration -OfficeRoot $officeRoot -ExpectedVersion $version -Architecture $architecture | Out-Null } -TeamsVersionReader { param($file) Get-MicrosoftTeamsPackageVersion -Path $file } } if ($recovery) { Write-PreInstallLog -Event 'CacheGenerationRecovered' -Message 'Validated previous USB cache generation restored after an interrupted directory swap.' -Data @{Id=$Id;Path=$Root;PreviousPath=$recovery.PreviousPath} } } function Remove-PreInstallTransactionResidue { param([Parameter(Mandatory)][string]$Root) $parent=Split-Path -Path $Root -Parent;$leaf=Split-Path -Path $Root -Leaf Get-ChildItem -LiteralPath $parent -Directory -ErrorAction SilentlyContinue| Where-Object{$_.Name -like "$leaf.osdstage-new-*" -or $_.Name -like "$leaf.osdstage-old-*"}| ForEach-Object{Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue} } function Assert-PreInstallSignedPayload { param( [Parameter(Mandatory)][string]$Id, [Parameter(Mandatory)][string]$Path ) # SetupComplete runs standalone: the private OSDStage module functions # are not guaranteed to be installed on the target Windows machine. $expected = switch ($Id) { 'Microsoft365Apps' { 'Microsoft Corporation'; break } 'MicrosoftTeams' { 'Microsoft Corporation'; break } 'GoogleChromeEnterprise' { 'Google LLC'; break } 'CiscoWebex' { 'Cisco Systems, Inc.'; break } 'ZoomWorkplace' { 'Zoom Communications, Inc.'; break } 'MozillaFirefoxEnterprise' { 'Mozilla Corporation'; break } default { $null } } if (-not $expected) { throw [System.Security.SecurityException]::new("No trusted signer policy exists for built-in '$Id'.") } if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Required signed built-in file missing: $Path" } $signature = Get-AuthenticodeSignature -LiteralPath $Path -ErrorAction Stop if ([string]$signature.Status -ne 'Valid' -or -not $signature.SignerCertificate) { throw [System.Security.SecurityException]::new("Built-in '$Id' signature validation failed (status: $($signature.Status)): $Path") } $subject = [string]$signature.SignerCertificate.Subject $orgMatch = [regex]::Match($subject, '(?:^|,\s*)O=(?:"(?<quoted>(?:[^"]|"")*)"|(?<plain>[^,]+))(?=,|$)') $org = if ($orgMatch.Success) { if ($orgMatch.Groups['quoted'].Success) { $orgMatch.Groups['quoted'].Value.Replace('""','"') } else { $orgMatch.Groups['plain'].Value.Trim() } } else { $null } if (-not [string]::Equals($org,$expected,[StringComparison]::OrdinalIgnoreCase)) { throw [System.Security.SecurityException]::new("Built-in '$Id' publisher mismatch. Expected '$expected'; signer subject: $subject") } Write-PreInstallLog -Event 'BuiltInSignatureVerified' -Message 'Built-in MSI/EXE Authenticode and expected publisher validated.' -Data @{Id=$Id;File=$Path;Publisher=$expected} } function Sync-PreInstallVendorMsi { param( [Parameter(Mandatory)][psobject]$App, [Parameter(Mandatory)][string]$RelativeRoot, [Parameter(Mandatory)][string]$PackageUri, [Parameter(Mandatory)][string]$DisplayName, [Parameter(Mandatory)][hashtable]$Acquisition, [ValidateSet('.msi','.zip')][string]$ExpectedExtension = '.msi' ) $id = [string]$App.Id $localRoot = Join-Path $StagedPath $RelativeRoot $acquireRoot = if ($usbRoot) { Join-Path $usbRoot $RelativeRoot } else { $localRoot } New-Item -ItemType Directory -Path $localRoot -Force | Out-Null if ($usbRoot) { Restore-PreInstallBuiltInCache -Root $acquireRoot -Id $id } New-Item -ItemType Directory -Path $acquireRoot -Force | Out-Null function Assert-SelectedPayload { param([string]$Root) $path = Assert-OSDStageCachedPayload -Root $Root -ExpectedExtension $ExpectedExtension -Id $id -Architecture ([string]$App.Architecture) if ($ExpectedExtension -eq '.msi') { Assert-PreInstallSignedPayload -Id $id -Path $path } else { Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop $zip = [IO.Compression.ZipFile]::OpenRead($path) try { if ($zip.Entries.Count -eq 0) { throw [IO.InvalidDataException]::new('Vendor ZIP is empty.') } } finally { $zip.Dispose() } } $path } if ($usbRoot -and -not (Test-OSDStageBuiltInPayload -Root $localRoot -ExpectedExtension $ExpectedExtension) -and (Test-OSDStageBuiltInPayload -Root $acquireRoot -ExpectedExtension $ExpectedExtension)) { $path = Assert-SelectedPayload -Root $acquireRoot Copy-OSDStageBuiltInPayload -Path $path -DestinationDirectory $localRoot Copy-Item -LiteralPath (Join-Path $acquireRoot 'CacheInfo.json') -Destination (Join-Path $localRoot 'CacheInfo.json') -Force -ErrorAction Stop Write-PreInstallLog -Event 'BuiltInCacheRestaged' -Message "$DisplayName USB payload staged locally." -Data @{Id=$id;PayloadFileName=(Split-Path $path -Leaf)} } if (-not $networkAvailable) { if (-not (Test-OSDStageBuiltInPayload -Root $localRoot -ExpectedExtension $ExpectedExtension)) { $Acquisition.CacheDecisionReason = 'The cached payload is missing or its metadata is unsupported; a new download is required.' throw "$DisplayName requires a download, but no network and no supported staged cache are available." } Assert-SelectedPayload -Root $localRoot | Out-Null Set-PreInstallAcquisitionResult -Result $Acquisition -Outcome OfflineCacheUsed Write-PreInstallLog -Event 'BuiltInRefreshSkipped' -Level Warning -Message "No network; validated $DisplayName payload will be used." -Data @{Id=$id} return } try { $previous = $null; $path = $null $refreshRequired = $false if (Test-OSDStageBuiltInPayload -Root $acquireRoot -ExpectedExtension $ExpectedExtension) { $path = Assert-SelectedPayload -Root $acquireRoot $previous = Get-Content -LiteralPath (Join-Path $acquireRoot 'CacheInfo.json') -Raw -Encoding UTF8 | ConvertFrom-Json } elseif (Test-Path -LiteralPath (Join-Path $acquireRoot 'CacheInfo.json')) { $refreshRequired = $true $Acquisition.CacheDecisionReason = 'The primary payload is missing, or the cache has unsupported metadata; a new validated generation is required.' Write-PreInstallLog -Event 'BuiltInCacheRefreshRequired' -Level Warning -Message $Acquisition.CacheDecisionReason -Data @{Id=$id;Path=$acquireRoot} } $remote = Get-RemoteMetadata -Uri $PackageUri $matches = $false if ($path -and $previous -and [string]$previous.PackageUri -ceq $PackageUri) { if ($remote.ETag -and $previous.RemoteETag) { $matches = $remote.ETag -eq [string]$previous.RemoteETag } elseif ($remote.LastModified -and $previous.RemoteLastModified -and $remote.ContentLength -and $previous.RemoteContentLength) { $matches = ($remote.LastModified -eq [string]$previous.RemoteLastModified -and [long]$remote.ContentLength -eq [long]$previous.RemoteContentLength) } } $Acquisition.CacheDecisionReason = Get-PreInstallHttpCacheReason -PackagePath $path -CacheInfo $previous -Remote $remote -MetadataMatches $matches if ($refreshRequired) { $Acquisition.CacheDecisionReason = 'The primary payload is missing, or the cache has unsupported metadata; a new validated generation is required.' } if (-not $matches) { Invoke-OSDStageBuiltInCacheTransaction -Root $acquireRoot -ExpectedBytes ([double]$remote.ContentLength) -MinimumFreeSpaceGB $(if($ExpectedExtension -eq '.zip'){3}else{1}) -Build { param($candidate) $download = Save-PreInstallDownload -Uri $PackageUri -DestinationDirectory $candidate -ExpectedExtension $ExpectedExtension -Id $id -Description $DisplayName -TimeoutMinutes 30 if (-not $download -or -not $download.FullName) { throw 'Download must return its actual vendor payload path.' } $payload = [string]$download.FullName if ($ExpectedExtension -eq '.msi') { Assert-PreInstallSignedPayload -Id $id -Path $payload } $record = [ordered]@{ Id=$id;Cached=[bool]$usbRoot;Version='Current';Architecture=$App.Architecture PayloadFileName=(Split-Path -Path $payload -Leaf) Sha256=(Get-FileHash -LiteralPath $payload -Algorithm SHA256).Hash;Size=(Get-Item -LiteralPath $payload).Length SourcePolicy='Evergreen';SyncMethod='HttpMetadata';Updated=$true;PackageUri=$PackageUri RemoteETag=$remote.ETag;RemoteLastModified=$remote.LastModified;RemoteContentLength=$remote.ContentLength RemoteFinalUri=$remote.FinalUri;SyncedAt=(Get-Date).ToUniversalTime().ToString('o') } if ($App.PSObject.Properties['Channel']) { $record.Channel = [string]$App.Channel } if ($App.PSObject.Properties['Language']) { $record.Language = [string]$App.Language } $record | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path $candidate 'CacheInfo.json') -Encoding UTF8 -ErrorAction Stop } -Validate { param($candidate) Assert-SelectedPayload -Root $candidate | Out-Null } | Out-Null } $path = Assert-SelectedPayload -Root $acquireRoot if ($usbRoot) { Copy-OSDStageBuiltInPayload -Path $path -DestinationDirectory $localRoot Copy-Item -LiteralPath (Join-Path $acquireRoot 'CacheInfo.json') -Destination (Join-Path $localRoot 'CacheInfo.json') -Force -ErrorAction Stop } Set-PreInstallAcquisitionResult -Result $Acquisition -Outcome $(if($matches){'CacheReused'}else{'Downloaded'}) Write-PreInstallLog -Event 'BuiltInRefreshComplete' -Message "$DisplayName is ready for installation." -Data @{Id=$id;PayloadFileName=(Split-Path $path -Leaf);PackageUpdated=(-not $matches);CacheSynchronized=[bool]$usbRoot} } catch { if ($_.Exception -is [Security.SecurityException] -or $_.Exception -is [IO.InvalidDataException]) { throw } $refreshError = $_ if (Test-OSDStageBuiltInPayload -Root $localRoot -ExpectedExtension $ExpectedExtension) { Assert-SelectedPayload -Root $localRoot | Out-Null Set-PreInstallAcquisitionResult -Result $Acquisition -Outcome FallbackCacheUsed -RefreshError $refreshError Write-PreInstallLog -Event 'BuiltInRefreshFailed' -Level Warning -Message $refreshError.Exception.Message -Data @{Id=$id;Fallback='ExistingStagedPayload'} return } throw "$DisplayName acquisition failed and no staged fallback is available. $($refreshError.Exception.Message)" } } try { if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { throw "Device manifest not found: $manifestPath" } . (Join-Path $PSScriptRoot 'OSDStageSleepPrevention.ps1') $manifest=Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json if (-not $manifest.Runtime) { throw 'Device manifest Runtime configuration is missing.' } if ($manifest.Runtime.ResultPath) { $resultPath = [IO.Path]::GetFullPath([Environment]::ExpandEnvironmentVariables([string]$manifest.Runtime.ResultPath)) $preInstallResultPath = Join-Path (Split-Path -Path $resultPath -Parent) 'PreInstall.json' } if (-not $manifest.Runtime.CacheVolumeLabel) { throw 'Device manifest Runtime.CacheVolumeLabel is missing.' } if (-not $manifest.Runtime.LogPath) { throw 'Device manifest Runtime.LogPath is missing.' } $cacheVolumeLabel = [string]$manifest.Runtime.CacheVolumeLabel $logPath = [Environment]::ExpandEnvironmentVariables([string]$manifest.Runtime.LogPath) $logDirectory = Split-Path -Path $logPath -Parent $preventSleep = Get-OSDStagePreventSleep -Runtime $manifest.Runtime $sleepLog = { param($EventName, $Level, $Message) Write-PreInstallLog -Event $EventName -Level $Level -Message $Message } $sleepRequest = Start-OSDStageSleepPrevention -Enabled $preventSleep -Reason 'OSDStage PreInstall is acquiring app content.' -Log $sleepLog $apps=if($manifest.PSObject.Properties.Name -contains 'Packages'){@($manifest.Packages)}else{@()} $builtInApps=@($apps | Where-Object { [string]$_.Source -eq 'BuiltIn' -and [string]$_.Type -notin @('WindowsInboxCleanupScript','WindowsFeatureScript','WindowsCapabilityScript','DefaultAssociationScript','EnvironmentVariableScript','ServiceScript','DesktopSettingsScript','WiFiProfileScript','CertificateScript','CompanyPortalAppxBundle') -and [string]$_.Id -ne 'CMTraceOpen' }) if($builtInApps.Count -eq 0){ $preInstallStopwatch.Stop() Write-PreInstallLog -Event 'RefreshSkipped' -Message 'No built-in apps require acquisition. Nothing to refresh.' try { Write-PreInstallResult -Status Succeeded } catch { Write-PreInstallLog -Event 'ResultWriteFailed' -Level Warning -Message $_.Exception.Message } exit 0 } $cacheVolume=Get-Volume -ErrorAction SilentlyContinue | Where-Object { $_.FileSystemLabel -eq $cacheVolumeLabel -and $_.DriveLetter } | Select-Object -First 1 $usbRoot=if($cacheVolume){"$($cacheVolume.DriveLetter):\OSDStage"}else{$null} $lockRoot = if ($usbRoot) { $usbRoot } else { $StagedPath } $cacheLock = Enter-OSDStageCacheLock -CachePath $lockRoot -Operation 'PreInstall cache acquisition and recovery' Write-PreInstallLog -Event 'RefreshStart' -Message 'Starting built-in acquisition and refresh before installation.' -Data @{StagedPath=$StagedPath;BuiltInApps=@($builtInApps.Id);CacheAvailable=[bool]$usbRoot;CacheRoot=$usbRoot} if($usbRoot){New-Item -ItemType Directory -Path $usbRoot -Force | Out-Null; Write-PreInstallLog -Event 'CacheVolumeFound' -Message 'OSDCloud USB cache is available and will be used automatically.' -Data @{CacheRoot=$usbRoot}} else{Write-PreInstallLog -Event 'CacheVolumeNotFound' -Message 'No OSDCloud USB cache is connected. Built-ins will be acquired directly to the local Windows runtime.'} $networkAvailable=[System.Net.NetworkInformation.NetworkInterface]::GetIsNetworkAvailable() foreach($app in $builtInApps){ $builtInStopwatch = [System.Diagnostics.Stopwatch]::StartNew() $appError = $null $appFailureType = $null $acquisition = @{ Outcome = 'Failed'; PackageUpdated = $null; CacheDecisionReason = $null; RefreshFailureType = $null; RefreshError = $null } try { switch($app.Id){ 'Microsoft365Apps' { $localRoot=Join-Path $StagedPath 'BuiltIn\Microsoft365Apps' New-Item -ItemType Directory -Path $localRoot -Force | Out-Null $acquireRoot=if($usbRoot){Join-Path $usbRoot 'BuiltIn\Microsoft365Apps'}else{$localRoot} if($usbRoot){Restore-PreInstallBuiltInCache -Root $acquireRoot -Id 'Microsoft365Apps'} New-Item -ItemType Directory -Path $acquireRoot -Force | Out-Null $localConfig=Join-Path $localRoot 'configuration.xml' if(-not (Test-Path -LiteralPath $localConfig -PathType Leaf)){throw 'Microsoft 365 Apps configuration.xml is missing from the staged deployment intent.'} $payloadProfile = Get-OSDStageOfficePayloadProfile -Path $localConfig $officeArchitecture = if ($payloadProfile.Architecture) { $payloadProfile.Architecture } else { [string]$app.Architecture } $officeUsbDecision = @{ CacheDecisionReason = $null } if($usbRoot -and -not (Test-LocalOfficeSource -Root $localRoot -ConfigurationPath $localConfig) -and (Test-LocalOfficeSource -Root $acquireRoot -ConfigurationPath $localConfig -Decision $officeUsbDecision)){ Copy-OfficeCachePayload -SourceRoot $acquireRoot -DestinationRoot $localRoot -ConfigurationPath $localConfig Write-PreInstallLog -Event 'BuiltInCacheRestaged' -Message 'Existing Microsoft 365 Apps USB cache was staged locally.' -Data @{Id='Microsoft365Apps'} } if(-not $networkAvailable){ if(Test-LocalOfficeSource -Root $localRoot -ConfigurationPath $localConfig){Assert-PreInstallSignedPayload -Id 'Microsoft365Apps' -Path (Join-Path $localRoot 'setup.exe');Set-PreInstallAcquisitionResult -Result $acquisition -Outcome OfflineCacheUsed;Write-PreInstallLog -Event 'BuiltInRefreshSkipped' -Level 'Warning' -Message 'No network is available. Existing staged Office payload will be used.' -Data @{Id='Microsoft365Apps'}; continue} throw 'Microsoft 365 Apps has no compatible staged or USB-cached payload for the requested configuration and no network connection is available to acquire one.' } $transactionRoot=$null;$backupRoot=$null try { # Keep the existing generation read-only. Only a compatible, # consistent and current source can bypass ODT acquisition. $officeSourceCompatible = Test-LocalOfficeSource -Root $localRoot -ConfigurationPath $localConfig -Decision $acquisition if ($officeSourceCompatible -and (Test-OSDStageOfficeCacheCurrent -OfficeRoot $localRoot -ConfigurationPath $localConfig -Decision $acquisition)) { Assert-PreInstallSignedPayload -Id 'Microsoft365Apps' -Path (Join-Path $localRoot 'setup.exe') Set-PreInstallAcquisitionResult -Result $acquisition -Outcome CacheReused Write-PreInstallLog -Event 'BuiltInCacheReused' -Message 'Compatible Microsoft 365 Apps cache matches the requested build or current CDN build; ODT acquisition was skipped.' -Data @{Id='Microsoft365Apps';Version=(Get-OfficeCacheVersion -OfficeRoot $localRoot)} continue } if (-not $officeSourceCompatible -and $officeUsbDecision.CacheDecisionReason) { $acquisition.CacheDecisionReason += ' USB cache assessment: ' + $officeUsbDecision.CacheDecisionReason } Assert-OSDStageTransactionSpace -Path $acquireRoot -ExistingPath $acquireRoot -MinimumFreeSpaceGB 8 -Operation 'Microsoft 365 Apps cache refresh' | Out-Null $transactionRoot="$acquireRoot.osdstage-new-$([guid]::NewGuid().ToString('N'))" $backupRoot="$acquireRoot.osdstage-old-$([guid]::NewGuid().ToString('N'))" New-Item -ItemType Directory -Path $transactionRoot -Force | Out-Null $acquireConfig=Join-Path $transactionRoot 'configuration.xml' Copy-Item -LiteralPath $localConfig -Destination $acquireConfig -Force $downloadConfig=Join-Path $transactionRoot 'download-configuration.xml' Set-OfficeDownloadSourcePath -SourcePath $localConfig -DestinationPath $downloadConfig $setupPath=Join-Path $transactionRoot 'setup.exe' $odtUri=if($app.OfficeDeploymentToolUri){[string]$app.OfficeDeploymentToolUri}else{'https://officecdn.microsoft.com/pr/wsus/setup.exe'} Save-PreInstallDownload -Uri $odtUri -DestinationPath $setupPath -Id 'Microsoft365Apps' -Description 'Office Deployment Tool' -TimeoutMinutes 5 | Out-Null Assert-PreInstallSignedPayload -Id 'Microsoft365Apps' -Path $setupPath if(-not (Test-InternetEndpoint -Uri $odtUri)){throw 'Microsoft 365 Apps CDN is not reachable.'} $previous=Get-OfficeCacheVersion -OfficeRoot $acquireRoot Write-PreInstallLog -Event 'BuiltInRefreshStart' -Message 'Synchronizing Microsoft 365 Apps content.' -Data @{Id='Microsoft365Apps';Target=if($usbRoot){'USB'}else{'Local'};PreviousVersion=$previous;TimeoutMinutes=$OfficeRefreshTimeoutMinutes} $process=Start-Process -FilePath $setupPath -ArgumentList @('/download',$downloadConfig) -WorkingDirectory $transactionRoot -PassThru $sw=[System.Diagnostics.Stopwatch]::StartNew() while(-not $process.HasExited){if($sw.Elapsed.TotalMinutes -ge $OfficeRefreshTimeoutMinutes){$terminated=Stop-PreInstallProcessTree -Process $process -TimeoutSeconds 5;throw "Office Deployment Tool cache refresh exceeded the $OfficeRefreshTimeoutMinutes minute timeout. ProcessTreeTerminated=$terminated"};Start-Sleep -Seconds 2;$process.Refresh()} $sw.Stop() if($process.ExitCode -ne 0){throw "Office Deployment Tool exited with code $($process.ExitCode)."} $version=Get-OfficeCacheVersion -OfficeRoot $transactionRoot if(-not $version){throw 'Office acquisition completed but Office\Data does not contain a resolvable version.'} if ($payloadProfile.Version -match '^\d+\.\d+\.\d+\.\d+$' -and $version -ne $payloadProfile.Version) { throw 'Downloaded Office version does not match the requested version.' } Test-OfficeCacheGeneration -OfficeRoot $transactionRoot -ExpectedVersion $version -Architecture $officeArchitecture | Out-Null $cacheInfo=[ordered]@{Id='Microsoft365Apps';Cached=[bool]$usbRoot;Version=$version;Architecture=$officeArchitecture;Channel=$payloadProfile.Channel;ProductId=@($payloadProfile.Products)[0];Language=@($payloadProfile.Languages | Select-Object -Unique);PayloadProfile=$payloadProfile;SourcePolicy='Evergreen';SyncMethod='OfficeDeploymentTool';Updated=($previous -ne $version);SyncedAt=(Get-Date).ToUniversalTime().ToString('o')} $cacheInfoPath=Join-Path $transactionRoot 'CacheInfo.json';$cacheInfo|ConvertTo-Json -Depth 10|Set-Content -LiteralPath $cacheInfoPath -Encoding UTF8 Remove-Item -LiteralPath $downloadConfig -Force $hadPrevious=Test-Path -LiteralPath $acquireRoot -PathType Container if($hadPrevious){Move-Item -LiteralPath $acquireRoot -Destination $backupRoot -ErrorAction Stop} try{Move-Item -LiteralPath $transactionRoot -Destination $acquireRoot -ErrorAction Stop} catch{if($hadPrevious -and (Test-Path -LiteralPath $backupRoot -PathType Container) -and -not(Test-Path -LiteralPath $acquireRoot)){Move-Item -LiteralPath $backupRoot -Destination $acquireRoot -ErrorAction SilentlyContinue};throw} if(Test-Path -LiteralPath $backupRoot -PathType Container){Remove-Item -LiteralPath $backupRoot -Recurse -Force} Remove-PreInstallTransactionResidue -Root $acquireRoot $setupPath=Join-Path $acquireRoot 'setup.exe';$acquireConfig=Join-Path $acquireRoot 'configuration.xml';$cacheInfoPath=Join-Path $acquireRoot 'CacheInfo.json';$removedVersions=@() if($usbRoot){ Copy-Item -LiteralPath $setupPath -Destination (Join-Path $localRoot 'setup.exe') -Force Copy-Item -LiteralPath $acquireConfig -Destination $localConfig -Force Copy-Item -LiteralPath $cacheInfoPath -Destination (Join-Path $localRoot 'CacheInfo.json') -Force Copy-DirectoryReplace -Source (Join-Path $acquireRoot 'Office') -Destination (Join-Path $localRoot 'Office') } $versionChanged = ($previous -ne $version) Set-PreInstallAcquisitionResult -Result $acquisition -Outcome Downloaded Write-PreInstallLog -Event 'BuiltInRefreshComplete' -Message 'Microsoft 365 Apps content is ready for installation.' -Data @{Id='Microsoft365Apps';Version=$version;CacheSynchronized=[bool]$usbRoot;VersionChanged=$versionChanged;RemovedVersions=$removedVersions;SourcePolicy='Evergreen';SyncMethod='OfficeDeploymentTool';DurationSeconds=[math]::Round($builtInStopwatch.Elapsed.TotalSeconds,1)} } catch { if($transactionRoot -and (Test-Path -LiteralPath $transactionRoot)){Remove-Item -LiteralPath $transactionRoot -Recurse -Force -ErrorAction SilentlyContinue} if($backupRoot -and (Test-Path -LiteralPath $backupRoot -PathType Container) -and -not(Test-Path -LiteralPath $acquireRoot)){Move-Item -LiteralPath $backupRoot -Destination $acquireRoot -ErrorAction SilentlyContinue} if(Test-LocalOfficeSource -Root $localRoot -ConfigurationPath $localConfig){Assert-PreInstallSignedPayload -Id 'Microsoft365Apps' -Path (Join-Path $localRoot 'setup.exe');Set-PreInstallAcquisitionResult -Result $acquisition -Outcome FallbackCacheUsed -RefreshError $_;Write-PreInstallLog -Event 'BuiltInRefreshFailed' -Level 'Warning' -Message $_.Exception.Message -Data @{Id='Microsoft365Apps';Fallback='ExistingStagedPayload'};continue} throw "Microsoft 365 Apps acquisition failed and no compatible staged fallback is available for the requested configuration. $($_.Exception.Message)" } } 'MicrosoftTeams' { $localRoot = Join-Path $StagedPath 'BuiltIn\MicrosoftTeams' $acquireRoot = if ($usbRoot) { Join-Path $usbRoot 'BuiltIn\MicrosoftTeams' } else { $localRoot } $architecture = [string]$app.Architecture if ($architecture -notin @('x86','x64','arm64')) { throw [IO.InvalidDataException]::new('Invalid Teams architecture.') } New-Item -ItemType Directory -Path $localRoot -Force | Out-Null if ($usbRoot) { Restore-PreInstallBuiltInCache -Root $acquireRoot -Id MicrosoftTeams } New-Item -ItemType Directory -Path $acquireRoot -Force | Out-Null $msixUri = [string]$app.MsixUri $bootstrapperUri = [string]$app.BootstrapperUri if ($usbRoot -and -not (Test-LocalMicrosoftTeamsSource -Root $localRoot) -and (Test-LocalMicrosoftTeamsSource -Root $acquireRoot)) { $payload = Assert-OSDStageCachedPayload -Root $acquireRoot -ExpectedExtension '.msix' -Id MicrosoftTeams -Architecture $architecture Assert-PreInstallSignedPayload -Id MicrosoftTeams -Path (Join-Path $acquireRoot 'teamsbootstrapper.exe') Copy-Item -LiteralPath (Join-Path $acquireRoot 'teamsbootstrapper.exe') -Destination (Join-Path $localRoot 'teamsbootstrapper.exe') -Force -ErrorAction Stop Copy-OSDStageBuiltInPayload -Path $payload -DestinationDirectory $localRoot Copy-Item -LiteralPath (Join-Path $acquireRoot 'CacheInfo.json') -Destination (Join-Path $localRoot 'CacheInfo.json') -Force -ErrorAction Stop } if (-not $networkAvailable) { Assert-OSDStageCachedPayload -Root $localRoot -ExpectedExtension '.msix' -Id MicrosoftTeams -Architecture $architecture | Out-Null Assert-PreInstallSignedPayload -Id MicrosoftTeams -Path (Join-Path $localRoot 'teamsbootstrapper.exe') Set-PreInstallAcquisitionResult -Result $acquisition -Outcome OfflineCacheUsed continue } try { $previous = $null; $activeMsix = $null; $matches = $false if (Test-LocalMicrosoftTeamsSource -Root $acquireRoot) { $activeMsix = Assert-OSDStageCachedPayload -Root $acquireRoot -ExpectedExtension '.msix' -Id MicrosoftTeams -Architecture $architecture $previous = Get-Content -LiteralPath (Join-Path $acquireRoot 'CacheInfo.json') -Raw -Encoding UTF8 | ConvertFrom-Json } $remote = Get-RemoteMetadata -Uri $msixUri if ($activeMsix -and $previous) { if ($remote.ETag -and $previous.RemoteETag) { $matches = $remote.ETag -eq [string]$previous.RemoteETag } elseif ($remote.LastModified -and $previous.RemoteLastModified -and $remote.ContentLength -and $previous.RemoteContentLength) { $matches = ($remote.LastModified -eq [string]$previous.RemoteLastModified -and [long]$remote.ContentLength -eq [long]$previous.RemoteContentLength) } } $acquisition.CacheDecisionReason = (Get-PreInstallHttpCacheReason -PackagePath $activeMsix -CacheInfo $previous -Remote $remote -MetadataMatches $matches) + ' The Teams bootstrapper is downloaded separately.' Invoke-OSDStageBuiltInCacheTransaction -Root $acquireRoot -ExpectedBytes ([double]$remote.ContentLength) -MinimumFreeSpaceGB 2 -Build { param($candidate) $bootstrapper = Join-Path $candidate 'teamsbootstrapper.exe' Save-PreInstallDownload -Uri $bootstrapperUri -DestinationPath $bootstrapper -Id MicrosoftTeams -Description 'Microsoft Teams bootstrapper' -TimeoutMinutes 5 | Out-Null Assert-PreInstallSignedPayload -Id MicrosoftTeams -Path $bootstrapper if ($matches) { Copy-OSDStageBuiltInPayload -Path $activeMsix -DestinationDirectory $candidate $msix = Join-Path $candidate ([string]$previous.PayloadFileName) } else { $download = Save-PreInstallDownload -Uri $msixUri -DestinationDirectory $candidate -ExpectedExtension '.msix' -Id MicrosoftTeams -Description 'Microsoft Teams MSIX' -TimeoutMinutes 15 $msix = [string]$download.FullName } $version = Get-MicrosoftTeamsPackageVersion -Path $msix if (-not $version) { throw [IO.InvalidDataException]::new('Downloaded Teams MSIX version could not be read.') } [ordered]@{ Id='MicrosoftTeams';Cached=[bool]$usbRoot;Version=$version;Architecture=$architecture PayloadFileName=(Split-Path -Path $msix -Leaf);Sha256=(Get-FileHash -LiteralPath $msix -Algorithm SHA256).Hash Size=(Get-Item -LiteralPath $msix).Length;SourcePolicy='Evergreen';SyncMethod='HttpMetadata';Updated=(-not $matches) BootstrapperUri=$bootstrapperUri;RemoteETag=$remote.ETag;RemoteLastModified=$remote.LastModified RemoteContentLength=$remote.ContentLength;RemoteFinalUri=$remote.FinalUri;SyncedAt=(Get-Date).ToUniversalTime().ToString('o') } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path $candidate 'CacheInfo.json') -Encoding UTF8 -ErrorAction Stop } -Validate { param($candidate) $msix = Assert-OSDStageCachedPayload -Root $candidate -ExpectedExtension '.msix' -Id MicrosoftTeams -Architecture $architecture Assert-PreInstallSignedPayload -Id MicrosoftTeams -Path (Join-Path $candidate 'teamsbootstrapper.exe') Get-MicrosoftTeamsPackageVersion -Path $msix | Out-Null } | Out-Null if ($usbRoot) { Copy-Item -LiteralPath (Join-Path $acquireRoot 'teamsbootstrapper.exe') -Destination (Join-Path $localRoot 'teamsbootstrapper.exe') -Force -ErrorAction Stop Copy-OSDStageBuiltInPayload -Path (Resolve-OSDStageBuiltInPayloadPath -Root $acquireRoot -ExpectedExtension '.msix') -DestinationDirectory $localRoot Copy-Item -LiteralPath (Join-Path $acquireRoot 'CacheInfo.json') -Destination (Join-Path $localRoot 'CacheInfo.json') -Force -ErrorAction Stop } Set-PreInstallAcquisitionResult -Result $acquisition -Outcome $(if($matches){'CacheReused'}else{'Downloaded'}) } catch { if ($_.Exception -is [Security.SecurityException] -or $_.Exception -is [IO.InvalidDataException]) { throw } $refreshError = $_ if (-not (Test-LocalMicrosoftTeamsSource -Root $localRoot)) { throw "Microsoft Teams acquisition failed and no staged fallback is available. $($refreshError.Exception.Message)" } Assert-OSDStageCachedPayload -Root $localRoot -ExpectedExtension '.msix' -Id MicrosoftTeams -Architecture $architecture | Out-Null Assert-PreInstallSignedPayload -Id MicrosoftTeams -Path (Join-Path $localRoot 'teamsbootstrapper.exe') Set-PreInstallAcquisitionResult -Result $acquisition -Outcome FallbackCacheUsed -RefreshError $refreshError } } 'AdobeAcrobatUnified' { $architecture = [string]$app.Architecture if ($architecture -notin @('x86','x64')) { throw [IO.InvalidDataException]::new('Invalid Adobe architecture.') } $packageUri = if ($app.PackageUri) { [string]$app.PackageUri } elseif ($architecture -eq 'x64') { 'https://trials.adobe.com/AdobeProducts/APRO/Acrobat_HelpX/win32/Acrobat_DC_Web_x64_WWMUI.zip' } else { 'https://trials.adobe.com/AdobeProducts/APRO/Acrobat_HelpX/win32/Acrobat_DC_Web_WWMUI.zip' } Sync-PreInstallVendorMsi -App $app -RelativeRoot (Join-Path 'BuiltIn\AdobeAcrobatUnified' $architecture) -PackageUri $packageUri -DisplayName 'Adobe Acrobat Unified' -Acquisition $acquisition -ExpectedExtension '.zip' } 'CiscoWebex' { $architecture = if ($app.Architecture) { [string]$app.Architecture } else { 'x64' } if ($architecture -notin @('x64','arm64')) { throw "Cisco Webex supports only x64 and arm64; received '$architecture'." } $packageUri = if ($app.PackageUri) { [string]$app.PackageUri } else { switch ($architecture) { 'x64' { 'https://binaries.webex.com/WebexOfclDesktop-Win-64-Gold/Webex_en.msi' } 'arm64' { 'https://binaries.webex.com/WebexOfclDesktop-Win-Arm-64-Gold/Webex_en.msi' } } } $relativeRoot = Join-Path 'BuiltIn\CiscoWebex' $architecture Sync-PreInstallVendorMsi -App $app -RelativeRoot $relativeRoot -PackageUri $packageUri -DisplayName 'Cisco Webex' -Acquisition $acquisition } { $app.Type -eq 'PrerequisiteExe' } { $spec = Get-OSDStagePrerequisiteSpec -Family $app.Family -Architecture $app.Architecture -RuntimeType $app.RuntimeType -Channel $app.Channel if ($app.Id -ne $spec.Id -or $app.PayloadRoot -ne $spec.RelativeRoot) { throw 'Invalid runtime deployment identity or path.' } $architecture = [string]$app.Architecture $relativeRoot = $spec.RelativeRoot $localRoot = Join-Path $StagedPath $relativeRoot $acquireRoot = if ($usbRoot) { Join-Path $usbRoot $relativeRoot } else { $localRoot } try { if (-not $networkAvailable) { throw 'No network connection is available.' } $sync = Sync-OSDStagePrerequisiteGeneration -Root $acquireRoot -Spec $spec -Log { param($record) Write-PreInstallLog -Event $record.Event -Level $record.Level -Message $record.Message -Data $record.Data } -Download { param($uri, $destination) Save-PreInstallDownload -Uri $uri -DestinationDirectory $destination -ExpectedExtension '.exe' -Id $spec.Id -Description 'Runtime EXE' -TimeoutMinutes 10 } if ($usbRoot) { New-Item -ItemType Directory -Path $localRoot -Force | Out-Null Copy-OSDStageBuiltInPayload -Path (Resolve-OSDStageBuiltInPayloadPath -Root $acquireRoot -ExpectedExtension '.exe') -DestinationDirectory $localRoot Copy-Item -LiteralPath (Join-Path $acquireRoot 'CacheInfo.json') -Destination (Join-Path $localRoot 'CacheInfo.json') -Force } Assert-OSDStagePrerequisiteGeneration -Root $localRoot -Spec $spec -VerifyPublisher | Out-Null Set-PreInstallAcquisitionResult -Result $acquisition -Outcome $(if ($sync.Updated) { 'Downloaded' } else { 'CacheReused' }) -CacheDecisionReason $sync.CacheDecisionReason } catch { if ($_.Exception -is [NotSupportedException]) { throw } $refreshFailure = $_ # A fallback must pass the same full hash and provenance checks. try { Assert-OSDStagePrerequisiteGeneration -Root $localRoot -Spec $spec -VerifyPublisher | Out-Null } catch { throw "Runtime acquisition failed with no valid staged fallback. $($refreshFailure.Exception.Message)" } Set-PreInstallAcquisitionResult -Result $acquisition -Outcome $(if ($networkAvailable) { 'FallbackCacheUsed' } else { 'OfflineCacheUsed' }) -RefreshError $refreshFailure } } 'VisualStudioCode' { $architecture = [string]$app.Architecture $relativeRoot = Join-Path 'BuiltIn\VisualStudioCode' $architecture $localRoot = Join-Path $StagedPath $relativeRoot $acquireRoot = if ($usbRoot) { Join-Path $usbRoot $relativeRoot } else { $localRoot } try { if (-not $networkAvailable) { throw 'No network connection is available.' } $sync = Sync-OSDStageVisualStudioCodeGeneration -Root $acquireRoot -Architecture $architecture -Download { param($uri, $destination) Save-PreInstallDownload -Uri $uri -DestinationDirectory $destination -ExpectedExtension '.exe' -Id 'VisualStudioCode' -Description 'VisualStudioCode EXE' -TimeoutMinutes 10 } if ($usbRoot) { New-Item -ItemType Directory -Path $localRoot -Force | Out-Null Copy-OSDStageBuiltInPayload -Path (Resolve-OSDStageBuiltInPayloadPath -Root $acquireRoot -ExpectedExtension '.exe') -DestinationDirectory $localRoot Copy-Item -LiteralPath (Join-Path $acquireRoot 'CacheInfo.json') -Destination (Join-Path $localRoot 'CacheInfo.json') -Force } Assert-OSDStageVisualStudioCodeGeneration -Root $localRoot -Architecture $architecture -VerifyPublisher | Out-Null Set-PreInstallAcquisitionResult -Result $acquisition -Outcome $(if ($sync.Updated) { 'Downloaded' } else { 'CacheReused' }) -CacheDecisionReason $sync.CacheDecisionReason } catch { $refreshFailure = $_ # A fallback must pass the same full hash and provenance checks. try { Assert-OSDStageVisualStudioCodeGeneration -Root $localRoot -Architecture $architecture -VerifyPublisher | Out-Null } catch { throw "VisualStudioCode acquisition failed with no valid staged fallback. $($refreshFailure.Exception.Message)" } Set-PreInstallAcquisitionResult -Result $acquisition -Outcome $(if ($networkAvailable) { 'FallbackCacheUsed' } else { 'OfflineCacheUsed' }) -RefreshError $refreshFailure } } '7Zip' { $architecture = [string]$app.Architecture $relativeRoot = Join-Path 'BuiltIn\7Zip' $architecture $localRoot = Join-Path $StagedPath $relativeRoot $acquireRoot = if ($usbRoot) { Join-Path $usbRoot $relativeRoot } else { $localRoot } try { if (-not $networkAvailable) { throw 'No network connection is available.' } $sync = Sync-OSDStage7ZipGeneration -Root $acquireRoot -Architecture $architecture -Log { param($record) Write-PreInstallLog -Event $record.Event -Level $record.Level -Message $record.Message -Data $record.Data } -Download { param($uri, $destination) Save-PreInstallDownload -Uri $uri -DestinationDirectory $destination -ExpectedExtension '.exe' -Id '7Zip' -Description '7Zip EXE' -TimeoutMinutes 10 } if ($usbRoot) { New-Item -ItemType Directory -Path $localRoot -Force | Out-Null Copy-OSDStageBuiltInPayload -Path (Resolve-OSDStageBuiltInPayloadPath -Root $acquireRoot -ExpectedExtension '.exe') -DestinationDirectory $localRoot Copy-Item -LiteralPath (Join-Path $acquireRoot 'CacheInfo.json') -Destination (Join-Path $localRoot 'CacheInfo.json') -Force } Assert-OSDStage7ZipGeneration -Root $localRoot -Architecture $architecture | Out-Null Set-PreInstallAcquisitionResult -Result $acquisition -Outcome $(if ($sync.Updated) { 'Downloaded' } else { 'CacheReused' }) -CacheDecisionReason $sync.CacheDecisionReason } catch { $refreshFailure = $_ # A fallback must pass the same full hash and provenance checks. try { Assert-OSDStage7ZipGeneration -Root $localRoot -Architecture $architecture | Out-Null } catch { throw "7Zip acquisition failed with no valid staged fallback. $($refreshFailure.Exception.Message)" } Set-PreInstallAcquisitionResult -Result $acquisition -Outcome $(if ($networkAvailable) { 'FallbackCacheUsed' } else { 'OfflineCacheUsed' }) -RefreshError $refreshFailure } } 'ZoomWorkplace' { $architecture = if ($app.Architecture) { [string]$app.Architecture } else { 'x64' } if ($architecture -notin @('x64','arm64')) { throw "Zoom Workplace supports only x64 and arm64; received '$architecture'." } $packageUri = if ($app.PackageUri) { [string]$app.PackageUri } else { switch ($architecture) { 'x64' { 'https://zoom.us/client/latest/ZoomInstallerFull.msi?archType=x64' } 'arm64' { 'https://zoom.us/client/latest/ZoomInstallerFull.msi?archType=winarm64' } } } $relativeRoot = Join-Path 'BuiltIn\ZoomWorkplace' $architecture Sync-PreInstallVendorMsi -App $app -RelativeRoot $relativeRoot -PackageUri $packageUri -DisplayName 'Zoom Workplace' -Acquisition $acquisition } 'GoogleChromeEnterprise' { $architecture = if ($app.Architecture) { [string]$app.Architecture } else { 'x64' } $packageUri = if ($app.PackageUri) { [string]$app.PackageUri } else { switch ($architecture) { 'x86' { 'https://dl.google.com/dl/chrome/install/googlechromestandaloneenterprise.msi' } default { 'https://dl.google.com/dl/chrome/install/googlechromestandaloneenterprise64.msi' } } } $relativeRoot = Join-Path 'BuiltIn\GoogleChromeEnterprise' $architecture Sync-PreInstallVendorMsi -App $app -RelativeRoot $relativeRoot -PackageUri $packageUri -DisplayName 'Google Chrome Enterprise' -Acquisition $acquisition } 'MozillaFirefoxEnterprise' { $channel = if ($app.Channel) { [string]$app.Channel } else { 'Rapid' } $architecture = if ($app.Architecture) { [string]$app.Architecture } else { 'x64' } $language = if ($app.Language) { [string]$app.Language } else { 'en-US' } $packageUri = if ($app.PackageUri) { [string]$app.PackageUri } else { $product = if ($channel -eq 'ESR') { 'firefox-esr-msi-latest-ssl' } else { 'firefox-msi-latest-ssl' } $os = if ($architecture -eq 'x64') { 'win64' } else { 'win' } "https://download.mozilla.org/?product=$product&os=$os&lang=$language" } $relativeRoot = Join-Path 'BuiltIn\MozillaFirefoxEnterprise' (Join-Path $channel (Join-Path $architecture $language)) Sync-PreInstallVendorMsi -App $app -RelativeRoot $relativeRoot -PackageUri $packageUri -DisplayName 'Mozilla Firefox Enterprise' -Acquisition $acquisition } default { throw [System.IO.InvalidDataException]::new("Unsupported built-in app Id '$($app.Id)' in the device manifest.") } } } catch { $appErrorRecord = $_ $appError = $appErrorRecord.Exception.Message $appFailureType = Get-PreInstallFailureType -ErrorRecord $appErrorRecord $fatal = ( $appErrorRecord.Exception -is [System.Security.SecurityException] -or $appErrorRecord.Exception -is [System.IO.InvalidDataException] ) Write-PreInstallLog -Event 'AppRefreshFailed' -Level 'Error' -Message $appError -Data @{ Id = $app.Id FailureType = $appFailureType ContinueQueue = (-not $fatal) DurationSeconds = [math]::Round($builtInStopwatch.Elapsed.TotalSeconds,1) } if ($fatal) { throw } } finally { if (-not $appError) { Set-OSDStageManifestPayload -App $app -StagedPath $StagedPath } if ($builtInStopwatch.IsRunning) { $builtInStopwatch.Stop() } $appResults.Add([pscustomobject]@{ Id = [string]$app.Id DisplayName = if ($app.DisplayName) { [string]$app.DisplayName } else { [string]$app.Id } Status = if ($appError) { 'Failed' } else { 'Succeeded' } DurationSeconds = [math]::Round($builtInStopwatch.Elapsed.TotalSeconds,1) CacheUsed = [bool]$usbRoot PackageUpdated = if ($appError) { $null } else { $acquisition.PackageUpdated } AcquisitionOutcome = if ($appError) { 'Failed' } else { $acquisition.Outcome } CacheDecisionReason = if ($appError) { 'Acquisition failed. ' + $appError } else { $acquisition.CacheDecisionReason } RefreshFailureType = $acquisition.RefreshFailureType RefreshError = $acquisition.RefreshError FailureType = $appFailureType Error = $appError }) $appResult = $appResults[$appResults.Count - 1] $level = if ($appError) { 'Error' } elseif ($acquisition.Outcome -in @('FallbackCacheUsed','OfflineCacheUsed')) { 'Warning' } else { 'Info' } Write-PreInstallLog -Event 'AppAcquisitionComplete' -Level $level -Message 'Built-in preparation outcome recorded; installation is a separate step.' -Data @{ Id = $app.Id Status = $appResult.Status AcquisitionOutcome = $appResult.AcquisitionOutcome CacheUsed = $appResult.CacheUsed PackageUpdated = $appResult.PackageUpdated CacheDecisionReason = $appResult.CacheDecisionReason RefreshFailureType = $appResult.RefreshFailureType RefreshError = $appResult.RefreshError } } } # Runner starts in a new process and reads this exact, atomically saved manifest. $manifestTemp = "$manifestPath.$([guid]::NewGuid().ToString('N')).tmp" try { $manifest | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $manifestTemp -Encoding UTF8 -ErrorAction Stop Move-Item -LiteralPath $manifestTemp -Destination $manifestPath -Force -ErrorAction Stop } finally { if (Test-Path -LiteralPath $manifestTemp) { Remove-Item -LiteralPath $manifestTemp -Force -ErrorAction SilentlyContinue } } $preInstallStopwatch.Stop() $failedPackageCount = @($appResults | Where-Object { $_.Status -eq 'Failed' }).Count if ($failedPackageCount -gt 0) { $summary = "$failedPackageCount built-in app(s) could not be prepared; independent apps will continue and no automatic retry is scheduled." Write-PreInstallLog -Event 'RefreshCompletedWithErrors' -Level 'Error' -Message $summary -Data @{CacheUsed=[bool]$usbRoot;BuiltInPackageCount=@($builtInApps).Count;FailedBuiltInPackageCount=$failedPackageCount;DurationSeconds=[math]::Round($preInstallStopwatch.Elapsed.TotalSeconds,1)} Write-PreInstallLog -Event 'PreInstallComplete' -Level 'Warning' -Message 'PreInstall completed with app errors. Runner execution may continue for unaffected apps.' -Data @{BuiltInPackageCount=@($builtInApps).Count;FailedBuiltInPackageCount=$failedPackageCount;DurationSeconds=[math]::Round($preInstallStopwatch.Elapsed.TotalSeconds,1)} try { Write-PreInstallResult -Status CompletedWithErrors -FailureType 'AppFailures' -ErrorMessage $summary } catch { Write-PreInstallLog -Event 'ResultWriteFailed' -Level Warning -Message $_.Exception.Message } exit 0 } Write-PreInstallLog -Event 'RefreshComplete' -Message 'Built-in acquisition and refresh completed. Installation will continue.' -Data @{CacheUsed=[bool]$usbRoot;DurationSeconds=[math]::Round($preInstallStopwatch.Elapsed.TotalSeconds,1)} $fallbackCount = @($appResults | Where-Object { $_.AcquisitionOutcome -eq 'FallbackCacheUsed' }).Count $offlineCount = @($appResults | Where-Object { $_.AcquisitionOutcome -eq 'OfflineCacheUsed' }).Count $completionLevel = if (($fallbackCount + $offlineCount) -gt 0) { 'Warning' } else { 'Info' } Write-PreInstallLog -Event 'PreInstallComplete' -Level $completionLevel -Message 'PreInstall completed successfully; inspect acquisition outcomes for cache fallback or offline use.' -Data @{BuiltInPackageCount=@($builtInApps).Count;FallbackPackageCount=$fallbackCount;OfflineCachePackageCount=$offlineCount;DurationSeconds=[math]::Round($preInstallStopwatch.Elapsed.TotalSeconds,1)} try { Write-PreInstallResult -Status Succeeded } catch { Write-PreInstallLog -Event 'ResultWriteFailed' -Level Warning -Message $_.Exception.Message } exit 0 } catch { if ($preInstallStopwatch.IsRunning) { $preInstallStopwatch.Stop() } $failureMessage = $_.Exception.Message $failureType = Get-PreInstallFailureType -ErrorRecord $_ Write-PreInstallLog -Event 'RefreshFailed' -Level 'Error' -Message $failureMessage -Data @{FailureType=$failureType;DurationSeconds=[math]::Round($preInstallStopwatch.Elapsed.TotalSeconds,1)} try { Write-PreInstallResult -Status Failed -FailureType $failureType -ErrorMessage $failureMessage } catch { Write-PreInstallLog -Event 'ResultWriteFailed' -Level Warning -Message $_.Exception.Message } exit 1 } finally { try { if ($cacheLock) { $cacheLock.Dispose() } } finally { if ($null -ne $sleepRequest) { Stop-OSDStageSleepPrevention -Request $sleepRequest -Log $sleepLog } } } |