diff --git a/DeployCenter.ini.example b/DeployCenter.ini.example index 772df48..44b5838 100644 --- a/DeployCenter.ini.example +++ b/DeployCenter.ini.example @@ -10,3 +10,7 @@ Paths=%DEPLOY_VOLUME% ; Laisser vide pour générer un mot de passe aléatoire à chaque lancement. ; Pour les tests, renseigner un mot de passe ici. Il sera encodé dans ultravnc.ini par createpassword64.exe. Password= + +[Partitioning] +; Taille de la partition WinRE en Mo ; défaut : 1024 (1 Go), minimum : 990. +; WinRESizeMB=1024 \ No newline at end of file diff --git a/DeployCenter.ps1 b/DeployCenter.ps1 index 9e9a846..8e685db 100644 --- a/DeployCenter.ps1 +++ b/DeployCenter.ps1 @@ -15,7 +15,12 @@ Add-Type -AssemblyName System.Drawing [System.Windows.Forms.Application]::EnableVisualStyles() Import-Module (Join-Path $PSScriptRoot "Modules\ThemeManager.psm1") -Force -Import-Module (Join-Path $PSScriptRoot "Modules\UI.psm1") -Force +$uiModulePath = Join-Path $PSScriptRoot 'Modules\UI.psm1' +if (-not (Test-Path -LiteralPath $uiModulePath -PathType Leaf)) { throw "Module UI introuvable : $uiModulePath" } +# Les workflows UI utilisent l'état de la session principale ; ce fichier est donc chargé dans ce scope. +. ([scriptblock]::Create([System.IO.File]::ReadAllText($uiModulePath))) +Import-Module (Join-Path $PSScriptRoot "Modules\Configuration.psm1") -Force +Import-Module (Join-Path $PSScriptRoot "Modules\HardwareInventory.psm1") -Force # GroupBox dessine par DeployCenter pour que le fond et le cadre suivent les themes. if (-not ([System.Management.Automation.PSTypeName]'DeployCenter.ThemedGroupBox').Type) { @@ -75,7 +80,6 @@ try { } catch { $script:DeployVolumeRoot = $null } -$script:HardwareScript = Join-Path $script:DeployRoot "HardwareInfo.ps1" $script:AssetsRoot = Join-Path $script:DeployRoot "Assets" $script:LogoPath = Join-Path $script:AssetsRoot "logo.png" $script:LogoDarkPath = Join-Path $script:AssetsRoot "logo-dark.png" @@ -96,6 +100,18 @@ if (-not (Test-Path -LiteralPath $loggingModulePath -PathType Leaf)) { } . ([scriptblock]::Create([System.IO.File]::ReadAllText($loggingModulePath))) Initialize-DeployCenterLogging -LogRoot $script:LogRoot -IPv4 $script:DeployIPv4 +# Garder le scope du script principal pour que Write-Log reste résolu dans les callbacks WinForms. +$script:SourceLogAction = { + param($Message, $Level) + if ([string]::IsNullOrWhiteSpace([string]$Level)) { $Level = 'INFO' } + Write-Log -Message $Message -Level $Level +} +# Analyse des sources et des images Windows. +$windowsSourcesModulePath = Join-Path $script:DeployRoot 'Modules\WindowsSources.psm1' +if (-not (Test-Path -LiteralPath $windowsSourcesModulePath -PathType Leaf)) { + throw "Module de sources Windows introuvable : $windowsSourcesModulePath" +} +Import-Module $windowsSourcesModulePath -Force # Les profils de partitionnement sont des fichiers de données PSD1, validés par leur module. $partitioningModulePath = Join-Path $script:DeployRoot 'Modules\Partitioning.psm1' @@ -103,13 +119,18 @@ if (-not (Test-Path -LiteralPath $partitioningModulePath -PathType Leaf)) { throw "Module de partitionnement introuvable : $partitioningModulePath" } Import-Module $partitioningModulePath -Force +$deploymentModulePath = Join-Path $script:DeployRoot 'Modules\Deployment.psm1' +if (-not (Test-Path -LiteralPath $deploymentModulePath -PathType Leaf)) { + throw "Module de déploiement introuvable : $deploymentModulePath" +} +Import-Module $deploymentModulePath -Force $script:PartitionProfilesRoot = Join-Path $script:DeployRoot 'Partitioning\Profiles' $script:PartitionProfiles = @() foreach ($profileName in (Get-DeployCenterPartitionProfileNames -ProfilesRoot $script:PartitionProfilesRoot)) { try { $script:PartitionProfiles += Import-DeployCenterPartitionProfile -ProfilesRoot $script:PartitionProfilesRoot -ProfileName $profileName } catch { - Write-Log "Profil de partitionnement '$profileName' ignoré : $($_.Exception.Message)" 'WARNING' + & $script:SourceLogAction "Profil de partitionnement '$profileName' ignoré : $($_.Exception.Message)" 'WARNING' } } if ($script:PartitionProfiles.Count -eq 0) { @@ -153,38 +174,6 @@ if (-not (Test-Path -LiteralPath $script:ThemesRoot -PathType Container)) { # ============================================================ $script:ConfigPath = Join-Path $script:DeployRoot "DeployCenter.ini" -$script:Config = @{ - Theme = "Light" - SourcePaths = "%DEPLOY_VOLUME%" - VncPassword = "" -} - -function Read-DeployCenterConfig { - if (-not (Test-Path -LiteralPath $script:ConfigPath -PathType Leaf)) { return } - $section = "" - foreach ($line in (Get-Content -LiteralPath $script:ConfigPath -ErrorAction SilentlyContinue)) { - $trimmed = $line.Trim() - if (-not $trimmed -or $trimmed.StartsWith(";") -or $trimmed.StartsWith("#")) { continue } - if ($trimmed -match '^\[(.+)\]$') { $section = $matches[1].Trim(); continue } - if ($trimmed -match '^([^=]+)=(.*)$') { - $key = $matches[1].Trim() - $value = $matches[2].Trim() - if ($section -ieq "General" -and $key -ieq "Theme") { - if ($value -match '^[A-Za-z0-9_-]+$' -and (Test-Path -LiteralPath (Join-Path $script:ThemesRoot ($value + ".psd1")) -PathType Leaf)) { - $script:Config.Theme = $value - } - } - elseif ($section -ieq "Sources" -and $key -ieq "Paths") { - $script:Config.SourcePaths = $value - } - elseif ($section -ieq "UltraVNC" -and $key -ieq "Password") { - # Mot de passe facultatif pour les tests ; s'il est renseigné, il remplace la génération aléatoire. - $script:Config.VncPassword = $value - } - } - } -} - # Themes par defaut : crees uniquement si absents, afin de faciliter les personnalisations. $defaultThemeFiles = @{ @@ -252,7 +241,22 @@ foreach ($themeFileName in $defaultThemeFiles.Keys) { } } -Read-DeployCenterConfig +$script:Config = Read-DeployCenterConfig -ConfigPath $script:ConfigPath +foreach ($partitionProfile in $script:PartitionProfiles) { + foreach ($partition in $partitionProfile.Partitions) { + if ([string]$partition.Size.Policy -ne 'ConfiguredMB') { continue } + $configKey = [string]$partition.Size.ConfigKey + if (-not $script:Config.ContainsKey($configKey)) { + throw "Le paramètre DeployCenter.ini '$configKey' requis par le profil '$($partitionProfile.Name)' est absent de la configuration." + } + $configuredSizeMB = [int]$script:Config[$configKey] + if ($configuredSizeMB -lt 990) { + throw "Le paramètre DeployCenter.ini '$configKey' doit être d’au moins 990 Mo." + } + $partition.Size.Value = $configuredSizeMB + $partition.Size.PreviewMB = $configuredSizeMB + } +} $availableThemes = @(Get-DeployCenterThemeNames -ThemesRoot $script:ThemesRoot) if ($availableThemes.Count -eq 0) { throw "Aucun thème valide trouvé dans $script:ThemesRoot" } if ($availableThemes -notcontains $script:Config.Theme) { $script:Config.Theme = "Light" } @@ -278,84 +282,8 @@ $script:FontGroup = [System.Drawing.Font]::new("Segoe UI", [single]11, [System.D # RESEAU, ULTRAVNC ET INVENTAIRE DE SESSION # ============================================================ -function Write-DeployInventory { - param($HardwareInfo = $null) - try { - if (-not (Test-Path -LiteralPath $script:InventoryDir -PathType Container)) { - New-Item -Path $script:InventoryDir -ItemType Directory -Force | Out-Null - } - if ($null -ne $HardwareInfo) { $script:HardwareInventory = $HardwareInfo } - $inventory = [ordered]@{ - GeneratedAt = (Get-Date).ToString('o') - DeploymentIPv4 = $script:DeployIPv4 - VncPassword = $script:VncPassword - Hardware = $script:HardwareInventory - } - $json = $inventory | ConvertTo-Json -Depth 8 - Set-Content -LiteralPath $script:InventoryFile -Value $json -Encoding UTF8 - - # Nettoyer les anciens exports séparés après l'écriture de l'inventaire consolidé. - $legacyInventoryFiles = @( - (Join-Path $env:TEMP 'HardwareInfo.json'), - (Join-Path $script:InventoryDir 'hardware.json') - ) - foreach ($legacyFile in $legacyInventoryFiles) { - if (Test-Path -LiteralPath $legacyFile -PathType Leaf) { - try { - Remove-Item -LiteralPath $legacyFile -Force -ErrorAction Stop - } catch { - Write-Log "Impossible de supprimer l'ancien export d'inventaire '$legacyFile' : $($_.Exception.Message)" 'WARNING' - } - } - } - } catch { - Write-Log "Impossible d'écrire l'inventaire temporaire : $($_.Exception.Message)" 'WARNING' - } -} -function Set-DeployCenterFormSizing { - param( - [Parameter(Mandatory=$true)][System.Windows.Forms.Form]$Form, - [switch]$Resizable - ) - if ($Resizable) { - $Form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::Sizable - $Form.MaximizeBox = $true - } else { - $Form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::FixedDialog - $Form.MaximizeBox = $false - } -} - -function Add-SessionFooter { - param( - [Parameter(Mandatory=$true)][System.Windows.Forms.Form]$TargetForm, - [bool]$ShowFooter = $true, - [bool]$ShowPassword = $true - ) - if (-not $ShowFooter) { return } - - $footer = New-Object System.Windows.Forms.Label - $footer.Name = 'DeployCenterSessionFooter' - $footer.Text = if ($script:DeployIPv4) { - "IP de déploiement : $($script:DeployIPv4)" - } else { - 'IP de déploiement indisponible' - } - if ($ShowPassword -and -not [string]::IsNullOrWhiteSpace([string]$script:VncPassword)) { - $footer.Text += " | Mot de passe VNC : $($script:VncPassword)" - } - $footer.Height = 26 - $footer.Dock = [System.Windows.Forms.DockStyle]::Bottom - $footer.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft - $footer.Padding = New-Object System.Windows.Forms.Padding(12, 0, 0, 0) - $footer.BackColor = $script:Theme.PanelAlt - $footer.ForeColor = $script:Theme.Text - $footer.Font = $script:FontSmall - $TargetForm.Controls.Add($footer) - $footer.BringToFront() -} # L'adresse IPv4 a été détectée par Modules\\Network.psm1 avant l'initialisation du journal. @@ -372,100 +300,16 @@ if ($script:EnableVnc) { . ([scriptblock]::Create($vncCode)) Start-DeployCenterVnc } catch { - Write-Log "Impossible de démarrer VNC : $($_.Exception.Message)" 'ERROR' + & $script:SourceLogAction "Impossible de démarrer VNC : $($_.Exception.Message)" 'ERROR' } } else { - Write-Log "UltraVNC non démarré : paramètre -vnc absent." + & $script:SourceLogAction "UltraVNC non démarré : paramètre -vnc absent." } # ============================================================ # CREATION DES CONTROLES # ============================================================ -function New-Label { - param( - [string]$Text, - [int]$X, - [int]$Y, - [int]$Width = 180, - [int]$Height = 24, - $Color = $null, - $Font = $null - ) - - $label = New-Object System.Windows.Forms.Label - $label.Text = $Text - $label.Location = New-Object System.Drawing.Point($X, $Y) - $label.Size = New-Object System.Drawing.Size($Width, $Height) - $label.AutoEllipsis = $true - $label.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft - $label.BackColor = [System.Drawing.Color]::Transparent - - if ($null -eq $Color) { - $label.ForeColor = $script:Theme.Text - } - else { - $label.ForeColor = $Color - } - - if ($null -eq $Font) { - $label.Font = $script:FontRegular - } - else { - $label.Font = $Font - } - - return $label -} - -function New-GroupBox { - param( - [string]$Text, - [int]$X, - [int]$Y, - [int]$Width, - [int]$Height - ) - - $group = New-Object DeployCenter.ThemedGroupBox - $group.Text = $Text - $group.Location = New-Object System.Drawing.Point($X, $Y) - $group.Size = New-Object System.Drawing.Size($Width, $Height) - $group.ForeColor = $script:Theme.Text - $group.BackColor = $script:Theme.Panel - $group.PanelColor = $script:Theme.Panel - $group.BorderColor = $script:Theme.Border - $group.TextColor = $script:Theme.Text - $group.Font = $script:FontGroup - return $group -} - -function New-Button { - param( - [string]$Text, - [int]$X, - [int]$Y, - [int]$Width, - [int]$Height, - [ValidateSet("Button", "PrimaryButton", "DangerButton")][string]$Role = "Button" - ) - - $button = New-Object System.Windows.Forms.Button - $button.Text = $Text - $button.Location = New-Object System.Drawing.Point($X, $Y) - $button.Size = New-Object System.Drawing.Size($Width, $Height) - $button.FlatStyle = [System.Windows.Forms.FlatStyle]::Flat - $button.FlatAppearance.BorderSize = 1 - $button.Tag = $Role - Set-DeployCenterButtonStyle -Button $button -Theme $script:Theme -Role $Role - $button.Font = $script:FontRegular - $button.Cursor = [System.Windows.Forms.Cursors]::Hand - $button.UseVisualStyleBackColor = $false - $button.AutoSize = $false - $button.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter - return $button -} - # ============================================================ # FENETRE PRINCIPALE # ============================================================ @@ -511,42 +355,8 @@ $titleX = 30 # Deux fichiers seulement : logo.png (clair et solution de repli) et logo-dark.png. # En mode sombre, logo-dark.png est prioritaire ; s'il manque, logo.png est utilise. # En mode clair, logo.png est utilise. -function Get-LogoPathForTheme { - param([string]$ThemeName) - - if ($ThemeName -eq "Dark" -and (Test-Path $script:LogoDarkPath)) { - return $script:LogoDarkPath - } - if (Test-Path $script:LogoPath) { - return $script:LogoPath - } - # Si logo.png manque, garder logo-dark.png comme dernier recours. - if (Test-Path $script:LogoDarkPath) { - return $script:LogoDarkPath - } - return $null -} - -function Update-DeployCenterLogo { - param([string]$ThemeName) - - if ($null -eq $script:pictureLogo) { return } - $logoPath = Get-LogoPathForTheme -ThemeName $ThemeName - if ([string]::IsNullOrWhiteSpace($logoPath)) { return } - - try { - $newImage = [System.Drawing.Image]::FromFile($logoPath) - $oldImage = $script:pictureLogo.Image - $script:pictureLogo.Image = $newImage - if ($null -ne $oldImage) { $oldImage.Dispose() } - $script:pictureLogo.Visible = $true - } - catch { - # En cas d'image absente ou invalide, conserver le logo deja affiche. - } -} - -$initialLogoPath = Get-LogoPathForTheme -ThemeName $script:CurrentTheme +$initialLogoPath = Get-DeployCenterLogoPathForTheme -ThemeName $script:CurrentTheme ` + -LogoPath $script:LogoPath -LogoDarkPath $script:LogoDarkPath if ($null -ne $initialLogoPath) { try { $script:pictureLogo = New-Object System.Windows.Forms.PictureBox @@ -565,13 +375,13 @@ if ($null -ne $initialLogoPath) { } $script:lblTitle = New-Label -Text "DeployCenter" ` - -X $titleX -Y 17 -Width 600 -Height 42 ` - -Color $script:Theme.Text -Font $script:FontTitle + -X $titleX -Y 17 -Width 600 -Height 42 -Theme $script:Theme -Font $script:FontTitle ` + -Color $script:Theme.Text $script:lblSubtitle = New-Label ` -Text "Environnement de déploiement Windows" ` - -X ($titleX + 4) -Y 61 -Width 650 -Height 24 ` - -Color $script:Theme.Secondary -Font $script:FontSubtitle + -X ($titleX + 4) -Y 61 -Width 650 -Height 24 -Theme $script:Theme -Font $script:FontSubtitle ` + -Color $script:Theme.Secondary $script:form.Controls.Add($script:lblTitle) $script:form.Controls.Add($script:lblSubtitle) @@ -627,21 +437,22 @@ $script:cmbTheme.BringToFront() # ============================================================ $script:groupPC = New-GroupBox -Text "Informations du PC" ` - -X $margin -Y 100 -Width $usableWidth -Height 105 + -X $margin -Y 100 -Width $usableWidth -Height 105 -Theme $script:Theme -Font $script:FontGroup $script:lblManufacturer = New-Label -Text "Fabricant : -" ` - -X 18 -Y 25 -Width 330 -Height 24 + -X 18 -Y 25 -Width 330 -Height 24 -Theme $script:Theme -Font $script:FontRegular $script:lblModel = New-Label -Text "Modèle : -" ` - -X 360 -Y 25 -Width 400 -Height 24 + -X 360 -Y 25 -Width 400 -Height 24 -Theme $script:Theme -Font $script:FontRegular $script:lblSerial = New-Label -Text "Numéro de série : -" ` - -X 18 -Y 52 -Width 330 -Height 24 + -X 18 -Y 52 -Width 330 -Height 24 -Theme $script:Theme -Font $script:FontRegular $script:lblFirmware = New-Label -Text "Firmware : -" ` - -X 360 -Y 52 -Width 220 -Height 24 + -X 360 -Y 52 -Width 220 -Height 24 -Theme $script:Theme -Font $script:FontRegular $script:lblArchitecture = New-Label -Text "Architecture : -" ` - -X 600 -Y 52 -Width 220 -Height 24 + -X 600 -Y 52 -Width 220 -Height 24 -Theme $script:Theme -Font $script:FontRegular $script:lblWinPE = New-Label -Text "Environnement : -" ` -X 18 -Y 78 -Width 350 -Height 22 ` - -Color $script:Theme.Secondary -Font $script:FontSmall + -Theme $script:Theme -Font $script:FontSmall ` + -Color $script:Theme.Secondary $script:groupPC.Controls.AddRange(@( $script:lblManufacturer, @@ -658,7 +469,7 @@ $script:form.Controls.Add($script:groupPC) # ============================================================ $script:groupDisks = New-GroupBox -Text "Préparation du déploiement" ` - -X $margin -Y 220 -Width $usableWidth -Height 226 + -X $margin -Y 220 -Width $usableWidth -Height 226 -Theme $script:Theme -Font $script:FontGroup $deploymentControls = New-DeployCenterDeploymentControls ` -Parent $script:groupDisks ` @@ -675,10 +486,7 @@ $script:cmbPartitionProfile.SelectedIndex = $script:SelectedPartitionProfileInde $script:form.Controls.Add($script:groupDisks) -function Set-DiskSelectorWidth { - if ($null -eq $script:cmbDisk -or $null -eq $script:groupDisks) { return } - Set-DeployCenterDeploymentControlsLayout -Parent $script:groupDisks -Controls $deploymentControls -} + # ============================================================ # JOURNAL DANS UNE FENETRE DEDIEE @@ -702,1115 +510,67 @@ $buttonY = if ($script:Windowed) { $buttonWidth = 140 $buttonGap = 12 $closeX = $screenWidth - $margin - $buttonWidth -$refreshX = $closeX - $buttonGap - $buttonWidth +$deployX = $closeX - $buttonGap - $buttonWidth +$refreshX = $deployX - $buttonGap - $buttonWidth $script:btnLog = New-Button -Text "Journal" ` -X $margin -Y $buttonY -Width $buttonWidth -Height $buttonHeight ` + -Theme $script:Theme -Font $script:FontRegular ` -Role "Button" $script:btnRefresh = New-Button -Text "Actualiser" ` -X $refreshX -Y $buttonY -Width $buttonWidth -Height $buttonHeight ` + -Theme $script:Theme -Font $script:FontRegular ` + -Role "PrimaryButton" + +$script:btnDeploy = New-Button -Text "Installer" ` + -X $deployX -Y $buttonY -Width $buttonWidth -Height $buttonHeight ` + -Theme $script:Theme -Font $script:FontRegular ` -Role "PrimaryButton" $script:btnClose = New-Button -Text "Fermer" ` -X $closeX -Y $buttonY -Width $buttonWidth -Height $buttonHeight ` + -Theme $script:Theme -Font $script:FontRegular ` -Role "DangerButton" $script:form.Controls.Add($script:btnLog) $script:form.Controls.Add($script:btnRefresh) +$script:form.Controls.Add($script:btnDeploy) $script:form.Controls.Add($script:btnClose) -Add-SessionFooter -TargetForm $script:form -ShowPassword $true +Add-SessionFooter -TargetForm $script:form -Theme $script:Theme -Font $script:FontSmall ` + -IPv4 $script:DeployIPv4 -VncPassword $script:VncPassword -ShowPassword $true # ============================================================ # APPLICATION DU THEME A TOUS LES CONTROLES # ============================================================ -function Set-ControlTheme { - param([System.Windows.Forms.Control]$Control) - # Le formulaire principal doit lui aussi changer de fond. - if ($Control -is [System.Windows.Forms.Form]) { - $Control.BackColor = $script:Theme.Background - $Control.ForeColor = $script:Theme.Text - } - elseif ($Control -is [DeployCenter.ThemedGroupBox]) { - $Control.BackColor = $script:Theme.Panel - $Control.PanelColor = $script:Theme.Panel - $Control.BorderColor = $script:Theme.Border - $Control.TextColor = $script:Theme.Text - $Control.ForeColor = $script:Theme.Text - $Control.Invalidate() - } - elseif ($Control -is [System.Windows.Forms.Label]) { - $Control.ForeColor = $script:Theme.Text - if ($Control.Name -eq 'lblSelectedWindows') { - $Control.ForeColor = if ($script:SelectedWindowsEdition) { $script:Theme.Success } else { $script:Theme.Secondary } - } - # Le pied de page VNC suit lui aussi la palette du thème actif. - if ($Control.Name -eq 'DeployCenterSessionFooter') { - $Control.BackColor = $script:Theme.PanelAlt - $Control.ForeColor = $script:Theme.Secondary - $Control.Invalidate() - } - } - elseif ($Control -is [System.Windows.Forms.ListView]) { - $Control.BackColor = $script:Theme.PanelAlt - $Control.ForeColor = $script:Theme.Text - } - elseif ($Control -is [System.Windows.Forms.ComboBox]) { - Set-DeployCenterComboBoxTheme -ComboBox $Control -Theme $script:Theme - } - elseif ($Control -is [System.Windows.Forms.TextBox]) { - $Control.BackColor = $script:Theme.PanelAlt - $Control.ForeColor = $script:Theme.Text - } - foreach ($child in $Control.Controls) { - Set-ControlTheme -Control $child - } -} -function Set-DeployCenterTheme { - param([Parameter(Mandatory=$true)][string]$ThemeName) - - $availableThemes = @(Get-DeployCenterThemeNames -ThemesRoot $script:ThemesRoot) - if ($availableThemes -notcontains $ThemeName) { return } - try { - $script:Theme = Import-DeployCenterTheme -ThemesRoot $script:ThemesRoot -ThemeName $ThemeName - } catch { - Write-Log ("Impossible de charger le theme '$ThemeName' : " + $_.Exception.Message) "ERROR" - return - } - $script:CurrentTheme = $ThemeName - $script:cmbTheme.BackColor = $script:Theme.Panel - $script:cmbTheme.ForeColor = $script:Theme.Text - $script:cmbTheme.Invalidate() - $script:cmbTheme.Refresh() - - Set-ControlTheme -Control $script:form - Set-ControlTheme -Control $script:logForm - - # Met a jour le logo si une variante propre au theme existe. - Update-DeployCenterLogo -ThemeName $script:CurrentTheme - - $script:lblTitle.ForeColor = $script:Theme.Text - $script:lblSubtitle.ForeColor = $script:Theme.Secondary - - if ($script:lblWinPE.Text -like "*WinPE*") { - $script:lblWinPE.ForeColor = $script:Theme.Success - } - else { - $script:lblWinPE.ForeColor = $script:Theme.Secondary - } - - Set-DeployCenterButtonStyle -Button $script:btnChooseWindows -Theme $script:Theme -Role 'Button' - Set-DeployCenterButtonStyle -Button $script:btnLog -Theme $script:Theme -Role 'Button' - Set-DeployCenterButtonStyle -Button $script:btnRefresh -Theme $script:Theme -Role 'PrimaryButton' - Set-DeployCenterButtonStyle -Button $script:btnClose -Theme $script:Theme -Role 'DangerButton' - # Actualise explicitement le pied de page principal lors du changement de thème. - foreach ($footer in $script:form.Controls.Find('DeployCenterSessionFooter', $true)) { - $footer.BackColor = $script:Theme.PanelAlt - $footer.ForeColor = $script:Theme.Secondary - $footer.Invalidate() - } - $script:cmbTheme.BackColor = $script:Theme.Panel - $script:cmbTheme.ForeColor = $script:Theme.Text - $script:cmbTheme.Invalidate() - $script:groupPC.Invalidate() - $script:groupDisks.Invalidate() - $script:cmbDisk.BackColor = $script:Theme.PanelAlt - $script:cmbDisk.ForeColor = $script:Theme.Text - $script:cmbDisk.Invalidate() - # Le bouton reste au-dessus des autres controles de l'en-tete. - $script:cmbTheme.BringToFront() - - # Forcer le rafraichissement apres le changement de palette. - $script:form.SuspendLayout() - try { - foreach ($control in $script:form.Controls) { - $control.Invalidate($true) - } - $script:form.Invalidate($true) - $script:form.Refresh() - } - finally { - $script:form.ResumeLayout($true) - } -} # ============================================================ # DETECTION DES SOURCES WINDOWS SUR PARTAGE SMB # ============================================================ -function Find-WindowsSources { - param([Parameter(Mandatory=$true)][string]$RootPath) - $results = New-Object System.Collections.Generic.List[object] - $roots = @($RootPath -split ';' | ForEach-Object { - $candidate = $_.Trim().Trim('"') - if ($candidate -notmatch '^[A-Za-z]:\\$') { $candidate = $candidate.TrimEnd('\') } - $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.' - } - - foreach ($root in $roots) { - if (-not (Test-Path -LiteralPath $root -PathType Container)) { - Write-Log "Source inaccessible, ignoree : $root" 'WARNING' - continue - } - - Write-Log "Recherche des sources Windows dans : $root" - # 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. - $allFiles = @(Get-ChildItem -LiteralPath $root -Recurse -File -ErrorAction SilentlyContinue) - $files = New-Object System.Collections.Generic.List[object] - foreach ($file in $allFiles) { - if ($file.Extension -ieq '.iso') { - [void]$files.Add($file) - continue - } - - if ($file.Extension -ine '.wim' -and $file.Extension -ine '.esd') { - continue - } - - $isBootWimInExtractedMedia = $false - if ($file.Name -ieq 'boot.wim' -and $file.Directory.Name -ieq 'Sources') { - $installWim = Join-Path $file.Directory.FullName 'install.wim' - $installEsd = Join-Path $file.Directory.FullName 'install.esd' - if ((Test-Path -LiteralPath $installWim -PathType Leaf) -or - (Test-Path -LiteralPath $installEsd -PathType Leaf)) { - $isBootWimInExtractedMedia = $true - } - } - - if (-not $isBootWimInExtractedMedia) { - [void]$files.Add($file) - } - } - - foreach ($file in $files) { - $kind = switch ($file.Extension.ToLowerInvariant()) { - '.iso' { 'Image ISO' } - '.wim' { 'Image WIM Windows' } - '.esd' { 'Image ESD Windows' } - } - $detail = if ($file.Name -ieq 'install.wim' -or $file.Name -ieq 'install.esd') { - "Image d’installation dans un support extrait" - } elseif ($file.Extension -ieq '.iso') { - "Support d’installation ISO" - } 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 - }) - } - } - - 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." - } - Write-Log ("Detection terminee : {0} element(s) trouve(s)." -f $results.Count) 'SUCCESS' - return @($results | Sort-Object Type, Nom, Chemin -Unique) -} - -function Get-WindowsImageEditions { - param([Parameter(Mandatory=$true)][string]$ImagePath) - - if (-not (Test-Path -LiteralPath $ImagePath -PathType Leaf)) { - throw "Fichier image introuvable : $ImagePath" - } - - $normalized = @() - $getWindowsImage = Get-Command Get-WindowsImage -ErrorAction SilentlyContinue - if ($getWindowsImage) { - try { - $images = @(Get-WindowsImage -ImagePath $ImagePath -ErrorAction Stop) - foreach ($image in $images) { - $indexValue = $null - foreach ($propertyName in @('ImageIndex', 'Index')) { - if ($image.PSObject.Properties[$propertyName] -and $image.$propertyName) { - $indexValue = [int]$image.$propertyName - break - } - } - if ($null -eq $indexValue) { continue } - - $nameValue = $null - foreach ($propertyName in @('ImageName', 'Name', 'ImageDescription')) { - if ($image.PSObject.Properties[$propertyName] -and $image.$propertyName) { - $nameValue = [string]$image.$propertyName - break - } - } - $archValue = $null - foreach ($propertyName in @('Architecture', 'ImageArchitecture')) { - if ($image.PSObject.Properties[$propertyName] -and $image.$propertyName) { - $archValue = $image.$propertyName - break - } - } - $versionValue = $null - foreach ($propertyName in @('Version', 'ImageVersion')) { - if ($image.PSObject.Properties[$propertyName] -and $image.$propertyName) { - $versionValue = [string]$image.$propertyName - break - } - } - $normalized += [pscustomobject]@{ - Index = $indexValue - Name = $nameValue - Description = if ($image.PSObject.Properties['ImageDescription']) { $image.ImageDescription } else { $null } - Architecture = $archValue - Version = $versionValue - Edition = if ($image.PSObject.Properties['EditionId']) { $image.EditionId } else { $null } - } - } - } catch { - Write-Log "Get-WindowsImage indisponible pour $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 ($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")" - } - - $current = $null - foreach ($line in $output) { - $lineText = [string]$line - if ($lineText -match '^\s*Index\s*:\s*(\d+)\s*$') { - if ($null -ne $current) { $normalized += [pscustomobject]$current } - $current = @{ Index = [int]$Matches[1]; Name = ''; Description = ''; Architecture = $null; Version = $null; Edition = $null } - } elseif ($null -ne $current -and $lineText -match '^\s*Name\s*:\s*(.*?)\s*$') { - $current.Name = $Matches[1] - } elseif ($null -ne $current -and $lineText -match '^\s*Description\s*:\s*(.*?)\s*$') { - $current.Description = $Matches[1] - } - } - if ($null -ne $current) { $normalized += [pscustomobject]$current } - } - - if ($normalized.Count -eq 0) { - throw "DISM n’a retourné aucune édition exploitable pour : $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. - 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) { - Write-Log "DISM n'a pas pu lire les metadonnees de l'index $($edition.Index) : $($detailOutput -join ' ')" 'WARNING' - continue - } - - $insideImageDetails = $false - $imageVersion = $null - $servicePackBuild = $null - foreach ($detailLine in $detailOutput) { - $detailText = [string]$detailLine - if ($detailText -match '^\s*Details for image\s*:') { - $insideImageDetails = $true - continue - } - # Ignorer la version de DISM affichée au début de sa sortie. - if (-not $insideImageDetails) { continue } - - if (-not $edition.Architecture -and $detailText -match '^\s*Architecture\s*:\s*(.*?)\s*$') { - $edition.Architecture = $Matches[1] - } elseif ($detailText -match '^\s*Version\s*:\s*(.*?)\s*$') { - $imageVersion = $Matches[1] - } elseif ($detailText -match '^\s*ServicePack Build\s*:\s*(.*?)\s*$') { - $servicePackBuild = $Matches[1] - } elseif (-not $edition.Name -and $detailText -match '^\s*Name\s*:\s*(.*?)\s*$') { - $edition.Name = $Matches[1] - } elseif (-not $edition.Edition -and $detailText -match '^\s*Edition\s*:\s*(.*?)\s*$') { - $edition.Edition = $Matches[1] - } - } - if ($imageVersion) { - $edition.Version = if ($servicePackBuild -and $servicePackBuild -ne '0') { - "$imageVersion.$servicePackBuild" - } else { - $imageVersion - } - } - } - - # 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. - $versionValues = @($normalized | ForEach-Object { - if (-not [string]::IsNullOrWhiteSpace([string]$_.Version)) { [string]$_.Version } - } | Select-Object -Unique) - $globalVersion = $null - if ($versionValues.Count -eq 1) { - $globalVersion = $versionValues[0] - } - - foreach ($edition in $normalized) { - if ($edition.Architecture -is [int] -or [string]$edition.Architecture -match '^\d+$') { - switch ([string]$edition.Architecture) { - '0' { $edition.Architecture = 'x86' } - '5' { $edition.Architecture = 'ARM' } - '9' { $edition.Architecture = 'x64' } - '12' { $edition.Architecture = 'ARM64' } - } - } - } - - return [pscustomobject]@{ - Editions = @($normalized) - GlobalVersion = $globalVersion - } -} - -function Resolve-InstallImagePath { - param([Parameter(Mandatory=$true)][string]$SelectedPath) - - if ([IO.Path]::GetExtension($SelectedPath) -in @('.wim', '.esd')) { - return $SelectedPath - } - - if (Test-Path -LiteralPath $SelectedPath -PathType Container) { - foreach ($relative in @('sources\install.wim', 'sources\install.esd', 'Sources\install.wim', 'Sources\install.esd')) { - $candidate = Join-Path $SelectedPath $relative - if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate } - } - throw "Aucun fichier Sources\install.wim ou Sources\install.esd dans : $SelectedPath" - } - - if ([IO.Path]::GetExtension($SelectedPath) -ieq '.iso') { - 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." - } - $diskImage = Mount-DiskImage -ImagePath $SelectedPath -Access ReadOnly -PassThru -ErrorAction Stop - try { - $volumes = @($diskImage | Get-Volume -ErrorAction SilentlyContinue | Where-Object { $_.DriveLetter }) - foreach ($volume in $volumes) { - foreach ($relative in @('sources\install.wim', 'sources\install.esd')) { - $candidate = "$($volume.DriveLetter):\$relative" - if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate } - } - } - throw "L'ISO ne contient pas de fichier Sources\install.wim ou Sources\install.esd." - } finally { - Dismount-DiskImage -ImagePath $SelectedPath -ErrorAction SilentlyContinue - } - } - - throw "Type de source non pris en charge pour l'analyse : $SelectedPath" -} - -function Show-WindowsSourcesDialog { - $dlg = New-Object System.Windows.Forms.Form - $dlg.Text = 'Sources Windows - lecteur DeployCenter et partage SMB' - $dlg.StartPosition = 'CenterParent' - $dlg.Size = New-Object System.Drawing.Size(1100, 700) - $dlg.MinimumSize = New-Object System.Drawing.Size(700, 450) - Set-DeployCenterFormSizing -Form $dlg -Resizable - # Ouvrir directement en plein écran pour exploiter toute la surface disponible. - $dlg.WindowState = [System.Windows.Forms.FormWindowState]::Maximized - $dlg.ShowInTaskbar = $false - $dlg.BackColor = $script:Theme.Background - $dlg.ForeColor = $script:Theme.Text - $dlg.Font = $script:FontRegular - - $lblPath = New-Object System.Windows.Forms.Label - $lblPath.Text = 'Chemins à analyser (séparés par ;) :' - $lblPath.Location = New-Object System.Drawing.Point(16, 16) - $lblPath.Size = New-Object System.Drawing.Size(260, 24) - $lblPath.ForeColor = $script:Theme.Text - $dlg.Controls.Add($lblPath) - - $txtPath = New-Object System.Windows.Forms.TextBox - $txtPath.Location = New-Object System.Drawing.Point(16, 42) - $txtPath.Size = New-Object System.Drawing.Size(560, 26) - $txtPath.Anchor = 'Top,Left,Right' - $defaultSourceRoot = $script:DeployVolumeRoot - if ([string]::IsNullOrWhiteSpace($defaultSourceRoot)) { $defaultSourceRoot = $script:DeployRoot } - $configuredPaths = [string]$script:Config.SourcePaths - if ([string]::IsNullOrWhiteSpace($configuredPaths)) { - $txtPath.Text = $defaultSourceRoot - } else { - $txtPath.Text = $configuredPaths.Replace("%DEPLOY_VOLUME%", $defaultSourceRoot) - } - $txtPath.BackColor = $script:Theme.PanelAlt - $txtPath.ForeColor = $script:Theme.Text - $dlg.Controls.Add($txtPath) - - $btnScan = New-Object System.Windows.Forms.Button - $btnScan.Text = 'Rechercher' - $btnScan.Location = New-Object System.Drawing.Point(650, 40) - $btnScan.Size = New-Object System.Drawing.Size(100, 30) - $btnScan.Anchor = 'Top,Right' - $btnScan.UseVisualStyleBackColor = $false - $btnScan.FlatStyle = [System.Windows.Forms.FlatStyle]::Flat - $btnScan.FlatAppearance.BorderColor = $script:Theme.Border - $btnScan.BackColor = $script:Theme.Accent - $btnScan.ForeColor = [System.Drawing.Color]::White - $dlg.Controls.Add($btnScan) - - $btnEditions = New-Object System.Windows.Forms.Button - $btnEditions.Text = 'Afficher les éditions' - $btnEditions.Location = New-Object System.Drawing.Point(760, 40) - $btnEditions.Size = New-Object System.Drawing.Size(150, 30) - $btnEditions.Anchor = 'Top,Right' - $btnEditions.UseVisualStyleBackColor = $false - $btnEditions.FlatStyle = [System.Windows.Forms.FlatStyle]::Flat - $btnEditions.FlatAppearance.BorderColor = $script:Theme.Border - $btnEditions.BackColor = $script:Theme.Panel - $btnEditions.ForeColor = $script:Theme.Text - $dlg.Controls.Add($btnEditions) - - $list = New-Object System.Windows.Forms.ListView - $list.Location = New-Object System.Drawing.Point(16, 84) - $list.Size = New-Object System.Drawing.Size(770, 420) - $list.Anchor = 'Top,Bottom,Left,Right' - $list.View = 'Details'; $list.FullRowSelect = $true; $list.GridLines = $true - $list.MultiSelect = $false - $list.HideSelection = $true - $list.BackColor = $script:Theme.PanelAlt - $list.ForeColor = $script:Theme.Text - $list.OwnerDraw = $true - [void]$list.Columns.Add('Type', 135) - [void]$list.Columns.Add('Nom', 260) - [void]$list.Columns.Add('Taille', 80) - [void]$list.Columns.Add('Chemin', 250) - - # La colonne Nom est prioritaire : elle absorbe l'espace libre. - $resizeSourceColumns = { - if ($list.Columns.Count -ge 4) { - $availableWidth = $list.ClientSize.Width - $list.Columns[0].Width - $list.Columns[2].Width - $list.Columns[3].Width - 4 - if ($list.Items.Count -gt 0 -and $list.Items.Count -gt [math]::Floor($list.ClientSize.Height / 20)) { - $availableWidth -= [System.Windows.Forms.SystemInformation]::VerticalScrollBarWidth - } - $list.Columns[1].Width = [Math]::Max(180, $availableWidth) - } - } - $list.Add_SizeChanged($resizeSourceColumns) - $list.Add_HandleCreated($resizeSourceColumns) - - # Dessin personnalise des en-tetes et des lignes pour que le ListView - # respecte le theme actif au lieu d'utiliser les en-tetes natifs blancs. - $list.Add_DrawColumnHeader({ - param($sender, $e) - $headerColor = if ($script:Theme.Contains('TableHeader')) { $script:Theme.TableHeader } else { $script:Theme.PanelAlt } - $headerBrush = New-Object System.Drawing.SolidBrush($headerColor) - $textBrush = New-Object System.Drawing.SolidBrush($script:Theme.Text) - $borderPen = New-Object System.Drawing.Pen($script:Theme.Border) - try { - $e.Graphics.FillRectangle($headerBrush, $e.Bounds) - $e.Graphics.DrawRectangle($borderPen, $e.Bounds.X, $e.Bounds.Y, $e.Bounds.Width - 1, $e.Bounds.Height - 1) - $flags = [System.Windows.Forms.TextFormatFlags]::Left -bor [System.Windows.Forms.TextFormatFlags]::VerticalCenter -bor [System.Windows.Forms.TextFormatFlags]::EndEllipsis - [System.Windows.Forms.TextRenderer]::DrawText($e.Graphics, $e.Header.Text, $list.Font, $e.Bounds, $script:Theme.Text, $flags) - } finally { - $headerBrush.Dispose(); $textBrush.Dispose(); $borderPen.Dispose() - } - }) - $list.Add_DrawItem({ - param($sender, $e) - if ($list.View -ne [System.Windows.Forms.View]::Details) { - $e.DrawDefault = $true - } - }) - $list.Add_DrawSubItem({ - param($sender, $e) - $rowBack = if (($e.Item.Index % 2) -eq 0) { $script:Theme.TableRowEven } else { $script:Theme.TableRowOdd } - $rowFore = $script:Theme.Text - # Ne surligner que l'element réellement sélectionné, jamais toutes les lignes. - if ($e.Item.Selected -and $list.Focused) { - $rowBack = $script:Theme.Accent - $rowFore = [System.Drawing.Color]::White - } - $rowBrush = New-Object System.Drawing.SolidBrush($rowBack) - $textBrush = New-Object System.Drawing.SolidBrush($rowFore) - $borderPen = New-Object System.Drawing.Pen($script:Theme.Border) - try { - $e.Graphics.FillRectangle($rowBrush, $e.Bounds) - $e.Graphics.DrawRectangle($borderPen, $e.Bounds.X, $e.Bounds.Y, $e.Bounds.Width - 1, $e.Bounds.Height - 1) - $flags = [System.Windows.Forms.TextFormatFlags]::Left -bor [System.Windows.Forms.TextFormatFlags]::VerticalCenter -bor [System.Windows.Forms.TextFormatFlags]::EndEllipsis -bor [System.Windows.Forms.TextFormatFlags]::NoPrefix - [System.Windows.Forms.TextRenderer]::DrawText($e.Graphics, $e.SubItem.Text, $list.Font, $e.Bounds, $rowFore, $flags) - } finally { - $rowBrush.Dispose(); $textBrush.Dispose(); $borderPen.Dispose() - } - }) - # Filtre local : il ne relance jamais l'analyse des lecteurs ou partages. - $lblFilter = New-Object System.Windows.Forms.Label - $lblFilter.Text = 'Filtrer la liste :' - $lblFilter.Location = New-Object System.Drawing.Point(16, 80) - $lblFilter.Size = New-Object System.Drawing.Size(100, 24) - $lblFilter.ForeColor = $script:Theme.Text - $dlg.Controls.Add($lblFilter) - - $txtFilter = New-Object System.Windows.Forms.TextBox - $txtFilter.Location = New-Object System.Drawing.Point(120, 78) - $txtFilter.Size = New-Object System.Drawing.Size(500, 26) - $txtFilter.Anchor = 'Top,Left,Right' - $txtFilter.BackColor = $script:Theme.PanelAlt - $txtFilter.ForeColor = $script:Theme.Text - $dlg.Controls.Add($txtFilter) - - $list.Location = New-Object System.Drawing.Point(16, 112) - $dlg.Controls.Add($list) - - # Etat partage explicite entre les gestionnaires d'evenements WinForms. - # En PowerShell, les callbacks peuvent s'executer dans une portee differente : - # une variable locale $allFound peut alors rester vide dans le filtre. - $sourceState = [hashtable]::Synchronized(@{ AllFound = @() }) - $renderSourceList = { - param([string]$Query) - $previousSelection = $null - if ($list.SelectedItems.Count -gt 0) { - $previousSelection = [string]$list.SelectedItems[0].SubItems[3].Text - } - - $needle = ([string]$Query).Trim() - $visibleEntries = New-Object System.Collections.Generic.List[object] - $entries = @($sourceState.AllFound) - - foreach ($entry in $entries) { - if ([string]::IsNullOrWhiteSpace($needle)) { - [void]$visibleEntries.Add($entry) - continue - } - - $searchable = '{0} {1} {2} {3} {4}' -f ` - [string]$entry.Type, [string]$entry.Nom, [string]$entry.Chemin, ` - [string]$entry.Taille, [string]$entry.Detail - - if ($searchable.IndexOf($needle, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) { - [void]$visibleEntries.Add($entry) - } - } - - $list.BeginUpdate() - try { - $list.Items.Clear() - foreach ($entry in $visibleEntries) { - $item = New-Object System.Windows.Forms.ListViewItem([string]$entry.Type) - [void]$item.SubItems.Add([string]$entry.Nom) - [void]$item.SubItems.Add([string]$entry.Taille) - [void]$item.SubItems.Add([string]$entry.Chemin) - if ($previousSelection -and ([string]$entry.Chemin -eq $previousSelection)) { - $item.Selected = $true - } - [void]$list.Items.Add($item) - } - } finally { - $list.EndUpdate() - } - - if ($entries.Count -gt 0) { - $lblStatus.Text = ('{0} résultat(s) affiché(s) sur {1}. Filtre instantané ; aucun fichier modifié.' -f $list.Items.Count, $entries.Count) - } elseif ([string]::IsNullOrWhiteSpace($needle)) { - $lblStatus.Text = 'Aucune source ISO/WIM/ESD ou installation extraite detectee dans les chemins accessibles.' - } else { - $lblStatus.Text = ('Aucun resultat pour « {0} ». Efface le filtre pour revoir la liste.' -f $needle) - } - } - $txtFilter.Add_TextChanged({ & $renderSourceList $txtFilter.Text }) - - $lblStatus = New-Object System.Windows.Forms.Label - $lblStatus.Text = 'Analyse en lecture seule du lecteur de DeployCenter et/ou du partage SMB. Plusieurs chemins peuvent etre separes par un point-virgule.' - $lblStatus.Location = New-Object System.Drawing.Point(16, 515) - $lblStatus.Size = New-Object System.Drawing.Size(770, 36) - $lblStatus.Anchor = 'Bottom,Left,Right' - $lblStatus.AutoSize = $false - $lblStatus.ForeColor = $script:Theme.Secondary - $dlg.Controls.Add($lblStatus) - - # Recalculer les positions depuis la taille réelle de la fenêtre : - # le tableau occupe l'espace restant et le statut reste toujours visible en bas. - $resizeLayout = { - $clientWidth = $dlg.ClientSize.Width - $clientHeight = $dlg.ClientSize.Height - $rightMargin = 16 - $gap = 12 - $btnWidth = 88 - $editionsWidth = 148 - $left = 12 - $right = [Math]::Max($left + $btnWidth, $clientWidth - $rightMargin) - $btnEditionsLeft = [Math]::Max($left, $right - $editionsWidth) - $btnScanLeft = [Math]::Max($left, $btnEditionsLeft - $gap - $btnWidth) - $txtPath.Width = [Math]::Max(100, $btnScanLeft - $left - $gap) - $btnEditions.Location = New-Object System.Drawing.Point($btnEditionsLeft, 40) - $btnEditions.Width = [Math]::Max(120, $right - $btnEditionsLeft) - $btnScan.Location = New-Object System.Drawing.Point($btnScanLeft, 40) - $btnScan.Width = [Math]::Max(80, $btnEditionsLeft - $gap - $btnScanLeft) - $btnScan.Anchor = 'Top,Right' - $btnEditions.Anchor = 'Top,Right' - $txtPath.Anchor = 'Top,Left,Right' - $lblFilter.Location = New-Object System.Drawing.Point(16, 80) - $txtFilter.Location = New-Object System.Drawing.Point(120, 78) - $txtFilter.Size = New-Object System.Drawing.Size([Math]::Max(180, $clientWidth - 152), 26) - $list.Location = New-Object System.Drawing.Point(16, 112) - $list.Size = New-Object System.Drawing.Size([Math]::Max(300, $clientWidth - 32), [Math]::Max(120, $clientHeight - 205)) - $list.Anchor = 'Top,Bottom,Left,Right' - $lblStatus.Location = New-Object System.Drawing.Point(16, ($clientHeight - 52)) - $lblStatus.Size = New-Object System.Drawing.Size([Math]::Max(300, $clientWidth - 32), 24) - $lblStatus.Anchor = 'Bottom,Left,Right' - } - $dlg.Add_Shown({ - & $resizeLayout - # Lancer automatiquement la première analyse à l'ouverture. - # Le bouton Rechercher reste disponible si l'utilisateur change les chemins. - $btnScan.PerformClick() - }) - $dlg.Add_SizeChanged({ & $resizeLayout }) - - $btnEditions.Add_Click({ - if ($list.SelectedItems.Count -eq 0) { - Show-DeployCenterMessage -Message 'Sélectionne d''abord une source dans la liste.' -Title 'Éditions Windows' -Icon Information -Owner $dlg -Theme $script:Theme -Font $script:FontRegular - return - } - - $selectedPath = [string]$list.SelectedItems[0].SubItems[3].Text - $selectedName = [string]$list.SelectedItems[0].SubItems[1].Text - $btnEditions.Enabled = $false - $btnScan.Enabled = $false - $dlg.Cursor = [System.Windows.Forms.Cursors]::WaitCursor - $lblStatus.Text = "Analyse des editions de $selectedName en cours..." - try { - $imagePath = Resolve-InstallImagePath -SelectedPath $selectedPath - $editionResult = Get-WindowsImageEditions -ImagePath $imagePath - $editions = @($editionResult.Editions) - $globalImageVersion = [string]$editionResult.GlobalVersion - $lines = foreach ($edition in $editions) { - $index = if ($edition.ImageIndex) { $edition.ImageIndex } elseif ($edition.Index) { $edition.Index } else { '?' } - $name = if ($edition.ImageName) { $edition.ImageName } elseif ($edition.Name) { $edition.Name } else { 'Nom non disponible' } - $arch = if ($edition.Architecture) { [string]$edition.Architecture } else { 'Architecture non disponible' } - $version = if ($edition.Version) { [string]$edition.Version } else { 'Version non disponible' } - "Index : $index`r`nNom : $name`r`nArchitecture : $arch`r`nVersion : $version" - } - # Le formulaire enfant est modal : actualiser le statut avant son affichage, - # sinon le formulaire parent reste visuellement sur « analyse en cours ». - $lblStatus.Text = "$($editions.Count) édition(s) détectée(s) dans $selectedName. Sélectionne une édition." - $dlg.Refresh() - - # Tableau des editions ; l'index DISM selectionne est conserve pour la suite. - $resultForm = New-Object System.Windows.Forms.Form - $resultForm.Text = 'Éditions Windows disponibles' - $resultForm.StartPosition = 'CenterParent' - $resultForm.Size = New-Object System.Drawing.Size(700, 480) - $resultForm.MinimumSize = New-Object System.Drawing.Size(520, 320) - Set-DeployCenterFormSizing -Form $resultForm - $resultForm.ShowInTaskbar = $false - $resultForm.BackColor = $script:Theme.Background - $resultForm.ForeColor = $script:Theme.Text - - $sourceLabel = New-Object System.Windows.Forms.Label - $sourceLabel.Text = "Source : $selectedPath" - $sourceLabel.Dock = [System.Windows.Forms.DockStyle]::Top - $sourceLabel.Height = 38 - $sourceLabel.Padding = New-Object System.Windows.Forms.Padding(10, 8, 10, 4) - $sourceLabel.AutoEllipsis = $true - $sourceLabel.BackColor = $script:Theme.Background - $sourceLabel.ForeColor = $script:Theme.Text - - $editionList = New-Object System.Windows.Forms.ListView - $editionList.View = [System.Windows.Forms.View]::Details - $editionList.FullRowSelect = $true - $editionList.GridLines = $true - $editionList.MultiSelect = $false - $editionList.HideSelection = $false - # Une seule édition peut être sélectionnée à la fois. - $editionList.Sorting = [System.Windows.Forms.SortOrder]::None - $editionList.Dock = [System.Windows.Forms.DockStyle]::Fill - $editionList.BackColor = $script:Theme.Panel - $editionList.ForeColor = $script:Theme.Text - [void]$editionList.Columns.Add('Index', 65) - [void]$editionList.Columns.Add('Édition', 260) - [void]$editionList.Columns.Add('Architecture', 100) - [void]$editionList.Columns.Add('Version', 105) - - # Édition est la colonne prioritaire : Version conserve une largeur fixe. - $resizeEditionColumns = { - if ($editionList.Columns.Count -ge 4) { - $availableWidth = $editionList.ClientSize.Width - $editionList.Columns[0].Width - $editionList.Columns[2].Width - $editionList.Columns[3].Width - 4 - if ($editionList.Items.Count -gt 0 -and $editionList.Items.Count -gt [Math]::Floor($editionList.ClientSize.Height / 20)) { - $availableWidth -= [System.Windows.Forms.SystemInformation]::VerticalScrollBarWidth - } - $editionList.Columns[1].Width = [Math]::Max(220, $availableWidth) - } - } - $editionList.Add_SizeChanged($resizeEditionColumns) - $resultForm.Add_Shown($resizeEditionColumns) - - # Dessin personnalise des en-tetes et cellules pour que ListView - # respecte le theme Light/Dark au lieu de garder un en-tete blanc. - $editionList.OwnerDraw = $true - $editionList.Add_DrawColumnHeader({ - param($sender, $e) - $headerColor = if ($script:Theme.Contains('TableHeader')) { $script:Theme.TableHeader } else { $script:Theme.PanelAlt } - $headerBrush = New-Object System.Drawing.SolidBrush($headerColor) - $headerPen = New-Object System.Drawing.Pen($script:Theme.Border) - try { - $e.Graphics.FillRectangle($headerBrush, $e.Bounds) - $e.Graphics.DrawRectangle($headerPen, $e.Bounds.X, $e.Bounds.Y, $e.Bounds.Width - 1, $e.Bounds.Height - 1) - [System.Windows.Forms.TextRenderer]::DrawText( - $e.Graphics, $e.Header.Text, $sender.Font, $e.Bounds, - $script:Theme.Text, - ([System.Windows.Forms.TextFormatFlags]::VerticalCenter -bor - [System.Windows.Forms.TextFormatFlags]::Left -bor - [System.Windows.Forms.TextFormatFlags]::EndEllipsis) - ) - } finally { - $headerBrush.Dispose() - $headerPen.Dispose() - } - }) - $editionList.Add_DrawItem({ param($sender, $e) }) - $editionList.Add_DrawSubItem({ - param($sender, $e) - $cellBack = if (($e.Item.Index % 2) -eq 0) { $script:Theme.TableRowEven } else { $script:Theme.TableRowOdd } - $cellFore = $script:Theme.Text - if ($e.Item.Selected) { - $cellBack = $script:Theme.Accent - $cellFore = [System.Drawing.Color]::White - } - $cellBrush = New-Object System.Drawing.SolidBrush($cellBack) - $cellPen = New-Object System.Drawing.Pen($script:Theme.Border) - try { - $e.Graphics.FillRectangle($cellBrush, $e.Bounds) - if ($sender.GridLines) { - $e.Graphics.DrawRectangle($cellPen, $e.Bounds.X, $e.Bounds.Y, $e.Bounds.Width - 1, $e.Bounds.Height - 1) - } - [System.Windows.Forms.TextRenderer]::DrawText( - $e.Graphics, $e.SubItem.Text, $sender.Font, $e.Bounds, - $cellFore, - ([System.Windows.Forms.TextFormatFlags]::VerticalCenter -bor - [System.Windows.Forms.TextFormatFlags]::Left -bor - [System.Windows.Forms.TextFormatFlags]::EndEllipsis) - ) - } finally { - $cellBrush.Dispose() - $cellPen.Dispose() - } - }) - - foreach ($edition in $editions) { - $index = if ($edition.ImageIndex) { [string]$edition.ImageIndex } elseif ($edition.Index) { [string]$edition.Index } else { '?' } - $name = if ($edition.ImageName) { [string]$edition.ImageName } elseif ($edition.Name) { [string]$edition.Name } elseif ($edition.ImageDescription) { [string]$edition.ImageDescription } else { 'Nom non disponible' } - $archValue = $null - foreach ($propertyName in @('Architecture', 'ImageArchitecture')) { - if ($edition.PSObject.Properties[$propertyName] -and $edition.$propertyName) { - $archValue = $edition.$propertyName - break - } - } - $arch = if ($null -ne $archValue) { - switch ([string]$archValue) { - '0' { 'x86' } - '5' { 'ARM' } - '9' { 'x64' } - '12' { 'ARM64' } - default { [string]$archValue } - } - } else { 'Non disponible' } - $version = if ($edition.Version) { [string]$edition.Version } else { 'Non disponible' } - - $row = New-Object System.Windows.Forms.ListViewItem($index) - [void]$row.SubItems.Add($name) - [void]$row.SubItems.Add($arch) - [void]$row.SubItems.Add($version) - $row.Tag = $edition - [void]$editionList.Items.Add($row) - } - - $buttonPanel = New-Object System.Windows.Forms.Panel - $buttonPanel.Dock = [System.Windows.Forms.DockStyle]::Bottom - $buttonPanel.Height = 52 - $buttonPanel.BackColor = $script:Theme.Background - - $cancelButton = New-Object System.Windows.Forms.Button - $cancelButton.Text = 'Annuler' - $cancelButton.Size = New-Object System.Drawing.Size(100, 30) - $cancelButton.Anchor = 'Right,Top' - $cancelButton.Location = New-Object System.Drawing.Point(0, 10) - $cancelButton.DialogResult = [System.Windows.Forms.DialogResult]::Cancel - - $validateButton = New-Object System.Windows.Forms.Button - $validateButton.Text = 'Valider l''édition' - $validateButton.Size = New-Object System.Drawing.Size(140, 30) - $validateButton.Anchor = 'Right,Top' - $validateButton.Location = New-Object System.Drawing.Point(0, 10) - $validateButton.Enabled = $false - - $editionList.Add_SelectedIndexChanged({ - $validateButton.Enabled = ($editionList.SelectedItems.Count -gt 0 -and $editionList.SelectedItems[0].SubItems[0].Text -ne '?') - }) - $validateButton.Add_Click({ - if ($editionList.SelectedItems.Count -gt 0) { - $resultForm.Tag = [pscustomobject]@{ - SourcePath = $selectedPath - ImagePath = $imagePath - Edition = $editionList.SelectedItems[0].Tag - Index = [int]$editionList.SelectedItems[0].SubItems[0].Text - Name = $editionList.SelectedItems[0].SubItems[1].Text - } - $resultForm.DialogResult = [System.Windows.Forms.DialogResult]::OK - $resultForm.Close() - } - }) - $editionList.Add_DoubleClick({ - if ($editionList.SelectedItems.Count -gt 0 -and $validateButton.Enabled) { - $validateButton.PerformClick() - } - }) - $buttonPanel.Controls.Add($cancelButton) - $buttonPanel.Controls.Add($validateButton) - $buttonPanel.Add_Resize({ - $validateButton.Location = New-Object System.Drawing.Point(($buttonPanel.ClientSize.Width - $validateButton.Width - 12), 10) - $cancelButton.Location = New-Object System.Drawing.Point(($validateButton.Left - $cancelButton.Width - 8), 10) - }) - - $resultForm.Controls.Add($editionList) - $resultForm.Controls.Add($buttonPanel) - $resultForm.Controls.Add($sourceLabel) - $resultForm.AcceptButton = $validateButton - $resultForm.CancelButton = $cancelButton - Add-SessionFooter -TargetForm $resultForm -ShowFooter $false - $dialogResult = $resultForm.ShowDialog($dlg) - $chosenEdition = $resultForm.Tag - $resultForm.Dispose() - - if ($dialogResult -eq [System.Windows.Forms.DialogResult]::OK -and $chosenEdition) { - $script:SelectedWindowsEdition = $chosenEdition - $lblStatus.Text = "Édition sélectionnée : $($chosenEdition.Name) (index $($chosenEdition.Index))." - Write-Log "Edition selectionnee : $($chosenEdition.Name), index $($chosenEdition.Index), image $imagePath" 'SUCCESS' - $dlg.DialogResult = [System.Windows.Forms.DialogResult]::OK - $dlg.Close() - } else { - $lblStatus.Text = "$($editions.Count) édition(s) trouvée(s) dans $selectedName. Aucune nouvelle édition sélectionnée." - } - Write-Log "Analyse des editions terminee : $imagePath ($($editions.Count) edition(s))." 'SUCCESS' - } catch { - $lblStatus.Text = "Echec de l'analyse des editions de $selectedName." - Write-Log "Erreur analyse editions $selectedPath : $($_.Exception.Message)" 'ERROR' - Show-DeployCenterMessage -Message $_.Exception.Message -Title 'Analyse des éditions' -Icon Warning -Owner $dlg -Theme $script:Theme -Font $script:FontRegular - } finally { - $dlg.Cursor = [System.Windows.Forms.Cursors]::Default - $btnEditions.Enabled = $true - $btnScan.Enabled = $true - } - }) - - $list.Add_DoubleClick({ - if ($list.SelectedItems.Count -gt 0 -and $btnEditions.Enabled) { - $btnEditions.PerformClick() - } - }) - - $btnScan.Add_Click({ - $btnScan.Enabled = $false - $dlg.Cursor = [System.Windows.Forms.Cursors]::WaitCursor - $lblStatus.Text = 'Recherche en cours sur les chemins indiques...' - $sourceState.AllFound = @() - $list.Items.Clear() - try { - # La recherche ne modifie jamais DeployCenter.ini. - $sourceState.AllFound = @(Find-WindowsSources -RootPath $txtPath.Text) - & $renderSourceList $txtFilter.Text - if (@($sourceState.AllFound).Count -eq 0) { - $lblStatus.Text = 'Aucune source ISO/WIM/ESD ou installation extraite detectee dans les chemins accessibles.' - Write-Log 'Aucune source Windows detectee dans les chemins accessibles.' 'WARNING' - } - } catch { - $lblStatus.Text = 'Echec de la recherche. Consulte le message.' - Write-Log "Erreur detection sources : $($_.Exception.Message)" 'ERROR' - Show-DeployCenterMessage -Message $_.Exception.Message -Title 'Détection des sources' -Icon Warning -Owner $dlg -Theme $script:Theme -Font $script:FontRegular - } finally { - $dlg.Cursor = [System.Windows.Forms.Cursors]::Default - $btnScan.Enabled = $true - } - }) - - Add-SessionFooter -TargetForm $dlg - [void]$dlg.ShowDialog($script:form) - $dlg.Dispose() -} # ============================================================ # INVENTAIRE MATERIEL # ============================================================ -function Update-HardwareView { - $script:btnRefresh.Enabled = $false - $script:form.Cursor = [System.Windows.Forms.Cursors]::WaitCursor - try { - if (-not (Test-Path $script:HardwareScript)) { - throw "Fichier HardwareInfo.ps1 introuvable : $script:HardwareScript" - } - . $script:HardwareScript - $info = Get-HardwareInfo - $script:lblManufacturer.Text = "Fabricant : $($info.Manufacturer)" - $script:lblModel.Text = "Modèle : $($info.Model)" - $script:lblSerial.Text = "Numéro de série : $($info.SerialNumber)" - $script:lblFirmware.Text = "Firmware : $($info.Firmware)" - $script:lblArchitecture.Text = "Architecture : $($info.Architecture)" - if ($info.WinPE) { - $script:lblWinPE.Text = "Environnement : WinPE" - $script:lblWinPE.ForeColor = $script:Theme.Success - } - else { - $script:lblWinPE.Text = "Environnement : Windows classique" - $script:lblWinPE.ForeColor = $script:Theme.Secondary - } - $script:HardwareInventory = $info - $previousDiskIndex = $null - if ($null -ne $script:cmbDisk.SelectedItem -and $null -ne $script:cmbDisk.SelectedItem.Tag) { - $previousDiskIndex = [string]$script:cmbDisk.SelectedItem.Tag - } - $script:RefreshingDiskList = $true - $script:cmbDisk.BeginUpdate() - try { - $script:cmbDisk.Items.Clear() - foreach ($disk in $info.Disks) { - $diskLabel = "Disque {0} - {1} - {2:N1} Go - {3} - {4}" -f $disk.Index, $disk.Model, $disk.SizeGB, $disk.BusType, $disk.Status - $diskChoice = [pscustomobject]@{ Text = $diskLabel; Tag = [string]$disk.Index } - [void]$script:cmbDisk.Items.Add($diskChoice) - } - $script:cmbDisk.DisplayMember = 'Text' - if ($script:cmbDisk.Items.Count -gt 0) { - $selectedIndex = -1 - if ($null -ne $previousDiskIndex) { - for ($i = 0; $i -lt $script:cmbDisk.Items.Count; $i++) { - if ([string]$script:cmbDisk.Items[$i].Tag -eq $previousDiskIndex) { $selectedIndex = $i; break } - } - } - if ($selectedIndex -lt 0 -and $script:cmbDisk.Items.Count -eq 1) { $selectedIndex = 0 } - $script:cmbDisk.SelectedIndex = $selectedIndex - } - } - finally { - $script:cmbDisk.EndUpdate() - $script:RefreshingDiskList = $false - } - $script:SelectedDiskIndex = if ($script:cmbDisk.SelectedIndex -ge 0) { [string]$script:cmbDisk.SelectedItem.Tag } else { $null } - Write-DeployInventory -HardwareInfo $info - Write-Log "Inventaire materiel actualise." "SUCCESS" - - foreach ($errorMessage in $info.Errors) { - Write-Log $errorMessage "WARNING" - } - - if ($info.Disks.Count -eq 0) { - Write-Log "Aucun disque physique detecte." "WARNING" - } - } - catch { - Write-Log "Erreur inventaire : $($_.Exception.Message)" "ERROR" - - Show-DeployCenterMessage -Message $_.Exception.Message -Title 'Erreur DeployCenter' -Icon Error -Owner $script:form -Theme $script:Theme -Font $script:FontRegular - } - finally { - $script:btnRefresh.Enabled = $true - $script:form.Cursor = [System.Windows.Forms.Cursors]::Default - } -} - -function Get-SelectedDeployDiskInfo { - if ($null -eq $script:cmbDisk.SelectedItem -or $null -eq $script:HardwareInventory) { return $null } - $diskIndex = [int]$script:cmbDisk.SelectedItem.Tag - $matches = @($script:HardwareInventory.Disks | Where-Object { [int]$_.Index -eq $diskIndex } | Select-Object -First 1) - if ($matches.Count -gt 0) { return $matches[0] } - return $null -} - -function Get-PartitionProfileSizingSelection { - param( - [Parameter(Mandatory=$true)][System.Collections.IDictionary]$Profile, - [Parameter(Mandatory=$true)]$DiskInfo, - [System.Collections.IDictionary]$InitialValues = @{} - ) - - $capacity = Get-DeployCenterPartitionSizingCapacity -Profile $Profile -DiskSizeBytes ([long]$DiskInfo.SizeBytes) - return Show-DeployCenterPartitionSizingDialog ` - -Profile $Profile ` - -AvailableGB $capacity.UsableGB ` - -Theme $script:Theme ` - -Font $script:FontRegular ` - -Owner $script:form ` - -InitialValues $InitialValues -} - -function Set-PartitionProfileSizingSelection { - param( - [Parameter(Mandatory=$true)][System.Collections.IDictionary]$Profile, - [Parameter(Mandatory=$true)]$DiskInfo, - [System.Collections.IDictionary]$InitialValues = @{} - ) - - $selection = Get-PartitionProfileSizingSelection -Profile $Profile -DiskInfo $DiskInfo -InitialValues $InitialValues - if ($null -eq $selection) { return $false } - $script:SelectedPartitionSizing = [pscustomobject]@{ - ProfileId = [string]$Profile.Id - DiskIndex = [int]$DiskInfo.Index - Inputs = $selection.Inputs - RemainderRole = $selection.RemainderRole - RemainderGB = [double]$selection.RemainderGB - AvailableGB = [double]$selection.AvailableGB - } - return $true -} # ============================================================ # MISE EN PAGE ADAPTATIVE # Les grandes zones suivent la fenetre ; les marges et hauteurs de controles restent fixes. # ============================================================ -function Update-DeployCenterLayout { - if ($null -eq $script:form -or $script:form.IsDisposed) { return } - $w = $script:form.ClientSize.Width - $h = $script:form.ClientSize.Height - $m = 24 - $contentWidth = [Math]::Max(300, $w - (2 * $m)) - # En-tete : le selecteur de theme reste accroche au bord droit. - if ($null -ne $script:cmbTheme) { - $script:cmbTheme.Location = New-Object System.Drawing.Point([Math]::Max($m, $w - $m - $script:cmbTheme.Width), 25) - } - - # Les deux panneaux superieurs conservent leur hauteur, mais s'etirent en largeur. - $script:groupPC.Location = New-Object System.Drawing.Point($m, 100) - $script:groupPC.Size = New-Object System.Drawing.Size($contentWidth, 105) - $script:groupDisks.Location = New-Object System.Drawing.Point($m, 220) - $script:groupDisks.Size = New-Object System.Drawing.Size($contentWidth, 226) - Set-DiskSelectorWidth - - $footerHeight = 26 - $buttonHeightLocal = 38 - $buttonYLocal = $h - $footerHeight - 14 - $buttonHeightLocal - - $buttonWidthLocal = 140 - $buttonGapLocal = 12 - $script:btnClose.Size = New-Object System.Drawing.Size($buttonWidthLocal, $buttonHeightLocal) - $script:btnRefresh.Size = New-Object System.Drawing.Size($buttonWidthLocal, $buttonHeightLocal) - $script:btnLog.Size = New-Object System.Drawing.Size($buttonWidthLocal, $buttonHeightLocal) - $script:btnClose.Location = New-Object System.Drawing.Point([Math]::Max($m, $w - $m - $buttonWidthLocal), $buttonYLocal) - $script:btnRefresh.Location = New-Object System.Drawing.Point([Math]::Max($m, $script:btnClose.Left - $buttonGapLocal - $buttonWidthLocal), $buttonYLocal) - $script:btnLog.Location = New-Object System.Drawing.Point($m, $buttonYLocal) - Set-DiskSelectorWidth -} # ============================================================ # EVENEMENTS @@ -1838,6 +598,100 @@ $script:btnRefresh.Add_Click({ Update-HardwareView }) +$script:btnDeploy.Add_Click({ + $diskIndex = $null + if ($null -ne $script:cmbDisk.SelectedItem) { $diskIndex = [int]$script:cmbDisk.SelectedItem.Tag } + $diskInfo = if ($null -ne $diskIndex) { Get-SelectedDeployDiskInfo -HardwareInfo $script:HardwareInventory -DiskIndex $diskIndex } else { $null } + $hostName = [string]$script:txtHostName.Text.Trim() + + if ($null -eq $diskInfo) { + Show-DeployCenterMessage -Message 'Sélectionne le disque de destination.' -Title 'Déploiement Windows' -Icon Warning -Owner $script:form -Theme $script:Theme -Font $script:FontRegular + return + } + if ($null -eq $script:SelectedWindowsEdition) { + Show-DeployCenterMessage -Message 'Sélectionne une édition Windows à installer.' -Title 'Déploiement Windows' -Icon Warning -Owner $script:form -Theme $script:Theme -Font $script:FontRegular + return + } + if ($hostName -notmatch '^(?=.*[A-Za-z])[A-Za-z0-9](?:[A-Za-z0-9-]{0,13}[A-Za-z0-9])?$') { + Show-DeployCenterMessage -Message "Le nom d’hôte doit contenir de 1 à 15 caractères, commencer et finir par une lettre ou un chiffre, et ne contenir que des lettres, chiffres ou tirets." -Title "Nom d’hôte invalide" -Icon Warning -Owner $script:form -Theme $script:Theme -Font $script:FontRegular + return + } + if ($script:SelectedPartitionSizing -and [int]$script:SelectedPartitionSizing.DiskIndex -ne [int]$diskInfo.Index) { + Show-DeployCenterMessage -Message 'La taille des partitions correspond à un autre disque. Sélectionne de nouveau le profil de partitionnement.' -Title 'Partitionnement' -Icon Warning -Owner $script:form -Theme $script:Theme -Font $script:FontRegular + return + } + + $sizeDescription = 'Tailles définies par le profil' + if ($script:SelectedPartitionSizing -and $script:SelectedPartitionSizing.Inputs.Count -gt 0) { + $sizeParts = @() + foreach ($role in $script:SelectedPartitionSizing.Inputs.Keys) { + $sizeParts += ("{0} : {1} Go" -f $role, $script:SelectedPartitionSizing.Inputs[$role]) + } + $sizeParts += ("espace restant : {0:N1} Go" -f $script:SelectedPartitionSizing.RemainderGB) + $sizeDescription = $sizeParts -join ', ' + } + $confirmation = Show-DeployCenterConfirmation ` + -Title "Confirmer l’effacement du disque" ` + -Message ("Disque {0} — {1} ({2:N1} Go)`r`nProfil : {3}`r`nÉdition : {4}`r`nTailles : {5}`r`nNom d’hôte : {6}`r`nTout le contenu du disque sera effacé. Cette opération est irréversible." -f $diskInfo.Index, $diskInfo.Model, $diskInfo.SizeGB, $script:SelectedPartitionProfile.Name, $script:SelectedWindowsEdition.Name, $sizeDescription, $hostName) ` + -Owner $script:form -Theme $script:Theme -Font $script:FontRegular + if (-not $confirmation) { return } + + $progress = New-DeployCenterDeploymentProgress -Theme $script:Theme -Font $script:FontRegular + $script:form.Enabled = $false + try { + $progress.Form.Show($script:form) + [System.Windows.Forms.Application]::DoEvents() + $deploymentLogAction = $script:SourceLogAction + $deploymentProgressMessage = 'Préparation du déploiement…' + $deploymentProgressAction = { + param($Message, $Percent) + if ($null -ne $Message) { + $deploymentProgressMessage = [string]$Message + if ($null -eq $Percent) { + $progress.ProgressBar.Style = [System.Windows.Forms.ProgressBarStyle]::Marquee + } + if ($deploymentLogAction -is [scriptblock]) { + & $deploymentLogAction ([string]$Message) 'INFO' + } + } + if ($null -ne $Percent) { + if ($progress.ProgressBar.Style -ne [System.Windows.Forms.ProgressBarStyle]::Continuous) { + $progress.ProgressBar.Style = [System.Windows.Forms.ProgressBarStyle]::Continuous + } + $progress.ProgressBar.Value = [int][Math]::Max(0, [Math]::Min(100, $Percent)) + $progress.StatusLabel.Text = '{0} ({1} %)' -f $deploymentProgressMessage, $progress.ProgressBar.Value + } elseif ($null -ne $Message) { + $progress.StatusLabel.Text = $deploymentProgressMessage + } + [System.Windows.Forms.Application]::DoEvents() + }.GetNewClosure() + + Invoke-DeployCenterWindowsDeployment ` + -DiskInfo $diskInfo ` + -Profile $script:SelectedPartitionProfile ` + -SizingState $script:SelectedPartitionSizing ` + -Edition $script:SelectedWindowsEdition ` + -HostName $hostName ` + -Firmware $script:HardwareInventory.Firmware ` + -TemporaryRoot $script:InventoryDir ` + -LogAction $script:SourceLogAction ` + -ProgressAction $deploymentProgressAction + + $progress.Form.Close() + $script:form.Enabled = $true + Show-DeployCenterMessage -Message 'Windows a été appliqué. Le déploiement est prêt à être finalisé par le redémarrage de WinPE.' -Title 'Déploiement terminé' -Icon Information -Owner $script:form -Theme $script:Theme -Font $script:FontRegular + } catch { + if (-not $progress.Form.IsDisposed) { $progress.Form.Close() } + $script:form.Enabled = $true + & $script:SourceLogAction "Échec du déploiement : $($_.Exception.Message)" 'ERROR' + Show-DeployCenterMessage -Message $_.Exception.Message -Title 'Échec du déploiement' -Icon Error -Owner $script:form -Theme $script:Theme -Font $script:FontRegular + } finally { + if (-not $progress.Form.IsDisposed) { $progress.Form.Dispose() } + $script:form.Enabled = $true + $script:form.Activate() + } +}) + $script:btnClose.Add_Click({ $script:form.Close() }) @@ -1859,7 +713,8 @@ $script:cmbPartitionProfile.Add_SelectedIndexChanged({ $candidateProfile = $script:PartitionProfiles[$selectedIndex] $sizeInputs = @($candidateProfile.Partitions | Where-Object { $_.Size.Policy -eq 'UserInputGB' }) if ($sizeInputs.Count -gt 0) { - $diskInfo = Get-SelectedDeployDiskInfo + $selectedDiskTag = if ($null -ne $script:cmbDisk.SelectedItem) { [int]$script:cmbDisk.SelectedItem.Tag } else { $null } + $diskInfo = if ($null -ne $selectedDiskTag) { Get-SelectedDeployDiskInfo -HardwareInfo $script:HardwareInventory -DiskIndex $selectedDiskTag } else { $null } $initialValues = @{} if ($script:SelectedPartitionSizing -and $script:SelectedPartitionSizing.ProfileId -eq $candidateProfile.Id) { $initialValues = $script:SelectedPartitionSizing.Inputs @@ -1879,10 +734,10 @@ $script:cmbPartitionProfile.Add_SelectedIndexChanged({ $script:SelectedPartitionProfileIndex = $selectedIndex $script:SelectedPartitionProfile = $candidateProfile - Write-Log "Profil de partitionnement sélectionné : $($candidateProfile.Name)" 'INFO' + & $script:SourceLogAction "Profil de partitionnement sélectionné : $($candidateProfile.Name)" 'INFO' if ($script:SelectedPartitionSizing) { foreach ($role in $script:SelectedPartitionSizing.Inputs.Keys) { - Write-Log "Taille saisie pour $role : $($script:SelectedPartitionSizing.Inputs[$role]) Go ; espace restant : $($script:SelectedPartitionSizing.RemainderGB) Go" 'INFO' + & $script:SourceLogAction "Taille saisie pour $role : $($script:SelectedPartitionSizing.Inputs[$role]) Go ; espace restant : $($script:SelectedPartitionSizing.RemainderGB) Go" 'INFO' } } } @@ -1890,7 +745,8 @@ $script:cmbPartitionProfile.Add_SelectedIndexChanged({ $script:cmbDisk.Add_SelectedIndexChanged({ if ($script:RefreshingDiskList -or $script:ChangingPartitionProfileSelection) { return } - $diskInfo = Get-SelectedDeployDiskInfo + $selectedDiskTag = if ($null -ne $script:cmbDisk.SelectedItem) { [int]$script:cmbDisk.SelectedItem.Tag } else { $null } + $diskInfo = if ($null -ne $selectedDiskTag) { Get-SelectedDeployDiskInfo -HardwareInfo $script:HardwareInventory -DiskIndex $selectedDiskTag } else { $null } if ($null -eq $diskInfo) { $script:SelectedDiskIndex = $null; return } $profileSizeInputs = @($script:SelectedPartitionProfile.Partitions | Where-Object { $_.Size.Policy -eq 'UserInputGB' }) @@ -1930,9 +786,9 @@ $script:form.Add_Shown({ # Use the real maximized client width to position the theme button. Update-DeployCenterLayout $script:cmbTheme.BringToFront() - Write-Log "DeployCenter demarre." - Write-Log "Dossier : $script:DeployRoot" - Write-Log "Journal : $script:LogFile" + & $script:SourceLogAction "DeployCenter demarre." + & $script:SourceLogAction "Dossier : $script:DeployRoot" + & $script:SourceLogAction "Journal : $script:LogFile" Update-HardwareView }) @@ -1956,6 +812,11 @@ try { [void]$script:form.ShowDialog() } finally { + if ($script:SelectedWindowsEdition -and $script:SelectedWindowsEdition.MountedImage) { + try { [void](Dismount-DeployCenterInstallImage -Image $script:SelectedWindowsEdition) } catch { + & $script:SourceLogAction "Impossible de démonter l'ISO à la fermeture : $($_.Exception.Message)" 'WARNING' + } + } if ($script:EnableVnc -and (Get-Command Stop-DeployCenterVnc -ErrorAction SilentlyContinue)) { Stop-DeployCenterVnc } diff --git a/HardwareInfo.ps1 b/HardwareInfo.ps1 index 48e79eb..d6534e6 100644 --- a/HardwareInfo.ps1 +++ b/HardwareInfo.ps1 @@ -1,101 +1,4 @@ - -# HardwareInfo.ps1 -# DeployCenter - Inventaire matériel -# Compatible Windows PowerShell 5.1 / WinPE - -function Get-HardwareInfo { - [CmdletBinding()] - param() - - $info = [ordered]@{ - Manufacturer = "Inconnu" - Model = "Inconnu" - SerialNumber = "Inconnu" - Firmware = "Inconnu" - Architecture = $env:PROCESSOR_ARCHITECTURE - WinPE = $false - Disks = @() - Errors = @() - } - - # Fabricant et modele du PC - try { - $computer = Get-CimInstance ` - -ClassName Win32_ComputerSystem ` - -ErrorAction Stop - - if ($computer.Manufacturer) { - $info.Manufacturer = $computer.Manufacturer.Trim() - } - - if ($computer.Model) { - $info.Model = $computer.Model.Trim() - } - } - catch { - $info.Errors += "Modele du PC : $($_.Exception.Message)" - } - - # Numero de serie - try { - $bios = Get-CimInstance ` - -ClassName Win32_BIOS ` - -ErrorAction Stop - - if ($bios.SerialNumber) { - $info.SerialNumber = $bios.SerialNumber.Trim() - } - } - catch { - $info.Errors += "Numero de serie : $($_.Exception.Message)" - } - - # Detection du firmware - try { - $firmwareKey = Get-ItemProperty ` - -Path "HKLM:\SYSTEM\CurrentControlSet\Control" ` - -Name PEFirmwareType ` - -ErrorAction Stop - - switch ([int]$firmwareKey.PEFirmwareType) { - 1 { $info.Firmware = "BIOS (Legacy)" } - 2 { $info.Firmware = "UEFI" } - default { $info.Firmware = "Indetermine" } - } - } - catch { - $info.Errors += "Mode firmware indisponible" - } - - # Detection de WinPE - $info.WinPE = Test-Path ` - "HKLM:\SYSTEM\CurrentControlSet\Control\MiniNT" - - # Inventaire des disques physiques - 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' } - - $info.Disks += [PSCustomObject]@{ - Index = [int]$disk.Number - Model = [string]$disk.FriendlyName - SizeBytes = [long]$disk.Size - SizeGB = [math]::Round( - ([double]$disk.Size / 1GB), 1 - ) - BusType = [string]$disk.BusType - Status = $status - HealthStatus = [string]$disk.HealthStatus - } - } - } - catch { - $info.Errors += "Inventaire des disques : $($_.Exception.Message)" - } - - # Retourner un objet exploitable par les autres modules - [PSCustomObject]$info -} +# Point d'entrée historique conservé pour les appels directs à HardwareInfo.ps1. +$hardwareInventoryModule = Join-Path $PSScriptRoot 'Modules\HardwareInventory.psm1' +Import-Module $hardwareInventoryModule -Force +Get-HardwareInfo diff --git a/Modules/Configuration.psm1 b/Modules/Configuration.psm1 new file mode 100644 index 0000000..df98ed9 --- /dev/null +++ b/Modules/Configuration.psm1 @@ -0,0 +1,42 @@ +# DeployCenter - Lecture des préférences utilisateur +# Lecture seule : ce module ne modifie jamais DeployCenter.ini. + +function Read-DeployCenterConfig { + [CmdletBinding()] + param([Parameter(Mandatory=$true)][string]$ConfigPath) + + $config = @{ + Theme = 'Light' + SourcePaths = '%DEPLOY_VOLUME%' + VncPassword = '' + WinRESizeMB = 1024 + } + if (-not (Test-Path -LiteralPath $ConfigPath -PathType Leaf)) { return $config } + + $section = '' + foreach ($line in (Get-Content -LiteralPath $ConfigPath -ErrorAction SilentlyContinue)) { + $trimmed = $line.Trim() + if (-not $trimmed -or $trimmed.StartsWith(';') -or $trimmed.StartsWith('#')) { continue } + if ($trimmed -match '^\[(.+)\]$') { $section = $matches[1].Trim(); continue } + if ($trimmed -notmatch '^([^=]+)=(.*)$') { continue } + + $key = $matches[1].Trim() + $value = $matches[2].Trim() + if ($section -ieq 'General' -and $key -ieq 'Theme' -and $value -match '^[A-Za-z0-9_-]+$') { + $config.Theme = $value + } elseif ($section -ieq 'Sources' -and $key -ieq 'Paths') { + $config.SourcePaths = $value + } elseif ($section -ieq 'UltraVNC' -and $key -ieq 'Password') { + $config.VncPassword = $value + } elseif ($section -ieq 'Partitioning' -and $key -ieq 'WinRESizeMB') { + $sizeMB = 0 + if (-not [int]::TryParse($value, [ref]$sizeMB) -or $sizeMB -lt 990) { + throw "Le paramètre [Partitioning] WinRESizeMB doit être un entier supérieur ou égal à 990. Valeur reçue : '$value'." + } + $config.WinRESizeMB = $sizeMB + } + } + return $config +} + +Export-ModuleMember -Function Read-DeployCenterConfig diff --git a/Modules/Deployment.psm1 b/Modules/Deployment.psm1 new file mode 100644 index 0000000..efd0dd1 --- /dev/null +++ b/Modules/Deployment.psm1 @@ -0,0 +1,577 @@ +# DeployCenter - Partitionnement et installation d'une edition Windows +# Compatible Windows PowerShell 5.1 / WinPE. + +function Write-DeployCenterDeploymentLog { + param([AllowNull()][scriptblock]$LogAction, [string]$Message, [string]$Level = 'INFO') + if ($LogAction) { & $LogAction $Message $Level } +} + +function ConvertTo-DeployCenterNativeArgument { + param([AllowNull()][string]$Value) + + if ($null -eq $Value) { $Value = '' } + if ($Value.Length -gt 0 -and $Value -notmatch '[\s"]') { return $Value } + + $builder = New-Object System.Text.StringBuilder + [void]$builder.Append('"') + $backslashCount = 0 + foreach ($character in $Value.ToCharArray()) { + if ($character -eq [char]92) { + $backslashCount++ + continue + } + if ($character -eq [char]34) { + if ($backslashCount -gt 0) { [void]$builder.Append(('\' * (2 * $backslashCount))) } + [void]$builder.Append('\"') + $backslashCount = 0 + continue + } + if ($backslashCount -gt 0) { [void]$builder.Append(('\' * $backslashCount)) } + [void]$builder.Append($character) + $backslashCount = 0 + } + if ($backslashCount -gt 0) { [void]$builder.Append(('\' * (2 * $backslashCount))) } + [void]$builder.Append('"') + return $builder.ToString() +} + +function New-DeployCenterDismSourceAccess { + param( + [Parameter(Mandatory=$true)][string]$ImagePath, + [Parameter(Mandatory=$true)][string]$TemporaryRoot, + [AllowNull()][scriptblock]$LogAction = $null + ) + + $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" } + $volumeRoot = $ImagePath.Substring(0, $volumeEnd + 2) + $relativePath = $ImagePath.Substring($volumeEnd + 2) + + $usedLetters = @{} + foreach ($volume in @(Get-Volume -ErrorAction SilentlyContinue)) { + if ($volume.DriveLetter) { $usedLetters[([string]$volume.DriveLetter).ToUpperInvariant()] = $true } + } + foreach ($drive in @(Get-PSDrive -PSProvider FileSystem -ErrorAction SilentlyContinue)) { + $usedLetters[([string]$drive.Name).ToUpperInvariant()] = $true + } + $letter = $null + foreach ($candidate in @('Z','Y','X','W','V','U','T','S','R','Q','P','O','N','M','L','K','J','I','H','G','F','E')) { + if ($candidate -notin @('C','D') -and -not $usedLetters.ContainsKey($candidate)) { + $letter = $candidate + break + } + } + if (-not $letter) { throw 'Aucune lettre temporaire disponible pour accéder à la source Windows.' } + + $mountVolPath = Join-Path $env:WINDIR 'System32\mountvol.exe' + if (-not (Test-Path -LiteralPath $mountVolPath -PathType Leaf)) { $mountVolPath = 'mountvol.exe' } + $letterPath = ("{0}:\{1}" -f $letter, $relativePath) + $mountError = $null + try { + Invoke-DeployCenterNativeCommand -FilePath $mountVolPath -Arguments @(("{0}:\" -f $letter), $volumeRoot) ` + -TemporaryRoot $TemporaryRoot -LogAction $LogAction + } 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 + } catch { } + throw "La source Windows n’est pas accessible après l’affectation temporaire de $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) ` + -Level 'WARNING' + } + $lease.ImagePath = $letterPath + $lease.DriveLetter = $letter + $lease.VolumeRoot = $volumeRoot + return $lease +} + +function Remove-DeployCenterDismSourceAccess { + param( + [AllowNull()]$Lease, + [Parameter(Mandatory=$true)][string]$TemporaryRoot, + [AllowNull()][scriptblock]$LogAction = $null + ) + if ($null -eq $Lease -or -not $Lease.DriveLetter) { return } + $mountVolPath = Join-Path $env:WINDIR 'System32\mountvol.exe' + if (-not (Test-Path -LiteralPath $mountVolPath -PathType Leaf)) { $mountVolPath = 'mountvol.exe' } + $releaseError = $null + try { + Invoke-DeployCenterNativeCommand -FilePath $mountVolPath -Arguments @(("{0}:\" -f $Lease.DriveLetter), '/D') ` + -TemporaryRoot $TemporaryRoot -LogAction $LogAction + } catch { + $releaseError = $_.Exception + } + if (Test-Path -LiteralPath ([string]$Lease.ImagePath) -PathType Leaf) { + $details = if ($releaseError) { $releaseError.Message } else { 'Le fichier source reste accessible.' } + Write-DeployCenterDeploymentLog -LogAction $LogAction ` + -Message "Impossible de confirmer la libération de la lettre temporaire $($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) ` + -Level 'WARNING' + } +} + +function Get-DeployCenterNativeProgressPercent { + param([Parameter(Mandatory=$true)][string]$OutputPath) + + $stream = $null + $reader = $null + try { + $stream = [System.IO.File]::Open($OutputPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) + $reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::Default, $true) + $content = $reader.ReadToEnd() + } catch { + return $null + } finally { + if ($reader) { $reader.Dispose() } + elseif ($stream) { $stream.Dispose() } + } + + $progressMatches = [regex]::Matches($content, '(?\d{1,3}(?:\.\d+)?)\s*%') + if ($progressMatches.Count -eq 0) { return $null } + $value = [double]$progressMatches[$progressMatches.Count - 1].Groups['percent'].Value + return [int][math]::Max(0, [math]::Min(100, [math]::Floor($value))) +} + +function Invoke-DeployCenterNativeCommand { + param( + [Parameter(Mandatory=$true)][string]$FilePath, + [Parameter(Mandatory=$true)][string[]]$Arguments, + [Parameter(Mandatory=$true)][string]$TemporaryRoot, + [AllowNull()][scriptblock]$LogAction = $null, + [AllowNull()][scriptblock]$ProgressAction = $null + ) + + if (-not (Test-Path -LiteralPath $TemporaryRoot -PathType Container)) { + New-Item -Path $TemporaryRoot -ItemType Directory -Force -ErrorAction Stop | Out-Null + } + $id = [guid]::NewGuid().ToString('N') + $stdoutPath = Join-Path $TemporaryRoot ("native_{0}.out" -f $id) + $stderrPath = Join-Path $TemporaryRoot ("native_{0}.err" -f $id) + $dismLogPath = Join-Path $TemporaryRoot ("dism_{0}.log" -f $id) + $isDism = [IO.Path]::GetFileName($FilePath) -ieq 'dism.exe' + if ($isDism) { + $Arguments += "/LogPath:$dismLogPath" + } + $argumentLine = ($Arguments | ForEach-Object { + ConvertTo-DeployCenterNativeArgument -Value ([string]$_) + }) -join ' ' + + try { + Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ("Commande native : {0} {1}" -f $FilePath, $argumentLine) + $process = Start-Process -FilePath $FilePath -ArgumentList $argumentLine ` + -PassThru -WindowStyle Hidden -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath -ErrorAction Stop + while (-not $process.WaitForExit(250)) { + if ($ProgressAction) { + [System.Windows.Forms.Application]::DoEvents() + if ($isDism -and (Test-Path -LiteralPath $stdoutPath -PathType Leaf)) { + $nativePercent = Get-DeployCenterNativeProgressPercent -OutputPath $stdoutPath + if ($null -ne $nativePercent) { & $ProgressAction $null $nativePercent } + } + } + } + $process.WaitForExit() + $process.Refresh() + $exitCode = $process.ExitCode + $nativeOutputLines = New-Object System.Collections.Generic.List[string] + foreach ($outputPath in @($stdoutPath, $stderrPath)) { + if (Test-Path -LiteralPath $outputPath -PathType Leaf) { + foreach ($line in [System.IO.File]::ReadAllLines($outputPath)) { + if (-not [string]::IsNullOrWhiteSpace($line)) { + [void]$nativeOutputLines.Add([string]$line) + Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ([string]$line) -Level 'INFO' + } + } + } + } + if ($ProgressAction -and $isDism) { + $nativePercent = Get-DeployCenterNativeProgressPercent -OutputPath $stdoutPath + if ($null -ne $nativePercent) { & $ProgressAction $null $nativePercent } + } + if ($null -eq $exitCode -and $isDism -and @($nativeOutputLines | Where-Object { $_ -match '(?i)completed successfully|a r.ussi' }).Count -gt 0) { + Write-DeployCenterDeploymentLog -LogAction $LogAction ` + -Message 'DISM a confirmé la réussite de l’opération, mais WinPE n’a pas fourni de code de sortie exploitable.' ` + -Level 'WARNING' + return + } + if ($null -eq $exitCode -and [IO.Path]::GetFileName($FilePath) -ieq 'diskpart.exe') { + # Dans certains WinPE, Start-Process ne renseigne pas ExitCode même + # lorsque DiskPart a terminé. L’appelant vérifie ensuite l’état réel + # des partitions et des lettres avant de poursuivre. + Write-DeployCenterDeploymentLog -LogAction $LogAction ` + -Message "DiskPart n'a pas fourni de code de sortie exploitable ; le résultat sera confirmé par vérification du disque." ` + -Level 'WARNING' + return + } + if ($null -eq $exitCode -or [int]$exitCode -ne 0) { + $diagnostic = @() + if ($isDism -and (Test-Path -LiteralPath $dismLogPath -PathType Leaf)) { + $diagnostic = @(Get-Content -LiteralPath $dismLogPath -Tail 30 -ErrorAction SilentlyContinue | ForEach-Object { [string]$_ }) + foreach ($line in $diagnostic) { + Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ("DISM : {0}" -f $line) -Level 'ERROR' + } + } + throw "La commande '$FilePath' s'est terminée avec le code '$exitCode'. $(($diagnostic -join ' | '))" + } + } finally { + foreach ($outputPath in @($stdoutPath, $stderrPath)) { + if (Test-Path -LiteralPath $outputPath -PathType Leaf) { + Remove-Item -LiteralPath $outputPath -Force -ErrorAction SilentlyContinue + } + } + if (Test-Path -LiteralPath $dismLogPath -PathType Leaf) { + Remove-Item -LiteralPath $dismLogPath -Force -ErrorAction SilentlyContinue + } + } +} + +function Get-DeployCenterTemporaryDriveLetters { + param([Parameter(Mandatory=$true)][int]$DiskNumber, [Parameter(Mandatory=$true)]$Profile) + + $targetLetters = @() + try { + $targetLetters = @(Get-Partition -DiskNumber $DiskNumber -ErrorAction SilentlyContinue | + Where-Object { $_.DriveLetter } | ForEach-Object { ([string]$_.DriveLetter).ToUpperInvariant() }) + } catch { } + $occupied = @{} + foreach ($volume in @(Get-Volume -ErrorAction SilentlyContinue)) { + if (-not $volume.DriveLetter) { continue } + $letter = ([string]$volume.DriveLetter).ToUpperInvariant() + if ($targetLetters -contains $letter) { continue } + $occupied[$letter] = $true + } + + $lettersByRole = @{} + foreach ($partition in $Profile.Partitions) { + if ([string]$partition.Role -eq 'Reserved') { continue } + $preferred = [string]$partition.DriveLetter + if ([string]$partition.Role -eq 'System') { $preferred = 'S' } + if ([string]$partition.Role -eq 'Recovery') { $preferred = 'R' } + if ([string]::IsNullOrWhiteSpace($preferred)) { $preferred = 'W' } + $preferred = $preferred.ToUpperInvariant() + if (-not $occupied.ContainsKey($preferred)) { + $selected = $preferred + } else { + $selected = $null + foreach ($candidate in @('W','V','U','T','S','R','Q','P','O','N','M','L','K','J','I','H','G','F','E','D','C')) { + if (-not $occupied.ContainsKey($candidate) -and $candidate -notin $lettersByRole.Values) { + $selected = $candidate + break + } + } + if (-not $selected) { throw "Aucune lettre de lecteur libre pour la partition '$($partition.Name)'." } + } + if ($selected -in $lettersByRole.Values) { throw "La lettre $selected`: est affectée à plusieurs partitions du profil." } + $lettersByRole[[string]$partition.Role] = $selected + $occupied[$selected] = $true + } + return $lettersByRole +} + +function New-DeployCenterPartitionPlan { + param( + [Parameter(Mandatory=$true)]$DiskInfo, + [Parameter(Mandatory=$true)][System.Collections.IDictionary]$Profile, + [AllowNull()]$SizingState + ) + + if ([string]$Profile.PartitionStyle -ne 'GPT' -or [string]$Profile.Firmware -ne 'UEFI') { + throw "Le profil '$($Profile.Name)' n’est pas pris en charge par le moteur actuel (UEFI/GPT requis)." + } + $totalMB = [long][math]::Floor(([double]$DiskInfo.SizeBytes / 1MB)) + $availableMB = $totalMB - 2 + $usedMB = 0L + $plan = New-Object System.Collections.Generic.List[object] + foreach ($partition in $Profile.Partitions) { + $policy = [string]$partition.Size.Policy + $sizeMB = $null + switch ($policy) { + 'FixedMB' { $sizeMB = [long]$partition.Size.Value } + 'ConfiguredMB' { $sizeMB = [long]$partition.Size.Value } + 'UserInputGB' { + if ($null -eq $SizingState -or [string]$SizingState.ProfileId -ne [string]$Profile.Id) { + throw "Le profil '$($Profile.Name)' nécessite une taille utilisateur, qui n’a pas été validée." + } + $role = [string]$partition.Role + if (-not $SizingState.Inputs.Contains($role)) { throw "La taille de la partition '$role' est absente." } + $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." } + } + { $_ -in @('Remaining','RemainderAfterInputs') } { + $sizeMB = $availableMB - $usedMB + if ($sizeMB -lt 1) { throw "Le disque ne dispose plus d’espace pour la 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." + } + } + default { throw "Politique de taille non prise en charge lors du partitionnement : $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é." } + return $plan.ToArray() +} + +function ConvertTo-DeployCenterUnattendXml { + param([Parameter(Mandatory=$true)][string]$HostName, [Parameter(Mandatory=$true)][string]$Architecture) + $processorArchitecture = switch -Regex ($Architecture.ToLowerInvariant()) { + 'arm64' { 'arm64'; break } + 'x86|32' { 'x86'; break } + default { 'amd64' } + } + return @" + + + + + $HostName + + + +"@ +} + +function Invoke-DeployCenterWindowsDeployment { + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)]$DiskInfo, + [Parameter(Mandatory=$true)][System.Collections.IDictionary]$Profile, + [AllowNull()]$SizingState, + [Parameter(Mandatory=$true)]$Edition, + [Parameter(Mandatory=$true)][string]$HostName, + [Parameter(Mandatory=$true)][string]$Firmware, + [Parameter(Mandatory=$true)][string]$TemporaryRoot, + [AllowNull()][scriptblock]$LogAction = $null, + [AllowNull()][scriptblock]$ProgressAction = $null + ) + + if ($HostName -notmatch '^(?=.*[A-Za-z])[A-Za-z0-9](?:[A-Za-z0-9-]{0,13}[A-Za-z0-9])?$') { + throw "Le nom d'hôte doit respecter les règles NetBIOS et ne pas dépasser 15 caractères." + } + if ([string]$Firmware -ne [string]$Profile.Firmware) { + throw "Le profil '$($Profile.Name)' exige le firmware $($Profile.Firmware), détecté : $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é." } + } + $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é." + } + $winrePartitionSizeMB = [long]$recoveryDefinitions[0].Size.Value + if ($winrePartitionSizeMB -lt 990) { + throw "La partition WinRE doit être configurée à au moins 990 Mo. Le disque n’a pas été modifié." + } + 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." + } + if (-not $Edition.Index -or [int]$Edition.Index -lt 1) { throw "L’index de l’édition Windows est invalide." } + + $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.IsOffline) { [void](Set-Disk -Number $disk.Number -IsOffline $false -ErrorAction Stop) } + if ($disk.IsReadOnly) { [void](Set-Disk -Number $disk.Number -IsReadOnly $false -ErrorAction Stop) } + + if (-not (Test-Path -LiteralPath $TemporaryRoot -PathType Container)) { + New-Item -Path $TemporaryRoot -ItemType Directory -Force -ErrorAction Stop | Out-Null + } + $sourceLease = New-DeployCenterDismSourceAccess -ImagePath ([string]$Edition.ImagePath) ` + -TemporaryRoot $TemporaryRoot -LogAction $LogAction + try { + $deploymentImagePath = [string]$sourceLease.ImagePath + $partitionSizingDisk = [pscustomobject]@{ Index = [int]$disk.Number; SizeBytes = [long]$disk.Size } + $partitionPlan = @(New-DeployCenterPartitionPlan -DiskInfo $partitionSizingDisk -Profile $Profile -SizingState $SizingState) + $driveLetters = Get-DeployCenterTemporaryDriveLetters -DiskNumber ([int]$disk.Number) -Profile $Profile + + $dismPath = Join-Path $env:WINDIR 'System32\dism.exe' + if (-not (Test-Path -LiteralPath $dismPath -PathType Leaf)) { $dismPath = 'dism.exe' } + $bcdbootPath = Join-Path $env:WINDIR 'System32\bcdboot.exe' + if (-not (Test-Path -LiteralPath $bcdbootPath -PathType Leaf)) { $bcdbootPath = 'bcdboot.exe' } + $reagentcPath = Join-Path $env:WINDIR 'System32\reagentc.exe' + if (-not (Test-Path -LiteralPath $reagentcPath -PathType Leaf)) { $reagentcPath = 'reagentc.exe' } + + $scriptLines = New-Object System.Collections.Generic.List[string] + $scriptLines.Add("select disk $($disk.Number)") + $scriptLines.Add('clean') + $scriptLines.Add('convert gpt') + foreach ($item in $partitionPlan) { + $definition = $item.Definition + $role = [string]$definition.Role + switch ($role) { + 'System' { $scriptLines.Add("create partition efi size=$($item.SizeMB)") } + 'Reserved' { $scriptLines.Add("create partition msr size=$($item.SizeMB)") } + default { + if ($null -eq $item.SizeMB -or [long]$item.SizeMB -ge ([long][math]::Floor(([double]$disk.Size / 1MB)) - 2)) { + $scriptLines.Add('create partition primary') + } else { + $scriptLines.Add("create partition primary size=$($item.SizeMB)") + } + } + } + if ($role -eq 'Reserved') { continue } + $label = [string]$definition.Name + if ($label -notmatch '^[A-Za-z0-9_-]{1,32}$') { throw "Nom de partition invalide dans le profil : $label" } + if ([string]$definition.FileSystem) { + $scriptLines.Add("format fs=$($definition.FileSystem) quick label=$label") + } + if ($definition.GptTypeId) { $scriptLines.Add("set id=$($definition.GptTypeId)") } + if ($definition.GptAttributes) { $scriptLines.Add("gpt attributes=$($definition.GptAttributes)") } + if ($driveLetters.ContainsKey($role)) { $scriptLines.Add("assign letter=$($driveLetters[$role])") } + } + + $diskpartPath = Join-Path $TemporaryRoot ("partition_{0}.txt" -f ([guid]::NewGuid().ToString('N'))) + $unattendPath = Join-Path $TemporaryRoot ("unattend_{0}.xml" -f ([guid]::NewGuid().ToString('N'))) + $partitionCreated = $false + $lettersReleased = $false + try { + [System.IO.File]::WriteAllLines($diskpartPath, $scriptLines.ToArray(), [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) + $partitionCreated = $true + Invoke-DeployCenterNativeCommand -FilePath 'diskpart.exe' -Arguments @('/s', $diskpartPath) ` + -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction + $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." + } + $orderedCreatedPartitions = @($createdPartitions | Sort-Object -Property PartitionNumber) + $partitionsByLetter = @{} + for ($index = 0; $index -lt $partitionPlan.Count; $index++) { + $role = [string]$partitionPlan[$index].Definition.Role + if ($role -eq 'Reserved') { continue } + $letter = [string]$driveLetters[$role] + $driveRoot = '{0}:\' -f $letter + if (-not (Test-Path -LiteralPath $driveRoot -PathType Container)) { + throw "La lettre temporaire $letter`: n’est pas accessible après le partitionnement. L’installation est interrompue." + } + # 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. + $partitionsByLetter[$letter] = $orderedCreatedPartitions[$index] + } + + $windowsLetter = [string]$driveLetters['Windows'] + $windowsPath = "${windowsLetter}:\" + $windowsRoot = Join-Path $windowsPath 'Windows' + $recoveryLetter = [string]$driveLetters['Recovery'] + $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." + } + + if ($ProgressAction) { & $ProgressAction "Application de Windows sur $windowsLetter`:…" } + Invoke-DeployCenterNativeCommand -FilePath $dismPath -Arguments @( + '/Apply-Image', "/ImageFile:$deploymentImagePath", "/Index:$([int]$Edition.Index)", "/ApplyDir:$windowsPath", '/CheckIntegrity' + ) -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction + + if ($ProgressAction) { & $ProgressAction 'Vérification de la capacité de la partition WinRE…' } + $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." + } + $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." + } + $minimumPartitionBytes = [long](990 * 1MB) + $minimumFreeBytes = [long]($winreFile.Length + (250 * 1MB)) + $recoveryFreeBytes = [long]$recoveryDriveInfo.AvailableFreeSpace + $expectedFreeBytes = $recoveryFreeBytes - [long]$winreFile.Length + if ([long]$recoveryPartition.Size -lt $minimumPartitionBytes -or + $recoveryFreeBytes -lt $minimumFreeBytes) { + $winreSizeForLog = [math]::Ceiling(([double]$winreFile.Length / 1MB)) + $availableAfterCopyMB = [math]::Floor(([double]$expectedFreeBytes / 1MB)) + $message = "La partition WinRE configurée à $winrePartitionSizeMB Mo ne respecte pas les prérequis Microsoft : taille de winre.wim $winreSizeForLog Mo, espace libre requis après copie 250 Mo, estimé $availableAfterCopyMB Mo. L’installation est interrompue avant l’activation de WinRE." + Write-DeployCenterDeploymentLog -LogAction $LogAction -Message $message -Level 'ERROR' + throw $message + } + $winreSizeForLog = [math]::Ceiling(([double]$winreFile.Length / 1MB)) + $availableAfterCopyMB = [math]::Floor(([double]$expectedFreeBytes / 1MB)) + 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' + + if ($ProgressAction) { & $ProgressAction "Configuration du nom d’hôte…" } + $editionArchitecture = if ($Edition.Edition -and $Edition.Edition.Architecture) { [string]$Edition.Edition.Architecture } else { [string]$Edition.Architecture } + $unattendXml = ConvertTo-DeployCenterUnattendXml -HostName $HostName -Architecture $editionArchitecture + [System.IO.File]::WriteAllText($unattendPath, $unattendXml, (New-Object System.Text.UTF8Encoding($false))) + Invoke-DeployCenterNativeCommand -FilePath $dismPath -Arguments @( + "/Image:$windowsPath", "/Apply-Unattend:$unattendPath" + ) -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction + + if ($ProgressAction) { & $ProgressAction 'Configuration du démarrage UEFI…' } + 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…' } + New-Item -Path $recoveryPath -ItemType Directory -Force -ErrorAction Stop | Out-Null + Copy-Item -LiteralPath $sourceWinre -Destination (Join-Path $recoveryPath 'winre.wim') -Force -ErrorAction Stop + Invoke-DeployCenterNativeCommand -FilePath $reagentcPath -Arguments @( + '/SetReImage', '/Path', $recoveryPath, '/Target', $windowsRoot + ) -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction + Invoke-DeployCenterNativeCommand -FilePath $reagentcPath -Arguments @( + '/Enable', '/Target', $windowsRoot + ) -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction + + if ($ProgressAction) { & $ProgressAction 'Finalisation des partitions…' } + $removeLines = New-Object System.Collections.Generic.List[string] + foreach ($letter in @($driveLetters.Values | Select-Object -Unique)) { + $removeLines.Add("select volume $letter") + $removeLines.Add("remove letter=$letter") + } + $removePath = Join-Path $TemporaryRoot ("cleanup_{0}.txt" -f ([guid]::NewGuid().ToString('N'))) + try { + [System.IO.File]::WriteAllLines($removePath, $removeLines.ToArray(), [System.Text.Encoding]::ASCII) + Invoke-DeployCenterNativeCommand -FilePath 'diskpart.exe' -Arguments @('/s', $removePath) ` + -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction + $remainingLetters = @( + Get-Partition -DiskNumber ([int]$disk.Number) -ErrorAction Stop | + Where-Object { $_.DriveLetter -and ([string]$_.DriveLetter).ToUpperInvariant() -in @($driveLetters.Values | ForEach-Object { ([string]$_).ToUpperInvariant() }) } + ) + if ($remainingLetters.Count -gt 0) { + $remaining = @($remainingLetters | ForEach-Object { ([string]$_.DriveLetter).ToUpperInvariant() } | Select-Object -Unique) -join ', ' + throw "DiskPart n’a pas libéré toutes les lettres temporaires du disque : $remaining." + } + $lettersReleased = $true + } finally { + if (Test-Path -LiteralPath $removePath -PathType Leaf) { Remove-Item -LiteralPath $removePath -Force -ErrorAction SilentlyContinue } + } + Write-DeployCenterDeploymentLog -LogAction $LogAction -Message ("Déploiement terminé : Windows {0}, disque {1}, hôte {2}." -f $Edition.Name, $disk.Number, $HostName) -Level 'SUCCESS' + } finally { + if ($partitionCreated -and -not $lettersReleased) { + # En cas d’échec après création, libérer les lettres temporaires même si + # l’image n’a pas pu être entièrement déployée. + foreach ($letter in @($driveLetters.Values | Select-Object -Unique)) { + try { + $cleanup = Join-Path $TemporaryRoot ("release_{0}.txt" -f ([guid]::NewGuid().ToString('N'))) + [System.IO.File]::WriteAllLines($cleanup, @("select volume $letter", "remove letter=$letter"), [System.Text.Encoding]::ASCII) + Invoke-DeployCenterNativeCommand -FilePath 'diskpart.exe' -Arguments @('/s', $cleanup) ` + -TemporaryRoot $TemporaryRoot -LogAction $LogAction -ProgressAction $ProgressAction + Remove-Item -LiteralPath $cleanup -Force -ErrorAction SilentlyContinue + } catch { } + } + } + foreach ($temporaryFile in @($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 + } +} + +Export-ModuleMember -Function Invoke-DeployCenterWindowsDeployment diff --git a/Modules/HardwareInventory.psm1 b/Modules/HardwareInventory.psm1 new file mode 100644 index 0000000..83ee711 --- /dev/null +++ b/Modules/HardwareInventory.psm1 @@ -0,0 +1,151 @@ + + +function Get-HardwareInfo { + [CmdletBinding()] + param() + + $info = [ordered]@{ + Manufacturer = "Inconnu" + Model = "Inconnu" + SerialNumber = "Inconnu" + Firmware = "Inconnu" + Architecture = $env:PROCESSOR_ARCHITECTURE + WinPE = $false + Disks = @() + Errors = @() + } + + # Fabricant et modele du PC + try { + $computer = Get-CimInstance ` + -ClassName Win32_ComputerSystem ` + -ErrorAction Stop + + if ($computer.Manufacturer) { + $info.Manufacturer = $computer.Manufacturer.Trim() + } + + if ($computer.Model) { + $info.Model = $computer.Model.Trim() + } + } + catch { + $info.Errors += "Modele du PC : $($_.Exception.Message)" + } + + # Numero de serie + try { + $bios = Get-CimInstance ` + -ClassName Win32_BIOS ` + -ErrorAction Stop + + if ($bios.SerialNumber) { + $info.SerialNumber = $bios.SerialNumber.Trim() + } + } + catch { + $info.Errors += "Numero de serie : $($_.Exception.Message)" + } + + # Detection du firmware + try { + $firmwareKey = Get-ItemProperty ` + -Path "HKLM:\SYSTEM\CurrentControlSet\Control" ` + -Name PEFirmwareType ` + -ErrorAction Stop + + switch ([int]$firmwareKey.PEFirmwareType) { + 1 { $info.Firmware = "BIOS (Legacy)" } + 2 { $info.Firmware = "UEFI" } + default { $info.Firmware = "Indetermine" } + } + } + catch { + $info.Errors += "Mode firmware indisponible" + } + + # Detection de WinPE + $info.WinPE = Test-Path ` + "HKLM:\SYSTEM\CurrentControlSet\Control\MiniNT" + + # Inventaire des disques physiques + 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' } + + $info.Disks += [PSCustomObject]@{ + Index = [int]$disk.Number + Model = [string]$disk.FriendlyName + SizeBytes = [long]$disk.Size + SizeGB = [math]::Round( + ([double]$disk.Size / 1GB), 1 + ) + BusType = [string]$disk.BusType + Status = $status + HealthStatus = [string]$disk.HealthStatus + } + } + } + catch { + $info.Errors += "Inventaire des disques : $($_.Exception.Message)" + } + + # Retourner un objet exploitable par les autres modules + [PSCustomObject]$info +} + +function Write-DeployInventory { + param( + [Parameter(Mandatory=$true)][string]$InventoryDir, + [Parameter(Mandatory=$true)][string]$InventoryFile, + [AllowNull()]$HardwareInfo, + [AllowNull()][string]$DeploymentIPv4, + [AllowNull()][string]$VncPassword, + [AllowNull()][scriptblock]$LogAction = $null + ) + try { + if (-not (Test-Path -LiteralPath $InventoryDir -PathType Container)) { + New-Item -Path $InventoryDir -ItemType Directory -Force | Out-Null + } + $inventory = [ordered]@{ + GeneratedAt = (Get-Date).ToString('o') + DeploymentIPv4 = $DeploymentIPv4 + VncPassword = $VncPassword + Hardware = $HardwareInfo + } + $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é. + $legacyInventoryFiles = @( + (Join-Path $env:TEMP 'HardwareInfo.json'), + (Join-Path $InventoryDir 'hardware.json') + ) + foreach ($legacyFile in $legacyInventoryFiles) { + if (Test-Path -LiteralPath $legacyFile -PathType Leaf) { + try { + Remove-Item -LiteralPath $legacyFile -Force -ErrorAction Stop + } catch { + if ($LogAction) { & $LogAction "Impossible de supprimer l'ancien export d'inventaire '$legacyFile' : $($_.Exception.Message)" 'WARNING' } + } + } + } + } catch { + if ($LogAction) { & $LogAction "Impossible d'écrire l'inventaire temporaire : $($_.Exception.Message)" 'WARNING' } + } +} + +function Get-SelectedDeployDiskInfo { + param( + [AllowNull()]$HardwareInfo, + [Parameter(Mandatory=$true)][int]$DiskIndex + ) + if ($null -eq $HardwareInfo) { return $null } + $matches = @($HardwareInfo.Disks | Where-Object { [int]$_.Index -eq $DiskIndex } | Select-Object -First 1) + if ($matches.Count -gt 0) { return $matches[0] } + return $null +} +Export-ModuleMember -Function Get-HardwareInfo, Write-DeployInventory, Get-SelectedDeployDiskInfo diff --git a/Modules/Partitioning.psm1 b/Modules/Partitioning.psm1 index bbe5cb1..ceaa567 100644 --- a/Modules/Partitioning.psm1 +++ b/Modules/Partitioning.psm1 @@ -56,6 +56,12 @@ function Import-DeployCenterPartitionProfile { 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." } } + 'ConfiguredMB' { + if ([string]::IsNullOrWhiteSpace([string]$partition.Size.ConfigKey) -or + [int]$partition.Size.Value -lt 990 -or [int]$partition.Size.PreviewMB -lt 990) { + throw "La taille configurée de la partition '$($partition.Name)' du profil '$ProfileName' doit définir ConfigKey et une valeur d’au moins 990 Mo." + } + } '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." @@ -85,7 +91,7 @@ function Get-DeployCenterPartitionSizingCapacity { $reservedMB = 0.0 foreach ($partition in $Profile.Partitions) { switch ([string]$partition.Size.Policy) { - 'FixedMB' { $reservedMB += [double]$partition.Size.Value } + { $_ -in @('FixedMB','ConfiguredMB') } { $reservedMB += [double]$partition.Size.Value } default { if ($partition.Size.ContainsKey('PreviewMB')) { $reservedMB += [double]$partition.Size.PreviewMB } } @@ -101,4 +107,21 @@ function Get-DeployCenterPartitionSizingCapacity { } } -Export-ModuleMember -Function Get-DeployCenterPartitionProfileNames, Import-DeployCenterPartitionProfile, Get-DeployCenterPartitionSizingCapacity +function New-DeployCenterPartitionSizingState { + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)][System.Collections.IDictionary]$Profile, + [Parameter(Mandatory=$true)]$DiskInfo, + [Parameter(Mandatory=$true)][System.Collections.IDictionary]$Selection + ) + return [pscustomobject]@{ + ProfileId = [string]$Profile.Id + DiskIndex = [int]$DiskInfo.Index + Inputs = $Selection.Inputs + RemainderRole = [string]$Selection.RemainderRole + RemainderGB = [double]$Selection.RemainderGB + AvailableGB = [double]$Selection.AvailableGB + } +} + +Export-ModuleMember -Function Get-DeployCenterPartitionProfileNames, Import-DeployCenterPartitionProfile, Get-DeployCenterPartitionSizingCapacity, New-DeployCenterPartitionSizingState diff --git a/Modules/UI.psm1 b/Modules/UI.psm1 index 7167f01..3ecadf3 100644 --- a/Modules/UI.psm1 +++ b/Modules/UI.psm1 @@ -250,7 +250,9 @@ function Show-DeployCenterPartitionSizingDialog { $capacityLabel = New-Object System.Windows.Forms.Label $capacityLabel.Location = New-Object System.Drawing.Point(18, 16) $capacityLabel.Size = New-Object System.Drawing.Size(430, 34) - $capacityLabel.Text = "Capacité estimée après réservation EFI, MSR et WinRE : {0:N1} Go.`r`nLa taille WinRE exacte sera recalculée lors du partitionnement." -f $AvailableGB + $recoveryPartition = @($Profile.Partitions | Where-Object { [string]$_.Role -eq 'Recovery' }) | Select-Object -First 1 + $winreSizeMB = if ($recoveryPartition) { [int]$recoveryPartition.Size.Value } else { 1024 } + $capacityLabel.Text = "Capacité estimée après réservation EFI, MSR et WinRE : {0:N1} Go.`r`nTaille WinRE configurée : {1} Mo." -f $AvailableGB, $winreSizeMB $capacityLabel.ForeColor = $Theme.Text $capacityLabel.BackColor = [System.Drawing.Color]::Transparent $capacityLabel.Font = $Font @@ -451,4 +453,1067 @@ function Show-DeployCenterMessage { $messageForm.Dispose() } -Export-ModuleMember -Function Set-DeployCenterButtonStyle, New-DeployCenterComboBox, Set-DeployCenterComboBoxTheme, New-DeployCenterPartitionProfileSelector, Set-DeployCenterPartitionProfileSelectorLayout, New-DeployCenterDeploymentControls, Set-DeployCenterDeploymentControlsLayout, New-DeployCenterLogWindow, Show-DeployCenterPartitionSizingDialog, Show-DeployCenterMessage +function Show-DeployCenterConfirmation { + param( + [Parameter(Mandatory=$true)][string]$Title, + [Parameter(Mandatory=$true)][string]$Message, + [Parameter(Mandatory=$true)][System.Windows.Forms.Form]$Owner, + [Parameter(Mandatory=$true)][System.Collections.IDictionary]$Theme, + [Parameter(Mandatory=$true)][System.Drawing.Font]$Font + ) + + $dialog = New-Object System.Windows.Forms.Form + $dialog.Text = $Title + $dialog.StartPosition = 'CenterParent' + $dialog.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::FixedDialog + $dialog.ShowInTaskbar = $false + $dialog.MinimizeBox = $false + $dialog.MaximizeBox = $false + $dialog.ClientSize = New-Object System.Drawing.Size(540, 210) + $dialog.BackColor = $Theme.Background + $dialog.ForeColor = $Theme.Text + $dialog.Font = $Font + + $label = New-Object System.Windows.Forms.Label + $label.Location = New-Object System.Drawing.Point(18, 18) + $label.Size = New-Object System.Drawing.Size(504, 120) + $label.Text = $Message + $label.ForeColor = $Theme.Text + $label.BackColor = [System.Drawing.Color]::Transparent + $label.Font = $Font + $dialog.Controls.Add($label) + + $cancel = New-Object System.Windows.Forms.Button + $cancel.Text = 'Annuler' + $cancel.Location = New-Object System.Drawing.Point(310, 158) + $cancel.Size = New-Object System.Drawing.Size(100, 36) + $cancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel + Set-DeployCenterButtonStyle -Button $cancel -Theme $Theme -Role 'Button' + $dialog.Controls.Add($cancel) + + $confirm = New-Object System.Windows.Forms.Button + $confirm.Text = 'Effacer et installer' + $confirm.Location = New-Object System.Drawing.Point(420, 158) + $confirm.Size = New-Object System.Drawing.Size(102, 36) + $confirm.DialogResult = [System.Windows.Forms.DialogResult]::Yes + Set-DeployCenterButtonStyle -Button $confirm -Theme $Theme -Role 'DangerButton' + $dialog.Controls.Add($confirm) + $dialog.CancelButton = $cancel + $dialog.AcceptButton = $confirm + + return ($dialog.ShowDialog($Owner) -eq [System.Windows.Forms.DialogResult]::Yes) +} + +function New-DeployCenterDeploymentProgress { + param( + [Parameter(Mandatory=$true)][System.Collections.IDictionary]$Theme, + [Parameter(Mandatory=$true)][System.Drawing.Font]$Font + ) + + $form = New-Object System.Windows.Forms.Form + $form.Text = 'Déploiement Windows' + $form.StartPosition = 'CenterParent' + $form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::FixedDialog + $form.ControlBox = $false + $form.ShowInTaskbar = $false + $form.ClientSize = New-Object System.Drawing.Size(520, 116) + $form.BackColor = $Theme.Background + $form.ForeColor = $Theme.Text + $form.Font = $Font + + $status = New-Object System.Windows.Forms.Label + $status.Location = New-Object System.Drawing.Point(16, 16) + $status.Size = New-Object System.Drawing.Size(488, 28) + $status.Text = 'Préparation du déploiement…' + $status.AutoEllipsis = $true + $status.ForeColor = $Theme.Text + $status.BackColor = [System.Drawing.Color]::Transparent + $status.Font = $Font + $form.Controls.Add($status) + + $bar = New-Object System.Windows.Forms.ProgressBar + $bar.Location = New-Object System.Drawing.Point(16, 56) + $bar.Size = New-Object System.Drawing.Size(488, 24) + $bar.Style = [System.Windows.Forms.ProgressBarStyle]::Marquee + $bar.MarqueeAnimationSpeed = 25 + $form.Controls.Add($bar) + + return [pscustomobject]@{ Form = $form; StatusLabel = $status; ProgressBar = $bar; Action = $null } +} + +function Set-DeployCenterFormSizing { + param( + [Parameter(Mandatory=$true)][System.Windows.Forms.Form]$Form, + [switch]$Resizable + ) + if ($Resizable) { + $Form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::Sizable + $Form.MaximizeBox = $true + } else { + $Form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::FixedDialog + $Form.MaximizeBox = $false + } +} + +function Add-SessionFooter { + param( + [Parameter(Mandatory=$true)][System.Windows.Forms.Form]$TargetForm, + [Parameter(Mandatory=$true)][System.Collections.IDictionary]$Theme, + [Parameter(Mandatory=$true)][System.Drawing.Font]$Font, + [AllowNull()][string]$IPv4, + [AllowNull()][string]$VncPassword, + [bool]$ShowFooter = $true, + [bool]$ShowPassword = $true + ) + if (-not $ShowFooter) { return } + + $footer = New-Object System.Windows.Forms.Label + $footer.Name = 'DeployCenterSessionFooter' + $footer.Text = if ($IPv4) { "IP de déploiement : $IPv4" } else { 'IP de déploiement indisponible' } + if ($ShowPassword -and -not [string]::IsNullOrWhiteSpace($VncPassword)) { + $footer.Text += " | Mot de passe VNC : $VncPassword" + } + $footer.Height = 26 + $footer.Dock = [System.Windows.Forms.DockStyle]::Bottom + $footer.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft + $footer.Padding = New-Object System.Windows.Forms.Padding(12, 0, 0, 0) + $footer.BackColor = $Theme.PanelAlt + $footer.ForeColor = $Theme.Text + $footer.Font = $Font + $TargetForm.Controls.Add($footer) + $footer.BringToFront() +} + +function New-Label { + param( + [Parameter(Mandatory=$true)][string]$Text, + [Parameter(Mandatory=$true)][int]$X, + [Parameter(Mandatory=$true)][int]$Y, + [Parameter(Mandatory=$true)][System.Collections.IDictionary]$Theme, + [Parameter(Mandatory=$true)][System.Drawing.Font]$Font, + [int]$Width = 180, + [int]$Height = 24, + [AllowNull()]$Color = $null + ) + + $label = New-Object System.Windows.Forms.Label + $label.Text = $Text + $label.Location = New-Object System.Drawing.Point($X, $Y) + $label.Size = New-Object System.Drawing.Size($Width, $Height) + $label.AutoEllipsis = $true + $label.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft + $label.BackColor = [System.Drawing.Color]::Transparent + $label.ForeColor = if ($null -eq $Color) { $Theme.Text } else { $Color } + $label.Font = $Font + return $label +} + +function New-GroupBox { + param( + [Parameter(Mandatory=$true)][string]$Text, + [Parameter(Mandatory=$true)][int]$X, + [Parameter(Mandatory=$true)][int]$Y, + [Parameter(Mandatory=$true)][int]$Width, + [Parameter(Mandatory=$true)][int]$Height, + [Parameter(Mandatory=$true)][System.Collections.IDictionary]$Theme, + [Parameter(Mandatory=$true)][System.Drawing.Font]$Font + ) + $group = New-Object DeployCenter.ThemedGroupBox + $group.Text = $Text + $group.Location = New-Object System.Drawing.Point($X, $Y) + $group.Size = New-Object System.Drawing.Size($Width, $Height) + $group.ForeColor = $Theme.Text + $group.BackColor = $Theme.Panel + $group.PanelColor = $Theme.Panel + $group.BorderColor = $Theme.Border + $group.TextColor = $Theme.Text + $group.Font = $Font + return $group +} + +function New-Button { + param( + [Parameter(Mandatory=$true)][string]$Text, + [Parameter(Mandatory=$true)][int]$X, + [Parameter(Mandatory=$true)][int]$Y, + [Parameter(Mandatory=$true)][int]$Width, + [Parameter(Mandatory=$true)][int]$Height, + [Parameter(Mandatory=$true)][System.Collections.IDictionary]$Theme, + [Parameter(Mandatory=$true)][System.Drawing.Font]$Font, + [ValidateSet('Button','PrimaryButton','DangerButton')][string]$Role = 'Button' + ) + $button = New-Object System.Windows.Forms.Button + $button.Text = $Text + $button.Location = New-Object System.Drawing.Point($X, $Y) + $button.Size = New-Object System.Drawing.Size($Width, $Height) + $button.Tag = $Role + Set-DeployCenterButtonStyle -Button $button -Theme $Theme -Role $Role + $button.Font = $Font + $button.Cursor = [System.Windows.Forms.Cursors]::Hand + $button.AutoSize = $false + $button.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter + return $button +} + +function Get-DeployCenterLogoPathForTheme { + param( + [Parameter(Mandatory=$true)][string]$ThemeName, + [Parameter(Mandatory=$true)][string]$LogoPath, + [Parameter(Mandatory=$true)][string]$LogoDarkPath + ) + if ($ThemeName -eq 'Dark' -and (Test-Path -LiteralPath $LogoDarkPath -PathType Leaf)) { return $LogoDarkPath } + if (Test-Path -LiteralPath $LogoPath -PathType Leaf) { return $LogoPath } + if (Test-Path -LiteralPath $LogoDarkPath -PathType Leaf) { return $LogoDarkPath } + return $null +} + +function Update-DeployCenterLogo { + param( + [Parameter(Mandatory=$true)][AllowNull()][System.Windows.Forms.PictureBox]$PictureBox, + [Parameter(Mandatory=$true)][string]$ThemeName, + [Parameter(Mandatory=$true)][string]$LogoPath, + [Parameter(Mandatory=$true)][string]$LogoDarkPath + ) + if ($null -eq $PictureBox) { return } + $selectedLogoPath = Get-DeployCenterLogoPathForTheme -ThemeName $ThemeName -LogoPath $LogoPath -LogoDarkPath $LogoDarkPath + if ([string]::IsNullOrWhiteSpace($selectedLogoPath)) { return } + + try { + $newImage = [System.Drawing.Image]::FromFile($selectedLogoPath) + $oldImage = $PictureBox.Image + $PictureBox.Image = $newImage + if ($null -ne $oldImage) { $oldImage.Dispose() } + $PictureBox.Visible = $true + } catch { + # Conserver le logo déjà affiché si le nouveau fichier est absent ou invalide. + } +} + + + +# Workflows de l'interface principale ; dot-sourcés pour partager explicitement l'état de session DeployCenter. +function Set-DiskSelectorWidth { + if ($null -eq $script:cmbDisk -or $null -eq $script:groupDisks) { return } + Set-DeployCenterDeploymentControlsLayout -Parent $script:groupDisks -Controls $deploymentControls +} + +function Set-ControlTheme { + param([System.Windows.Forms.Control]$Control) + + # Le formulaire principal doit lui aussi changer de fond. + if ($Control -is [System.Windows.Forms.Form]) { + $Control.BackColor = $script:Theme.Background + $Control.ForeColor = $script:Theme.Text + } + elseif ($Control -is [DeployCenter.ThemedGroupBox]) { + $Control.BackColor = $script:Theme.Panel + $Control.PanelColor = $script:Theme.Panel + $Control.BorderColor = $script:Theme.Border + $Control.TextColor = $script:Theme.Text + $Control.ForeColor = $script:Theme.Text + $Control.Invalidate() + } + elseif ($Control -is [System.Windows.Forms.Label]) { + $Control.ForeColor = $script:Theme.Text + if ($Control.Name -eq 'lblSelectedWindows') { + $Control.ForeColor = if ($script:SelectedWindowsEdition) { $script:Theme.Success } else { $script:Theme.Secondary } + } + # Le pied de page VNC suit lui aussi la palette du thème actif. + if ($Control.Name -eq 'DeployCenterSessionFooter') { + $Control.BackColor = $script:Theme.PanelAlt + $Control.ForeColor = $script:Theme.Secondary + $Control.Invalidate() + } + } + elseif ($Control -is [System.Windows.Forms.ListView]) { + $Control.BackColor = $script:Theme.PanelAlt + $Control.ForeColor = $script:Theme.Text + } + elseif ($Control -is [System.Windows.Forms.ComboBox]) { + Set-DeployCenterComboBoxTheme -ComboBox $Control -Theme $script:Theme + } + elseif ($Control -is [System.Windows.Forms.TextBox]) { + $Control.BackColor = $script:Theme.PanelAlt + $Control.ForeColor = $script:Theme.Text + } + + foreach ($child in $Control.Controls) { + Set-ControlTheme -Control $child + } +} + +function Set-DeployCenterTheme { + param([Parameter(Mandatory=$true)][string]$ThemeName) + + $availableThemes = @(Get-DeployCenterThemeNames -ThemesRoot $script:ThemesRoot) + if ($availableThemes -notcontains $ThemeName) { return } + try { + $script:Theme = Import-DeployCenterTheme -ThemesRoot $script:ThemesRoot -ThemeName $ThemeName + } catch { + & $script:SourceLogAction ("Impossible de charger le theme '$ThemeName' : " + $_.Exception.Message) "ERROR" + return + } + $script:CurrentTheme = $ThemeName + $script:cmbTheme.BackColor = $script:Theme.Panel + $script:cmbTheme.ForeColor = $script:Theme.Text + $script:cmbTheme.Invalidate() + $script:cmbTheme.Refresh() + + Set-ControlTheme -Control $script:form + Set-ControlTheme -Control $script:logForm + + # Met a jour le logo si une variante propre au theme existe. + Update-DeployCenterLogo -PictureBox $script:pictureLogo -ThemeName $script:CurrentTheme ` + -LogoPath $script:LogoPath -LogoDarkPath $script:LogoDarkPath + + $script:lblTitle.ForeColor = $script:Theme.Text + $script:lblSubtitle.ForeColor = $script:Theme.Secondary + + if ($script:lblWinPE.Text -like "*WinPE*") { + $script:lblWinPE.ForeColor = $script:Theme.Success + } + else { + $script:lblWinPE.ForeColor = $script:Theme.Secondary + } + + Set-DeployCenterButtonStyle -Button $script:btnChooseWindows -Theme $script:Theme -Role 'Button' + Set-DeployCenterButtonStyle -Button $script:btnLog -Theme $script:Theme -Role 'Button' + Set-DeployCenterButtonStyle -Button $script:btnRefresh -Theme $script:Theme -Role 'PrimaryButton' + Set-DeployCenterButtonStyle -Button $script:btnDeploy -Theme $script:Theme -Role 'PrimaryButton' + Set-DeployCenterButtonStyle -Button $script:btnClose -Theme $script:Theme -Role 'DangerButton' + # Actualise explicitement le pied de page principal lors du changement de thème. + foreach ($footer in $script:form.Controls.Find('DeployCenterSessionFooter', $true)) { + $footer.BackColor = $script:Theme.PanelAlt + $footer.ForeColor = $script:Theme.Secondary + $footer.Invalidate() + } + $script:cmbTheme.BackColor = $script:Theme.Panel + $script:cmbTheme.ForeColor = $script:Theme.Text + $script:cmbTheme.Invalidate() + $script:groupPC.Invalidate() + $script:groupDisks.Invalidate() + $script:cmbDisk.BackColor = $script:Theme.PanelAlt + $script:cmbDisk.ForeColor = $script:Theme.Text + $script:cmbDisk.Invalidate() + # Le bouton reste au-dessus des autres controles de l'en-tete. + $script:cmbTheme.BringToFront() + + # Forcer le rafraichissement apres le changement de palette. + $script:form.SuspendLayout() + try { + foreach ($control in $script:form.Controls) { + $control.Invalidate($true) + } + $script:form.Invalidate($true) + $script:form.Refresh() + } + finally { + $script:form.ResumeLayout($true) + } +} + +function Show-WindowsSourcesDialog { + $dlg = New-Object System.Windows.Forms.Form + $dlg.Text = 'Sources Windows - lecteur DeployCenter et partage SMB' + $dlg.StartPosition = 'CenterParent' + $dlg.Size = New-Object System.Drawing.Size(1100, 700) + $dlg.MinimumSize = New-Object System.Drawing.Size(700, 450) + Set-DeployCenterFormSizing -Form $dlg -Resizable + # Ouvrir directement en plein écran pour exploiter toute la surface disponible. + $dlg.WindowState = [System.Windows.Forms.FormWindowState]::Maximized + $dlg.ShowInTaskbar = $false + $dlg.BackColor = $script:Theme.Background + $dlg.ForeColor = $script:Theme.Text + $dlg.Font = $script:FontRegular + + $lblPath = New-Object System.Windows.Forms.Label + $lblPath.Text = 'Chemins à analyser (séparés par ;) :' + $lblPath.Location = New-Object System.Drawing.Point(16, 16) + $lblPath.Size = New-Object System.Drawing.Size(260, 24) + $lblPath.ForeColor = $script:Theme.Text + $dlg.Controls.Add($lblPath) + + $txtPath = New-Object System.Windows.Forms.TextBox + $txtPath.Location = New-Object System.Drawing.Point(16, 42) + $txtPath.Size = New-Object System.Drawing.Size(560, 26) + $txtPath.Anchor = 'Top,Left,Right' + $defaultSourceRoot = $script:DeployVolumeRoot + if ([string]::IsNullOrWhiteSpace($defaultSourceRoot)) { $defaultSourceRoot = $script:DeployRoot } + $configuredPaths = [string]$script:Config.SourcePaths + if ([string]::IsNullOrWhiteSpace($configuredPaths)) { + $txtPath.Text = $defaultSourceRoot + } else { + $txtPath.Text = $configuredPaths.Replace("%DEPLOY_VOLUME%", $defaultSourceRoot) + } + $txtPath.BackColor = $script:Theme.PanelAlt + $txtPath.ForeColor = $script:Theme.Text + $dlg.Controls.Add($txtPath) + + $btnScan = New-Object System.Windows.Forms.Button + $btnScan.Text = 'Rechercher' + $btnScan.Location = New-Object System.Drawing.Point(650, 40) + $btnScan.Size = New-Object System.Drawing.Size(100, 30) + $btnScan.Anchor = 'Top,Right' + $btnScan.UseVisualStyleBackColor = $false + $btnScan.FlatStyle = [System.Windows.Forms.FlatStyle]::Flat + $btnScan.FlatAppearance.BorderColor = $script:Theme.Border + $btnScan.BackColor = $script:Theme.Accent + $btnScan.ForeColor = [System.Drawing.Color]::White + $dlg.Controls.Add($btnScan) + + $btnEditions = New-Object System.Windows.Forms.Button + $btnEditions.Text = 'Afficher les éditions' + $btnEditions.Location = New-Object System.Drawing.Point(760, 40) + $btnEditions.Size = New-Object System.Drawing.Size(150, 30) + $btnEditions.Anchor = 'Top,Right' + $btnEditions.UseVisualStyleBackColor = $false + $btnEditions.FlatStyle = [System.Windows.Forms.FlatStyle]::Flat + $btnEditions.FlatAppearance.BorderColor = $script:Theme.Border + $btnEditions.BackColor = $script:Theme.Panel + $btnEditions.ForeColor = $script:Theme.Text + $dlg.Controls.Add($btnEditions) + + $list = New-Object System.Windows.Forms.ListView + $list.Location = New-Object System.Drawing.Point(16, 84) + $list.Size = New-Object System.Drawing.Size(770, 420) + $list.Anchor = 'Top,Bottom,Left,Right' + $list.View = 'Details'; $list.FullRowSelect = $true; $list.GridLines = $true + $list.MultiSelect = $false + $list.HideSelection = $true + $list.BackColor = $script:Theme.PanelAlt + $list.ForeColor = $script:Theme.Text + $list.OwnerDraw = $true + [void]$list.Columns.Add('Type', 135) + [void]$list.Columns.Add('Nom', 260) + [void]$list.Columns.Add('Taille', 80) + [void]$list.Columns.Add('Chemin', 250) + + # La colonne Nom est prioritaire : elle absorbe l'espace libre. + $resizeSourceColumns = { + if ($list.Columns.Count -ge 4) { + $availableWidth = $list.ClientSize.Width - $list.Columns[0].Width - $list.Columns[2].Width - $list.Columns[3].Width - 4 + if ($list.Items.Count -gt 0 -and $list.Items.Count -gt [math]::Floor($list.ClientSize.Height / 20)) { + $availableWidth -= [System.Windows.Forms.SystemInformation]::VerticalScrollBarWidth + } + $list.Columns[1].Width = [Math]::Max(180, $availableWidth) + } + } + $list.Add_SizeChanged($resizeSourceColumns) + $list.Add_HandleCreated($resizeSourceColumns) + + # Dessin personnalise des en-tetes et des lignes pour que le ListView + # respecte le theme actif au lieu d'utiliser les en-tetes natifs blancs. + $list.Add_DrawColumnHeader({ + param($sender, $e) + $headerColor = if ($script:Theme.Contains('TableHeader')) { $script:Theme.TableHeader } else { $script:Theme.PanelAlt } + $headerBrush = New-Object System.Drawing.SolidBrush($headerColor) + $textBrush = New-Object System.Drawing.SolidBrush($script:Theme.Text) + $borderPen = New-Object System.Drawing.Pen($script:Theme.Border) + try { + $e.Graphics.FillRectangle($headerBrush, $e.Bounds) + $e.Graphics.DrawRectangle($borderPen, $e.Bounds.X, $e.Bounds.Y, $e.Bounds.Width - 1, $e.Bounds.Height - 1) + $flags = [System.Windows.Forms.TextFormatFlags]::Left -bor [System.Windows.Forms.TextFormatFlags]::VerticalCenter -bor [System.Windows.Forms.TextFormatFlags]::EndEllipsis + [System.Windows.Forms.TextRenderer]::DrawText($e.Graphics, $e.Header.Text, $list.Font, $e.Bounds, $script:Theme.Text, $flags) + } finally { + $headerBrush.Dispose(); $textBrush.Dispose(); $borderPen.Dispose() + } + }) + $list.Add_DrawItem({ + param($sender, $e) + if ($list.View -ne [System.Windows.Forms.View]::Details) { + $e.DrawDefault = $true + } + }) + $list.Add_DrawSubItem({ + param($sender, $e) + $rowBack = if (($e.Item.Index % 2) -eq 0) { $script:Theme.TableRowEven } else { $script:Theme.TableRowOdd } + $rowFore = $script:Theme.Text + # Ne surligner que l'element réellement sélectionné, jamais toutes les lignes. + if ($e.Item.Selected -and $list.Focused) { + $rowBack = $script:Theme.Accent + $rowFore = [System.Drawing.Color]::White + } + $rowBrush = New-Object System.Drawing.SolidBrush($rowBack) + $textBrush = New-Object System.Drawing.SolidBrush($rowFore) + $borderPen = New-Object System.Drawing.Pen($script:Theme.Border) + try { + $e.Graphics.FillRectangle($rowBrush, $e.Bounds) + $e.Graphics.DrawRectangle($borderPen, $e.Bounds.X, $e.Bounds.Y, $e.Bounds.Width - 1, $e.Bounds.Height - 1) + $flags = [System.Windows.Forms.TextFormatFlags]::Left -bor [System.Windows.Forms.TextFormatFlags]::VerticalCenter -bor [System.Windows.Forms.TextFormatFlags]::EndEllipsis -bor [System.Windows.Forms.TextFormatFlags]::NoPrefix + [System.Windows.Forms.TextRenderer]::DrawText($e.Graphics, $e.SubItem.Text, $list.Font, $e.Bounds, $rowFore, $flags) + } finally { + $rowBrush.Dispose(); $textBrush.Dispose(); $borderPen.Dispose() + } + }) + # Filtre local : il ne relance jamais l'analyse des lecteurs ou partages. + $lblFilter = New-Object System.Windows.Forms.Label + $lblFilter.Text = 'Filtrer la liste :' + $lblFilter.Location = New-Object System.Drawing.Point(16, 80) + $lblFilter.Size = New-Object System.Drawing.Size(100, 24) + $lblFilter.ForeColor = $script:Theme.Text + $dlg.Controls.Add($lblFilter) + + $txtFilter = New-Object System.Windows.Forms.TextBox + $txtFilter.Location = New-Object System.Drawing.Point(120, 78) + $txtFilter.Size = New-Object System.Drawing.Size(500, 26) + $txtFilter.Anchor = 'Top,Left,Right' + $txtFilter.BackColor = $script:Theme.PanelAlt + $txtFilter.ForeColor = $script:Theme.Text + $dlg.Controls.Add($txtFilter) + + $list.Location = New-Object System.Drawing.Point(16, 112) + $dlg.Controls.Add($list) + + # Etat partage explicite entre les gestionnaires d'evenements WinForms. + # En PowerShell, les callbacks peuvent s'executer dans une portee differente : + # une variable locale $allFound peut alors rester vide dans le filtre. + $sourceState = [hashtable]::Synchronized(@{ AllFound = @() }) + $renderSourceList = { + param([string]$Query) + $previousSelection = $null + if ($list.SelectedItems.Count -gt 0) { + $previousSelection = [string]$list.SelectedItems[0].SubItems[3].Text + } + + $needle = ([string]$Query).Trim() + $visibleEntries = New-Object System.Collections.Generic.List[object] + $entries = @($sourceState.AllFound) + + foreach ($entry in $entries) { + if ([string]::IsNullOrWhiteSpace($needle)) { + [void]$visibleEntries.Add($entry) + continue + } + + $searchable = '{0} {1} {2} {3} {4}' -f ` + [string]$entry.Type, [string]$entry.Nom, [string]$entry.Chemin, ` + [string]$entry.Taille, [string]$entry.Detail + + if ($searchable.IndexOf($needle, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) { + [void]$visibleEntries.Add($entry) + } + } + + $list.BeginUpdate() + try { + $list.Items.Clear() + foreach ($entry in $visibleEntries) { + $item = New-Object System.Windows.Forms.ListViewItem([string]$entry.Type) + [void]$item.SubItems.Add([string]$entry.Nom) + [void]$item.SubItems.Add([string]$entry.Taille) + [void]$item.SubItems.Add([string]$entry.Chemin) + if ($previousSelection -and ([string]$entry.Chemin -eq $previousSelection)) { + $item.Selected = $true + } + [void]$list.Items.Add($item) + } + } finally { + $list.EndUpdate() + } + + if ($entries.Count -gt 0) { + $lblStatus.Text = ('{0} résultat(s) affiché(s) sur {1}. Filtre instantané ; aucun fichier modifié.' -f $list.Items.Count, $entries.Count) + } elseif ([string]::IsNullOrWhiteSpace($needle)) { + $lblStatus.Text = 'Aucune source ISO/WIM/ESD ou installation extraite detectee dans les chemins accessibles.' + } else { + $lblStatus.Text = ('Aucun resultat pour « {0} ». Efface le filtre pour revoir la liste.' -f $needle) + } + } + $txtFilter.Add_TextChanged({ & $renderSourceList $txtFilter.Text }) + + $lblStatus = New-Object System.Windows.Forms.Label + $lblStatus.Text = 'Analyse en lecture seule du lecteur de DeployCenter et/ou du partage SMB. Plusieurs chemins peuvent etre separes par un point-virgule.' + $lblStatus.Location = New-Object System.Drawing.Point(16, 515) + $lblStatus.Size = New-Object System.Drawing.Size(770, 36) + $lblStatus.Anchor = 'Bottom,Left,Right' + $lblStatus.AutoSize = $false + $lblStatus.ForeColor = $script:Theme.Secondary + $dlg.Controls.Add($lblStatus) + + $progressAnalysis = New-Object System.Windows.Forms.ProgressBar + $progressAnalysis.Name = 'progressWindowsSourceAnalysis' + $progressAnalysis.Style = [System.Windows.Forms.ProgressBarStyle]::Marquee + $progressAnalysis.MarqueeAnimationSpeed = 30 + $progressAnalysis.Visible = $false + $progressAnalysis.Anchor = 'Bottom,Right' + $dlg.Controls.Add($progressAnalysis) + + # Recalculer les positions depuis la taille réelle de la fenêtre : + # le tableau occupe l'espace restant et le statut reste toujours visible en bas. + $resizeLayout = { + $clientWidth = $dlg.ClientSize.Width + $clientHeight = $dlg.ClientSize.Height + $rightMargin = 16 + $gap = 12 + $btnWidth = 88 + $editionsWidth = 148 + $left = 12 + $right = [Math]::Max($left + $btnWidth, $clientWidth - $rightMargin) + $btnEditionsLeft = [Math]::Max($left, $right - $editionsWidth) + $btnScanLeft = [Math]::Max($left, $btnEditionsLeft - $gap - $btnWidth) + $txtPath.Width = [Math]::Max(100, $btnScanLeft - $left - $gap) + $btnEditions.Location = New-Object System.Drawing.Point($btnEditionsLeft, 40) + $btnEditions.Width = [Math]::Max(120, $right - $btnEditionsLeft) + $btnScan.Location = New-Object System.Drawing.Point($btnScanLeft, 40) + $btnScan.Width = [Math]::Max(80, $btnEditionsLeft - $gap - $btnScanLeft) + $btnScan.Anchor = 'Top,Right' + $btnEditions.Anchor = 'Top,Right' + $txtPath.Anchor = 'Top,Left,Right' + $lblFilter.Location = New-Object System.Drawing.Point(16, 80) + $txtFilter.Location = New-Object System.Drawing.Point(120, 78) + $txtFilter.Size = New-Object System.Drawing.Size([Math]::Max(180, $clientWidth - 152), 26) + $list.Location = New-Object System.Drawing.Point(16, 112) + $list.Size = New-Object System.Drawing.Size([Math]::Max(300, $clientWidth - 32), [Math]::Max(120, $clientHeight - 205)) + $list.Anchor = 'Top,Bottom,Left,Right' + $lblStatus.Location = New-Object System.Drawing.Point(16, ($clientHeight - 52)) + $lblStatus.Size = New-Object System.Drawing.Size([Math]::Max(150, $clientWidth - 164), 24) + $lblStatus.Anchor = 'Bottom,Left,Right' + $progressAnalysis.Location = New-Object System.Drawing.Point(($clientWidth - 136), ($clientHeight - 52)) + $progressAnalysis.Size = New-Object System.Drawing.Size(120, 20) + } + $dlg.Add_Shown({ + & $resizeLayout + # Lancer automatiquement la première analyse à l'ouverture. + # Le bouton Rechercher reste disponible si l'utilisateur change les chemins. + $btnScan.PerformClick() + }) + $dlg.Add_SizeChanged({ & $resizeLayout }) + + $btnEditions.Add_Click({ + if ($list.SelectedItems.Count -eq 0) { + Show-DeployCenterMessage -Message 'Sélectionne d''abord une source dans la liste.' -Title 'Éditions Windows' -Icon Information -Owner $dlg -Theme $script:Theme -Font $script:FontRegular + return + } + + $selectedPath = [string]$list.SelectedItems[0].SubItems[3].Text + $selectedName = [string]$list.SelectedItems[0].SubItems[1].Text + $resolvedImage = $null + $mountTransferred = $false + $btnEditions.Enabled = $false + $btnScan.Enabled = $false + $dlg.Cursor = [System.Windows.Forms.Cursors]::WaitCursor + $lblStatus.Text = "Analyse des editions de $selectedName en cours..." + $progressAnalysis.Visible = $true + $progressAnalysis.BringToFront() + $dlg.Refresh() + try { + $resolvedImage = Resolve-InstallImagePath -SelectedPath $selectedPath -ExistingImage $script:SelectedWindowsEdition + $imagePath = [string]$resolvedImage.ImagePath + $editionResult = Get-WindowsImageEditions -ImagePath $imagePath -LogAction $script:SourceLogAction + $editions = @($editionResult.Editions) + $globalImageVersion = [string]$editionResult.GlobalVersion + $lines = foreach ($edition in $editions) { + $index = if ($edition.ImageIndex) { $edition.ImageIndex } elseif ($edition.Index) { $edition.Index } else { '?' } + $name = if ($edition.ImageName) { $edition.ImageName } elseif ($edition.Name) { $edition.Name } else { 'Nom non disponible' } + $arch = if ($edition.Architecture) { [string]$edition.Architecture } else { 'Architecture non disponible' } + $version = if ($edition.Version) { [string]$edition.Version } else { 'Version non disponible' } + "Index : $index`r`nNom : $name`r`nArchitecture : $arch`r`nVersion : $version" + } + # Le formulaire enfant est modal : actualiser le statut avant son affichage, + # sinon le formulaire parent reste visuellement sur « analyse en cours ». + $lblStatus.Text = "$($editions.Count) édition(s) détectée(s) dans $selectedName. Sélectionne une édition." + $dlg.Refresh() + + # Tableau des editions ; l'index DISM selectionne est conserve pour la suite. + $resultForm = New-Object System.Windows.Forms.Form + $resultForm.Text = 'Éditions Windows disponibles' + $resultForm.StartPosition = 'CenterParent' + $resultForm.Size = New-Object System.Drawing.Size(700, 480) + $resultForm.MinimumSize = New-Object System.Drawing.Size(520, 320) + Set-DeployCenterFormSizing -Form $resultForm + $resultForm.ShowInTaskbar = $false + $resultForm.BackColor = $script:Theme.Background + $resultForm.ForeColor = $script:Theme.Text + + $sourceLabel = New-Object System.Windows.Forms.Label + $sourceLabel.Text = "Source : $selectedPath" + $sourceLabel.Dock = [System.Windows.Forms.DockStyle]::Top + $sourceLabel.Height = 38 + $sourceLabel.Padding = New-Object System.Windows.Forms.Padding(10, 8, 10, 4) + $sourceLabel.AutoEllipsis = $true + $sourceLabel.BackColor = $script:Theme.Background + $sourceLabel.ForeColor = $script:Theme.Text + + $editionList = New-Object System.Windows.Forms.ListView + $editionList.View = [System.Windows.Forms.View]::Details + $editionList.FullRowSelect = $true + $editionList.GridLines = $true + $editionList.MultiSelect = $false + $editionList.HideSelection = $false + # Une seule édition peut être sélectionnée à la fois. + $editionList.Sorting = [System.Windows.Forms.SortOrder]::None + $editionList.Dock = [System.Windows.Forms.DockStyle]::Fill + $editionList.BackColor = $script:Theme.Panel + $editionList.ForeColor = $script:Theme.Text + [void]$editionList.Columns.Add('Index', 65) + [void]$editionList.Columns.Add('Édition', 260) + [void]$editionList.Columns.Add('Architecture', 100) + [void]$editionList.Columns.Add('Version', 105) + + # Édition est la colonne prioritaire : Version conserve une largeur fixe. + $resizeEditionColumns = { + if ($editionList.Columns.Count -ge 4) { + $availableWidth = $editionList.ClientSize.Width - $editionList.Columns[0].Width - $editionList.Columns[2].Width - $editionList.Columns[3].Width - 4 + if ($editionList.Items.Count -gt 0 -and $editionList.Items.Count -gt [Math]::Floor($editionList.ClientSize.Height / 20)) { + $availableWidth -= [System.Windows.Forms.SystemInformation]::VerticalScrollBarWidth + } + $editionList.Columns[1].Width = [Math]::Max(220, $availableWidth) + } + } + $editionList.Add_SizeChanged($resizeEditionColumns) + $resultForm.Add_Shown($resizeEditionColumns) + + # Dessin personnalise des en-tetes et cellules pour que ListView + # respecte le theme Light/Dark au lieu de garder un en-tete blanc. + $editionList.OwnerDraw = $true + $editionList.Add_DrawColumnHeader({ + param($sender, $e) + $headerColor = if ($script:Theme.Contains('TableHeader')) { $script:Theme.TableHeader } else { $script:Theme.PanelAlt } + $headerBrush = New-Object System.Drawing.SolidBrush($headerColor) + $headerPen = New-Object System.Drawing.Pen($script:Theme.Border) + try { + $e.Graphics.FillRectangle($headerBrush, $e.Bounds) + $e.Graphics.DrawRectangle($headerPen, $e.Bounds.X, $e.Bounds.Y, $e.Bounds.Width - 1, $e.Bounds.Height - 1) + [System.Windows.Forms.TextRenderer]::DrawText( + $e.Graphics, $e.Header.Text, $sender.Font, $e.Bounds, + $script:Theme.Text, + ([System.Windows.Forms.TextFormatFlags]::VerticalCenter -bor + [System.Windows.Forms.TextFormatFlags]::Left -bor + [System.Windows.Forms.TextFormatFlags]::EndEllipsis) + ) + } finally { + $headerBrush.Dispose() + $headerPen.Dispose() + } + }) + $editionList.Add_DrawItem({ param($sender, $e) }) + $editionList.Add_DrawSubItem({ + param($sender, $e) + $cellBack = if (($e.Item.Index % 2) -eq 0) { $script:Theme.TableRowEven } else { $script:Theme.TableRowOdd } + $cellFore = $script:Theme.Text + if ($e.Item.Selected) { + $cellBack = $script:Theme.Accent + $cellFore = [System.Drawing.Color]::White + } + $cellBrush = New-Object System.Drawing.SolidBrush($cellBack) + $cellPen = New-Object System.Drawing.Pen($script:Theme.Border) + try { + $e.Graphics.FillRectangle($cellBrush, $e.Bounds) + if ($sender.GridLines) { + $e.Graphics.DrawRectangle($cellPen, $e.Bounds.X, $e.Bounds.Y, $e.Bounds.Width - 1, $e.Bounds.Height - 1) + } + [System.Windows.Forms.TextRenderer]::DrawText( + $e.Graphics, $e.SubItem.Text, $sender.Font, $e.Bounds, + $cellFore, + ([System.Windows.Forms.TextFormatFlags]::VerticalCenter -bor + [System.Windows.Forms.TextFormatFlags]::Left -bor + [System.Windows.Forms.TextFormatFlags]::EndEllipsis) + ) + } finally { + $cellBrush.Dispose() + $cellPen.Dispose() + } + }) + + foreach ($edition in $editions) { + $index = if ($edition.ImageIndex) { [string]$edition.ImageIndex } elseif ($edition.Index) { [string]$edition.Index } else { '?' } + $name = if ($edition.ImageName) { [string]$edition.ImageName } elseif ($edition.Name) { [string]$edition.Name } elseif ($edition.ImageDescription) { [string]$edition.ImageDescription } else { 'Nom non disponible' } + $archValue = $null + foreach ($propertyName in @('Architecture', 'ImageArchitecture')) { + if ($edition.PSObject.Properties[$propertyName] -and $edition.$propertyName) { + $archValue = $edition.$propertyName + break + } + } + $arch = if ($null -ne $archValue) { + switch ([string]$archValue) { + '0' { 'x86' } + '5' { 'ARM' } + '9' { 'x64' } + '12' { 'ARM64' } + default { [string]$archValue } + } + } else { 'Non disponible' } + $version = if ($edition.Version) { [string]$edition.Version } else { 'Non disponible' } + + $row = New-Object System.Windows.Forms.ListViewItem($index) + [void]$row.SubItems.Add($name) + [void]$row.SubItems.Add($arch) + [void]$row.SubItems.Add($version) + $row.Tag = $edition + [void]$editionList.Items.Add($row) + } + + $buttonPanel = New-Object System.Windows.Forms.Panel + $buttonPanel.Dock = [System.Windows.Forms.DockStyle]::Bottom + $buttonPanel.Height = 52 + $buttonPanel.BackColor = $script:Theme.Background + + $cancelButton = New-Object System.Windows.Forms.Button + $cancelButton.Text = 'Annuler' + $cancelButton.Size = New-Object System.Drawing.Size(100, 30) + $cancelButton.Anchor = 'Right,Top' + $cancelButton.Location = New-Object System.Drawing.Point(0, 10) + $cancelButton.DialogResult = [System.Windows.Forms.DialogResult]::Cancel + + $validateButton = New-Object System.Windows.Forms.Button + $validateButton.Text = 'Valider l''édition' + $validateButton.Size = New-Object System.Drawing.Size(140, 30) + $validateButton.Anchor = 'Right,Top' + $validateButton.Location = New-Object System.Drawing.Point(0, 10) + $validateButton.Enabled = $false + + $editionList.Add_SelectedIndexChanged({ + $validateButton.Enabled = ($editionList.SelectedItems.Count -gt 0 -and $editionList.SelectedItems[0].SubItems[0].Text -ne '?') + }) + $validateButton.Add_Click({ + if ($editionList.SelectedItems.Count -gt 0) { + $resultForm.Tag = [pscustomobject]@{ + SourcePath = $selectedPath + ImagePath = $imagePath + MountedImage = $resolvedImage.MountedImage + Edition = $editionList.SelectedItems[0].Tag + Index = [int]$editionList.SelectedItems[0].SubItems[0].Text + Name = $editionList.SelectedItems[0].SubItems[1].Text + } + $resultForm.DialogResult = [System.Windows.Forms.DialogResult]::OK + $resultForm.Close() + } + }) + $editionList.Add_DoubleClick({ + if ($editionList.SelectedItems.Count -gt 0 -and $validateButton.Enabled) { + $validateButton.PerformClick() + } + }) + $buttonPanel.Controls.Add($cancelButton) + $buttonPanel.Controls.Add($validateButton) + $buttonPanel.Add_Resize({ + $validateButton.Location = New-Object System.Drawing.Point(($buttonPanel.ClientSize.Width - $validateButton.Width - 12), 10) + $cancelButton.Location = New-Object System.Drawing.Point(($validateButton.Left - $cancelButton.Width - 8), 10) + }) + + $resultForm.Controls.Add($editionList) + $resultForm.Controls.Add($buttonPanel) + $resultForm.Controls.Add($sourceLabel) + $resultForm.AcceptButton = $validateButton + $resultForm.CancelButton = $cancelButton + Add-SessionFooter -TargetForm $resultForm -Theme $script:Theme -Font $script:FontSmall ` + -IPv4 $script:DeployIPv4 -VncPassword $script:VncPassword -ShowFooter $false + $dialogResult = $resultForm.ShowDialog($dlg) + $chosenEdition = $resultForm.Tag + $resultForm.Dispose() + + if ($dialogResult -eq [System.Windows.Forms.DialogResult]::OK -and $chosenEdition) { + if ($script:SelectedWindowsEdition -and + -not [string]::Equals([string]$script:SelectedWindowsEdition.SourcePath, [string]$chosenEdition.SourcePath, [StringComparison]::OrdinalIgnoreCase)) { + [void](Dismount-DeployCenterInstallImage -Image $script:SelectedWindowsEdition) + } + $script:SelectedWindowsEdition = $chosenEdition + $mountTransferred = $true + $lblStatus.Text = "Édition sélectionnée : $($chosenEdition.Name) (index $($chosenEdition.Index))." + & $script:SourceLogAction "Edition selectionnee : $($chosenEdition.Name), index $($chosenEdition.Index), image $imagePath" 'SUCCESS' + $dlg.DialogResult = [System.Windows.Forms.DialogResult]::OK + $dlg.Close() + } else { + $lblStatus.Text = "$($editions.Count) édition(s) trouvée(s) dans $selectedName. Aucune nouvelle édition sélectionnée." + } + & $script:SourceLogAction "Analyse des editions terminee : $imagePath ($($editions.Count) edition(s))." 'SUCCESS' + } catch { + $lblStatus.Text = "Echec de l'analyse des editions de $selectedName." + & $script:SourceLogAction "Erreur analyse editions $selectedPath : $($_.Exception.Message)" 'ERROR' + Show-DeployCenterMessage -Message $_.Exception.Message -Title 'Analyse des éditions' -Icon Warning -Owner $dlg -Theme $script:Theme -Font $script:FontRegular + } finally { + if ($resolvedImage -and $resolvedImage.MountedImage -and -not $mountTransferred -and + (-not $script:SelectedWindowsEdition -or $resolvedImage.MountedImage -ne $script:SelectedWindowsEdition.MountedImage)) { + try { [void](Dismount-DeployCenterInstallImage -Image $resolvedImage) } catch { + & $script:SourceLogAction "Impossible de démonter l'ISO après l'analyse : $($_.Exception.Message)" 'WARNING' + } + } + $dlg.Cursor = [System.Windows.Forms.Cursors]::Default + $progressAnalysis.Visible = $false + $btnEditions.Enabled = $true + $btnScan.Enabled = $true + } + }) + + $list.Add_DoubleClick({ + if ($list.SelectedItems.Count -gt 0 -and $btnEditions.Enabled) { + $btnEditions.PerformClick() + } + }) + + $btnScan.Add_Click({ + $btnScan.Enabled = $false + $dlg.Cursor = [System.Windows.Forms.Cursors]::WaitCursor + $lblStatus.Text = 'Recherche en cours sur les chemins indiques...' + $sourceState.AllFound = @() + $list.Items.Clear() + try { + # La recherche ne modifie jamais DeployCenter.ini. + $sourceState.AllFound = @(Find-WindowsSources -RootPath $txtPath.Text -LogAction $script:SourceLogAction) + & $renderSourceList $txtFilter.Text + if (@($sourceState.AllFound).Count -eq 0) { + $lblStatus.Text = 'Aucune source ISO/WIM/ESD ou installation extraite detectee dans les chemins accessibles.' + & $script:SourceLogAction 'Aucune source Windows detectee dans les chemins accessibles.' 'WARNING' + } + } catch { + $lblStatus.Text = 'Echec de la recherche. Consulte le message.' + & $script:SourceLogAction "Erreur detection sources : $($_.Exception.Message)" 'ERROR' + Show-DeployCenterMessage -Message $_.Exception.Message -Title 'Détection des sources' -Icon Warning -Owner $dlg -Theme $script:Theme -Font $script:FontRegular + } finally { + $dlg.Cursor = [System.Windows.Forms.Cursors]::Default + $btnScan.Enabled = $true + } + }) + + Add-SessionFooter -TargetForm $dlg -Theme $script:Theme -Font $script:FontSmall ` + -IPv4 $script:DeployIPv4 -VncPassword $script:VncPassword + [void]$dlg.ShowDialog($script:form) + $dlg.Dispose() +} + +function Update-HardwareView { + $script:btnRefresh.Enabled = $false + $script:form.Cursor = [System.Windows.Forms.Cursors]::WaitCursor + + try { + if (-not (Get-Command Get-HardwareInfo -ErrorAction SilentlyContinue)) { + throw 'Le module HardwareInventory ne fournit pas Get-HardwareInfo.' + } + $info = Get-HardwareInfo + + $script:lblManufacturer.Text = "Fabricant : $($info.Manufacturer)" + $script:lblModel.Text = "Modèle : $($info.Model)" + $script:lblSerial.Text = "Numéro de série : $($info.SerialNumber)" + $script:lblFirmware.Text = "Firmware : $($info.Firmware)" + $script:lblArchitecture.Text = "Architecture : $($info.Architecture)" + + if ($info.WinPE) { + $script:lblWinPE.Text = "Environnement : WinPE" + $script:lblWinPE.ForeColor = $script:Theme.Success + } + else { + $script:lblWinPE.Text = "Environnement : Windows classique" + $script:lblWinPE.ForeColor = $script:Theme.Secondary + } + + $script:HardwareInventory = $info + $previousDiskIndex = $null + if ($null -ne $script:cmbDisk.SelectedItem -and $null -ne $script:cmbDisk.SelectedItem.Tag) { + $previousDiskIndex = [string]$script:cmbDisk.SelectedItem.Tag + } + $script:RefreshingDiskList = $true + $script:cmbDisk.BeginUpdate() + try { + $script:cmbDisk.Items.Clear() + foreach ($disk in $info.Disks) { + $diskLabel = "Disque {0} - {1} - {2:N1} Go - {3} - {4}" -f $disk.Index, $disk.Model, $disk.SizeGB, $disk.BusType, $disk.Status + $diskChoice = [pscustomobject]@{ Text = $diskLabel; Tag = [string]$disk.Index } + [void]$script:cmbDisk.Items.Add($diskChoice) + } + $script:cmbDisk.DisplayMember = 'Text' + if ($script:cmbDisk.Items.Count -gt 0) { + $selectedIndex = -1 + if ($null -ne $previousDiskIndex) { + for ($i = 0; $i -lt $script:cmbDisk.Items.Count; $i++) { + if ([string]$script:cmbDisk.Items[$i].Tag -eq $previousDiskIndex) { $selectedIndex = $i; break } + } + } + if ($selectedIndex -lt 0 -and $script:cmbDisk.Items.Count -eq 1) { $selectedIndex = 0 } + $script:cmbDisk.SelectedIndex = $selectedIndex + } + } + finally { + $script:cmbDisk.EndUpdate() + $script:RefreshingDiskList = $false + } + $script:SelectedDiskIndex = if ($script:cmbDisk.SelectedIndex -ge 0) { [string]$script:cmbDisk.SelectedItem.Tag } else { $null } + + Write-DeployInventory -InventoryDir $script:InventoryDir -InventoryFile $script:InventoryFile -HardwareInfo $info -DeploymentIPv4 $script:DeployIPv4 -VncPassword $script:VncPassword -LogAction $script:SourceLogAction + + & $script:SourceLogAction "Inventaire materiel actualise." "SUCCESS" + + foreach ($errorMessage in $info.Errors) { + & $script:SourceLogAction $errorMessage "WARNING" + } + + if ($info.Disks.Count -eq 0) { + & $script:SourceLogAction "Aucun disque physique detecte." "WARNING" + } + } + catch { + & $script:SourceLogAction "Erreur inventaire : $($_.Exception.Message)" "ERROR" + + Show-DeployCenterMessage -Message $_.Exception.Message -Title 'Erreur DeployCenter' -Icon Error -Owner $script:form -Theme $script:Theme -Font $script:FontRegular + } + finally { + $script:btnRefresh.Enabled = $true + $script:form.Cursor = [System.Windows.Forms.Cursors]::Default + } +} + +function Get-PartitionProfileSizingSelection { + param( + [Parameter(Mandatory=$true)][System.Collections.IDictionary]$Profile, + [Parameter(Mandatory=$true)]$DiskInfo, + [System.Collections.IDictionary]$InitialValues = @{} + ) + + $capacity = Get-DeployCenterPartitionSizingCapacity -Profile $Profile -DiskSizeBytes ([long]$DiskInfo.SizeBytes) + return Show-DeployCenterPartitionSizingDialog ` + -Profile $Profile ` + -AvailableGB $capacity.UsableGB ` + -Theme $script:Theme ` + -Font $script:FontRegular ` + -Owner $script:form ` + -InitialValues $InitialValues +} + +function Set-PartitionProfileSizingSelection { + param( + [Parameter(Mandatory=$true)][System.Collections.IDictionary]$Profile, + [Parameter(Mandatory=$true)]$DiskInfo, + [System.Collections.IDictionary]$InitialValues = @{} + ) + + $selection = Get-PartitionProfileSizingSelection -Profile $Profile -DiskInfo $DiskInfo -InitialValues $InitialValues + if ($null -eq $selection) { return $false } + $script:SelectedPartitionSizing = New-DeployCenterPartitionSizingState -Profile $Profile -DiskInfo $DiskInfo -Selection $selection + return $true +} + +function Update-DeployCenterLayout { + if ($null -eq $script:form -or $script:form.IsDisposed) { return } + $w = $script:form.ClientSize.Width + $h = $script:form.ClientSize.Height + $m = 24 + $contentWidth = [Math]::Max(300, $w - (2 * $m)) + + # En-tete : le selecteur de theme reste accroche au bord droit. + if ($null -ne $script:cmbTheme) { + $script:cmbTheme.Location = New-Object System.Drawing.Point([Math]::Max($m, $w - $m - $script:cmbTheme.Width), 25) + } + + # Les deux panneaux superieurs conservent leur hauteur, mais s'etirent en largeur. + $script:groupPC.Location = New-Object System.Drawing.Point($m, 100) + $script:groupPC.Size = New-Object System.Drawing.Size($contentWidth, 105) + $script:groupDisks.Location = New-Object System.Drawing.Point($m, 220) + $script:groupDisks.Size = New-Object System.Drawing.Size($contentWidth, 226) + Set-DiskSelectorWidth + + $footerHeight = 26 + $buttonHeightLocal = 38 + $buttonYLocal = $h - $footerHeight - 14 - $buttonHeightLocal + + $buttonWidthLocal = 140 + $buttonGapLocal = 12 + $script:btnClose.Size = New-Object System.Drawing.Size($buttonWidthLocal, $buttonHeightLocal) + $script:btnRefresh.Size = New-Object System.Drawing.Size($buttonWidthLocal, $buttonHeightLocal) + $script:btnDeploy.Size = New-Object System.Drawing.Size($buttonWidthLocal, $buttonHeightLocal) + $script:btnLog.Size = New-Object System.Drawing.Size($buttonWidthLocal, $buttonHeightLocal) + $script:btnClose.Location = New-Object System.Drawing.Point([Math]::Max($m, $w - $m - $buttonWidthLocal), $buttonYLocal) + $script:btnDeploy.Location = New-Object System.Drawing.Point([Math]::Max($m, $script:btnClose.Left - $buttonGapLocal - $buttonWidthLocal), $buttonYLocal) + $script:btnRefresh.Location = New-Object System.Drawing.Point([Math]::Max($m, $script:btnDeploy.Left - $buttonGapLocal - $buttonWidthLocal), $buttonYLocal) + $script:btnLog.Location = New-Object System.Drawing.Point($m, $buttonYLocal) + Set-DiskSelectorWidth +} diff --git a/Modules/VNC.psm1 b/Modules/VNC.psm1 index c54052e..8458904 100644 --- a/Modules/VNC.psm1 +++ b/Modules/VNC.psm1 @@ -9,22 +9,22 @@ function Start-DeployCenterVnc { $sourceIni = Join-Path $sourceVnc 'ultravnc.ini' if ([string]::IsNullOrWhiteSpace($script:DeployIPv4)) { - Write-Log 'Aucune adresse IPv4 de déploiement détectée.' 'WARNING' + & $script:SourceLogAction 'Aucune adresse IPv4 de déploiement détectée.' 'WARNING' } if (-not (Test-Path -LiteralPath $sourceExe -PathType Leaf)) { - Write-Log "UltraVNC non démarré : exécutable absent ($sourceExe)." 'WARNING' - Write-DeployInventory + & $script:SourceLogAction "UltraVNC non démarré : exécutable absent ($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)) { - Write-Log "UltraVNC non démarré : createpassword64.exe absent ($passwordTool)." 'WARNING' - Write-DeployInventory + & $script:SourceLogAction "UltraVNC non démarré : 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)) { - Write-Log "UltraVNC non démarré : fichier absent ($sourceIni)." 'WARNING' - Write-DeployInventory + & $script:SourceLogAction "UltraVNC non démarré : fichier absent ($sourceIni)." 'WARNING' + Write-DeployInventory -InventoryDir $script:InventoryDir -InventoryFile $script:InventoryFile -HardwareInfo $script:HardwareInventory -DeploymentIPv4 $script:DeployIPv4 -VncPassword $script:VncPassword -LogAction $script:SourceLogAction return } @@ -48,13 +48,13 @@ function Start-DeployCenterVnc { foreach ($outputLine in $wpeutilOutput) { $outputText = [string]$outputLine if (-not [string]::IsNullOrWhiteSpace($outputText)) { - Write-Log "wpeutil DisableFirewall : $outputText" 'INFO' + & $script:SourceLogAction "wpeutil DisableFirewall : $outputText" 'INFO' } } if ($wpeutilExitCode -ne 0) { throw "wpeutil DisableFirewall a renvoyé le code $wpeutilExitCode." } - Write-Log "Pare-feu WinPE désactivé à la demande de -Vnc (code wpeutil : $wpeutilExitCode)." 'WARNING' + & $script:SourceLogAction "Pare-feu WinPE désactivé à la demande de -Vnc (code wpeutil : $wpeutilExitCode)." 'WARNING' # Force le mode portable : l'INI actif est celui placé à côté de winvnc.exe. $vncStage = 'activation du mode portable UltraVNC' @@ -97,13 +97,13 @@ function Start-DeployCenterVnc { throw "winvnc.exe s'est arrêté immédiatement (code $($script:VncProcess.ExitCode))." } $script:VncStarted = $true - Write-Log "UltraVNC démarré. IP : $($script:DeployIPv4) ; mot de passe $passwordSource." 'SUCCESS' + & $script:SourceLogAction "UltraVNC démarré. IP : $($script:DeployIPv4) ; mot de passe $passwordSource." 'SUCCESS' } catch { - Write-Log "Échec du démarrage UltraVNC à l'étape '$vncStage' : $($_.Exception.Message)" 'ERROR' + & $script:SourceLogAction "Échec du démarrage UltraVNC à l'étape '$vncStage' : $($_.Exception.Message)" 'ERROR' $script:VncStarted = $false $script:VncPassword = $null } - Write-DeployInventory + Write-DeployInventory -InventoryDir $script:InventoryDir -InventoryFile $script:InventoryFile -HardwareInfo $script:HardwareInventory -DeploymentIPv4 $script:DeployIPv4 -VncPassword $script:VncPassword -LogAction $script:SourceLogAction } function Stop-DeployCenterVnc { @@ -120,7 +120,7 @@ function Stop-DeployCenterVnc { } } } catch { - Write-Log "Arrêt UltraVNC : $($_.Exception.Message)" 'WARNING' + & $script:SourceLogAction "Arrêt 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_) : on ne diff --git a/Modules/WindowsSources.psm1 b/Modules/WindowsSources.psm1 new file mode 100644 index 0000000..6aa5e85 --- /dev/null +++ b/Modules/WindowsSources.psm1 @@ -0,0 +1,385 @@ +# DeployCenter - Recherche et lecture des sources Windows +# Compatible Windows PowerShell 5.1 + +function Find-WindowsSources { + param( + [Parameter(Mandatory=$true)][string]$RootPath, + [AllowNull()][scriptblock]$LogAction = $null + ) + + $results = New-Object System.Collections.Generic.List[object] + $roots = @($RootPath -split ';' | ForEach-Object { + $candidate = $_.Trim().Trim('"') + if ($candidate -notmatch '^[A-Za-z]:\\$') { $candidate = $candidate.TrimEnd('\') } + $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.' + } + + foreach ($root in $roots) { + if (-not (Test-Path -LiteralPath $root -PathType Container)) { + if ($LogAction) { & $LogAction "Source inaccessible, ignoree : $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. + $allFiles = @(Get-ChildItem -LiteralPath $root -Recurse -File -ErrorAction SilentlyContinue) + $files = New-Object System.Collections.Generic.List[object] + foreach ($file in $allFiles) { + if ($file.Extension -ieq '.iso') { + [void]$files.Add($file) + continue + } + + if ($file.Extension -ine '.wim' -and $file.Extension -ine '.esd') { + continue + } + + $isBootWimInExtractedMedia = $false + if ($file.Name -ieq 'boot.wim' -and $file.Directory.Name -ieq 'Sources') { + $installWim = Join-Path $file.Directory.FullName 'install.wim' + $installEsd = Join-Path $file.Directory.FullName 'install.esd' + if ((Test-Path -LiteralPath $installWim -PathType Leaf) -or + (Test-Path -LiteralPath $installEsd -PathType Leaf)) { + $isBootWimInExtractedMedia = $true + } + } + + if (-not $isBootWimInExtractedMedia) { + [void]$files.Add($file) + } + } + + foreach ($file in $files) { + $kind = switch ($file.Extension.ToLowerInvariant()) { + '.iso' { 'Image ISO' } + '.wim' { 'Image WIM Windows' } + '.esd' { 'Image ESD Windows' } + } + $detail = if ($file.Name -ieq 'install.wim' -or $file.Name -ieq 'install.esd') { + "Image d’installation dans un support extrait" + } elseif ($file.Extension -ieq '.iso') { + "Support d’installation ISO" + } 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 + }) + } + } + + 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." + } + if ($LogAction) { & $LogAction ("Detection terminee : {0} element(s) trouve(s)." -f $results.Count) 'SUCCESS' } + return @($results | Sort-Object Type, Nom, Chemin -Unique) +} + +function Get-WindowsImageEditions { + param( + [Parameter(Mandatory=$true)][string]$ImagePath, + [AllowNull()][scriptblock]$LogAction = $null + ) + + if (-not (Test-Path -LiteralPath $ImagePath -PathType Leaf)) { + throw "Fichier image introuvable : $ImagePath" + } + + $normalized = @() + $getWindowsImage = Get-Command Get-WindowsImage -ErrorAction SilentlyContinue + if ($getWindowsImage) { + try { + $images = @(Get-WindowsImage -ImagePath $ImagePath -ErrorAction Stop) + foreach ($image in $images) { + $indexValue = $null + foreach ($propertyName in @('ImageIndex', 'Index')) { + if ($image.PSObject.Properties[$propertyName] -and $image.$propertyName) { + $indexValue = [int]$image.$propertyName + break + } + } + if ($null -eq $indexValue) { continue } + + $nameValue = $null + foreach ($propertyName in @('ImageName', 'Name', 'ImageDescription')) { + if ($image.PSObject.Properties[$propertyName] -and $image.$propertyName) { + $nameValue = [string]$image.$propertyName + break + } + } + $archValue = $null + foreach ($propertyName in @('Architecture', 'ImageArchitecture')) { + if ($image.PSObject.Properties[$propertyName] -and $image.$propertyName) { + $archValue = $image.$propertyName + break + } + } + $versionValue = $null + foreach ($propertyName in @('Version', 'ImageVersion')) { + if ($image.PSObject.Properties[$propertyName] -and $image.$propertyName) { + $versionValue = [string]$image.$propertyName + break + } + } + $normalized += [pscustomobject]@{ + Index = $indexValue + Name = $nameValue + Description = if ($image.PSObject.Properties['ImageDescription']) { $image.ImageDescription } else { $null } + Architecture = $archValue + Version = $versionValue + Edition = if ($image.PSObject.Properties['EditionId']) { $image.EditionId } else { $null } + } + } + } catch { + if ($LogAction) { & $LogAction "Get-WindowsImage indisponible pour $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 ($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")" + } + + $current = $null + foreach ($line in $output) { + $lineText = [string]$line + if ($lineText -match '^\s*Index\s*:\s*(\d+)\s*$') { + if ($null -ne $current) { $normalized += [pscustomobject]$current } + $current = @{ Index = [int]$Matches[1]; Name = ''; Description = ''; Architecture = $null; Version = $null; Edition = $null } + } elseif ($null -ne $current -and $lineText -match '^\s*Name\s*:\s*(.*?)\s*$') { + $current.Name = $Matches[1] + } elseif ($null -ne $current -and $lineText -match '^\s*Description\s*:\s*(.*?)\s*$') { + $current.Description = $Matches[1] + } + } + if ($null -ne $current) { $normalized += [pscustomobject]$current } + } + + if ($normalized.Count -eq 0) { + throw "DISM n’a retourné aucune édition exploitable pour : $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. + 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' } + continue + } + + $insideImageDetails = $false + $imageVersion = $null + $servicePackBuild = $null + foreach ($detailLine in $detailOutput) { + $detailText = [string]$detailLine + if ($detailText -match '^\s*Details for image\s*:') { + $insideImageDetails = $true + continue + } + # Ignorer la version de DISM affichée au début de sa sortie. + if (-not $insideImageDetails) { continue } + + if (-not $edition.Architecture -and $detailText -match '^\s*Architecture\s*:\s*(.*?)\s*$') { + $edition.Architecture = $Matches[1] + } elseif ($detailText -match '^\s*Version\s*:\s*(.*?)\s*$') { + $imageVersion = $Matches[1] + } elseif ($detailText -match '^\s*ServicePack Build\s*:\s*(.*?)\s*$') { + $servicePackBuild = $Matches[1] + } elseif (-not $edition.Name -and $detailText -match '^\s*Name\s*:\s*(.*?)\s*$') { + $edition.Name = $Matches[1] + } elseif (-not $edition.Edition -and $detailText -match '^\s*Edition\s*:\s*(.*?)\s*$') { + $edition.Edition = $Matches[1] + } + } + if ($imageVersion) { + $edition.Version = if ($servicePackBuild -and $servicePackBuild -ne '0') { + "$imageVersion.$servicePackBuild" + } else { + $imageVersion + } + } + } + + # 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. + $versionValues = @($normalized | ForEach-Object { + if (-not [string]::IsNullOrWhiteSpace([string]$_.Version)) { [string]$_.Version } + } | Select-Object -Unique) + $globalVersion = $null + if ($versionValues.Count -eq 1) { + $globalVersion = $versionValues[0] + } + + foreach ($edition in $normalized) { + if ($edition.Architecture -is [int] -or [string]$edition.Architecture -match '^\d+$') { + switch ([string]$edition.Architecture) { + '0' { $edition.Architecture = 'x86' } + '5' { $edition.Architecture = 'ARM' } + '9' { $edition.Architecture = 'x64' } + '12' { $edition.Architecture = 'ARM64' } + } + } + } + + return [pscustomobject]@{ + Editions = @($normalized) + GlobalVersion = $globalVersion + } +} + +function Resolve-InstallImagePath { + param( + [Parameter(Mandatory=$true)][string]$SelectedPath, + [AllowNull()][object]$ExistingImage = $null + ) + + if ([IO.Path]::GetExtension($SelectedPath) -in @('.wim', '.esd')) { + return [pscustomobject]@{ SourcePath = $SelectedPath; ImagePath = $SelectedPath; MountedImage = $null } + } + + if (Test-Path -LiteralPath $SelectedPath -PathType Container) { + foreach ($relative in @('sources\install.wim', 'sources\install.esd', 'Sources\install.wim', 'Sources\install.esd')) { + $candidate = Join-Path $SelectedPath $relative + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return [pscustomobject]@{ SourcePath = $SelectedPath; ImagePath = $candidate; MountedImage = $null } + } + } + throw "Aucun fichier Sources\install.wim ou Sources\install.esd dans : $SelectedPath" + } + + if ([IO.Path]::GetExtension($SelectedPath) -ieq '.iso') { + if ($ExistingImage -and $ExistingImage.SourcePath -and + [string]::Equals([IO.Path]::GetFullPath([string]$ExistingImage.SourcePath), [IO.Path]::GetFullPath($SelectedPath), [StringComparison]::OrdinalIgnoreCase) -and + $ExistingImage.MountedImage -and $ExistingImage.ImagePath) { + 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." + } + # Ne pas laisser Windows attribuer une lettre arbitraire (souvent D:) à l'ISO. + $diskImage = Mount-DiskImage -ImagePath $SelectedPath -Access ReadOnly -NoDriveLetter -PassThru -ErrorAction Stop + $mountInfo = [pscustomobject]@{ + SourcePath = $SelectedPath + ImagePath = $null + MountedImage = $diskImage + MountedPartition = $null + DriveLetter = $null + } + try { + # Partir des volumes explicitement associés à cette image évite de + # confondre le lecteur CDROM virtuel et un disque physique dans WinPE. + $volumes = @(Get-Volume -DiskImage $diskImage -ErrorAction Stop) + if ($volumes.Count -eq 0) { throw 'Aucun volume ISO expose par Windows.' } + $usedLetters = @( + Get-Volume -ErrorAction SilentlyContinue | Where-Object { $_.DriveLetter } | ForEach-Object { ([string]$_.DriveLetter).ToUpperInvariant() } + Get-PSDrive -PSProvider FileSystem -ErrorAction SilentlyContinue | ForEach-Object { ([string]$_.Name).ToUpperInvariant() } + ) + $availableLetter = $null + foreach ($candidateLetter in @('Z','Y','X','W','V','U','T','S','R','Q','P','O','N','M','L','K','J','I','H','G','F','E')) { + if ($candidateLetter -notin @('C','D') -and $candidateLetter -notin $usedLetters) { + $availableLetter = $candidateLetter + break + } + } + if (-not $availableLetter) { throw 'Aucune lettre de lecteur temporaire disponible pour le montage ISO.' } + + $checkedPaths = @() + foreach ($volume in $volumes) { + $volumeRoot = [string]$volume.Path + if (-not $volumeRoot.StartsWith('\\?\Volume{', [StringComparison]::OrdinalIgnoreCase) -and $volume.DriveLetter) { + $mountVolCommand = Join-Path $env:WINDIR 'System32\mountvol.exe' + if (-not (Test-Path -LiteralPath $mountVolCommand -PathType Leaf)) { $mountVolCommand = 'mountvol.exe' } + $volumeMountPath = '{0}:\' -f [string]$volume.DriveLetter + $volumeNameOutput = & $mountVolCommand $volumeMountPath '/L' 2>&1 + $volumeGuidPath = @($volumeNameOutput | ForEach-Object { [string]$_ } | Where-Object { $_.StartsWith('\\?\Volume{', [StringComparison]::OrdinalIgnoreCase) }) | Select-Object -First 1 + if ($volumeGuidPath) { $volumeRoot = [string]$volumeGuidPath } + } + if ($volumeRoot -and $volumeRoot.StartsWith('\\?\Volume{', [StringComparison]::OrdinalIgnoreCase)) { + if (-not $volumeRoot.EndsWith('\')) { $volumeRoot += '\' } + foreach ($relative in @('sources\install.wim', 'sources\install.esd')) { + $candidate = $volumeRoot + $relative + $checkedPaths += $candidate + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + if ([string]$volume.DriveLetter -eq 'D') { + $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 ' ')" } + } + $mountInfo.ImagePath = $candidate + return $mountInfo + } + } + } + + $partition = Get-Partition -Volume $volume -ErrorAction Stop + if ($null -eq $partition) { continue } + $existingDrivePaths = @($partition.AccessPaths | Where-Object { [string]$_ -match '^[A-Za-z]:\\$' }) + $reusableDrivePath = $existingDrivePaths | Where-Object { [string]$_ -notmatch '^[CDcd]:\\$' } | Select-Object -First 1 + if ($reusableDrivePath) { + $driveAccessPath = [string]$reusableDrivePath + $driveLetter = $driveAccessPath.Substring(0, 1).ToUpperInvariant() + } else { + foreach ($existingDrivePath in $existingDrivePaths) { + Remove-PartitionAccessPath -InputObject $partition -AccessPath ([string]$existingDrivePath) -ErrorAction Stop | Out-Null + } + $driveLetter = $availableLetter + $driveAccessPath = '{0}:\' -f $driveLetter + Add-PartitionAccessPath -InputObject $partition -AccessPath $driveAccessPath -ErrorAction Stop + } + $mountInfo.MountedPartition = $partition + $mountInfo.DriveLetter = $driveLetter + foreach ($relative in @('sources\install.wim', 'sources\install.esd')) { + $candidate = "${driveLetter}:\$relative" + $checkedPaths += $candidate + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + $mountInfo.ImagePath = $candidate + return $mountInfo + } + } + if ($driveLetter -eq $availableLetter) { + Remove-PartitionAccessPath -InputObject $partition -AccessPath $driveAccessPath -ErrorAction SilentlyContinue | Out-Null + } + $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 ', ')." + } catch { + try { [void](Dismount-DeployCenterInstallImage -Image $mountInfo -ErrorAction SilentlyContinue) } catch { } + throw + } + } + + throw "Type de source non pris en charge pour l'analyse : $SelectedPath" +} + +function Dismount-DeployCenterInstallImage { + param([AllowNull()][object]$Image) + + if ($null -eq $Image -or $null -eq $Image.MountedImage) { return } + try { + if ($Image.MountedPartition -and $Image.DriveLetter) { + $driveAccessPath = '{0}:\' -f [string]$Image.DriveLetter + Remove-PartitionAccessPath -InputObject $Image.MountedPartition -AccessPath $driveAccessPath -ErrorAction Stop | Out-Null + } + } finally { + Dismount-DiskImage -InputObject $Image.MountedImage -ErrorAction Stop | Out-Null + } +} + +Export-ModuleMember -Function Find-WindowsSources, Get-WindowsImageEditions, Resolve-InstallImagePath, Dismount-DeployCenterInstallImage diff --git a/Partitioning/Profiles/UEFI-GPT-WinRE-C-D.psd1 b/Partitioning/Profiles/UEFI-GPT-WinRE-C-D.psd1 index f14c5fc..a73e7e2 100644 --- a/Partitioning/Profiles/UEFI-GPT-WinRE-C-D.psd1 +++ b/Partitioning/Profiles/UEFI-GPT-WinRE-C-D.psd1 @@ -26,7 +26,7 @@ FileSystem = 'NTFS' GptTypeId = 'DE94BBA4-06D1-4D40-A16A-BFD50179D6AC' GptAttributes = '0x8000000000000001' - Size = @{ Policy = 'ImageSizePlusFreeSpace'; FreeSpaceMB = 250; PreviewMB = 1024 } + Size = @{ Policy = 'ConfiguredMB'; ConfigKey = 'WinRESizeMB'; Value = 1024; PreviewMB = 1024 } } @{ Name = 'Windows' diff --git a/Partitioning/Profiles/UEFI-GPT-WinRE-before-C.psd1 b/Partitioning/Profiles/UEFI-GPT-WinRE-before-C.psd1 index e87143e..6f27e94 100644 --- a/Partitioning/Profiles/UEFI-GPT-WinRE-before-C.psd1 +++ b/Partitioning/Profiles/UEFI-GPT-WinRE-before-C.psd1 @@ -26,7 +26,7 @@ FileSystem = 'NTFS' GptTypeId = 'DE94BBA4-06D1-4D40-A16A-BFD50179D6AC' GptAttributes = '0x8000000000000001' - Size = @{ Policy = 'ImageSizePlusFreeSpace'; FreeSpaceMB = 250; PreviewMB = 1024 } + Size = @{ Policy = 'ConfiguredMB'; ConfigKey = 'WinRESizeMB'; Value = 1024; PreviewMB = 1024 } } @{ Name = 'Windows' diff --git a/README.md b/README.md index 9a57455..8048f6a 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ -# DeployCenter — première version de la réécriture +# DeployCenter — première version de la réécriture ## Contenu - `DeployCenter.ps1` : point d'entrée et logique applicative existante. - `Modules/ThemeManager.psm1` : découverte, chargement et validation des thèmes PSD1, avec prise en charge des rôles de boutons. -- `Modules/UI.psm1` : style centralisé des boutons (normal, survol, appui et désactivé). +- `Modules/UI.psm1` : contrôles et fenêtres d’interface, construits selon le thème actif. +- `Modules/Deployment.psm1` : partitionnement UEFI/GPT et installation de l’édition Windows sélectionnée. - `Themes/Light.psd1` et `Themes/Dark.psd1` : palettes et styles des boutons. ## Lancement @@ -27,11 +28,11 @@ Conserver à côté du script les ressources habituelles du projet (`Assets`, `u Le redimensionnement de la fenêtre recalcule désormais les largeurs des panneaux, la taille du tableau et du journal, ainsi que la position des boutons. Les marges et hauteurs des contrôles restent volontairement fixes pour préserver la lisibilité sous WinPE. -## Limites connues de cette première étape +## Architecture et validation -Cette version centralise le chargement des thèmes et le style des boutons, et introduit une mise en page adaptative. La logique de déploiement, la détection des sources, l'inventaire et UltraVNC restent pour l'instant dans le script principal : leur extraction en modules indépendants doit être faite progressivement afin de ne pas casser les dépendances PowerShell ni le fonctionnement WinPE. +`DeployCenter.ps1` orchestre les modules : `UI.psm1` porte les workflows et contrôles d’interface dans le scope de session partagé, `ThemeManager.psm1` charge les palettes, `HardwareInventory.psm1` collecte et exporte les informations matérielles, `Partitioning.psm1` valide les profils et calcule les capacités, `WindowsSources.psm1` analyse les images Windows, et `Deployment.psm1` applique le profil puis déploie l’image. -Le script n'a pas pu être exécuté dans cet environnement, faute de moteur Windows PowerShell/WinForms. Il doit être validé sur la machine WinPE cible avant remplacement de la version actuelle. +La syntaxe PowerShell a été vérifiée statiquement. Le déroulement DISM/DiskPart/reagentc et le démarrage UEFI restent à valider sur la machine WinPE cible avant le premier déploiement réel. ## Mot de passe UltraVNC de test @@ -45,6 +46,24 @@ Password=MonMotDePasseDeTest Le mot de passe est stocké en clair dans `DeployCenter.ini` : utilisez cette option uniquement dans un environnement de test protégé. +## Taille de la partition WinRE + +La taille configurée par défaut est de 1 Go (1024 Mo). Elle peut être remplacée dans `DeployCenter.ini`, sous `[Partitioning]`, par `WinRESizeMB=1536` par exemple. Les valeurs doivent être des entiers d’au moins 990 Mo. Après l’application de Windows, DeployCenter vérifie la taille réelle de `winre.wim` et estime l’espace restant après sa copie ; l’installation est interrompue si la partition ne conserve pas au moins 250 Mo libres ou si elle est plus petite que 990 Mo. + +## Historique récent + +- Correction du retour du plan de partitionnement : conversion explicite de la liste générique en tableau pour éviter une erreur de types sous Windows PowerShell 5.1 avant l’exécution de DiskPart. +- Sous certains WinPE, le code de sortie de DiskPart peut être absent malgré une exécution réussie. DeployCenter confirme désormais le partitionnement et le retrait des lettres en relisant l’état des partitions. +- Remplacement de l’apostrophe typographique de « d’hôte » par une apostrophe ASCII simple dans le message de validation NetBIOS de `Deployment.psm1`. +- Correction de la chaîne du journal DiskPart : les apostrophes typographiques dans une chaîne PowerShell délimitée par apostrophes simples provoquaient une erreur de syntaxe signalée plus loin dans le fichier. +- Vérification des lettres de partition par requête directe `Get-Partition -DriveLetter`, puis contrôle du numéro de disque, afin de ne pas dépendre des lettres présentes dans l’instantané retourné par `Get-Partition -DiskNumber` sous WinPE. +- La partition Recovery, marquée cachée par ses attributs GPT, peut ne pas être exposée par `Get-Partition -DriveLetter`. DeployCenter l’associe alors à sa position dans les partitions fraîchement créées par DiskPart. +- Les partitions EFI et Recovery pouvant être absentes des requêtes `Get-Partition -DriveLetter` dans WinPE, l’association des rôles suit désormais l’ordre des partitions créées et vérifie l’accès effectif à chaque lettre temporaire. +- Retrait de l’ajout automatique de `/ScratchDir` aux commandes DISM afin de tester le comportement sans cette redirection dans WinPE ; le journal DISM dédié est conservé. +- La progression de l’application d’image lit maintenant les pourcentages émis par DISM pendant son exécution et les transmet à la barre WinForms. Si le code de sortie DISM est absent, le message de réussite explicite est aussi pris en compte. +- Le pourcentage DISM est aussi affiché dans le libellé de l’étape de progression, sans ajouter de ligne de journal à chaque mise à jour. +- Vérification de l’espace WinRE via `System.IO.DriveInfo` : `Get-Volume -DriveLetter` ne trouve pas toujours les volumes de récupération cachés dans WinPE, même lorsqu’ils sont montés et accessibles. + ### Correctifs v3 - En-têtes du tableau des éditions Windows avec une couleur dédiée définie par le thème (`TableHeader`). @@ -92,15 +111,48 @@ La gestion d'UltraVNC est isolée dans `Modules/VNC.psm1`. Le fichier est charg - `Modules/Network.psm1` : détection de l’adresse IPv4 de déploiement, utilisée par le pied de page et le nom du journal. - `Modules/Logging.psm1` : initialisation et écriture des journaux. L’adresse IPv4 détectée lui est transmise par le script principal. -- `Modules/Partitioning.psm1` : découverte et validation des profils de partitionnement déclarés dans `Partitioning/Profiles/*.psd1`. +- `Modules/Configuration.psm1` : lecture de `DeployCenter.ini` en lecture seule. +- `Modules/ThemeManager.psm1` : chargement et validation des palettes déclarées dans `Themes/*.psd1`. +- `Modules/UI.psm1` : contrôles, boîtes de dialogue, fenêtres Sources Windows et workflows d’interface. +- `Modules/HardwareInventory.psm1` : collecte matérielle, sélection d’un disque et export consolidé de l’inventaire. +- `Modules/Partitioning.psm1` : découverte et validation des profils PSD1, calcul de capacité et construction de l’état de dimensionnement. +- `Modules/Deployment.psm1` : orchestration du partitionnement et de l’installation Windows pour les profils UEFI/GPT. +- `Modules/WindowsSources.psm1` : recherche de supports Windows et lecture des éditions WIM/ESD. ## Profils de partitionnement -Les fichiers PSD1 de `Partitioning/Profiles` décrivent les schémas de partitionnement comme des données, sans exécuter de code contenu dans les profils. Le profil par défaut « UEFI / GPT — WinRE avant C: » crée EFI (300 Mo), MSR (16 Mo), WinRE (taille de `winre.wim` plus 250 Mo libres), puis Windows sur l’espace restant. Le profil « UEFI / GPT — WinRE, C: et D: » demande la taille de C: et attribue le reste à D:, dans l’ordre physique C: puis D:. Les propriétés de taille déclarées dans le profil alimentent un formulaire générique. Le profil fournit une estimation de la capacité disponible avec une réserve WinRE de 1 Go ; le calcul effectif de WinRE et l’exécution DiskPart ne sont pas encore implémentés. +Les fichiers PSD1 de `Partitioning/Profiles` décrivent les schémas de partitionnement comme des données, sans exécuter de code contenu dans les profils. Le profil par défaut « UEFI / GPT — WinRE avant C: » crée EFI (300 Mo), MSR (16 Mo), WinRE (taille définie dans `DeployCenter.ini`, 1 Go par défaut), puis Windows sur l’espace restant. Le profil « UEFI / GPT — WinRE, C: et D: » demande la taille de C: et attribue le reste à D:, dans l’ordre physique C: puis D:. Les propriétés de taille déclarées dans le profil alimentent un formulaire générique. + +Le bouton « Installer » demande une confirmation explicite avant d’effacer le disque sélectionné. Le flux vérifie le firmware, crée les partitions définies par le profil, applique l’image avec DISM, vérifie ensuite que WinRE tient dans la partition configurée avec au moins 250 Mo libres, configure le nom d’hôte, crée les fichiers de démarrage UEFI et active WinRE. Les opérations longues affichent une fenêtre de progression et sont consignées dans le journal. Le moteur actuel prend en charge les profils UEFI/GPT et requiert une image contenant `Windows\System32\Recovery\winre.wim`. ## Historique des changements +### 2026-10-17 + +- Correction de l’échappement des arguments des commandes natives Windows. Les chemins terminés par une barre oblique inverse, comme `W:\`, ne ferment plus prématurément leur guillemet ; cela permet à `mountvol` de recevoir correctement sa lettre et le chemin de volume. +- L’affectation temporaire d’une lettre source vérifie l’accès effectif au fichier WIM après `mountvol`, afin de gérer les environnements WinPE qui ne remontent pas correctement son code de sortie. +- Le point de montage DISM temporaire est créé à la racine du lecteur système WinPE plutôt que sous `%TEMP%\DeployCenter`, après l’échec d’accès DISM dans ce dernier emplacement ; la libération de la lettre source est confirmée par l’inaccessibilité du WIM après `mountvol /D`. +- La taille de WinRE est lue en montant l’édition sélectionnée avec DISM en lecture seule, pour les sources `.wim` comme `.esd`, puis en démontant l’image sans modification. Cette méthode remplace l’extraction directe de `winre.wim`, qui remplissait le disque RAM WinPE (erreur WIMGAPI `112`). +- Remplacement de cette lecture préalable par une taille WinRE configurable dans `[Partitioning] WinRESizeMB` (1024 Mo par défaut). Le profil réserve cette taille sans monter ni extraire `install.wim` ; après l’application de Windows, DeployCenter mesure `winre.wim` et arrête la configuration WinRE si la partition est inférieure à 990 Mo ou ne conserverait pas 250 Mo libres après copie. +- Correction de la fermeture du callback de progression : le callback de journalisation est maintenant capturé explicitement avant `GetNewClosure()`, afin qu’il reste accessible depuis le scope de l’action de progression. +- Pour la préparation DISM, une source ISO exposée sous un chemin de volume GUID reçoit temporairement une lettre libre autre que C: ou D:. DISM reçoit aussi son répertoire temporaire et son propre journal pour faciliter le diagnostic des erreurs de montage. +- Resserrement de l’espacement dans la confirmation d’effacement pour rapprocher l’avertissement du nom d’hôte. +- Ajout du flux intégré « Installer » : confirmation avant effacement, tailles des partitions calculées depuis le profil, application DISM, configuration du nom d’hôte, fichiers de démarrage UEFI et activation de WinRE. +- Création de `Modules/Deployment.psm1` pour isoler l’exécution du déploiement ; ajout dans `UI.psm1` des fenêtres thémées de confirmation et de progression. Le nouveau bouton d’installation suit le thème actif. +- Suppression des objets de résultat de démontage ISO à la fois dans `WindowsSources.psm1` et aux appels UI, afin qu’ils ne s’affichent pas dans la console PowerShell. +- Prise en charge des volumes ISO exposés par WinPE sans objet partition : recherche via le chemin de volume (GUID), puis libération de D: avec `mountvol` si cette lettre est attribuée à l’ISO. +- Recherche de `install.wim`/`install.esd` à partir des volumes associés explicitement à l’ISO, puis association de chaque volume à sa partition. +- Le montage ISO réutilise une lettre déjà attribuée si elle n’est ni C: ni D: ; si WinPE a attribué l’une de ces lettres, elle est retirée avant l’affectation temporaire. +- Correction des appels `Add-PartitionAccessPath` et `Remove-PartitionAccessPath` : avec `-InputObject`, la lettre du lecteur doit être fournie comme `-AccessPath` pour respecter leurs jeux de paramètres. +- Résolution du disque ISO par `Get-Disk -Number` à partir de `MSFT_DiskImage.Number`, sans pipeline ambigu dans certaines versions de WinPE. +- Correction de l’association entre un ISO monté et son disque dans WinPE : résolution via `Get-DiskImage | Get-Disk` plutôt que par le chemin `\\.\CDROMn`. +- Le montage ISO n’attribue plus de lettre automatiquement : une lettre temporaire disponible est choisie en excluant C: et D:. Une barre d’activité apparaît pendant l’analyse des éditions. +- Renommage de la commande de nettoyage du montage ISO avec le verbe PowerShell approuvé `Dismount`, pour supprimer l’avertissement à l’import du module WindowsSources. +- Le montage d’un ISO reste actif tant qu’il est la source Windows sélectionnée ; il est libéré après analyse annulée, remplacement par une autre source, erreur ou fermeture de DeployCenter. +- Le callback de journalisation utilise `INFO` lorsque le niveau est omis ou vide, afin d’éviter une erreur de validation `ValidateSet` au démarrage. +- Correction du callback de journalisation au démarrage : il conserve désormais le scope du script principal pour résoudre `Write-Log` depuis les événements WinForms. Les derniers appels directs de `DeployCenter.ps1` passent aussi par ce callback. + ### 2026-10-15 - Les anciens exports de `HardwareInfo.ps1` ont d’abord été déplacés sous `%TEMP%\DeployCenter`, puis retirés au profit du seul inventaire consolidé décrit ci-dessous. @@ -131,3 +183,19 @@ Les fichiers PSD1 de `Partitioning/Profiles` décrivent les schémas de partitio - Le champ du nom d’hôte est aligné à droite du panneau avec un espacement réservé après son libellé afin d’éviter tout chevauchement. - Dans Sources Windows, le double-clic sur une ligne sélectionnée lance aussi l’affichage des éditions, comme le bouton dédié. - Après validation d’une édition Windows, la fenêtre Sources Windows se ferme pour revenir directement à l’écran principal. + +- Extraction de la recherche, de la lecture des éditions et de la résolution des images Windows vers Modules/WindowsSources.psm1. DeployCenter.ps1 charge ce module après l’initialisation du journal. +- Le module Sources Windows reçoit explicitement un callback de journalisation fourni par Logging.psm1, sans dépendre implicitement de la portée du script principal. + +- Déplacement dans UI.psm1 des constructeurs génériques de labels, groupes et boutons, du pied de page de session et du réglage de fenêtre. Les fonctions reçoivent explicitement thème, police et données de session. +- Extraction dans UI.psm1 de la sélection du logo selon le thème et de son remplacement ; chemins et PictureBox sont transmis en paramètres. +- Création de Modules/Configuration.psm1 : la lecture de DeployCenter.ini est isolée et retourne un objet de préférences sans modifier le fichier. +- Répartition des fonctions restantes : workflows de fenêtre et de thème dans UI.psm1, préférences dans Configuration.psm1, collecte/export/lecture disque dans HardwareInventory.psm1. Les profils et calculs de capacité restent dans Partitioning.psm1. +- Configuration.psm1 renvoie désormais un objet de configuration avec des valeurs par défaut et ne dépend plus de variables script externes. +- HardwareInventory.psm1 exporte la collecte et l’export avec des paramètres explicites ; HardwareInfo.ps1 reste un point d’entrée de compatibilité. +- Nettoyage des espaces en fin de ligne dans les modules UI et Partitioning après leur déplacement. +- Partitioning.psm1 construit désormais l’état des tailles choisi à partir du profil, du disque et du résultat du dialogue UI. +- Ajout du BOM UTF-8 aux nouveaux fichiers PowerShell pour Windows PowerShell 5.1 et tolérance de l’absence temporaire d’inventaire lors de la résolution d’un disque. +- Mise à jour de la description d’architecture et de la liste des modules après l’extraction des fonctions restantes. +- Correction des appels d’export d’inventaire depuis le module VNC : les chemins requis et les données de session sont transmis explicitement, sans invite interactive au lancement. +- Les workflows UI et VNC transmettent désormais leurs messages au callback de Logging.psm1, sans appeler Write-Log directement depuis leurs scopes.