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.
This commit is contained in:
benoit committed 2026-10-18 04:14:17 +02:00
1 parent cb917c97ce
commit 2212e25d31
26 files changed
+2625 -830

No files matched your search

+13 -4
View File
@@ -1,5 +1,5 @@
# DeployCenter - Lecture des préférences utilisateur
# Lecture seule : ce module ne modifie jamais DeployCenter.ini.
# DeployCenter - Read user preferences
# Read-only: this module never modifies DeployCenter.ini.
function Read-DeployCenterConfig {
[CmdletBinding()]
@@ -11,12 +11,15 @@ function Read-DeployCenterConfig {
VncPassword = ''
WinRESizeMB = 1024
UnattendAdminName = 'admin'
UnattendAdminDisplayName = 'Administrateur local'
UnattendAdminDisplayName = 'Local Administrator'
UnattendAdminGroup = 'Administrators'
UnattendAdminPasswordBase64 = ''
UnattendSupportProvider = ''
UnattendSupportURL = ''
UnattendSupportAppURL = ''
UnattendWallpaperPath = 'Assets\blorand.png'
DriverRootPath = ''
DriverUrls = @{ Lenovo = ''; Dell = ''; HP = ''; Microsoft = '' }
}
if (-not (Test-Path -LiteralPath $ConfigPath -PathType Leaf)) { return $config }
@@ -38,7 +41,7 @@ function Read-DeployCenterConfig {
} elseif ($section -ieq 'Partitioning' -and $key -ieq 'WinRESizeMB') {
$sizeMB = 0
if (-not [int]::TryParse($value, [ref]$sizeMB) -or $sizeMB -le 0) {
throw "Le paramètre [Partitioning] WinRESizeMB doit être un entier strictement positif. Valeur reçue : '$value'."
throw "[Partitioning] WinRESizeMB must be a positive integer. Value received: '$value'."
}
$config.WinRESizeMB = $sizeMB
} elseif ($section -ieq 'Unattend' -and $key -ieq 'AdminName') {
@@ -51,10 +54,16 @@ function Read-DeployCenterConfig {
$config.UnattendAdminPasswordBase64 = $value
} elseif ($section -ieq 'Unattend' -and $key -ieq 'SupportProvider') {
$config.UnattendSupportProvider = $value
} elseif ($section -ieq 'Unattend' -and $key -ieq 'SupportURL') {
$config.UnattendSupportURL = $value
} elseif ($section -ieq 'Unattend' -and $key -ieq 'SupportAppURL') {
$config.UnattendSupportAppURL = $value
} elseif ($section -ieq 'Unattend' -and $key -ieq 'WallpaperPath') {
$config.UnattendWallpaperPath = $value
} elseif ($section -ieq 'Drivers' -and $key -ieq 'RootPath') {
$config.DriverRootPath = $value
} elseif ($section -ieq 'Drivers' -and $key -match '^(Lenovo|Dell|HP|Microsoft)URL$') {
$config.DriverUrls[$matches[1]] = $value
}
}
return $config
+422 -95
View File
@@ -1,11 +1,11 @@
# DeployCenter - Partitionnement et installation d'une edition Windows
# Compatible Windows PowerShell 5.1 / WinPE.
# 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')
if ($LogAction) { & $LogAction $Message $Level }
param([AllowNull()][scriptblock]$LogAction, [string]$Message, [string]$Level = 'INFO', [switch]$SystemOutput)
if ($LogAction) { & $LogAction $Message $Level ([bool]$SystemOutput) }
}
function Update-DeployCenterHostStorageCache {
@@ -14,11 +14,11 @@ function Update-DeployCenterHostStorageCache {
try {
Update-HostStorageCache -ErrorAction Stop
Write-DeployCenterDeploymentLog -LogAction $LogAction `
-Message 'Cache de stockage WinPE actualisé.' -Level 'INFO'
-Message 'WinPE storage cache refreshed.' -Level 'INFO'
return $true
} catch {
Write-DeployCenterDeploymentLog -LogAction $LogAction `
-Message ("Actualisation du cache de stockage WinPE indisponible : {0}" -f $_.Exception.Message) `
-Message ("WinPE storage cache refresh is unavailable: {0}" -f $_.Exception.Message) `
-Level 'WARNING'
return $false
}
@@ -30,19 +30,19 @@ function Restart-DeployCenterWinPE {
$wpeutilPath = Join-Path $env:WINDIR 'System32\wpeutil.exe'
if (-not (Test-Path -LiteralPath $wpeutilPath -PathType Leaf)) {
throw 'wpeutil.exe est introuvable ; le redémarrage WinPE ne peut pas être demandé.'
throw 'wpeutil.exe was not found; a WinPE restart cannot be requested.'
}
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message 'Redémarrage de WinPE demandé via wpeutil.' -Level 'INFO'
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'
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ([string]$line) -Level 'INFO' -SystemOutput
}
}
if ($null -ne $exitCode -and $exitCode -ne 0) {
throw "wpeutil Reboot a renvoyé le code $exitCode."
throw "wpeutil Reboot returned exit code $exitCode."
}
}
@@ -79,13 +79,14 @@ function New-DeployCenterDismSourceAccess {
param(
[Parameter(Mandatory=$true)][string]$ImagePath,
[Parameter(Mandatory=$true)][string]$TemporaryRoot,
[AllowNull()][scriptblock]$LogAction = $null
[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 "Chemin de volume GUID invalide pour l’image : $ImagePath" }
if ($volumeEnd -lt 0) { throw "Invalid volume GUID path for image: $ImagePath" }
$volumeRoot = $ImagePath.Substring(0, $volumeEnd + 2)
$relativePath = $ImagePath.Substring($volumeEnd + 2)
@@ -103,7 +104,7 @@ function New-DeployCenterDismSourceAccess {
break
}
}
if (-not $letter) { throw 'Aucune lettre temporaire disponible pour accéder à la source Windows.' }
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' }
@@ -111,20 +112,20 @@ function New-DeployCenterDismSourceAccess {
$mountError = $null
try {
Invoke-DeployCenterNativeCommand -FilePath $mountVolPath -Arguments @(("{0}:\" -f $letter), $volumeRoot) `
-TemporaryRoot $TemporaryRoot -LogAction $LogAction
-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
-TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction
} catch { }
throw "La source Windows n’est pas accessible après l’affectation temporaire de ${letter}:."
throw "The Windows source is not accessible after temporarily assigning ${letter}:."
}
if ($mountError) {
Write-DeployCenterDeploymentLog -LogAction $LogAction `
-Message ("mountvol n’a pas fourni de code de sortie exploitable, mais la source est accessible via {0}:. L’affectation est confirmée." -f $letter) `
-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
@@ -137,7 +138,8 @@ function Remove-DeployCenterDismSourceAccess {
param(
[AllowNull()]$Lease,
[Parameter(Mandatory=$true)][string]$TemporaryRoot,
[AllowNull()][scriptblock]$LogAction = $null
[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'
@@ -145,18 +147,18 @@ function Remove-DeployCenterDismSourceAccess {
$releaseError = $null
try {
Invoke-DeployCenterNativeCommand -FilePath $mountVolPath -Arguments @(("{0}:\" -f $Lease.DriveLetter), '/D') `
-TemporaryRoot $TemporaryRoot -LogAction $LogAction
-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 { 'Le fichier source reste accessible.' }
$details = if ($releaseError) { $releaseError.Message } else { 'The source file is still accessible.' }
Write-DeployCenterDeploymentLog -LogAction $LogAction `
-Message "Impossible de confirmer la libération de la lettre temporaire $($Lease.DriveLetter): $details" -Level 'WARNING'
-Message "Could not confirm release of temporary drive letter $($Lease.DriveLetter): $details" -Level 'WARNING'
} elseif ($releaseError) {
Write-DeployCenterDeploymentLog -LogAction $LogAction `
-Message ("mountvol n’a pas fourni de code de sortie exploitable, mais la source n’est plus accessible via {0}:. La libération est confirmée." -f $Lease.DriveLetter) `
-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'
}
}
@@ -183,6 +185,155 @@ function Get-DeployCenterNativeProgressPercent {
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,
@@ -199,6 +350,8 @@ function Invoke-DeployCenterNativeCommand {
$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) {
@@ -209,7 +362,7 @@ function Invoke-DeployCenterNativeCommand {
}) -join ' '
try {
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ("Commande native : {0} {1}" -f $FilePath, $argumentLine)
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 `
@@ -234,7 +387,7 @@ function Invoke-DeployCenterNativeCommand {
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'
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ([string]$line) -Level 'INFO' -SystemOutput
}
}
}
@@ -245,17 +398,55 @@ function Invoke-DeployCenterNativeCommand {
}
if ($null -eq $exitCode -and @($nativeOutputLines | Where-Object { $_ -match '(?i)completed successfully|a r.ussi' }).Count -gt 0) {
Write-DeployCenterDeploymentLog -LogAction $LogAction `
-Message 'DISM confirme que Windows a été appliqué, mais le code de sortie est absent dans WinPE.' `
-Message 'DISM confirms Windows was applied, but the exit code is unavailable in WinPE.' `
-Level 'WARNING'
return
}
} else {
$commandOutput = @(& $FilePath @Arguments 2>&1)
$exitCode = $LASTEXITCODE
foreach ($line in $commandOutput) {
if (-not [string]::IsNullOrWhiteSpace([string]$line)) {
[void]$nativeOutputLines.Add([string]$line)
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ([string]$line) -Level 'INFO'
$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
}
}
}
}
}
@@ -264,11 +455,11 @@ function Invoke-DeployCenterNativeCommand {
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 ("DISM : {0}" -f $line) -Level 'ERROR'
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ([string]$line) -Level 'ERROR' -SystemOutput
}
}
$diagnosticMessage = $diagnostic -join ' | '
throw ("La commande {0} s'est terminée avec le code {1}. {2}" -f $FilePath, $exitCode, $diagnosticMessage)
throw ("Command {0} exited with code {1}. {2}" -f $FilePath, $exitCode, $diagnosticMessage)
}
} finally {
foreach ($outputPath in @($stdoutPath, $stderrPath)) {
@@ -279,6 +470,11 @@ function Invoke-DeployCenterNativeCommand {
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
}
}
}
}
@@ -286,11 +482,12 @@ 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 "Magasin BCD introuvable après la création des fichiers de démarrage : $BcdStorePath"
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' }
@@ -298,19 +495,20 @@ function Get-DeployCenterOfflineWindowsBootGuid {
$exitCode = $LASTEXITCODE
foreach ($line in $output) {
if (-not [string]::IsNullOrWhiteSpace([string]$line)) {
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ([string]$line) -Level 'INFO'
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ([string]$line) -Level 'INFO' -SystemOutput
}
}
if ($null -eq $exitCode -or [int]$exitCode -ne 0) {
throw "bcdedit n’a pas pu lire le magasin BCD $BcdStorePath (code $exitCode)."
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 '^\s*(?:identifier|identificateur)\s+(?<guid>\{[0-9A-Fa-f-]{36}\})\s*$') {
if ($line -match $identifierPattern) {
if ($null -ne $currentEntry) { $entries.Add($currentEntry) }
$currentEntry = [pscustomobject]@{
Guid = [string]$Matches['guid']
@@ -332,7 +530,7 @@ function Get-DeployCenterOfflineWindowsBootGuid {
$_.Device -eq $targetLetter -and $_.OsDevice -eq $targetLetter
})
if ($matchingEntries.Count -ne 1) {
throw "Impossible d’identifier une entrée BCD unique pour Windows sur ${targetLetter}: (entrées correspondantes : $($matchingEntries.Count))."
throw "Could not identify a unique BCD entry for Windows on ${targetLetter}: (matching entries: $($matchingEntries.Count))."
}
return [string]$matchingEntries[0].Guid
}
@@ -370,9 +568,9 @@ function Get-DeployCenterTemporaryDriveLetters {
break
}
}
if (-not $selected) { throw "Aucune lettre de lecteur libre pour la partition '$($partition.Name)'." }
if (-not $selected) { throw "No free drive letter is available for partition '$($partition.Name)'." }
}
if ($selected -in $lettersByRole.Values) { throw "La lettre ${selected}: est affectée à plusieurs partitions du profil." }
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
}
@@ -387,7 +585,7 @@ function New-DeployCenterPartitionPlan {
)
if ([string]$Profile.PartitionStyle -ne 'GPT' -or [string]$Profile.Firmware -ne 'UEFI') {
throw "Le profil '$($Profile.Name)' n’est pas pris en charge par le moteur actuel (UEFI/GPT requis)."
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
@@ -401,26 +599,26 @@ function New-DeployCenterPartitionPlan {
'ConfiguredMB' { $sizeMB = [long]$partition.Size.Value }
'UserInputGB' {
if ($null -eq $SizingState -or [string]$SizingState.ProfileId -ne [string]$Profile.Id) {
throw "Le profil '$($Profile.Name)' nécessite une taille utilisateur, qui n’a pas été validée."
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 "La taille de la partition '$role' est absente." }
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 "La partition '$role' est sous sa taille minimale." }
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 "Le disque ne dispose plus d’espace pour la partition '$($partition.Name)'." }
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 "L’espace restant pour '$($partition.Name)' est inférieur au minimum du profil."
throw "The remaining space for '$($partition.Name)' is below the profile minimum."
}
}
default { throw "Politique de taille non prise en charge lors du partitionnement : $policy" }
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 "La taille demandée dépasse la capacité du disque sélectionné." }
if ($usedMB -gt $availableMB) { throw "The requested size exceeds the selected disk capacity." }
return $plan.ToArray()
}
@@ -432,38 +630,49 @@ function Invoke-DeployCenterWindowsDeployment {
[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 'La configuration DeployCenter.ini est absente pour le profil Unattend sélectionné.' }
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 ("Le profil {0} exige le firmware {1}, détecté : {2}." -f $Profile.Name, $Profile.Firmware, $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 "Le profil doit définir exactement une partition de rôle '$requiredRole'. Le disque n’a pas été modifié." }
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 "Le profil doit définir une taille configurée pour la partition Recovery. Le disque n’a pas été modifié."
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 "La taille configurée de la partition WinRE doit être strictement positive. Le disque n’a pas été modifié."
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 "Le chemin de l’image Windows sélectionnée est introuvable."
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"
}
}
if (-not $Edition.Index -or [int]$Edition.Index -lt 1) { throw "L’index de l’édition Windows est invalide." }
$wallpaperSourcePath = $null
if ($null -ne $UnattendProfile -and $null -ne $UnattendConfiguration) {
@@ -476,16 +685,16 @@ function Invoke-DeployCenterWindowsDeployment {
$wallpaperSourcePath = [System.IO.Path]::GetFullPath((Join-Path $deployRoot $configuredWallpaperPath))
}
if ([System.IO.Path]::GetExtension($wallpaperSourcePath) -notin @('.png', '.jpg', '.bmp')) {
throw 'WallpaperPath doit désigner un fichier .png, .jpg ou .bmp.'
throw 'WallpaperPath must point to a .png, .jpg, or .bmp file.'
}
if (-not (Test-Path -LiteralPath $wallpaperSourcePath -PathType Leaf)) {
throw "Image de fond introuvable : $wallpaperSourcePath"
throw "Wallpaper image not found: $wallpaperSourcePath"
}
}
}
$disk = Get-Disk -Number ([int]$DiskInfo.Index) -ErrorAction Stop
if ($disk.IsBoot -or $disk.IsSystem) { throw 'Le disque sélectionné héberge le système WinPE et ne peut pas être effacé.' }
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) }
@@ -493,7 +702,8 @@ function Invoke-DeployCenterWindowsDeployment {
New-Item -Path $TemporaryRoot -ItemType Directory -Force -ErrorAction Stop | Out-Null
}
$sourceLease = New-DeployCenterDismSourceAccess -ImagePath ([string]$Edition.ImagePath) `
-TemporaryRoot $TemporaryRoot -LogAction $LogAction
-TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction
$windowsDeployCenterPath = $null
try {
$deploymentImagePath = [string]$sourceLease.ImagePath
$partitionSizingDisk = [pscustomobject]@{ Index = [int]$disk.Number; SizeBytes = [long]$disk.Size }
@@ -524,7 +734,7 @@ function Invoke-DeployCenterWindowsDeployment {
}
if ($role -eq 'Reserved') { continue }
$label = [string]$definition.Name
if ($label -notmatch '^[A-Za-z0-9_-]{1,32}$') { throw "Nom de partition invalide dans le profil : $label" }
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")
}
@@ -544,8 +754,8 @@ function Invoke-DeployCenterWindowsDeployment {
$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 'Effacement et partitionnement du disque…' }
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ("Partitionnement du disque {0} avec le profil '{1}' ; partition WinRE configurée : {2} Mo." -f $disk.Number, $Profile.Name, $winrePartitionSizeMB)
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)
@@ -556,7 +766,7 @@ function Invoke-DeployCenterWindowsDeployment {
[void](Update-DeployCenterHostStorageCache -LogAction $LogAction)
$createdPartitions = @(Get-Partition -DiskNumber ([int]$disk.Number) -ErrorAction Stop)
if ($createdPartitions.Count -ne $partitionPlan.Count) {
throw "DiskPart a créé $($createdPartitions.Count) partition(s), alors que le profil en prévoit $($partitionPlan.Count). L’installation est interrompue."
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
@@ -566,12 +776,12 @@ function Invoke-DeployCenterWindowsDeployment {
break
}
}
if ($recoveryPlanIndex -lt 0) { throw 'La partition Recovery est absente du plan exécuté.' }
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 "La lettre temporaire ${recoveryLetter}: de WinRE n’est pas accessible après le partitionnement."
throw "The temporary drive letter ${recoveryLetter}: de WinRE is not accessible after partitioning."
}
$partitionsByLetter = @{}
for ($index = 0; $index -lt $partitionPlan.Count; $index++) {
@@ -580,21 +790,22 @@ function Invoke-DeployCenterWindowsDeployment {
$letter = [string]$driveLetters[$role]
$driveRoot = '{0}:\' -f $letter
if (-not (Test-Path -LiteralPath $driveRoot -PathType Container)) {
throw "La lettre temporaire ${letter}: n’est pas accessible après le partitionnement. L’installation est interrompue."
throw "The temporary drive letter ${letter}: is not accessible after partitioning. Installation has been stopped."
}
# WinPE peut ne pas exposer les partitions EFI/Recovery dans les
# requêtes Get-Partition par lettre. Leur ordre sur le disque suit
# directement celui des définitions exécutées par DiskPart.
# 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 "Le profil doit définir les rôles System, Windows et Recovery pour permettre l’installation UEFI avec WinRE."
throw "The profile must define the System, Windows, and Recovery roles for UEFI installation with WinRE."
}
$wallpaperTargetPath = $null
@@ -604,31 +815,126 @@ function Invoke-DeployCenterWindowsDeployment {
$wallpaperDirectory = Join-Path $windowsRoot 'Web\Wallpaper\DeployCenter'
$wallpaperDestination = Join-Path $wallpaperDirectory $wallpaperName
$wallpaperTargetPath = '%WINDIR%\Web\Wallpaper\DeployCenter\' + $wallpaperName
if ($wallpaperDestination.Length -gt 259) { throw "Le chemin configuré du fond d’écran dépasse 259 caractères dans Windows." }
if ($wallpaperDestination.Length -gt 259) { throw "The configured wallpaper path exceeds 259 characters in Windows." }
}
if ($ProgressAction) { & $ProgressAction "Application de Windows sur ${windowsLetter}:…" }
$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 ("Image de fond copiée dans Windows : {0}." -f $wallpaperDestination) -Level 'INFO'
-Message ("Wallpaper image copied to Windows: {0}." -f $wallpaperDestination) -Level 'INFO'
}
if ($ProgressAction) { & $ProgressAction 'Vérification de la capacité de la partition WinRE…' }
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 est absent de l’image Windows appliquée ; impossible de configurer WinRE."
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 "Le volume de récupération ${recoveryLetter}: n’est pas prêt pour vérifier l’espace WinRE."
throw "The recovery volume ${recoveryLetter}: is not ready to check WinRE space."
}
$minimumPartitionBytes = [long](990 * 1MB)
$minimumFreeAfterCopyBytes = [long](250 * 1MB)
@@ -638,29 +944,29 @@ function Invoke-DeployCenterWindowsDeployment {
$availableFreeMB = [math]::Floor(([double]$recoveryFreeBytes / 1MB))
$availableAfterCopyMB = [math]::Floor(([double]$expectedFreeBytes / 1MB))
if ($recoveryFreeBytes -lt [long]$winreFile.Length) {
$message = "Espace insuffisant sur la partition WinRE pour copier winre.wim : fichier de $winreSizeForLog Mo, espace disponible $availableFreeMB Mo. Le déploiement ne peut pas configurer WinRE."
$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("taille de partition : $actualPartitionMB Mo, minimum recommandé : 990 Mo")
[void]$recommendationWarnings.Add("partition size: $actualPartitionMB MB, recommended minimum: 990 MB")
}
if ($expectedFreeBytes -lt $minimumFreeAfterCopyBytes) {
[void]$recommendationWarnings.Add("espace libre après copie : $availableAfterCopyMB Mo, minimum recommandé : 250 Mo")
[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 = "Avertissement WinRE : les recommandations Microsoft ne sont pas toutes respectées ($warningDetails). La copie de winre.wim reste possible ; le déploiement continue."
$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 ("Vérification WinRE réussie : partition {0} Mo, winre.wim {1} Mo, espace restant estimé après copie {2} Mo." -f $winrePartitionSizeMB, $winreSizeForLog, $availableAfterCopyMB) -Level 'SUCCESS'
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 'Préparation du fichier de réponses Windows…' }
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) `
@@ -670,35 +976,45 @@ function Invoke-DeployCenterWindowsDeployment {
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 ("Fichier de réponses Windows préparé avec le profil '{0}' (nom d’hôte : {1})." -f $UnattendProfile.Name, $HostName) -Level 'INFO'
-Message ("Windows answer file prepared with profile '{0}' (computer name: {1})." -f $UnattendProfile.Name, $HostName) -Level 'INFO'
}
if ($ProgressAction) { & $ProgressAction 'Configuration du démarrage UEFI…' }
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 'Configuration de la partition de récupération WinRE…' }
if ($ProgressAction) { & $ProgressAction 'Configuring the WinRE recovery partition…' }
New-Item -Path $recoveryPath -ItemType Directory -Force -ErrorAction Stop | Out-Null
Copy-Item -LiteralPath $sourceWinre -Destination (Join-Path $recoveryPath 'winre.wim') -Force -ErrorAction Stop
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 est introuvable dans Windows appliqué et dans WinPE.'
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 -LogAction $LogAction
-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 'Finalisation des partitions…' }
if ($ProgressAction) { & $ProgressAction 'Finalizing partitions…' }
$removeLines = New-Object System.Collections.Generic.List[string]
$lettersToRemove = @()
foreach ($roleToRemove in @('System', 'Recovery')) {
@@ -719,28 +1035,39 @@ function Invoke-DeployCenterWindowsDeployment {
Invoke-DeployCenterNativeCommand -FilePath 'diskpart.exe' -Arguments @('/s', $removePath) `
-TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction
[void](Update-DeployCenterHostStorageCache -LogAction $LogAction)
# Get-Partition peut conserver la lettre Recovery dans ses propriétés
# après que DiskPart l'a effectivement retirée sous WinPE.
# 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 n’a pas libéré toutes les lettres temporaires EFI/WinRE : $remaining."
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) { "profil Unattend '$($UnattendProfile.Name)'" } else { 'Avec OOBE' }
Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ("Déploiement terminé : Windows {0}, disque {1}, mode {2}." -f $Edition.Name, $disk.Number, $completionMode) -Level 'SUCCESS'
$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 {
Remove-DeployCenterDismSourceAccess -Lease $sourceLease -TemporaryRoot $TemporaryRoot -LogAction $LogAction
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)
+76
View File
@@ -0,0 +1,76 @@
# DeployCenter - offline driver pack detection.
function ConvertTo-DeployCenterDriverKey {
param([AllowNull()][string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
return ([regex]::Replace($Value, '[^A-Za-z0-9]', '')).ToUpperInvariant()
}
function Get-DeployCenterDriverManufacturerFolder {
param([AllowNull()][string]$Manufacturer)
if ($Manufacturer -match '(?i)lenovo') { return 'Lenovo' }
if ($Manufacturer -match '(?i)dell') { return 'Dell' }
if ($Manufacturer -match '(?i)hewlett|\bHP\b') { return 'HP' }
if ($Manufacturer -match '(?i)microsoft') { return 'Microsoft' }
return $null
}
function Get-DeployCenterDriverWindowsFolder {
param([AllowNull()][string]$EditionName)
if ([string]::IsNullOrWhiteSpace($EditionName)) { return $null }
if ($EditionName -match '(?i)Windows\s+Server\s+(\d{4})') { return "Windows Server $($matches[1])" }
if ($EditionName -match '(?i)Windows\s+11') { return 'Windows 11' }
if ($EditionName -match '(?i)Windows\s+10') { return 'Windows 10' }
return $null
}
function Find-DeployCenterDriverPack {
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)][string]$DriverRoot,
[AllowNull()][string]$Manufacturer,
[AllowNull()][string]$Model,
[AllowNull()][string]$SystemSKU,
[AllowNull()][string]$EditionName
)
$vendor = Get-DeployCenterDriverManufacturerFolder -Manufacturer $Manufacturer
$osFolder = Get-DeployCenterDriverWindowsFolder -EditionName $EditionName
$vendorRoot = if ($vendor) { Join-Path $DriverRoot $vendor } else { Join-Path $DriverRoot '_unknown_vendor' }
$modelFolder = $null
if (Test-Path -LiteralPath $vendorRoot -PathType Container) {
$prefixes = @()
$modelKey = ConvertTo-DeployCenterDriverKey $Model
$skuKey = ConvertTo-DeployCenterDriverKey $SystemSKU
if ($modelKey) { $prefixes += $modelKey }
if ($skuKey) { $prefixes += $skuKey }
$candidates = @()
foreach ($directory in @(Get-ChildItem -LiteralPath $vendorRoot -Directory -ErrorAction SilentlyContinue)) {
$key = ConvertTo-DeployCenterDriverKey $directory.Name
if (-not $key) { continue }
foreach ($prefix in $prefixes) {
if ($prefix.StartsWith($key, [StringComparison]::OrdinalIgnoreCase)) {
$candidates += [pscustomobject]@{ Path = $directory.FullName; KeyLength = $key.Length }
break
}
}
}
$longest = ($candidates | Measure-Object -Property KeyLength -Maximum).Maximum
$best = @($candidates | Where-Object { $_.KeyLength -eq $longest })
if ($best.Count -eq 1) { $modelFolder = $best[0].Path }
elseif ($best.Count -gt 1) {
return [pscustomobject]@{ Found=$false; Status='Ambiguous model match'; DriverPath=$null; SearchPath=$vendorRoot; InfCount=0 }
}
}
$searchPath = if ($modelFolder -and $osFolder) { Join-Path $modelFolder $osFolder } elseif ($vendor -and $osFolder) { Join-Path $vendorRoot ('<model>\' + $osFolder) } elseif ($vendor) { Join-Path $vendorRoot '<Windows version>' } else { Join-Path $DriverRoot '<vendor>\<model>\<Windows version>' }
if (-not $vendor) { return [pscustomobject]@{ Found=$false; Status='Unsupported manufacturer'; DriverPath=$null; SearchPath=$searchPath; InfCount=0 } }
if (-not $osFolder) { return [pscustomobject]@{ Found=$false; Status='Unrecognized Windows version'; DriverPath=$null; SearchPath=$searchPath; InfCount=0 } }
if (-not $modelFolder) { return [pscustomobject]@{ Found=$false; Status='Model not found'; DriverPath=$null; SearchPath=$searchPath; InfCount=0 } }
$searchPath = Join-Path $modelFolder $osFolder
if (-not (Test-Path -LiteralPath $searchPath -PathType Container)) { return [pscustomobject]@{ Found=$false; Status='Version folder not found'; DriverPath=$null; SearchPath=$searchPath; InfCount=0 } }
$infCount = @(Get-ChildItem -LiteralPath $searchPath -Filter '*.inf' -File -Recurse -ErrorAction SilentlyContinue).Count
if ($infCount -eq 0) { return [pscustomobject]@{ Found=$false; Status='No INF files'; DriverPath=$null; SearchPath=$searchPath; InfCount=0 } }
return [pscustomobject]@{ Found=$true; Status='Driver pack found'; DriverPath=$searchPath; SearchPath=$searchPath; InfCount=$infCount }
}
Export-ModuleMember -Function Find-DeployCenterDriverPack
+21 -17
View File
@@ -5,17 +5,18 @@ function Get-HardwareInfo {
param()
$info = [ordered]@{
Manufacturer = "Inconnu"
Model = "Inconnu"
SerialNumber = "Inconnu"
Firmware = "Inconnu"
Manufacturer = "Unknown"
Model = "Unknown"
SystemSKU = ""
SerialNumber = "Unknown"
Firmware = "Unknown"
Architecture = $env:PROCESSOR_ARCHITECTURE
WinPE = $false
Disks = @()
Errors = @()
}
# Fabricant et modele du PC
# PC manufacturer and model
try {
$computer = Get-CimInstance `
-ClassName Win32_ComputerSystem `
@@ -28,12 +29,15 @@ function Get-HardwareInfo {
if ($computer.Model) {
$info.Model = $computer.Model.Trim()
}
if ($computer.SystemSKUNumber) {
$info.SystemSKU = ([string]$computer.SystemSKUNumber).Trim()
}
}
catch {
$info.Errors += "Modele du PC : $($_.Exception.Message)"
$info.Errors += "PC model: $($_.Exception.Message)"
}
# Numero de serie
# Serial number
try {
$bios = Get-CimInstance `
-ClassName Win32_BIOS `
@@ -47,7 +51,7 @@ function Get-HardwareInfo {
$info.Errors += "Numero de serie : $($_.Exception.Message)"
}
# Detection du firmware
# Firmware detection
try {
$firmwareKey = Get-ItemProperty `
-Path "HKLM:\SYSTEM\CurrentControlSet\Control" `
@@ -61,20 +65,20 @@ function Get-HardwareInfo {
}
}
catch {
$info.Errors += "Mode firmware indisponible"
$info.Errors += "Firmware mode unavailable"
}
# Detection de WinPE
# WinPE detection
$info.WinPE = Test-Path `
"HKLM:\SYSTEM\CurrentControlSet\Control\MiniNT"
# Inventaire des disques physiques
# Physical disk inventory
try {
$disks = @(Get-Disk -ErrorAction Stop)
foreach ($disk in $disks) {
$operationalStatus = @($disk.OperationalStatus | ForEach-Object { [string]$_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
$status = if ($operationalStatus.Count -gt 0) { $operationalStatus -join ', ' } else { 'Inconnu' }
$status = if ($operationalStatus.Count -gt 0) { $operationalStatus -join ', ' } else { 'Unknown' }
$info.Disks += [PSCustomObject]@{
Index = [int]$disk.Number
@@ -90,10 +94,10 @@ function Get-HardwareInfo {
}
}
catch {
$info.Errors += "Inventaire des disques : $($_.Exception.Message)"
$info.Errors += "Disk inventory: $($_.Exception.Message)"
}
# Retourner un objet exploitable par les autres modules
# Return an object that other modules can use.
[PSCustomObject]$info
}
@@ -119,7 +123,7 @@ function Write-DeployInventory {
$json = $inventory | ConvertTo-Json -Depth 8
Set-Content -LiteralPath $InventoryFile -Value $json -Encoding UTF8
# Nettoyer les anciens exports séparés après l'écriture de l'inventaire consolidé.
# Remove the old separate exports after writing the consolidated inventory.
$legacyInventoryFiles = @(
(Join-Path $env:TEMP 'HardwareInfo.json'),
(Join-Path $InventoryDir 'hardware.json')
@@ -129,12 +133,12 @@ function Write-DeployInventory {
try {
Remove-Item -LiteralPath $legacyFile -Force -ErrorAction Stop
} catch {
if ($LogAction) { & $LogAction "Impossible de supprimer l'ancien export d'inventaire '$legacyFile' : $($_.Exception.Message)" 'WARNING' }
if ($LogAction) { & $LogAction "Unable to remove the previous inventory export '$legacyFile' : $($_.Exception.Message)" 'WARNING' }
}
}
}
} catch {
if ($LogAction) { & $LogAction "Impossible d'écrire l'inventaire temporaire : $($_.Exception.Message)" 'WARNING' }
if ($LogAction) { & $LogAction "Unable to write the temporary inventory: $($_.Exception.Message)" 'WARNING' }
}
}
+55
View File
@@ -0,0 +1,55 @@
# DeployCenter - localize application text using the current WinPE language.
$script:DeployCenterLocale = 'fr-FR'
$script:DeployCenterText = @{}
function Initialize-DeployCenterLocalization {
[CmdletBinding()]
param([AllowNull()][string]$CultureName = [Globalization.CultureInfo]::CurrentUICulture.Name)
$culture = if ([string]::IsNullOrWhiteSpace($CultureName)) { 'fr-FR' } else { $CultureName }
$localeName = if ($culture.StartsWith('fr', [StringComparison]::OrdinalIgnoreCase)) { 'fr-FR' } else { 'en-US' }
$resourcePath = Join-Path $PSScriptRoot (Join-Path '..' (Join-Path 'Locales' ($localeName + '.psd1')))
$resourcePath = [IO.Path]::GetFullPath($resourcePath)
if (-not (Test-Path -LiteralPath $resourcePath -PathType Leaf)) {
throw "Localization resource not found: $resourcePath"
}
$resource = Import-PowerShellDataFile -LiteralPath $resourcePath
$script:DeployCenterLocale = $localeName
$script:DeployCenterText = $resource.Strings
return $localeName
}
function Get-DeployCenterText {
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)][AllowEmptyString()][string]$Text,
[AllowEmptyCollection()][object[]]$FormatArguments = @()
)
$localized = $Text
if ($script:DeployCenterText.ContainsKey($Text)) {
$localized = [string]$script:DeployCenterText[$Text]
} else {
foreach ($key in @($script:DeployCenterText.Keys | Sort-Object { $_.Length } -Descending)) {
if ($Text.StartsWith([string]$key, [StringComparison]::Ordinal)) {
$localized = [string]$script:DeployCenterText[$key] + $Text.Substring(([string]$key).Length)
break
}
}
}
if ($FormatArguments.Count -gt 0) {
return [string]::Format([Globalization.CultureInfo]::CurrentCulture, $localized, $FormatArguments)
}
return $localized
}
function ConvertTo-DeployCenterLocalizedLogMessage {
[CmdletBinding()]
param([Parameter(Mandatory=$true)][AllowEmptyString()][string]$Message)
return Get-DeployCenterText -Text $Message
}
Export-ModuleMember -Function Initialize-DeployCenterLocalization, Get-DeployCenterText, ConvertTo-DeployCenterLocalizedLogMessage
+31 -5
View File
@@ -1,5 +1,5 @@
# DeployCenter - Gestion des journaux
# Compatible Windows PowerShell 5.1
# DeployCenter - Log management
# Compatible with Windows PowerShell 5.1
function Initialize-DeployCenterLogging {
[CmdletBinding()]
@@ -16,11 +16,11 @@ function Initialize-DeployCenterLogging {
New-Item -Path $script:LogRoot -ItemType Directory -Force -ErrorAction Stop | Out-Null
}
# L'adresse IPv4 est fournie par Modules\\Network.psm1.
# The IPv4 address is provided by Modules\\Network.psm1.
$script:LogIPv4 = $IPv4
$logIpTag = if ([string]::IsNullOrWhiteSpace($script:LogIPv4)) {
'sans-ip'
'no-ip'
} else {
$script:LogIPv4 -replace '[^0-9A-Za-z.-]', '_'
}
@@ -48,7 +48,7 @@ function Write-Log {
[System.Text.Encoding]::UTF8
)
} catch {
# Le journal graphique reste disponible si le fichier n'est pas accessible.
# The graphical log remains available if the file cannot be accessed.
}
if ($null -ne $script:txtLog -and -not $script:txtLog.IsDisposed) {
@@ -57,3 +57,29 @@ function Write-Log {
$script:txtLog.ScrollToCaret()
}
}
function Copy-DeployCenterLog {
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)][string]$DestinationRoot
)
if ([string]::IsNullOrWhiteSpace([string]$script:LogFile) -or
-not (Test-Path -LiteralPath $script:LogFile -PathType Leaf)) {
Write-Log -Message (ConvertTo-DeployCenterLocalizedLogMessage -Message 'Log copy skipped: no log file is available.') -Level 'WARNING'
return $null
}
try {
if (-not (Test-Path -LiteralPath $DestinationRoot -PathType Container)) {
throw "Destination folder is not accessible: $DestinationRoot"
}
$destinationPath = Join-Path $DestinationRoot ([System.IO.Path]::GetFileName($script:LogFile))
Write-Log -Message (ConvertTo-DeployCenterLocalizedLogMessage -Message "Copying the log to $destinationPath.") -Level 'INFO'
Copy-Item -LiteralPath $script:LogFile -Destination $destinationPath -Force -ErrorAction Stop | Out-Null
return $destinationPath
} catch {
Write-Log -Message (ConvertTo-DeployCenterLocalizedLogMessage -Message "Unable to copy the log to '$DestinationRoot' : $($_.Exception.Message)") -Level 'WARNING'
return $null
}
}
+6 -6
View File
@@ -1,12 +1,12 @@
# DeployCenter - Détection réseau
# Compatible Windows PowerShell 5.1
# DeployCenter - Network detection
# Compatible with Windows PowerShell 5.1
function Get-DeployNetworkIPv4 {
[CmdletBinding()]
param()
# Priorité à une interface avec passerelle par défaut ; exclut loopback,
# APIPA et adresses IPv4 non routables couramment inutiles en WinPE.
# Prefer an interface with a default gateway; exclude loopback,
# APIPA, and non-routable IPv4 addresses that are generally useless in WinPE.
$candidates = @()
try {
foreach ($interface in [System.Net.NetworkInformation.NetworkInterface]::GetAllNetworkInterfaces()) {
@@ -36,9 +36,9 @@ function Get-DeployNetworkIPv4 {
}
}
} catch {
# La détection réseau ne doit pas empêcher le démarrage de DeployCenter.
# Network detection must not prevent DeployCenter from starting.
if (Get-Command Write-Log -ErrorAction SilentlyContinue) {
Write-Log "Détection réseau : $($_.Exception.Message)" 'WARNING'
Write-Log (ConvertTo-DeployCenterLocalizedLogMessage -Message "Network detection: $($_.Exception.Message)") 'WARNING'
}
}
+21 -21
View File
@@ -1,5 +1,5 @@
# DeployCenter - Chargement et validation des profils de partitionnement
# Les profils PSD1 sont des fichiers de données ; ils ne contiennent aucun code exécuté.
# DeployCenter - Load and validate partitioning profiles
# PSD1 profiles are data files; they contain no executable code.
function Get-DeployCenterPartitionProfileNames {
[CmdletBinding()]
@@ -20,61 +20,61 @@ function Import-DeployCenterPartitionProfile {
[Parameter(Mandatory=$true)][string]$ProfileName
)
if ($ProfileName -notmatch '^[A-Za-z0-9_-]+$') { throw "Nom de profil invalide : $ProfileName" }
if ($ProfileName -notmatch '^[A-Za-z0-9_-]+$') { throw "Invalid profile name: $ProfileName" }
$path = Join-Path $ProfilesRoot ($ProfileName + '.psd1')
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Profil introuvable : $ProfileName" }
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Profile not found: $ProfileName" }
$profile = Import-PowerShellDataFile -LiteralPath $path
foreach ($key in @('Id','Name','Description','Firmware','PartitionStyle','Partitions')) {
if (-not $profile.ContainsKey($key)) { throw "Le profil '$ProfileName' ne contient pas la clé '$key'." }
if (-not $profile.ContainsKey($key)) { throw "The profile '$ProfileName' does not contain key '$key'." }
}
if ([string]$profile.Id -ne $ProfileName) { throw "L'identifiant du profil '$ProfileName' ne correspond pas au nom du fichier." }
if (@($profile.Partitions).Count -lt 2) { throw "Le profil '$ProfileName' doit définir au moins deux partitions." }
if ([string]$profile.Id -ne $ProfileName) { throw "Profile ID '$ProfileName' does not match the file name." }
if (@($profile.Partitions).Count -lt 2) { throw 'The profile must define at least two partitions.' }
if (@($profile.Partitions | Where-Object { [string]$_.Role -eq 'Windows' }).Count -ne 1) {
throw "Le profil '$ProfileName' doit définir exactement une partition Windows."
throw 'The profile must define exactly one Windows partition.'
}
$userInputPartitions = @($profile.Partitions | Where-Object { [string]$_.Size.Policy -eq 'UserInputGB' })
$remainderPartitions = @($profile.Partitions | Where-Object { [string]$_.Size.Policy -eq 'RemainderAfterInputs' })
$remainingPartitions = @($profile.Partitions | Where-Object { [string]$_.Size.Policy -eq 'Remaining' })
if ($userInputPartitions.Count -gt 0) {
if ($remainderPartitions.Count -ne 1 -or $remainingPartitions.Count -gt 0) {
throw "Le profil '$ProfileName' doit associer les saisies utilisateur à une partition de reste unique."
throw "Profile '$ProfileName' must associate user inputs with exactly one remaining-space partition."
}
} elseif ($remainingPartitions.Count -ne 1 -or [string]$profile.Partitions[-1].Size.Policy -ne 'Remaining') {
throw "Le profil '$ProfileName' doit placer une unique partition utilisant l'espace restant en dernière position."
throw "Profile '$ProfileName' must place exactly one remaining-space partition last."
}
foreach ($partition in $profile.Partitions) {
foreach ($key in @('Name','Role','FileSystem','Size')) {
if (-not $partition.ContainsKey($key)) { throw "Une partition du profil '$ProfileName' ne contient pas la clé '$key'." }
if (-not $partition.ContainsKey($key)) { throw "A profile partition '$ProfileName' does not contain key '$key'." }
}
if (-not $partition.Size.ContainsKey('Policy')) { throw "La taille d'une partition du profil '$ProfileName' ne définit pas Policy." }
if (-not $partition.Size.ContainsKey('Policy')) { throw "The size of a profile partition '$ProfileName' does not define Policy." }
switch ([string]$partition.Size.Policy) {
'FixedMB' {
if ([int]$partition.Size.Value -le 0) { throw "Une taille fixe du profil '$ProfileName' doit être supérieure à zéro." }
if ([int]$partition.Size.Value -le 0) { throw "A fixed size in profile '$ProfileName' must be greater than zero." }
}
'ImageSizePlusFreeSpace' {
if ([int]$partition.Size.FreeSpaceMB -lt 250) { throw "La marge libre WinRE du profil '$ProfileName' doit être d'au moins 250 Mo." }
if ([int]$partition.Size.PreviewMB -le 0) { throw "Le profil '$ProfileName' doit définir une réserve PreviewMB pour estimer la capacité disponible." }
if ([int]$partition.Size.FreeSpaceMB -lt 250) { throw 'The WinRE free-space allowance must be at least 250 MB.' }
if ([int]$partition.Size.PreviewMB -le 0) { throw "Profile '$ProfileName' must define a PreviewMB reserve to estimate available capacity." }
}
'ConfiguredMB' {
if ([string]::IsNullOrWhiteSpace([string]$partition.Size.ConfigKey) -or
[int]$partition.Size.Value -le 0 -or [int]$partition.Size.PreviewMB -le 0) {
throw "La taille configurée de la partition '$($partition.Name)' du profil '$ProfileName' doit définir ConfigKey et des tailles strictement positives."
throw "The configured size of partition '$($partition.Name)' in profile '$ProfileName' must define ConfigKey and strictly positive sizes."
}
}
'UserInputGB' {
if ([int]$partition.Size.MinimumGB -le 0 -or [int]$partition.Size.DefaultPercent -le 0 -or [int]$partition.Size.DefaultPercent -ge 100) {
throw "La saisie de taille utilisateur du profil '$ProfileName' doit définir MinimumGB et un DefaultPercent entre 1 et 99."
throw 'The user-size input must define MinimumGB and a DefaultPercent from 1 to 99.'
}
if ([string]::IsNullOrWhiteSpace([string]$partition.Size.Label)) { throw "La saisie de taille utilisateur du profil '$ProfileName' doit définir Label." }
if ([string]::IsNullOrWhiteSpace([string]$partition.Size.Label)) { throw "The profile user-size input '$ProfileName' must define Label." }
}
'RemainderAfterInputs' {
if ([double]$partition.Size.MinimumGB -lt 0 -or [string]::IsNullOrWhiteSpace([string]$partition.Size.Label)) {
throw "La partition de reste du profil '$ProfileName' doit définir un MinimumGB non négatif et un Label."
throw "The remaining-space partition in profile '$ProfileName' must define a non-negative MinimumGB and a Label."
}
}
'Remaining' { }
default { throw "Politique de taille inconnue dans le profil '$ProfileName' : $($partition.Size.Policy)" }
default { throw "Unknown size policy in profile '$ProfileName': $($partition.Size.Policy)" }
}
}
@@ -99,7 +99,7 @@ function Get-DeployCenterPartitionSizingCapacity {
}
$capacityBytes = $DiskSizeBytes - [long][math]::Ceiling($reservedMB * 1MB)
if ($capacityBytes -le 0) { throw "Le disque est trop petit pour les réserves du profil '$($Profile.Name)'." }
if ($capacityBytes -le 0) { throw "The disk is too small for the profile reserves '$($Profile.Name)'." }
return [pscustomobject]@{
UsableBytes = [long]$capacityBytes
UsableGB = [math]::Round(($capacityBytes / 1GB), 1)
+4 -4
View File
@@ -4,7 +4,7 @@ function ConvertFrom-ThemeColor {
param([Parameter(Mandatory=$true)][string]$Value)
$hex = $Value.Trim().TrimStart('#')
if ($hex -notmatch '^[0-9A-Fa-f]{6}$') {
throw "Couleur invalide dans un thème : '$Value'. Utiliser #RRGGBB."
throw "Invalid color in theme: '$Value'. Use #RRGGBB."
}
return [System.Drawing.Color]::FromArgb(
[Convert]::ToInt32($hex.Substring(0,2),16),
@@ -27,13 +27,13 @@ function Import-DeployCenterTheme {
[Parameter(Mandatory=$true)][string]$ThemesRoot,
[Parameter(Mandatory=$true)][string]$ThemeName
)
if ($ThemeName -notmatch '^[A-Za-z0-9_-]+$') { throw "Nom de thème invalide : $ThemeName" }
if ($ThemeName -notmatch '^[A-Za-z0-9_-]+$') { throw "Invalid theme name: $ThemeName" }
$path = Join-Path $ThemesRoot ($ThemeName + '.psd1')
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Thème introuvable : $ThemeName" }
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Theme not found: $ThemeName" }
$data = Import-PowerShellDataFile -LiteralPath $path
$required = @('Background','Panel','PanelAlt','Text','Secondary','Accent','Success','Border','Danger')
foreach ($key in $required) {
if (-not $data.ContainsKey($key)) { throw "Le thème '$ThemeName' ne contient pas la clé '$key'." }
if (-not $data.ContainsKey($key)) { throw "Theme '$ThemeName' does not contain key '$key'." }
}
$theme = @{}
foreach ($key in $required) { $theme[$key] = ConvertFrom-ThemeColor -Value ([string]$data[$key]) }
+572 -166
View File
File diff suppressed because it is too large. Load diff
+30 -22
View File
@@ -1,5 +1,5 @@
# DeployCenter - Découverte et génération des profils de réponses Windows.
# Les profils PSD1 restent des fichiers de données ; les réponses sont construites comme XML.
# DeployCenter - Discover and generate Windows answer-file profiles.
# PSD1 profiles remain data files; answer files are built as XML.
function Get-DeployCenterUnattendProfileNames {
[CmdletBinding()]
@@ -20,20 +20,20 @@ function Import-DeployCenterUnattendProfile {
[Parameter(Mandatory=$true)][string]$ProfileName
)
if ($ProfileName -notmatch '^[A-Za-z0-9_-]+$') { throw "Nom de profil Unattend invalide : $ProfileName" }
if ($ProfileName -notmatch '^[A-Za-z0-9_-]+$') { throw "Invalid Unattend profile name: $ProfileName" }
$path = Join-Path $ProfilesRoot ($ProfileName + '.psd1')
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Profil Unattend introuvable : $ProfileName" }
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Unattend profile not found: $ProfileName" }
$profile = Import-PowerShellDataFile -LiteralPath $path
foreach ($key in @('Id','Name','Description','Target','Template','ComputerName','LocalAccount')) {
if (-not $profile.ContainsKey($key)) { throw ('Le profil Unattend {0} ne contient pas la cle {1}.' -f $ProfileName, $key) }
if (-not $profile.ContainsKey($key)) { throw ('Unattend profile {0} does not contain key {1}.' -f $ProfileName, $key) }
}
if ([string]$profile.Id -ne $ProfileName) { throw ('L identifiant du profil Unattend {0} ne correspond pas au nom du fichier.' -f $ProfileName) }
if ([string]$profile.Target -notin @('Workstation', 'Server')) { throw ('La propriete Target du profil Unattend {0} doit etre Workstation ou Server.' -f $ProfileName) }
if ([string]$profile.Template -notmatch '^[A-Za-z0-9_-]+\.xml$') { throw ('Nom de template XML invalide dans le profil {0}.' -f $ProfileName) }
if ([string]$profile.Id -ne $ProfileName) { throw ('Unattend profile ID {0} does not match the file name.' -f $ProfileName) }
if ([string]$profile.Target -notin @('Workstation', 'Server')) { throw ('The Target property of Unattend profile {0} must be Workstation or Server.' -f $ProfileName) }
if ([string]$profile.Template -notmatch '^[A-Za-z0-9_-]+\.xml$') { throw ('Invalid XML template name in profile {0}.' -f $ProfileName) }
$templatesRoot = Join-Path (Split-Path -Parent $ProfilesRoot) 'Templates'
$templatePath = Join-Path $templatesRoot ([string]$profile.Template)
if (-not (Test-Path -LiteralPath $templatePath -PathType Leaf)) { throw "Template XML introuvable pour le profil '$ProfileName' : $templatePath" }
if (-not [string]$profile.Name -or [string]::IsNullOrWhiteSpace([string]$profile.Name)) { throw ('Le profil Unattend {0} doit definir un nom affichable.' -f $ProfileName) }
if (-not (Test-Path -LiteralPath $templatePath -PathType Leaf)) { throw "XML template not found for profile '$ProfileName': $templatePath" }
if (-not [string]$profile.Name -or [string]::IsNullOrWhiteSpace([string]$profile.Name)) { throw ('Unattend profile {0} must define a display name.' -f $ProfileName) }
$profile.TemplatePath = $templatePath
return $profile
}
@@ -47,25 +47,25 @@ function Test-DeployCenterUnattendConfiguration {
)
if ($Profile.ComputerName -and $HostName -notmatch '^(?=.*[A-Za-z])[A-Za-z0-9](?:[A-Za-z0-9-]{0,13}[A-Za-z0-9])?$') {
throw 'Le nom de machine doit respecter les règles NetBIOS et ne pas dépasser 15 caractères.'
throw 'The computer name must follow NetBIOS rules and not exceed 15 characters.'
}
if ($Profile.LocalAccount) {
$accountName = [string]$Configuration.UnattendAdminName
$displayName = [string]$Configuration.UnattendAdminDisplayName
$groupName = [string]$Configuration.UnattendAdminGroup
$passwordBase64 = [string]$Configuration.UnattendAdminPasswordBase64
if ($accountName -notmatch '^[^"/\[\]:;|=,+*?<>\\]{1,20}$') { throw 'Le paramètre [Unattend] AdminName doit contenir de 1 à 20 caractères et éviter les caractères interdits par Windows.' }
if ([string]::IsNullOrWhiteSpace($displayName)) { throw 'Le paramètre [Unattend] AdminDisplayName ne peut pas être vide.' }
if ([string]::IsNullOrWhiteSpace($groupName)) { throw 'Le paramètre [Unattend] AdminGroup ne peut pas être vide.' }
if ([string]::IsNullOrWhiteSpace($passwordBase64)) { throw 'Le paramètre [Unattend] AdminPasswordBase64 est requis pour ce profil.' }
if ($accountName -notmatch '^[^"/\[\]:;|=,+*?<>\\]{1,20}$') { throw '[Unattend] AdminName must be 1 to 20 characters and must not contain characters prohibited by Windows.' }
if ([string]::IsNullOrWhiteSpace($displayName)) { throw '[Unattend] AdminDisplayName cannot be empty.' }
if ([string]::IsNullOrWhiteSpace($groupName)) { throw '[Unattend] AdminGroup cannot be empty.' }
if ([string]::IsNullOrWhiteSpace($passwordBase64)) { throw 'The [Unattend] AdminPasswordBase64 setting is required for this profile.' }
try {
$passwordBytes = [Convert]::FromBase64String($passwordBase64)
if ($passwordBytes.Length -eq 0 -or ($passwordBytes.Length % 2) -ne 0 -or
[string]::IsNullOrEmpty([Text.Encoding]::Unicode.GetString($passwordBytes))) {
throw 'Format UTF-16LE Base64 invalide.'
throw 'Invalid UTF-16LE Base64 format.'
}
} catch {
throw 'Le paramètre [Unattend] AdminPasswordBase64 doit être un mot de passe UTF-16LE encodé en Base64.'
throw '[Unattend] AdminPasswordBase64 must be a UTF-16LE password encoded as Base64.'
}
}
}
@@ -98,7 +98,7 @@ function New-DeployCenterUnattendXml {
}
if ($Profile.ComputerName) {
$node = $document.SelectSingleNode('/u:unattend/u:settings[@pass="specialize"]/u:component[@name="Microsoft-Windows-Shell-Setup"]/u:ComputerName', $namespaces)
if ($null -eq $node) { throw ('Le template du profil {0} ne definit pas le noeud ComputerName dans specialize.' -f $Profile.Id) }
if ($null -eq $node) { throw ('The template for profile {0} does not define the ComputerName node in specialize.' -f $Profile.Id) }
$node.InnerText = $HostName
}
if ($Profile.LocalAccount) {
@@ -111,20 +111,28 @@ function New-DeployCenterUnattendXml {
foreach ($name in $values.Keys) {
$field = if ($name -eq 'Password') { 'Password/u:Value' } else { $name }
$node = $document.SelectSingleNode("/u:unattend/u:settings[@pass='oobeSystem']/u:component[@name='Microsoft-Windows-Shell-Setup']/u:UserAccounts/u:LocalAccounts/u:LocalAccount/u:$field", $namespaces)
if ($null -eq $node) { throw ('Le template du profil {0} ne definit pas le champ de compte {1}.' -f $Profile.Id, $name) }
if ($null -eq $node) { throw ('The template for profile {0} does not define the account field {1}.' -f $Profile.Id, $name) }
$node.InnerText = $values[$name]
}
}
$supportAppUrl = [string]$Configuration.UnattendSupportAppURL
$supportUrl = [string]$Configuration.UnattendSupportURL
# Compatibility: before SupportURL was added, a web URL could be provided in SupportAppURL.
if ($supportAppUrl -match '^https?://') {
if ([string]::IsNullOrWhiteSpace($supportUrl)) { $supportUrl = $supportAppUrl }
$supportAppUrl = ''
}
$supportSettings = @(
@{ Name = 'SupportProvider'; Value = [string]$Configuration.UnattendSupportProvider },
@{ Name = 'SupportAppURL'; Value = [string]$Configuration.UnattendSupportAppURL }
@{ Name = 'SupportURL'; Value = $supportUrl },
@{ Name = 'SupportAppURL'; Value = $supportAppUrl }
)
$oemInformationPath = '/u:unattend/u:settings[@pass="oobeSystem"]/u:component[@name="Microsoft-Windows-Shell-Setup"]/u:OEMInformation'
foreach ($supportSetting in $supportSettings) {
$supportNode = $document.SelectSingleNode(($oemInformationPath + '/u:' + $supportSetting.Name), $namespaces)
if ($null -eq $supportNode) {
if (-not [string]::IsNullOrWhiteSpace($supportSetting.Value)) {
throw ('Le template du profil {0} ne definit pas le noeud {1}.' -f $Profile.Id, $supportSetting.Name)
throw ('The template for profile {0} does not define the {1} node.' -f $Profile.Id, $supportSetting.Name)
}
continue
}
@@ -140,7 +148,7 @@ function New-DeployCenterUnattendXml {
}
if (-not [string]::IsNullOrWhiteSpace($WallpaperPath)) {
$shellSetupNode = $document.SelectSingleNode('/u:unattend/u:settings[@pass="oobeSystem"]/u:component[@name="Microsoft-Windows-Shell-Setup"]', $namespaces)
if ($null -eq $shellSetupNode) { throw ('Le template du profil {0} ne definit pas le composant Shell-Setup dans oobeSystem.' -f $Profile.Id) }
if ($null -eq $shellSetupNode) { throw ('The template for profile {0} does not define the Shell-Setup component in oobeSystem.' -f $Profile.Id) }
$themesNode = $shellSetupNode.SelectSingleNode('u:Themes', $namespaces)
if ($null -eq $themesNode) {
$themesNode = $document.CreateElement('Themes', 'urn:schemas-microsoft-com:unattend')
+33 -33
View File
@@ -1,5 +1,5 @@
# Module VNC de DeployCenter
# Exécuté uniquement avec -Vnc dans le scope principal pour partager le contexte WinPE.
# DeployCenter VNC module
# Run only with -Vnc in the main scope to share the WinPE context.
function Start-DeployCenterVnc {
$parentRoot = Split-Path -Path $script:DeployRoot -Parent
@@ -9,36 +9,36 @@ function Start-DeployCenterVnc {
$sourceIni = Join-Path $sourceVnc 'ultravnc.ini'
if ([string]::IsNullOrWhiteSpace($script:DeployIPv4)) {
& $script:SourceLogAction 'Aucune adresse IPv4 de déploiement détectée.' 'WARNING'
& $script:SourceLogAction 'No deployment IPv4 address detected.' 'WARNING'
}
if (-not (Test-Path -LiteralPath $sourceExe -PathType Leaf)) {
& $script:SourceLogAction "UltraVNC non démarré : exécutable absent ($sourceExe)." 'WARNING'
& $script:SourceLogAction "UltraVNC was not started: executable not found ($sourceExe)." 'WARNING'
Write-DeployInventory -InventoryDir $script:InventoryDir -InventoryFile $script:InventoryFile -HardwareInfo $script:HardwareInventory -DeploymentIPv4 $script:DeployIPv4 -VncPassword $script:VncPassword -LogAction $script:SourceLogAction
return
}
if (-not (Test-Path -LiteralPath $passwordTool -PathType Leaf)) {
& $script:SourceLogAction "UltraVNC non démarré : createpassword64.exe absent ($passwordTool)." 'WARNING'
& $script:SourceLogAction "UltraVNC was not started: createpassword64.exe absent ($passwordTool)." 'WARNING'
Write-DeployInventory -InventoryDir $script:InventoryDir -InventoryFile $script:InventoryFile -HardwareInfo $script:HardwareInventory -DeploymentIPv4 $script:DeployIPv4 -VncPassword $script:VncPassword -LogAction $script:SourceLogAction
return
}
if (-not (Test-Path -LiteralPath $sourceIni -PathType Leaf)) {
& $script:SourceLogAction "UltraVNC non démarré : fichier absent ($sourceIni)." 'WARNING'
& $script:SourceLogAction "UltraVNC was not started: file not found ($sourceIni)." 'WARNING'
Write-DeployInventory -InventoryDir $script:InventoryDir -InventoryFile $script:InventoryFile -HardwareInfo $script:HardwareInventory -DeploymentIPv4 $script:DeployIPv4 -VncPassword $script:VncPassword -LogAction $script:SourceLogAction
return
}
$vncStage = 'préparation du dossier temporaire'
$vncStage = 'preparing the temporary folder'
try {
if (Test-Path -LiteralPath $script:VncDirectory) {
Remove-Item -LiteralPath $script:VncDirectory -Recurse -Force -ErrorAction Stop
}
New-Item -Path $script:VncDirectory -ItemType Directory -Force | Out-Null
$vncStage = "copie des fichiers depuis $sourceVnc vers $script:VncDirectory"
$vncStage = "copying files from $sourceVnc to $script:VncDirectory"
Copy-Item -Path (Join-Path $sourceVnc '*') -Destination $script:VncDirectory -Recurse -Force
# WinPE ne permet pas toujours d'ajouter des règles de pare-feu.
# La désactivation globale n'est effectuée que lorsque VNC est demandé.
$vncStage = 'désactivation du pare-feu WinPE'
# WinPE does not always allow firewall rules to be added.
# The firewall is disabled globally only when VNC is requested.
$vncStage = 'disabling the WinPE firewall'
$wpeutil = Join-Path $env:WINDIR 'System32\wpeutil.exe'
if (-not (Test-Path -LiteralPath $wpeutil -PathType Leaf)) {
$wpeutil = 'wpeutil.exe'
@@ -48,58 +48,58 @@ function Start-DeployCenterVnc {
foreach ($outputLine in $wpeutilOutput) {
$outputText = [string]$outputLine
if (-not [string]::IsNullOrWhiteSpace($outputText)) {
& $script:SourceLogAction "wpeutil DisableFirewall : $outputText" 'INFO'
& $script:SourceLogAction "wpeutil DisableFirewall: $outputText" 'INFO' $true
}
}
if ($wpeutilExitCode -ne 0) {
throw "wpeutil DisableFirewall a renvoyé le code $wpeutilExitCode."
throw "wpeutil DisableFirewall returned exit code $wpeutilExitCode."
}
& $script:SourceLogAction "Pare-feu WinPE désactivé à la demande de -Vnc (code wpeutil : $wpeutilExitCode)." 'WARNING'
& $script:SourceLogAction "WinPE firewall disabled at the request of -Vnc (wpeutil exit code: $wpeutilExitCode)." 'WARNING'
# Force le mode portable : l'INI actif est celui placé à côté de winvnc.exe.
$vncStage = 'activation du mode portable UltraVNC'
# Force portable mode: the active INI is the one next to winvnc.exe.
$vncStage = 'enabling UltraVNC portable mode'
$portableFlag = Join-Path $script:VncDirectory 'ultravnc.portable'
Set-Content -LiteralPath $portableFlag -Value '' -Encoding ASCII
if (-not [string]::IsNullOrWhiteSpace([string]$script:Config.VncPassword)) {
$script:VncPassword = [string]$script:Config.VncPassword
$passwordSource = 'configuré dans DeployCenter.ini'
$passwordSource = 'configured in DeployCenter.ini'
}
else {
$script:VncPassword = -join (1..8 | ForEach-Object {
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'
$alphabet[(Get-Random -Minimum 0 -Maximum $alphabet.Length)]
})
$passwordSource = 'généré automatiquement'
$passwordSource = 'generated automatically'
}
# createpassword64 encode le mot de passe choisi dans le champ passwd d'ultravnc.ini.
$vncStage = 'génération du mot de passe UltraVNC'
# createpassword64 encodes the selected password in the passwd field of ultravnc.ini.
$vncStage = 'generating the UltraVNC password'
$tool = Join-Path $script:VncDirectory 'createpassword64.exe'
$script:VncProcess = Start-Process -FilePath $tool -ArgumentList @($script:VncPassword) `
-WorkingDirectory $script:VncDirectory -Wait -PassThru -WindowStyle Hidden
if ($script:VncProcess.ExitCode -ne 0) {
throw "createpassword64.exe a renvoyé le code $($script:VncProcess.ExitCode)."
throw "createpassword64.exe returned exit code $($script:VncProcess.ExitCode)."
}
$activeIni = Join-Path $script:VncDirectory 'ultravnc.ini'
if (-not (Test-Path -LiteralPath $activeIni -PathType Leaf) -or
-not (Select-String -LiteralPath $activeIni -Pattern '^passwd\s*=' -Quiet)) {
throw "createpassword64.exe n'a pas créé le champ passwd dans ultravnc.ini."
throw "createpassword64.exe did not create the passwd field in ultravnc.ini."
}
$server = Join-Path $script:VncDirectory 'winvnc.exe'
$vncStage = 'démarrage du serveur UltraVNC'
$vncStage = 'starting the UltraVNC server'
$script:VncProcess = Start-Process -FilePath $server -ArgumentList @('-run') `
-WorkingDirectory $script:VncDirectory -PassThru -WindowStyle Hidden
Start-Sleep -Milliseconds 800
if ($script:VncProcess.HasExited) {
throw "winvnc.exe s'est arrêté immédiatement (code $($script:VncProcess.ExitCode))."
throw "winvnc.exe exited immediately (code $($script:VncProcess.ExitCode))."
}
$script:VncStarted = $true
& $script:SourceLogAction "UltraVNC démarré. IP : $($script:DeployIPv4) ; mot de passe $passwordSource." 'SUCCESS'
& $script:SourceLogAction "UltraVNC started. IP: $($script:DeployIPv4) ; password $passwordSource." 'SUCCESS'
} catch {
& $script:SourceLogAction "Échec du démarrage UltraVNC à l'étape '$vncStage' : $($_.Exception.Message)" 'ERROR'
& $script:SourceLogAction "UltraVNC startup failed at stage '$vncStage' : $($_.Exception.Message)" 'ERROR'
$script:VncStarted = $false
$script:VncPassword = $null
}
@@ -112,20 +112,20 @@ function Stop-DeployCenterVnc {
$proc = Get-Process -Id $script:VncProcess.Id -ErrorAction SilentlyContinue
if ($null -ne $proc) { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue }
}
# Au cas où winvnc lance un processus serveur distinct, ne cibler que les
# processus dont le chemin correspond à notre copie temporaire.
# If winvnc starts a separate server process, target only processes
# whose executable path matches our temporary copy.
foreach ($proc in (Get-CimInstance Win32_Process -Filter "Name='winvnc.exe'" -ErrorAction SilentlyContinue)) {
if ($proc.ExecutablePath -and $proc.ExecutablePath.StartsWith($script:VncDirectory, [StringComparison]::OrdinalIgnoreCase)) {
Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue
}
}
} catch {
& $script:SourceLogAction "Arrêt UltraVNC : $($_.Exception.Message)" 'WARNING'
& $script:SourceLogAction "Stopping UltraVNC: $($_.Exception.Message)" 'WARNING'
}
# Le processus winvnc peut conserver brièvement un handle sur son dossier.
# Le dossier VNC est propre à ce processus PowerShell (uvnc_<PID>) : on ne
# supprime pas récursivement le dossier partagé InventoryDir, ce qui évite
# de bloquer le lancement suivant si un handle est encore ouvert.
# The winvnc process may briefly keep a handle open on its folder.
# The VNC folder belongs to this PowerShell process (uvnc_<PID>); do not
# recursively delete the shared InventoryDir folder. This avoids
# blocking the next launch if a handle is still open.
try {
if (Test-Path -LiteralPath $script:InventoryFile -PathType Leaf) {
Remove-Item -LiteralPath $script:InventoryFile -Force -ErrorAction SilentlyContinue
+37 -37
View File
@@ -1,5 +1,5 @@
# DeployCenter - Recherche et lecture des sources Windows
# Compatible Windows PowerShell 5.1
# DeployCenter - Find and read Windows sources
# Compatible with Windows PowerShell 5.1
function Find-WindowsSources {
param(
@@ -14,20 +14,20 @@ function Find-WindowsSources {
$candidate
} | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)
if ($roots.Count -eq 0) {
throw 'Indique au moins un chemin local et/ou un partage SMB, separes par un point-virgule.'
throw 'Specify at least one local path and/or SMB share, separated by semicolons.'
}
foreach ($root in $roots) {
if (-not (Test-Path -LiteralPath $root -PathType Container)) {
if ($LogAction) { & $LogAction "Source inaccessible, ignoree : $root" 'WARNING' }
if ($LogAction) { & $LogAction "Source inaccessible, skipped: $root" 'WARNING' }
continue
}
if ($LogAction) { & $LogAction "Recherche des sources Windows dans : $root" 'INFO' }
# Parcours en lecture seule. Les erreurs d'acces aux sous-dossiers sont ignorees.
# Afficher les ISO et les fichiers WIM/ESD autonomes.
# Exception : masquer boot.wim uniquement lorsqu'il fait partie d'un
# support Windows extrait dont le même dossier Sources contient install.wim/esd.
if ($LogAction) { & $LogAction "Searching for Windows sources in: $root" 'INFO' }
# Read-only scan. Errors accessing subfolders are ignored.
# Show ISO files and standalone WIM/ESD files.
# Exception: hide boot.wim only when it is part of an
# extracted Windows medium whose Sources folder also contains install.wim/esd.
$allFiles = @(Get-ChildItem -LiteralPath $root -Recurse -File -ErrorAction SilentlyContinue)
$files = New-Object System.Collections.Generic.List[object]
foreach ($file in $allFiles) {
@@ -62,24 +62,24 @@ function Find-WindowsSources {
'.esd' { 'Image ESD Windows' }
}
$detail = if ($file.Name -ieq 'install.wim' -or $file.Name -ieq 'install.esd') {
"Image d’installation dans un support extrait"
"Installation image on extracted media"
} elseif ($file.Extension -ieq '.iso') {
"Support d’installation ISO"
"ISO installation media"
} else {
"Image WIM/ESD autonome"
}
$results.Add([pscustomobject]@{
Type = $kind; Nom = $file.Name; Chemin = $file.FullName
Taille = ('{0:N2} Go' -f ($file.Length / 1GB)); Detail = $detail
Type = $kind; Name = $file.Name; Path = $file.FullName
Size = ('{0:N2} GB' -f ($file.Length / 1GB)); Detail = $detail
})
}
}
if (-not ($roots | Where-Object { Test-Path -LiteralPath $_ -PathType Container })) {
throw "Aucun des chemins n'est accessible : $($roots -join '; ')`nVerifie que le lecteur local et le partage SMB sont accessibles."
throw "None of the paths is accessible: $($roots -join '; ')`nCheck that the local drive and SMB share are available."
}
if ($LogAction) { & $LogAction ("Detection terminee : {0} element(s) trouve(s)." -f $results.Count) 'SUCCESS' }
return @($results | Sort-Object Type, Nom, Chemin -Unique)
if ($LogAction) { & $LogAction ("Detection complete: {0} element(s) trouve(s)." -f $results.Count) 'SUCCESS' }
return @($results | Sort-Object Type, Name, Path -Unique)
}
function Get-WindowsImageEditions {
@@ -89,7 +89,7 @@ function Get-WindowsImageEditions {
)
if (-not (Test-Path -LiteralPath $ImagePath -PathType Leaf)) {
throw "Fichier image introuvable : $ImagePath"
throw "Image file not found: $ImagePath"
}
$normalized = @()
@@ -138,18 +138,18 @@ function Get-WindowsImageEditions {
}
}
} catch {
if ($LogAction) { & $LogAction "Get-WindowsImage indisponible pour $ImagePath : $($_.Exception.Message)" 'WARNING' }
if ($LogAction) { & $LogAction "Get-WindowsImage unavailable for $ImagePath : $($_.Exception.Message)" 'WARNING' }
}
}
$dism = Join-Path $env:WINDIR 'System32\dism.exe'
if (-not (Test-Path -LiteralPath $dism)) { $dism = 'dism.exe' }
# Si le module PowerShell n'a pas renvoyé les index/noms, les récupérer avec DISM.
# If the PowerShell module did not return indexes/names, retrieve them with DISM.
if ($normalized.Count -eq 0) {
$output = & $dism /English /Get-WimInfo "/WimFile:$ImagePath" 2>&1
if ($LASTEXITCODE -ne 0) {
throw "DISM n'a pas pu lire l'image : $ImagePath`n$($output -join "`n")"
throw "DISM could not read the image: $ImagePath`n$($output -join "`n")"
}
$current = $null
@@ -168,17 +168,17 @@ function Get-WindowsImageEditions {
}
if ($normalized.Count -eq 0) {
throw "DISM n’a retourné aucune édition exploitable pour : $ImagePath"
throw "DISM returned no usable editions for: $ImagePath"
}
# Interroger DISM pour chaque index même si Get-WindowsImage a fourni les noms :
# selon la version du module, les propriétés Architecture/Version peuvent être absentes.
# Query DISM for every index even if Get-WindowsImage returned the names:
# Architecture/Version properties may be missing in some module versions.
foreach ($edition in $normalized) {
if ($edition.Architecture -and $edition.Version) { continue }
$detailOutput = & $dism /English /Get-WimInfo "/WimFile:$ImagePath" "/Index:$($edition.Index)" 2>&1
if ($LASTEXITCODE -ne 0) {
if ($LogAction) { & $LogAction "DISM n'a pas pu lire les metadonnees de l'index $($edition.Index) : $($detailOutput -join ' ')" 'WARNING' }
if ($LogAction) { & $LogAction "DISM could not read metadata for index $($edition.Index) : $($detailOutput -join ' ')" 'WARNING' }
continue
}
@@ -191,7 +191,7 @@ function Get-WindowsImageEditions {
$insideImageDetails = $true
continue
}
# Ignorer la version de DISM affichée au début de sa sortie.
# Ignore the DISM version printed at the start of its output.
if (-not $insideImageDetails) { continue }
if (-not $edition.Architecture -and $detailText -match '^\s*Architecture\s*:\s*(.*?)\s*$') {
@@ -215,8 +215,8 @@ function Get-WindowsImageEditions {
}
}
# Version globale de l'image : ne l'afficher que si les métadonnées des index
# donnent une seule version cohérente. Ne pas répéter cette valeur sur chaque ligne.
# Overall image version: show it only if the index metadata
# reports one consistent version. Do not repeat it on every row.
$versionValues = @($normalized | ForEach-Object {
if (-not [string]::IsNullOrWhiteSpace([string]$_.Version)) { [string]$_.Version }
} | Select-Object -Unique)
@@ -259,7 +259,7 @@ function Resolve-InstallImagePath {
return [pscustomobject]@{ SourcePath = $SelectedPath; ImagePath = $candidate; MountedImage = $null }
}
}
throw "Aucun fichier Sources\install.wim ou Sources\install.esd dans : $SelectedPath"
throw "No Sources\install.wim or Sources\install.esd file in: $SelectedPath"
}
if ([IO.Path]::GetExtension($SelectedPath) -ieq '.iso') {
@@ -269,9 +269,9 @@ function Resolve-InstallImagePath {
return $ExistingImage
}
if (-not (Get-Command Mount-DiskImage -ErrorAction SilentlyContinue)) {
throw "Le montage ISO n'est pas disponible dans cet environnement (commande Mount-DiskImage absente). Pour analyser cette source, utilise le dossier extrait ou un fichier install.wim/esd."
throw "ISO mounting is unavailable in this environment (Mount-DiskImage command not found). Use an extracted folder or an install.wim/esd file to analyze this source."
}
# Ne pas laisser Windows attribuer une lettre arbitraire (souvent D:) à l'ISO.
# Do not let Windows assign an arbitrary drive letter (often D:) to the ISO.
$diskImage = Mount-DiskImage -ImagePath $SelectedPath -Access ReadOnly -NoDriveLetter -PassThru -ErrorAction Stop
$mountInfo = [pscustomobject]@{
SourcePath = $SelectedPath
@@ -281,10 +281,10 @@ function Resolve-InstallImagePath {
DriveLetter = $null
}
try {
# Partir des volumes explicitement associés à cette image évite de
# confondre le lecteur CDROM virtuel et un disque physique dans WinPE.
# Start with the volumes explicitly associated with this image to avoid
# confusing the virtual CD-ROM drive with a physical disk in WinPE.
$volumes = @(Get-Volume -DiskImage $diskImage -ErrorAction Stop)
if ($volumes.Count -eq 0) { throw 'Aucun volume ISO expose par Windows.' }
if ($volumes.Count -eq 0) { throw 'Windows did not expose any ISO volumes.' }
$usedLetters = @(
Get-Volume -ErrorAction SilentlyContinue | Where-Object { $_.DriveLetter } | ForEach-Object { ([string]$_.DriveLetter).ToUpperInvariant() }
Get-PSDrive -PSProvider FileSystem -ErrorAction SilentlyContinue | ForEach-Object { ([string]$_.Name).ToUpperInvariant() }
@@ -296,7 +296,7 @@ function Resolve-InstallImagePath {
break
}
}
if (-not $availableLetter) { throw 'Aucune lettre de lecteur temporaire disponible pour le montage ISO.' }
if (-not $availableLetter) { throw 'No temporary drive letter is available to mount the ISO.' }
$checkedPaths = @()
foreach ($volume in $volumes) {
@@ -319,7 +319,7 @@ function Resolve-InstallImagePath {
$mountVolCommand = Join-Path $env:WINDIR 'System32\mountvol.exe'
if (-not (Test-Path -LiteralPath $mountVolCommand -PathType Leaf)) { $mountVolCommand = 'mountvol.exe' }
$removeOutput = & $mountVolCommand 'D:\' '/D' 2>&1
if ($LASTEXITCODE -ne 0) { throw "Impossible de libérer D: pour le volume ISO : $($removeOutput -join ' ')" }
if ($LASTEXITCODE -ne 0) { throw "Could not release D: for the ISO volume: $($removeOutput -join ' ')" }
}
$mountInfo.ImagePath = $candidate
return $mountInfo
@@ -358,14 +358,14 @@ function Resolve-InstallImagePath {
$mountInfo.MountedPartition = $null
$mountInfo.DriveLetter = $null
}
throw "Aucun fichier Sources\install.wim ou Sources\install.esd trouvé dans l'ISO. Chemins vérifiés : $($checkedPaths -join ', ')."
throw "No Sources\install.wim or Sources\install.esd file was found in the ISO. Checked paths: $($checkedPaths -join ', ')."
} catch {
try { [void](Dismount-DeployCenterInstallImage -Image $mountInfo -ErrorAction SilentlyContinue) } catch { }
throw
}
}
throw "Type de source non pris en charge pour l'analyse : $SelectedPath"
throw "Unsupported source type for analysis: $SelectedPath"
}
function Dismount-DeployCenterInstallImage {