Files
benoit 2212e25d31 Ajout de la langue anglaise
Ajout d'un README.md avec le descriptif et les instructions
L'ancien README.md s'appelle désormais CHANGELOG.md
Activation du pavé numérique par défaut pour les installations sans assistance
Nouveau design pour la fenêtre de déploiement.
Ajout de paramètre pour pouvoir procéder à une installation totalement automatisée
Ajout de paramètre pour copier au choix sources, drivers ou les deux sur C:\DeployVenter avant leur usage par DISM.
2026-10-18 04:14:17 +02:00

1079 lines
61 KiB
PowerShell

# DeployCenter - Windows partitioning and installation
# Compatible with Windows PowerShell 5.1 / WinPE.
Import-Module (Join-Path $PSScriptRoot 'Unattend.psm1') -Force
function Write-DeployCenterDeploymentLog {
param([AllowNull()][scriptblock]$LogAction, [string]$Message, [string]$Level = 'INFO', [switch]$SystemOutput)
if ($LogAction) { & $LogAction $Message $Level ([bool]$SystemOutput) }
}
function Update-DeployCenterHostStorageCache {
param([AllowNull()][scriptblock]$LogAction = $null)
try {
Update-HostStorageCache -ErrorAction Stop
Write-DeployCenterDeploymentLog -LogAction $LogAction `
-Message 'WinPE storage cache refreshed.' -Level 'INFO'
return $true
} catch {
Write-DeployCenterDeploymentLog -LogAction $LogAction `
-Message ("WinPE storage cache refresh is unavailable: {0}" -f $_.Exception.Message) `
-Level 'WARNING'
return $false
}
}
function Restart-DeployCenterWinPE {
[CmdletBinding()]
param([AllowNull()][scriptblock]$LogAction = $null)
$wpeutilPath = Join-Path $env:WINDIR 'System32\wpeutil.exe'
if (-not (Test-Path -LiteralPath $wpeutilPath -PathType Leaf)) {
throw 'wpeutil.exe was not found; a WinPE restart cannot be requested.'
}
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message 'WinPE restart requested through wpeutil.' -Level 'INFO'
$output = @(& $wpeutilPath Reboot 2>&1)
$exitCode = $LASTEXITCODE
foreach ($line in $output) {
if (-not [string]::IsNullOrWhiteSpace([string]$line)) {
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ([string]$line) -Level 'INFO' -SystemOutput
}
}
if ($null -ne $exitCode -and $exitCode -ne 0) {
throw "wpeutil Reboot returned exit code $exitCode."
}
}
function ConvertTo-DeployCenterNativeArgument {
param([AllowNull()][string]$Value)
if ($null -eq $Value) { $Value = '' }
if ($Value.Length -gt 0 -and $Value -notmatch '[\s"]') { return $Value }
$builder = New-Object System.Text.StringBuilder
[void]$builder.Append('"')
$backslashCount = 0
foreach ($character in $Value.ToCharArray()) {
if ($character -eq [char]92) {
$backslashCount++
continue
}
if ($character -eq [char]34) {
if ($backslashCount -gt 0) { [void]$builder.Append(('\' * (2 * $backslashCount))) }
[void]$builder.Append('\"')
$backslashCount = 0
continue
}
if ($backslashCount -gt 0) { [void]$builder.Append(('\' * $backslashCount)) }
[void]$builder.Append($character)
$backslashCount = 0
}
if ($backslashCount -gt 0) { [void]$builder.Append(('\' * (2 * $backslashCount))) }
[void]$builder.Append('"')
return $builder.ToString()
}
function New-DeployCenterDismSourceAccess {
param(
[Parameter(Mandatory=$true)][string]$ImagePath,
[Parameter(Mandatory=$true)][string]$TemporaryRoot,
[AllowNull()][scriptblock]$LogAction = $null,
[AllowNull()][scriptblock]$ProgressAction = $null
)
$lease = [pscustomobject]@{ ImagePath = $ImagePath; DriveLetter = $null; VolumeRoot = $null }
if (-not $ImagePath.StartsWith('\\?\Volume{', [StringComparison]::OrdinalIgnoreCase)) { return $lease }
$volumeEnd = $ImagePath.IndexOf('}\', [StringComparison]::OrdinalIgnoreCase)
if ($volumeEnd -lt 0) { throw "Invalid volume GUID path for image: $ImagePath" }
$volumeRoot = $ImagePath.Substring(0, $volumeEnd + 2)
$relativePath = $ImagePath.Substring($volumeEnd + 2)
$usedLetters = @{}
foreach ($volume in @(Get-Volume -ErrorAction SilentlyContinue)) {
if ($volume.DriveLetter) { $usedLetters[([string]$volume.DriveLetter).ToUpperInvariant()] = $true }
}
foreach ($drive in @(Get-PSDrive -PSProvider FileSystem -ErrorAction SilentlyContinue)) {
$usedLetters[([string]$drive.Name).ToUpperInvariant()] = $true
}
$letter = $null
foreach ($candidate in @('Z','Y','X','W','V','U','T','S','R','Q','P','O','N','M','L','K','J','I','H','G','F','E')) {
if ($candidate -notin @('C','D') -and -not $usedLetters.ContainsKey($candidate)) {
$letter = $candidate
break
}
}
if (-not $letter) { throw 'No temporary drive letter is available to access the Windows source.' }
$mountVolPath = Join-Path $env:WINDIR 'System32\mountvol.exe'
if (-not (Test-Path -LiteralPath $mountVolPath -PathType Leaf)) { $mountVolPath = 'mountvol.exe' }
$letterPath = ("{0}:\{1}" -f $letter, $relativePath)
$mountError = $null
try {
Invoke-DeployCenterNativeCommand -FilePath $mountVolPath -Arguments @(("{0}:\" -f $letter), $volumeRoot) `
-TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction
} catch {
$mountError = $_.Exception
}
if (-not (Test-Path -LiteralPath $letterPath -PathType Leaf)) {
try {
Invoke-DeployCenterNativeCommand -FilePath $mountVolPath -Arguments @(("{0}:\" -f $letter), '/D') `
-TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction
} catch { }
throw "The Windows source is not accessible after temporarily assigning ${letter}:."
}
if ($mountError) {
Write-DeployCenterDeploymentLog -LogAction $LogAction `
-Message ("mountvol did not provide a usable exit code, but the source is accessible through {0}:. The assignment is confirmed." -f $letter) `
-Level 'WARNING'
}
$lease.ImagePath = $letterPath
$lease.DriveLetter = $letter
$lease.VolumeRoot = $volumeRoot
return $lease
}
function Remove-DeployCenterDismSourceAccess {
param(
[AllowNull()]$Lease,
[Parameter(Mandatory=$true)][string]$TemporaryRoot,
[AllowNull()][scriptblock]$LogAction = $null,
[AllowNull()][scriptblock]$ProgressAction = $null
)
if ($null -eq $Lease -or -not $Lease.DriveLetter) { return }
$mountVolPath = Join-Path $env:WINDIR 'System32\mountvol.exe'
if (-not (Test-Path -LiteralPath $mountVolPath -PathType Leaf)) { $mountVolPath = 'mountvol.exe' }
$releaseError = $null
try {
Invoke-DeployCenterNativeCommand -FilePath $mountVolPath -Arguments @(("{0}:\" -f $Lease.DriveLetter), '/D') `
-TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction
} catch {
$releaseError = $_.Exception
}
[void](Update-DeployCenterHostStorageCache -LogAction $LogAction)
if (Test-Path -LiteralPath ([string]$Lease.ImagePath) -PathType Leaf) {
$details = if ($releaseError) { $releaseError.Message } else { 'The source file is still accessible.' }
Write-DeployCenterDeploymentLog -LogAction $LogAction `
-Message "Could not confirm release of temporary drive letter $($Lease.DriveLetter): $details" -Level 'WARNING'
} elseif ($releaseError) {
Write-DeployCenterDeploymentLog -LogAction $LogAction `
-Message ("mountvol did not provide a usable exit code, but the source is no longer accessible through {0}:. The drive letter has been released." -f $Lease.DriveLetter) `
-Level 'WARNING'
}
}
function Get-DeployCenterNativeProgressPercent {
param([Parameter(Mandatory=$true)][string]$OutputPath)
$stream = $null
$reader = $null
try {
$stream = [System.IO.File]::Open($OutputPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
$reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::Default, $true)
$content = $reader.ReadToEnd()
} catch {
return $null
} finally {
if ($reader) { $reader.Dispose() }
elseif ($stream) { $stream.Dispose() }
}
$progressMatches = [regex]::Matches($content, '(?<![\d.])(?<percent>\d{1,3}(?:\.\d+)?)\s*%')
if ($progressMatches.Count -eq 0) { return $null }
$value = [double]$progressMatches[$progressMatches.Count - 1].Groups['percent'].Value
return [int][math]::Max(0, [math]::Min(100, [math]::Floor($value)))
}
function Test-DeployCenterRobocopyCopy {
param(
[Parameter(Mandatory=$true)][string]$SourceDirectory,
[Parameter(Mandatory=$true)][string]$DestinationDirectory,
[Parameter(Mandatory=$true)][string[]]$CopyArguments
)
$sourceRoot = [IO.Path]::GetFullPath($SourceDirectory).TrimEnd('\')
$fileFilters = @($CopyArguments | Where-Object { -not ([string]$_).StartsWith('/') })
$sourceFiles = @(Get-ChildItem -LiteralPath $SourceDirectory -File -Recurse -ErrorAction Stop)
if ($fileFilters.Count -gt 0) {
$sourceFiles = @($sourceFiles | Where-Object {
$fileName = $_.Name
@($fileFilters | Where-Object { $fileName -like [string]$_ }).Count -gt 0
})
}
if ($sourceFiles.Count -eq 0) { return $false }
foreach ($sourceFile in $sourceFiles) {
$relativePath = $sourceFile.FullName.Substring($sourceRoot.Length).TrimStart('\')
$destinationPath = Join-Path $DestinationDirectory $relativePath
if (-not (Test-Path -LiteralPath $destinationPath -PathType Leaf)) { return $false }
$destinationFile = Get-Item -LiteralPath $destinationPath -ErrorAction Stop
if ([long]$sourceFile.Length -ne [long]$destinationFile.Length) { return $false }
}
return $true
}
function ConvertTo-DeployCenterPowerShellLiteral {
param([AllowNull()][string]$Value)
if ($null -eq $Value) { $Value = '' }
return "'" + $Value.Replace("'", "''") + "'"
}
function Invoke-DeployCenterRobocopy {
param(
[Parameter(Mandatory=$true)][string]$SourceDirectory,
[Parameter(Mandatory=$true)][string]$DestinationDirectory,
[Parameter(Mandatory=$true)][string[]]$CopyArguments,
[Parameter(Mandatory=$true)][string]$TemporaryRoot,
[AllowNull()][scriptblock]$LogAction = $null,
[AllowNull()][scriptblock]$ProgressAction = $null
)
if (-not (Test-Path -LiteralPath $TemporaryRoot -PathType Container)) {
New-Item -Path $TemporaryRoot -ItemType Directory -Force -ErrorAction Stop | Out-Null
}
$robocopyPath = Join-Path $env:WINDIR 'System32\robocopy.exe'
if (-not (Test-Path -LiteralPath $robocopyPath -PathType Leaf)) {
$robocopyCommand = Get-Command 'robocopy.exe' -ErrorAction SilentlyContinue
if ($null -eq $robocopyCommand) { throw 'Robocopy.exe was not found in WinPE.' }
$robocopyPath = [string]$robocopyCommand.Path
}
$id = [guid]::NewGuid().ToString('N')
$stdoutPath = Join-Path $TemporaryRoot ("robocopy_{0}.out" -f $id)
$stderrPath = Join-Path $TemporaryRoot ("robocopy_{0}.err" -f $id)
$runnerPath = Join-Path $TemporaryRoot ("robocopy_{0}.ps1" -f $id)
$exitCodePath = Join-Path $TemporaryRoot ("robocopy_{0}.exitcode" -f $id)
$arguments = @($SourceDirectory, $DestinationDirectory) + @($CopyArguments)
$argumentLine = ($arguments | ForEach-Object {
ConvertTo-DeployCenterNativeArgument -Value ([string]$_)
}) -join ' '
$powerShellPath = Join-Path $PSHOME 'powershell.exe'
if (-not (Test-Path -LiteralPath $powerShellPath -PathType Leaf)) {
$powerShellCommand = Get-Command 'powershell.exe' -ErrorAction SilentlyContinue
if ($null -eq $powerShellCommand) { throw 'Windows PowerShell was not found to capture the Robocopy exit code.' }
$powerShellPath = [string]$powerShellCommand.Path
}
try {
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ("Native command: {0} {1}" -f $robocopyPath, $argumentLine)
$runnerCommand = '& ' + (ConvertTo-DeployCenterPowerShellLiteral -Value $robocopyPath)
foreach ($argument in $arguments) {
$runnerCommand += ' ' + (ConvertTo-DeployCenterPowerShellLiteral -Value ([string]$argument))
}
$runnerLines = @(
$runnerCommand,
'$robocopyExitCode = $LASTEXITCODE',
('[System.IO.File]::WriteAllText(' + (ConvertTo-DeployCenterPowerShellLiteral -Value $exitCodePath) + ', [string]$robocopyExitCode, [System.Text.Encoding]::ASCII)')
)
[System.IO.File]::WriteAllLines($runnerPath, [string[]]$runnerLines, (New-Object System.Text.UTF8Encoding($true)))
$runnerArguments = @('-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', $runnerPath)
$runnerArgumentLine = ($runnerArguments | ForEach-Object {
ConvertTo-DeployCenterNativeArgument -Value ([string]$_)
}) -join ' '
$process = Start-Process -FilePath $powerShellPath -ArgumentList $runnerArgumentLine `
-PassThru -WindowStyle Hidden -RedirectStandardOutput $stdoutPath `
-RedirectStandardError $stderrPath -ErrorAction Stop
while (-not $process.WaitForExit(250)) {
if ($ProgressAction -and (Test-Path -LiteralPath $stdoutPath -PathType Leaf)) {
[System.Windows.Forms.Application]::DoEvents()
$copyPercent = Get-DeployCenterNativeProgressPercent -OutputPath $stdoutPath
if ($null -ne $copyPercent) { & $ProgressAction $null $copyPercent }
}
}
$process.WaitForExit()
$exitCode = $null
if (Test-Path -LiteralPath $exitCodePath -PathType Leaf) {
$exitCodeText = [System.IO.File]::ReadAllText($exitCodePath).Trim()
$parsedExitCode = 0
if ([int]::TryParse($exitCodeText, [ref]$parsedExitCode)) { $exitCode = $parsedExitCode }
}
$outputEncoding = [System.Console]::OutputEncoding
if ($null -eq $outputEncoding) { $outputEncoding = [System.Text.Encoding]::Default }
foreach ($outputPath in @($stdoutPath, $stderrPath)) {
if (Test-Path -LiteralPath $outputPath -PathType Leaf) {
foreach ($line in [System.IO.File]::ReadAllLines($outputPath, $outputEncoding)) {
if (-not [string]::IsNullOrWhiteSpace($line)) {
if ($line -match '^\s*\d{1,3}(?:[.,]\d+)?\s*%\s*$') { continue }
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ([string]$line) -Level 'INFO' -SystemOutput
}
}
}
}
if ($ProgressAction -and (Test-Path -LiteralPath $stdoutPath -PathType Leaf)) {
$copyPercent = Get-DeployCenterNativeProgressPercent -OutputPath $stdoutPath
if ($null -ne $copyPercent) { & $ProgressAction $null $copyPercent }
}
# Robocopy uses codes 0 to 7 for non-failure results; 8 and above indicate an error.
if ($null -eq $exitCode) {
if (-not (Test-DeployCenterRobocopyCopy -SourceDirectory $SourceDirectory `
-DestinationDirectory $DestinationDirectory -CopyArguments $CopyArguments)) {
throw 'Robocopy did not report an exit code and file verification failed.'
}
Write-DeployCenterDeploymentLog -LogAction $LogAction `
-Message 'Robocopy did not report an exit code; file verification succeeded.' `
-Level 'WARNING'
return $null
}
if ([int]$exitCode -ge 8) {
throw ("Robocopy exited with code {0}." -f $exitCode)
}
return [int]$exitCode
} finally {
foreach ($outputPath in @($stdoutPath, $stderrPath)) {
if (Test-Path -LiteralPath $outputPath -PathType Leaf) {
Remove-Item -LiteralPath $outputPath -Force -ErrorAction SilentlyContinue
}
}
foreach ($temporaryPath in @($runnerPath, $exitCodePath)) {
if (Test-Path -LiteralPath $temporaryPath -PathType Leaf) {
Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue
}
}
}
}
function Invoke-DeployCenterNativeCommand {
param(
[Parameter(Mandatory=$true)][string]$FilePath,
[Parameter(Mandatory=$true)][string[]]$Arguments,
[Parameter(Mandatory=$true)][string]$TemporaryRoot,
[AllowNull()][scriptblock]$LogAction = $null,
[AllowNull()][scriptblock]$ProgressAction = $null
)
if (-not (Test-Path -LiteralPath $TemporaryRoot -PathType Container)) {
New-Item -Path $TemporaryRoot -ItemType Directory -Force -ErrorAction Stop | Out-Null
}
$id = [guid]::NewGuid().ToString('N')
$stdoutPath = Join-Path $TemporaryRoot ("native_{0}.out" -f $id)
$stderrPath = Join-Path $TemporaryRoot ("native_{0}.err" -f $id)
$dismLogPath = Join-Path $TemporaryRoot ("dism_{0}.log" -f $id)
$runnerPath = Join-Path $TemporaryRoot ("native_{0}.ps1" -f $id)
$exitCodePath = Join-Path $TemporaryRoot ("native_{0}.exitcode" -f $id)
$isDism = [IO.Path]::GetFileName($FilePath) -ieq 'dism.exe'
$isImageApply = $isDism -and @($Arguments | Where-Object { [string]$_ -ieq '/Apply-Image' }).Count -gt 0
if ($isDism) {
$Arguments += "/LogPath:$dismLogPath"
}
$argumentLine = ($Arguments | ForEach-Object {
ConvertTo-DeployCenterNativeArgument -Value ([string]$_)
}) -join ' '
try {
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ("Native command: {0} {1}" -f $FilePath, $argumentLine)
$nativeOutputLines = New-Object System.Collections.Generic.List[string]
if ($isImageApply) {
$process = Start-Process -FilePath $FilePath -ArgumentList $argumentLine `
-PassThru -WindowStyle Hidden -RedirectStandardOutput $stdoutPath `
-RedirectStandardError $stderrPath -ErrorAction Stop
while (-not $process.WaitForExit(250)) {
if ($ProgressAction) {
[System.Windows.Forms.Application]::DoEvents()
if (Test-Path -LiteralPath $stdoutPath -PathType Leaf) {
$nativePercent = Get-DeployCenterNativeProgressPercent -OutputPath $stdoutPath
if ($null -ne $nativePercent) { & $ProgressAction $null $nativePercent }
}
}
}
$process.WaitForExit()
$process.Refresh()
$exitCode = $process.ExitCode
$nativeOutputEncoding = [System.Console]::OutputEncoding
if ($null -eq $nativeOutputEncoding) { $nativeOutputEncoding = [System.Text.Encoding]::Default }
foreach ($outputPath in @($stdoutPath, $stderrPath)) {
if (Test-Path -LiteralPath $outputPath -PathType Leaf) {
foreach ($line in [System.IO.File]::ReadAllLines($outputPath, $nativeOutputEncoding)) {
if (-not [string]::IsNullOrWhiteSpace($line)) {
[void]$nativeOutputLines.Add([string]$line)
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ([string]$line) -Level 'INFO' -SystemOutput
}
}
}
}
if ($ProgressAction -and (Test-Path -LiteralPath $stdoutPath -PathType Leaf)) {
$nativePercent = Get-DeployCenterNativeProgressPercent -OutputPath $stdoutPath
if ($null -ne $nativePercent) { & $ProgressAction $null $nativePercent }
}
if ($null -eq $exitCode -and @($nativeOutputLines | Where-Object { $_ -match '(?i)completed successfully|a r.ussi' }).Count -gt 0) {
Write-DeployCenterDeploymentLog -LogAction $LogAction `
-Message 'DISM confirms Windows was applied, but the exit code is unavailable in WinPE.' `
-Level 'WARNING'
return
}
} else {
$powerShellPath = Join-Path $PSHOME 'powershell.exe'
if (-not (Test-Path -LiteralPath $powerShellPath -PathType Leaf)) {
$powerShellCommand = Get-Command 'powershell.exe' -ErrorAction SilentlyContinue
if ($null -eq $powerShellCommand) { throw 'Windows PowerShell was not found to capture the native command exit code.' }
$powerShellPath = [string]$powerShellCommand.Path
}
$runnerCommand = '& ' + (ConvertTo-DeployCenterPowerShellLiteral -Value $FilePath)
foreach ($argument in $Arguments) {
$runnerCommand += ' ' + (ConvertTo-DeployCenterPowerShellLiteral -Value ([string]$argument))
}
$runnerLines = @(
$runnerCommand,
'$nativeCommandExitCode = $LASTEXITCODE',
('[System.IO.File]::WriteAllText(' + (ConvertTo-DeployCenterPowerShellLiteral -Value $exitCodePath) + ', [string]$nativeCommandExitCode, [System.Text.Encoding]::ASCII)')
)
[System.IO.File]::WriteAllLines($runnerPath, [string[]]$runnerLines, (New-Object System.Text.UTF8Encoding($true)))
$runnerArguments = @('-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', $runnerPath)
$runnerArgumentLine = ($runnerArguments | ForEach-Object {
ConvertTo-DeployCenterNativeArgument -Value ([string]$_)
}) -join ' '
$process = Start-Process -FilePath $powerShellPath -ArgumentList $runnerArgumentLine `
-PassThru -WindowStyle Hidden -RedirectStandardOutput $stdoutPath `
-RedirectStandardError $stderrPath -ErrorAction Stop
while (-not $process.WaitForExit(250)) {
if ($ProgressAction) { [System.Windows.Forms.Application]::DoEvents() }
}
$process.WaitForExit()
$exitCode = $null
if (Test-Path -LiteralPath $exitCodePath -PathType Leaf) {
$exitCodeText = [System.IO.File]::ReadAllText($exitCodePath).Trim()
$parsedExitCode = 0
if ([int]::TryParse($exitCodeText, [ref]$parsedExitCode)) { $exitCode = $parsedExitCode }
}
if ($null -eq $exitCode) { throw ("Could not capture the exit code from {0}." -f [IO.Path]::GetFileName($FilePath)) }
$nativeOutputEncoding = [System.Console]::OutputEncoding
if ($null -eq $nativeOutputEncoding) { $nativeOutputEncoding = [System.Text.Encoding]::Default }
foreach ($outputPath in @($stdoutPath, $stderrPath)) {
if (Test-Path -LiteralPath $outputPath -PathType Leaf) {
foreach ($line in [System.IO.File]::ReadAllLines($outputPath, $nativeOutputEncoding)) {
if (-not [string]::IsNullOrWhiteSpace([string]$line)) {
[void]$nativeOutputLines.Add([string]$line)
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ([string]$line) -Level 'INFO' -SystemOutput
}
}
}
}
}
if ($null -eq $exitCode -or [int]$exitCode -ne 0) {
$diagnostic = @()
if ($isDism -and (Test-Path -LiteralPath $dismLogPath -PathType Leaf)) {
$diagnostic = @(Get-Content -LiteralPath $dismLogPath -Tail 30 -ErrorAction SilentlyContinue | ForEach-Object { [string]$_ })
foreach ($line in $diagnostic) {
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ([string]$line) -Level 'ERROR' -SystemOutput
}
}
$diagnosticMessage = $diagnostic -join ' | '
throw ("Command {0} exited with code {1}. {2}" -f $FilePath, $exitCode, $diagnosticMessage)
}
} finally {
foreach ($outputPath in @($stdoutPath, $stderrPath)) {
if (Test-Path -LiteralPath $outputPath -PathType Leaf) {
Remove-Item -LiteralPath $outputPath -Force -ErrorAction SilentlyContinue
}
}
if (Test-Path -LiteralPath $dismLogPath -PathType Leaf) {
Remove-Item -LiteralPath $dismLogPath -Force -ErrorAction SilentlyContinue
}
foreach ($temporaryPath in @($runnerPath, $exitCodePath)) {
if (Test-Path -LiteralPath $temporaryPath -PathType Leaf) {
Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue
}
}
}
}
function Get-DeployCenterOfflineWindowsBootGuid {
param(
[Parameter(Mandatory=$true)][string]$BcdStorePath,
[Parameter(Mandatory=$true)][string]$WindowsDriveLetter,
[Parameter(Mandatory=$true)][string]$IdentifierLabel,
[AllowNull()][scriptblock]$LogAction = $null
)
if (-not (Test-Path -LiteralPath $BcdStorePath -PathType Leaf)) {
throw "BCD store was not found after boot files were created: $BcdStorePath"
}
$bcdeditPath = Join-Path $env:WINDIR 'System32\bcdedit.exe'
if (-not (Test-Path -LiteralPath $bcdeditPath -PathType Leaf)) { $bcdeditPath = 'bcdedit.exe' }
$output = @(& $bcdeditPath /store $BcdStorePath /enum osloader /v 2>&1)
$exitCode = $LASTEXITCODE
foreach ($line in $output) {
if (-not [string]::IsNullOrWhiteSpace([string]$line)) {
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ([string]$line) -Level 'INFO' -SystemOutput
}
}
if ($null -eq $exitCode -or [int]$exitCode -ne 0) {
throw "bcdedit could not read the BCD store $BcdStorePath (exit code $exitCode)."
}
$targetLetter = $WindowsDriveLetter.TrimEnd(':').ToUpperInvariant()
$entries = New-Object System.Collections.Generic.List[object]
$currentEntry = $null
$identifierPattern = '^\s*' + [regex]::Escape($IdentifierLabel) + '\s+(?<guid>\{[0-9A-Fa-f-]{36}\})\s*$'
foreach ($lineObject in $output) {
$line = [string]$lineObject
if ($line -match $identifierPattern) {
if ($null -ne $currentEntry) { $entries.Add($currentEntry) }
$currentEntry = [pscustomobject]@{
Guid = [string]$Matches['guid']
Device = $null
OsDevice = $null
}
continue
}
if ($null -eq $currentEntry) { continue }
if ($line -match '^\s*device\s+partition=(?<letter>[A-Za-z]):\\?\s*$') {
$currentEntry.Device = $Matches['letter'].ToUpperInvariant()
} elseif ($line -match '^\s*osdevice\s+partition=(?<letter>[A-Za-z]):\\?\s*$') {
$currentEntry.OsDevice = $Matches['letter'].ToUpperInvariant()
}
}
if ($null -ne $currentEntry) { $entries.Add($currentEntry) }
$matchingEntries = @($entries | Where-Object {
$_.Device -eq $targetLetter -and $_.OsDevice -eq $targetLetter
})
if ($matchingEntries.Count -ne 1) {
throw "Could not identify a unique BCD entry for Windows on ${targetLetter}: (matching entries: $($matchingEntries.Count))."
}
return [string]$matchingEntries[0].Guid
}
function Get-DeployCenterTemporaryDriveLetters {
param([Parameter(Mandatory=$true)][int]$DiskNumber, [Parameter(Mandatory=$true)]$Profile)
$targetLetters = @()
try {
$targetLetters = @(Get-Partition -DiskNumber $DiskNumber -ErrorAction SilentlyContinue |
Where-Object { $_.DriveLetter } | ForEach-Object { ([string]$_.DriveLetter).ToUpperInvariant() })
} catch { }
$occupied = @{}
foreach ($volume in @(Get-Volume -ErrorAction SilentlyContinue)) {
if (-not $volume.DriveLetter) { continue }
$letter = ([string]$volume.DriveLetter).ToUpperInvariant()
if ($targetLetters -contains $letter) { continue }
$occupied[$letter] = $true
}
$lettersByRole = @{}
foreach ($partition in $Profile.Partitions) {
if ([string]$partition.Role -eq 'Reserved') { continue }
$preferred = [string]$partition.DriveLetter
if ([string]$partition.Role -eq 'System') { $preferred = 'S' }
if ([string]$partition.Role -eq 'Recovery') { $preferred = 'R' }
if ([string]::IsNullOrWhiteSpace($preferred)) { $preferred = 'W' }
$preferred = $preferred.ToUpperInvariant()
if (-not $occupied.ContainsKey($preferred)) {
$selected = $preferred
} else {
$selected = $null
foreach ($candidate in @('W','V','U','T','S','R','Q','P','O','N','M','L','K','J','I','H','G','F','E','D','C')) {
if (-not $occupied.ContainsKey($candidate) -and $candidate -notin $lettersByRole.Values) {
$selected = $candidate
break
}
}
if (-not $selected) { throw "No free drive letter is available for partition '$($partition.Name)'." }
}
if ($selected -in $lettersByRole.Values) { throw "The drive letter ${selected}: is assigned to multiple partitions in the profile." }
$lettersByRole[[string]$partition.Role] = $selected
$occupied[$selected] = $true
}
return $lettersByRole
}
function New-DeployCenterPartitionPlan {
param(
[Parameter(Mandatory=$true)]$DiskInfo,
[Parameter(Mandatory=$true)][System.Collections.IDictionary]$Profile,
[AllowNull()]$SizingState
)
if ([string]$Profile.PartitionStyle -ne 'GPT' -or [string]$Profile.Firmware -ne 'UEFI') {
throw "The profile '$($Profile.Name)' is not supported by the current engine (UEFI/GPT required)."
}
$totalMB = [long][math]::Floor(([double]$DiskInfo.SizeBytes / 1MB))
$availableMB = $totalMB - 2
$usedMB = 0L
$plan = New-Object System.Collections.Generic.List[object]
foreach ($partition in $Profile.Partitions) {
$policy = [string]$partition.Size.Policy
$sizeMB = $null
switch ($policy) {
'FixedMB' { $sizeMB = [long]$partition.Size.Value }
'ConfiguredMB' { $sizeMB = [long]$partition.Size.Value }
'UserInputGB' {
if ($null -eq $SizingState -or [string]$SizingState.ProfileId -ne [string]$Profile.Id) {
throw "Profile '$($Profile.Name)' requires a user-defined size, which has not been validated."
}
$role = [string]$partition.Role
if (-not $SizingState.Inputs.Contains($role)) { throw "The size for partition '$role' is missing." }
$sizeMB = [long][math]::Round(([double]$SizingState.Inputs[$role] * 1024), 0)
if ($sizeMB -lt ([long]$partition.Size.MinimumGB * 1024)) { throw "Partition '$role' is below its minimum size." }
}
{ $_ -in @('Remaining','RemainderAfterInputs') } {
$sizeMB = $availableMB - $usedMB
if ($sizeMB -lt 1) { throw "The disk no longer has enough space for partition '$($partition.Name)'." }
if ($policy -eq 'RemainderAfterInputs' -and $sizeMB -lt ([long]$partition.Size.MinimumGB * 1024)) {
throw "The remaining space for '$($partition.Name)' is below the profile minimum."
}
}
default { throw "Unsupported partition size policy: $policy" }
}
if ($null -ne $sizeMB) { $usedMB += [long]$sizeMB }
$plan.Add([pscustomobject]@{ Definition = $partition; SizeMB = $sizeMB })
}
if ($usedMB -gt $availableMB) { throw "The requested size exceeds the selected disk capacity." }
return $plan.ToArray()
}
function Invoke-DeployCenterWindowsDeployment {
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)]$DiskInfo,
[Parameter(Mandatory=$true)][System.Collections.IDictionary]$Profile,
[AllowNull()]$SizingState,
[Parameter(Mandatory=$true)]$Edition,
[AllowNull()][string]$HostName = $null,
[Parameter(Mandatory=$true)][string]$IdentifierLabel,
[AllowNull()][System.Collections.IDictionary]$UnattendProfile = $null,
[AllowNull()][System.Collections.IDictionary]$UnattendConfiguration = $null,
[Parameter(Mandatory=$true)][string]$Firmware,
[Parameter(Mandatory=$true)][string]$TemporaryRoot,
[AllowNull()][string]$DriverPath = $null,
[int]$DriverInfCount = 0,
[ValidateSet('Sources', 'Drivers')]
[string[]]$CopyLocally = @(),
[AllowNull()][scriptblock]$LogAction = $null,
[AllowNull()][scriptblock]$ProgressAction = $null
)
if ($null -ne $UnattendProfile) {
if ($null -eq $UnattendConfiguration) { throw 'DeployCenter.ini configuration is missing for the selected Unattend profile.' }
Test-DeployCenterUnattendConfiguration -Profile $UnattendProfile -Configuration $UnattendConfiguration -HostName ([string]$HostName)
}
$winreRecommendationWarnings = @()
if ([string]$Firmware -ne [string]$Profile.Firmware) {
throw ("The profile {0} requires firmware {1}, detected: {2}." -f $Profile.Name, $Profile.Firmware, $Firmware)
}
foreach ($requiredRole in @('System','Reserved','Recovery','Windows')) {
$roleCount = @($Profile.Partitions | Where-Object { [string]$_.Role -eq $requiredRole }).Count
if ($roleCount -ne 1) { throw "The profile must define exactly one partition with the '$requiredRole' role. The disk was not modified." }
}
$recoveryDefinitions = @($Profile.Partitions | Where-Object { [string]$_.Role -eq 'Recovery' })
if ($recoveryDefinitions.Count -ne 1 -or [string]$recoveryDefinitions[0].Size.Policy -ne 'ConfiguredMB') {
throw "The profile must define a configured size for the Recovery partition. The disk was not modified."
}
$winrePartitionSizeMB = [long]$recoveryDefinitions[0].Size.Value
if ($winrePartitionSizeMB -le 0) {
throw "The configured WinRE partition size must be greater than zero. The disk was not modified."
}
if (-not $Edition.ImagePath -or -not (Test-Path -LiteralPath ([string]$Edition.ImagePath) -PathType Leaf)) {
throw "The selected Windows image path was not found."
}
if (-not $Edition.Index -or [int]$Edition.Index -lt 1) { throw "The Windows edition index is invalid." }
if (-not [string]::IsNullOrWhiteSpace($DriverPath)) {
if (-not (Test-Path -LiteralPath $DriverPath -PathType Container)) { throw "The selected driver folder was not found: $DriverPath" }
if ($DriverInfCount -le 0 -or @(Get-ChildItem -LiteralPath $DriverPath -Filter '*.inf' -File -Recurse -ErrorAction SilentlyContinue).Count -eq 0) {
throw "The selected driver folder no longer contains an INF file: $DriverPath"
}
}
$wallpaperSourcePath = $null
if ($null -ne $UnattendProfile -and $null -ne $UnattendConfiguration) {
$configuredWallpaperPath = [string]$UnattendConfiguration.UnattendWallpaperPath
if (-not [string]::IsNullOrWhiteSpace($configuredWallpaperPath)) {
$deployRoot = Split-Path -Path $PSScriptRoot -Parent
if ([System.IO.Path]::IsPathRooted($configuredWallpaperPath)) {
$wallpaperSourcePath = [System.IO.Path]::GetFullPath($configuredWallpaperPath)
} else {
$wallpaperSourcePath = [System.IO.Path]::GetFullPath((Join-Path $deployRoot $configuredWallpaperPath))
}
if ([System.IO.Path]::GetExtension($wallpaperSourcePath) -notin @('.png', '.jpg', '.bmp')) {
throw 'WallpaperPath must point to a .png, .jpg, or .bmp file.'
}
if (-not (Test-Path -LiteralPath $wallpaperSourcePath -PathType Leaf)) {
throw "Wallpaper image not found: $wallpaperSourcePath"
}
}
}
$disk = Get-Disk -Number ([int]$DiskInfo.Index) -ErrorAction Stop
if ($disk.IsBoot -or $disk.IsSystem) { throw 'The selected disk hosts WinPE and cannot be erased.' }
if ($disk.IsOffline) { [void](Set-Disk -Number $disk.Number -IsOffline $false -ErrorAction Stop) }
if ($disk.IsReadOnly) { [void](Set-Disk -Number $disk.Number -IsReadOnly $false -ErrorAction Stop) }
if (-not (Test-Path -LiteralPath $TemporaryRoot -PathType Container)) {
New-Item -Path $TemporaryRoot -ItemType Directory -Force -ErrorAction Stop | Out-Null
}
$sourceLease = New-DeployCenterDismSourceAccess -ImagePath ([string]$Edition.ImagePath) `
-TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction
$windowsDeployCenterPath = $null
try {
$deploymentImagePath = [string]$sourceLease.ImagePath
$partitionSizingDisk = [pscustomobject]@{ Index = [int]$disk.Number; SizeBytes = [long]$disk.Size }
$partitionPlan = @(New-DeployCenterPartitionPlan -DiskInfo $partitionSizingDisk -Profile $Profile -SizingState $SizingState)
$driveLetters = Get-DeployCenterTemporaryDriveLetters -DiskNumber ([int]$disk.Number) -Profile $Profile
$dismPath = Join-Path $env:WINDIR 'System32\dism.exe'
if (-not (Test-Path -LiteralPath $dismPath -PathType Leaf)) { $dismPath = 'dism.exe' }
$bcdbootPath = Join-Path $env:WINDIR 'System32\bcdboot.exe'
if (-not (Test-Path -LiteralPath $bcdbootPath -PathType Leaf)) { $bcdbootPath = 'bcdboot.exe' }
$scriptLines = New-Object System.Collections.Generic.List[string]
$scriptLines.Add("select disk $($disk.Number)")
$scriptLines.Add('convert gpt')
foreach ($item in $partitionPlan) {
$definition = $item.Definition
$role = [string]$definition.Role
switch ($role) {
'System' { $scriptLines.Add("create partition efi size=$($item.SizeMB)") }
'Reserved' { $scriptLines.Add("create partition msr size=$($item.SizeMB)") }
default {
if ($null -eq $item.SizeMB -or [long]$item.SizeMB -ge ([long][math]::Floor(([double]$disk.Size / 1MB)) - 2)) {
$scriptLines.Add('create partition primary')
} else {
$scriptLines.Add("create partition primary size=$($item.SizeMB)")
}
}
}
if ($role -eq 'Reserved') { continue }
$label = [string]$definition.Name
if ($label -notmatch '^[A-Za-z0-9_-]{1,32}$') { throw "Invalid partition name in profile: $label" }
if ([string]$definition.FileSystem) {
$scriptLines.Add("format fs=$($definition.FileSystem) quick label=$label")
}
if ($role -eq 'Recovery' -and $driveLetters.ContainsKey($role)) {
$scriptLines.Add("assign letter=$($driveLetters[$role])")
}
if ($definition.GptTypeId) { $scriptLines.Add("set id=$($definition.GptTypeId)") }
if ($definition.GptAttributes) { $scriptLines.Add("gpt attributes=$($definition.GptAttributes)") }
if ($driveLetters.ContainsKey($role) -and $role -ne 'Recovery') {
$scriptLines.Add("assign letter=$($driveLetters[$role])")
}
}
$cleanLines = @("select disk $($disk.Number)", 'clean')
$cleanPath = Join-Path $TemporaryRoot ("clean_{0}.txt" -f ([guid]::NewGuid().ToString('N')))
$diskpartPath = Join-Path $TemporaryRoot ("partition_{0}.txt" -f ([guid]::NewGuid().ToString('N')))
$unattendPath = Join-Path $TemporaryRoot ("unattend_{0}.xml" -f ([guid]::NewGuid().ToString('N')))
try {
[System.IO.File]::WriteAllLines($cleanPath, $cleanLines, [System.Text.Encoding]::ASCII)
if ($ProgressAction) { & $ProgressAction 'Erasing and partitioning the disk…' }
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ("Partitioning disk {0} with profile '{1}' ; configured WinRE partition: {2} MB." -f $disk.Number, $Profile.Name, $winrePartitionSizeMB)
Invoke-DeployCenterNativeCommand -FilePath 'diskpart.exe' -Arguments @('/s', $cleanPath) `
-TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction
[void](Update-DeployCenterHostStorageCache -LogAction $LogAction)
[System.IO.File]::WriteAllLines($diskpartPath, $scriptLines.ToArray(), [System.Text.Encoding]::ASCII)
Invoke-DeployCenterNativeCommand -FilePath 'diskpart.exe' -Arguments @('/s', $diskpartPath) `
-TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction
[void](Update-DeployCenterHostStorageCache -LogAction $LogAction)
$createdPartitions = @(Get-Partition -DiskNumber ([int]$disk.Number) -ErrorAction Stop)
if ($createdPartitions.Count -ne $partitionPlan.Count) {
throw "DiskPart created $($createdPartitions.Count) partition(s), but the profile specifies $($partitionPlan.Count). Installation has been stopped."
}
$orderedCreatedPartitions = @($createdPartitions | Sort-Object -Property PartitionNumber)
$recoveryPlanIndex = -1
for ($index = 0; $index -lt $partitionPlan.Count; $index++) {
if ([string]$partitionPlan[$index].Definition.Role -eq 'Recovery') {
$recoveryPlanIndex = $index
break
}
}
if ($recoveryPlanIndex -lt 0) { throw 'The Recovery partition is missing from the executed plan.' }
$recoveryPartition = $orderedCreatedPartitions[$recoveryPlanIndex]
$recoveryLetter = [string]$driveLetters['Recovery']
$recoveryDriveRoot = '{0}:\' -f $recoveryLetter
if (-not (Test-Path -LiteralPath $recoveryDriveRoot -PathType Container)) {
throw "The temporary drive letter ${recoveryLetter}: de WinRE is not accessible after partitioning."
}
$partitionsByLetter = @{}
for ($index = 0; $index -lt $partitionPlan.Count; $index++) {
$role = [string]$partitionPlan[$index].Definition.Role
if ($role -eq 'Reserved') { continue }
$letter = [string]$driveLetters[$role]
$driveRoot = '{0}:\' -f $letter
if (-not (Test-Path -LiteralPath $driveRoot -PathType Container)) {
throw "The temporary drive letter ${letter}: is not accessible after partitioning. Installation has been stopped."
}
# WinPE may not expose EFI/Recovery partitions in
# Get-Partition queries by drive letter. Their disk order follows
# the order of the definitions executed by DiskPart.
$partitionsByLetter[$letter] = $orderedCreatedPartitions[$index]
}
$windowsLetter = [string]$driveLetters['Windows']
$windowsPath = "${windowsLetter}:\"
$windowsRoot = Join-Path $windowsPath 'Windows'
$windowsDeployCenterPath = Join-Path $windowsPath 'DeployCenter'
$recoveryPath = "${recoveryLetter}:\Recovery\WindowsRE"
$efiLetter = [string]$driveLetters['System']
if (-not $windowsLetter -or -not $recoveryLetter -or -not $efiLetter) {
throw "The profile must define the System, Windows, and Recovery roles for UEFI installation with WinRE."
}
$wallpaperTargetPath = $null
$wallpaperDestination = $null
if ($wallpaperSourcePath) {
$wallpaperName = [System.IO.Path]::GetFileName($wallpaperSourcePath)
$wallpaperDirectory = Join-Path $windowsRoot 'Web\Wallpaper\DeployCenter'
$wallpaperDestination = Join-Path $wallpaperDirectory $wallpaperName
$wallpaperTargetPath = '%WINDIR%\Web\Wallpaper\DeployCenter\' + $wallpaperName
if ($wallpaperDestination.Length -gt 259) { throw "The configured wallpaper path exceeds 259 characters in Windows." }
}
$imageFileName = [System.IO.Path]::GetFileName($deploymentImagePath)
if ([string]::IsNullOrWhiteSpace($imageFileName)) { throw "Could not determine the selected Windows image filename." }
$imageStagingPath = $null
$imageStagingDirectory = $null
if ('Sources' -in $CopyLocally) {
$imageStagingPath = Join-Path (Join-Path (Join-Path $windowsPath 'DeployCenter') 'Sources') $imageFileName
$imageStagingDirectory = Split-Path -Path $imageStagingPath -Parent
} else {
Write-DeployCenterDeploymentLog -LogAction $LogAction `
-Message ("Using the Windows image directly from {0}." -f $deploymentImagePath) -Level 'INFO'
}
$imageFile = Get-Item -LiteralPath $deploymentImagePath -ErrorAction Stop
$windowsDriveInfo = New-Object System.IO.DriveInfo -ArgumentList ("{0}:\" -f $windowsLetter)
$driverPackSizeBytes = [long]0
if (-not [string]::IsNullOrWhiteSpace($DriverPath) -and 'Drivers' -in $CopyLocally) {
$driverFiles = @(Get-ChildItem -LiteralPath $DriverPath -Force -File -Recurse -ErrorAction Stop)
$driverPackSizeBytes = [long](($driverFiles | Measure-Object -Property Length -Sum).Sum)
}
$requiredStagingBytes = [long]0
if ('Sources' -in $CopyLocally) { $requiredStagingBytes += [long]$imageFile.Length }
if ('Drivers' -in $CopyLocally) { $requiredStagingBytes += $driverPackSizeBytes }
if ($requiredStagingBytes -gt 0 -and (-not $windowsDriveInfo.IsReady -or [long]$windowsDriveInfo.AvailableFreeSpace -lt $requiredStagingBytes)) {
$availableImageSpaceMB = if ($windowsDriveInfo.IsReady) { [math]::Floor(([double]$windowsDriveInfo.AvailableFreeSpace / 1MB)) } else { 0 }
$requiredStagingSpaceMB = [math]::Ceiling(([double]$requiredStagingBytes / 1MB))
throw ("Insufficient space on ${windowsLetter}: to stage the Windows image and driver pack: {0} MB required, {1} MB available." -f $requiredStagingSpaceMB, $availableImageSpaceMB)
}
if ('Sources' -in $CopyLocally) {
$imageCopyTimer = [System.Diagnostics.Stopwatch]::StartNew()
try {
New-Item -Path $imageStagingDirectory -ItemType Directory -Force -ErrorAction Stop | Out-Null
if ($ProgressAction) { & $ProgressAction "Copying the Windows image locally…" }
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ("Copying image {0} from {1} to {2} locally." -f $imageFileName, $deploymentImagePath, $imageStagingDirectory) -Level 'INFO'
[void](Invoke-DeployCenterRobocopy -SourceDirectory (Split-Path -Path $deploymentImagePath -Parent) `
-DestinationDirectory $imageStagingDirectory -CopyArguments @($imageFileName, '/J', '/R:2', '/W:2', '/ETA') `
-TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction)
$stagedImage = Get-Item -LiteralPath $imageStagingPath -ErrorAction Stop
if ([long]$stagedImage.Length -ne [long]$imageFile.Length) {
throw ("The local Windows image copy is incomplete: {0} bytes copied of {1}." -f $stagedImage.Length, $imageFile.Length)
}
$imageCopyTimer.Stop()
$imageSizeMB = [math]::Round(([double]$stagedImage.Length / 1MB), 0)
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ("Local Windows image copy completed in {0:N1} s ({1} MB)." -f $imageCopyTimer.Elapsed.TotalSeconds, $imageSizeMB) -Level 'SUCCESS'
$deploymentImagePath = $imageStagingPath
} finally {
if ($imageCopyTimer.IsRunning) { $imageCopyTimer.Stop() }
}
}
$driverDeploymentPath = $DriverPath
if (-not [string]::IsNullOrWhiteSpace($DriverPath) -and 'Drivers' -in $CopyLocally) {
$driverStagingPath = Join-Path $windowsDeployCenterPath 'Drivers'
$driverCopyTimer = [System.Diagnostics.Stopwatch]::StartNew()
try {
if ($ProgressAction) { & $ProgressAction 'Copying drivers locally…' }
Write-DeployCenterDeploymentLog -LogAction $LogAction `
-Message ("Copying {0} INF file(s) from {1} to {2} before applying Windows." -f $DriverInfCount, $DriverPath, $driverStagingPath) -Level 'INFO'
New-Item -Path $driverStagingPath -ItemType Directory -Force -ErrorAction Stop | Out-Null
[void](Invoke-DeployCenterRobocopy -SourceDirectory $DriverPath -DestinationDirectory $driverStagingPath `
-CopyArguments @('/E', '/J', '/R:2', '/W:2', '/ETA') -TemporaryRoot $TemporaryRoot `
-LogAction $LogAction -ProgressAction $ProgressAction)
$stagedInfCount = @(Get-ChildItem -LiteralPath $driverStagingPath -Filter '*.inf' -File -Recurse -ErrorAction Stop).Count
if ($stagedInfCount -ne $DriverInfCount) {
throw ("The local driver copy is incomplete: {0} INF file(s) copied of {1}." -f $stagedInfCount, $DriverInfCount)
}
$driverCopyTimer.Stop()
Write-DeployCenterDeploymentLog -LogAction $LogAction `
-Message ("Local driver copy completed in {0:N1} s ({1} INF file(s))." -f $driverCopyTimer.Elapsed.TotalSeconds, $stagedInfCount) -Level 'SUCCESS'
$driverDeploymentPath = $driverStagingPath
} finally {
if ($driverCopyTimer.IsRunning) { $driverCopyTimer.Stop() }
}
} elseif (-not [string]::IsNullOrWhiteSpace($DriverPath)) {
Write-DeployCenterDeploymentLog -LogAction $LogAction `
-Message ("Using the driver pack directly from {0}." -f $DriverPath) -Level 'INFO'
}
if ($ProgressAction) { & $ProgressAction "Applying Windows to ${windowsLetter}:…" }
Invoke-DeployCenterNativeCommand -FilePath $dismPath -Arguments @(
'/Apply-Image', "/ImageFile:$deploymentImagePath", "/Index:$([int]$Edition.Index)", "/ApplyDir:$windowsPath", '/CheckIntegrity'
) -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction
if ([string]::IsNullOrWhiteSpace($DriverDeploymentPath)) {
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message 'No matching driver pack; injection skipped.' -Level 'INFO'
} else {
$driverInstallTimer = $null
try {
if ($ProgressAction) { & $ProgressAction 'Installing hardware drivers…' }
$driverInstallTimer = [System.Diagnostics.Stopwatch]::StartNew()
Invoke-DeployCenterNativeCommand -FilePath $dismPath -Arguments @(
"/Image:$windowsPath", '/Add-Driver', "/Driver:$driverDeploymentPath", '/Recurse'
) -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction
$driverInstallTimer.Stop()
Write-DeployCenterDeploymentLog -LogAction $LogAction `
-Message ("DISM driver injection completed in {0:N1} s." -f $driverInstallTimer.Elapsed.TotalSeconds) -Level 'SUCCESS'
} finally {
if ($driverInstallTimer -and $driverInstallTimer.IsRunning) { $driverInstallTimer.Stop() }
}
}
if ($wallpaperSourcePath) {
New-Item -Path $wallpaperDirectory -ItemType Directory -Force -ErrorAction Stop | Out-Null
Copy-Item -LiteralPath $wallpaperSourcePath -Destination $wallpaperDestination -Force -ErrorAction Stop
Write-DeployCenterDeploymentLog -LogAction $LogAction `
-Message ("Wallpaper image copied to Windows: {0}." -f $wallpaperDestination) -Level 'INFO'
}
if ($ProgressAction) { & $ProgressAction 'Checking WinRE partition capacity…' }
$sourceWinre = Join-Path $windowsRoot 'System32\Recovery\winre.wim'
if (-not (Test-Path -LiteralPath $sourceWinre -PathType Leaf)) {
throw "winre.wim is missing from the applied Windows image; WinRE cannot be configured."
}
$winreFile = Get-Item -LiteralPath $sourceWinre -ErrorAction Stop
$recoveryPartition = $partitionsByLetter[$recoveryLetter]
$recoveryDriveInfo = New-Object System.IO.DriveInfo -ArgumentList "${recoveryLetter}:\"
if (-not $recoveryDriveInfo.IsReady) {
throw "The recovery volume ${recoveryLetter}: is not ready to check WinRE space."
}
$minimumPartitionBytes = [long](990 * 1MB)
$minimumFreeAfterCopyBytes = [long](250 * 1MB)
$recoveryFreeBytes = [long]$recoveryDriveInfo.AvailableFreeSpace
$expectedFreeBytes = $recoveryFreeBytes - [long]$winreFile.Length
$winreSizeForLog = [math]::Ceiling(([double]$winreFile.Length / 1MB))
$availableFreeMB = [math]::Floor(([double]$recoveryFreeBytes / 1MB))
$availableAfterCopyMB = [math]::Floor(([double]$expectedFreeBytes / 1MB))
if ($recoveryFreeBytes -lt [long]$winreFile.Length) {
$message = "Insufficient space on the WinRE partition to copy winre.wim: file size: $winreSizeForLog MB, available space $availableFreeMB MB. Deployment cannot configure WinRE."
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message $message -Level 'ERROR'
throw $message
}
$recommendationWarnings = New-Object System.Collections.Generic.List[string]
if ([long]$recoveryPartition.Size -lt $minimumPartitionBytes) {
$actualPartitionMB = [math]::Floor(([double]$recoveryPartition.Size / 1MB))
[void]$recommendationWarnings.Add("partition size: $actualPartitionMB MB, recommended minimum: 990 MB")
}
if ($expectedFreeBytes -lt $minimumFreeAfterCopyBytes) {
[void]$recommendationWarnings.Add("free space after copy: $availableAfterCopyMB MB, recommended minimum: 250 MB")
}
if ($recommendationWarnings.Count -gt 0) {
$warningDetails = $recommendationWarnings -join '; '
$winreRecommendationWarnings = @($recommendationWarnings.ToArray())
$message = "WinRE warning: Microsoft recommendations are not all met ($warningDetails). winre.wim can still be copied; deployment will continue."
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message $message -Level 'WARNING'
} else {
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ("WinRE validation succeeded: partition {0} MB, winre.wim {1} MB, estimated free space after copy {2} MB." -f $winrePartitionSizeMB, $winreSizeForLog, $availableAfterCopyMB) -Level 'SUCCESS'
}
if ($null -ne $UnattendProfile) {
if ($ProgressAction) { & $ProgressAction 'Preparing the Windows answer file…' }
$editionArchitecture = if ($Edition.Edition -and $Edition.Edition.Architecture) { [string]$Edition.Edition.Architecture } else { [string]$Edition.Architecture }
$unattendXml = New-DeployCenterUnattendXml -Profile $UnattendProfile `
-Configuration $UnattendConfiguration -HostName ([string]$HostName) `
-Architecture $editionArchitecture -WallpaperPath ([string]$wallpaperTargetPath)
[System.IO.File]::WriteAllText($unattendPath, $unattendXml, (New-Object System.Text.UTF8Encoding($false)))
$pantherUnattendDirectory = Join-Path $windowsRoot 'Panther\Unattend'
New-Item -Path $pantherUnattendDirectory -ItemType Directory -Force -ErrorAction Stop | Out-Null
Copy-Item -LiteralPath $unattendPath -Destination (Join-Path $pantherUnattendDirectory 'Unattend.xml') -Force -ErrorAction Stop
Write-DeployCenterDeploymentLog -LogAction $LogAction `
-Message ("Windows answer file prepared with profile '{0}' (computer name: {1})." -f $UnattendProfile.Name, $HostName) -Level 'INFO'
}
if ($ProgressAction) { & $ProgressAction 'Configuring UEFI boot…' }
Invoke-DeployCenterNativeCommand -FilePath $bcdbootPath -Arguments @(
("{0}:\Windows" -f $windowsLetter), '/s', ("{0}:" -f $efiLetter), '/f', 'UEFI'
) -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction
if ($ProgressAction) { & $ProgressAction 'Configuring the WinRE recovery partition…' }
New-Item -Path $recoveryPath -ItemType Directory -Force -ErrorAction Stop | Out-Null
if ($ProgressAction) { & $ProgressAction 'Copying winre.wim…' }
$recoveryImagePath = Join-Path $recoveryPath 'winre.wim'
[void](Invoke-DeployCenterRobocopy -SourceDirectory (Split-Path -Path $sourceWinre -Parent) `
-DestinationDirectory $recoveryPath -CopyArguments @('winre.wim', '/J', '/R:2', '/W:2', '/ETA') `
-TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction)
$copiedWinre = Get-Item -LiteralPath $recoveryImagePath -ErrorAction Stop
if ([long]$copiedWinre.Length -ne [long]$winreFile.Length) {
throw ("The WinRE image copy is incomplete: {0} bytes copied of {1}." -f $copiedWinre.Length, $winreFile.Length)
}
$reagentcPath = Join-Path $windowsRoot 'System32\reagentc.exe'
if (-not (Test-Path -LiteralPath $reagentcPath -PathType Leaf)) {
$reagentcPath = Join-Path $env:WINDIR 'System32\reagentc.exe'
}
if (-not (Test-Path -LiteralPath $reagentcPath -PathType Leaf)) {
throw 'REAgentC.exe was not found in the applied Windows image or WinPE.'
}
$bcdStorePath = "${efiLetter}:\EFI\Microsoft\Boot\BCD"
$windowsBootGuid = Get-DeployCenterOfflineWindowsBootGuid -BcdStorePath $bcdStorePath `
-WindowsDriveLetter $windowsLetter -IdentifierLabel $IdentifierLabel -LogAction $LogAction
if ($ProgressAction) { & $ProgressAction 'Registering the WinRE image…' }
Invoke-DeployCenterNativeCommand -FilePath $reagentcPath -Arguments @(
'/SetReImage', '/Path', $recoveryPath, '/Target', $windowsRoot
) -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction
if ($ProgressAction) { & $ProgressAction 'Enabling WinRE…' }
Invoke-DeployCenterNativeCommand -FilePath $reagentcPath -Arguments @(
'/Enable', '/OSGUID', $windowsBootGuid
) -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction
if ($ProgressAction) { & $ProgressAction 'Finalizing partitions…' }
$removeLines = New-Object System.Collections.Generic.List[string]
$lettersToRemove = @()
foreach ($roleToRemove in @('System', 'Recovery')) {
$letter = [string]$driveLetters[$roleToRemove]
if (-not [string]::IsNullOrWhiteSpace($letter)) { $lettersToRemove += $letter }
}
$lettersToRemove = @($lettersToRemove | Select-Object -Unique)
foreach ($letter in $lettersToRemove) {
$partitionToRemove = $partitionsByLetter[$letter]
$removeLines.Add("select disk $($disk.Number)")
$removeLines.Add("select partition $([int]$partitionToRemove.PartitionNumber)")
$removeLines.Add("remove letter=$letter")
}
if ($removeLines.Count -gt 0) {
$removePath = Join-Path $TemporaryRoot ("cleanup_{0}.txt" -f ([guid]::NewGuid().ToString('N')))
try {
[System.IO.File]::WriteAllLines($removePath, $removeLines.ToArray(), [System.Text.Encoding]::ASCII)
Invoke-DeployCenterNativeCommand -FilePath 'diskpart.exe' -Arguments @('/s', $removePath) `
-TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction
[void](Update-DeployCenterHostStorageCache -LogAction $LogAction)
# Get-Partition may retain the Recovery drive letter in its properties
# after DiskPart has actually removed it in WinPE.
$remainingPaths = @($lettersToRemove | Where-Object {
[System.IO.Directory]::Exists(('{0}:\' -f $_))
})
if ($remainingPaths.Count -gt 0) {
$remaining = (@($remainingPaths | ForEach-Object { ([string]$_).ToUpperInvariant() } | Select-Object -Unique)) -join ', '
throw "DiskPart did not release all temporary EFI/WinRE drive letters: $remaining."
}
} finally {
if (Test-Path -LiteralPath $removePath -PathType Leaf) { Remove-Item -LiteralPath $removePath -Force -ErrorAction SilentlyContinue }
}
}
$completionMode = if ($null -ne $UnattendProfile) { "Unattend profile '$($UnattendProfile.Name)'" } else { 'Use OOBE' }
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ("Deployment complete: Windows {0}, disk {1}, mode {2}." -f $Edition.Name, $disk.Number, $completionMode) -Level 'SUCCESS'
if ($ProgressAction) { & $ProgressAction 'Cleaning up temporary files…' }
} finally {
foreach ($temporaryFile in @($cleanPath, $diskpartPath, $unattendPath)) {
if (Test-Path -LiteralPath $temporaryFile -PathType Leaf) { Remove-Item -LiteralPath $temporaryFile -Force -ErrorAction SilentlyContinue }
}
}
} finally {
if ($windowsDeployCenterPath -and (Test-Path -LiteralPath $windowsDeployCenterPath -PathType Container)) {
try {
Remove-Item -LiteralPath $windowsDeployCenterPath -Recurse -Force -ErrorAction Stop
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message 'Temporary C:\DeployCenter directory removed.' -Level 'INFO'
} catch {
Write-DeployCenterDeploymentLog -LogAction $LogAction `
-Message ("Unable to remove the temporary C:\DeployCenter directory: {0}" -f $_.Exception.Message) -Level 'WARNING'
}
}
Remove-DeployCenterDismSourceAccess -Lease $sourceLease -TemporaryRoot $TemporaryRoot `
-LogAction $LogAction -ProgressAction $ProgressAction
}
return [pscustomobject]@{
WinRERecommendationWarnings = @($winreRecommendationWarnings)
WinRERecommendationsMet = ($winreRecommendationWarnings.Count -eq 0)
}
}
Export-ModuleMember -Function Invoke-DeployCenterWindowsDeployment, Restart-DeployCenterWinPE