commit 7c724d475ecc38fc493b5a26882e02bdcad06ae8 Author: Benoit LORAND Date: Thu Oct 15 21:15:33 2026 +0200 DeployCenter diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aac2254 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +Logs/* \ No newline at end of file diff --git a/Assets/logo-dark.png b/Assets/logo-dark.png new file mode 100644 index 0000000..6e1350c Binary files /dev/null and b/Assets/logo-dark.png differ diff --git a/Assets/logo.png b/Assets/logo.png new file mode 100644 index 0000000..319bbe3 Binary files /dev/null and b/Assets/logo.png differ diff --git a/DeployCenter.ini.example b/DeployCenter.ini.example new file mode 100644 index 0000000..772df48 --- /dev/null +++ b/DeployCenter.ini.example @@ -0,0 +1,12 @@ +; Preferences DeployCenter +; Le fichier est lu au démarrage et n'est jamais modifié automatiquement. +[General] +Theme=Light + +[Sources] +Paths=%DEPLOY_VOLUME% + +[UltraVNC] +; 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= diff --git a/DeployCenter.ps1 b/DeployCenter.ps1 new file mode 100644 index 0000000..7bc77b6 --- /dev/null +++ b/DeployCenter.ps1 @@ -0,0 +1,1817 @@ +# DeployCenter - Interface WinPE +# Compatible Windows PowerShell 5.1 + +param( + # Affiche DeployCenter dans une fenetre redimensionnable pour le debug. + [switch]$Windowed, + [switch]$Vnc +) + +$script:Windowed = [bool]$Windowed +$ErrorActionPreference = "Stop" + +Add-Type -AssemblyName System.Windows.Forms +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 + +# GroupBox dessine par DeployCenter pour que le fond et le cadre suivent les themes. +if (-not ([System.Management.Automation.PSTypeName]'DeployCenter.ThemedGroupBox').Type) { + Add-Type -TypeDefinition @' +using System; +using System.Drawing; +using System.Windows.Forms; + +namespace DeployCenter { + public class ThemedGroupBox : GroupBox { + public Color PanelColor { get; set; } + public Color BorderColor { get; set; } + public Color TextColor { get; set; } + + public ThemedGroupBox() { + SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | + ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); + PanelColor = SystemColors.Control; + BorderColor = SystemColors.ControlDark; + TextColor = SystemColors.ControlText; + } + + protected override void OnPaint(PaintEventArgs e) { + Graphics g = e.Graphics; + g.Clear(PanelColor); + Size textSize = TextRenderer.MeasureText(Text, Font); + int textLeft = 10; + int lineY = Math.Max(8, (int)Math.Ceiling(textSize.Height / 2.0)); + using (Pen pen = new Pen(BorderColor)) { + g.DrawLine(pen, 0, lineY, textLeft - 2, lineY); + int afterText = textLeft + textSize.Width + 4; + if (afterText < Width - 2) g.DrawLine(pen, afterText, lineY, Width - 2, lineY); + g.DrawLine(pen, 0, lineY, 0, Height - 2); + g.DrawLine(pen, 0, Height - 2, Width - 2, Height - 2); + g.DrawLine(pen, Width - 2, lineY, Width - 2, Height - 2); + } + TextRenderer.DrawText(g, Text, Font, new Point(textLeft, 0), TextColor, + PanelColor, TextFormatFlags.NoPadding | TextFormatFlags.SingleLine); + } + } +} +'@ -ReferencedAssemblies System.Windows.Forms,System.Drawing +} + +# ============================================================ +# CHEMINS +# ============================================================ + +$script:DeployRoot = $PSScriptRoot +# Racine du lecteur qui heberge DeployCenter (ex. Y:\), sans lecteur code en dur. +$script:DeployVolumeRoot = $null +try { + $deployQualifier = Split-Path -Path $script:DeployRoot -Qualifier -ErrorAction Stop + if (-not [string]::IsNullOrWhiteSpace($deployQualifier)) { + $script:DeployVolumeRoot = $deployQualifier.TrimEnd('\') + '\' + } +} 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" +$script:LogRoot = Join-Path $script:DeployRoot "Logs" + +# Détection réseau centralisée dans Modules\\Network.psm1. +$networkModulePath = Join-Path $PSScriptRoot "Modules\\Network.psm1" +if (-not (Test-Path -LiteralPath $networkModulePath -PathType Leaf)) { + throw "Module réseau introuvable : $networkModulePath" +} +. ([scriptblock]::Create([System.IO.File]::ReadAllText($networkModulePath))) +$script:DeployIPv4 = Get-DeployNetworkIPv4 + +# La gestion des journaux est centralisee dans Modules\\Logging.psm1. +$loggingModulePath = Join-Path $PSScriptRoot "Modules\\Logging.psm1" +if (-not (Test-Path -LiteralPath $loggingModulePath -PathType Leaf)) { + throw "Module de journalisation introuvable : $loggingModulePath" +} +. ([scriptblock]::Create([System.IO.File]::ReadAllText($loggingModulePath))) +Initialize-DeployCenterLogging -LogRoot $script:LogRoot -IPv4 $script:DeployIPv4 +$script:HardwareJson = Join-Path $env:TEMP "HardwareInfo.json" + +# Inventaire de session et UltraVNC portable : tout reste dans le dossier temporaire. +$script:InventoryDir = Join-Path $env:TEMP 'DeployCenter' +$script:InventoryFile = Join-Path $script:InventoryDir 'DeployCenter_Inventory.json' +$script:VncPassword = $null +$script:VncDirectory = Join-Path $script:InventoryDir ("uvnc_{0}" -f $PID) +$script:VncProcess = $null +$script:VncStarted = $false +$script:HardwareInventory = $null + +# ============================================================ +# THEMES EXTERNES (Themes\*.psd1) +# Les thèmes sont des fichiers de données ; aucun code PowerShell n'est exécuté. +# ============================================================ + +$script:ThemesRoot = Join-Path $script:DeployRoot "Themes" +if (-not (Test-Path -LiteralPath $script:ThemesRoot -PathType Container)) { + New-Item -Path $script:ThemesRoot -ItemType Directory -Force | Out-Null +} + +# ============================================================ +# PREFERENCES (DeployCenter.ini en lecture seule : aucune ecriture automatique) +# ============================================================ + +$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 = @{ + Dark = @" +@{ + Background = "#171A21" + Panel = "#222731" + PanelAlt = "#1C2029" + Text = "#F1F5F9" + Secondary = "#9CA9BC" + Accent = "#3B82F6" + Success = "#4ADE80" + Border = "#414A59" + Danger = "#EF4444" + Button = @{ + Background = "#303744"; Foreground = "#F1F5F9"; Border = "#414A59" + HoverBackground = "#3B4555"; PressedBackground = "#485365" + DisabledBackground = "#292E38"; DisabledForeground = "#7F8999" + } + PrimaryButton = @{ + Background = "#3B82F6"; Foreground = "#FFFFFF"; Border = "#3B82F6" + HoverBackground = "#2563EB"; PressedBackground = "#1D4ED8" + DisabledBackground = "#344A6A"; DisabledForeground = "#9AAAC1" + } + DangerButton = @{ + Background = "#EF4444"; Foreground = "#FFFFFF"; Border = "#EF4444" + HoverBackground = "#DC2626"; PressedBackground = "#B91C1C" + DisabledBackground = "#59383B"; DisabledForeground = "#BBA0A2" + } +} +"@ + Light = @" +@{ + Background = "#F5F7FA" + Panel = "#FFFFFF" + PanelAlt = "#F8FAFC" + Text = "#1E293B" + Secondary = "#475569" + Accent = "#2563EB" + Success = "#16A34A" + Border = "#CBD5E1" + Danger = "#DC2626" + Button = @{ + Background = "#E8EDF3"; Foreground = "#1E293B"; Border = "#CBD5E1" + HoverBackground = "#DCE4EE"; PressedBackground = "#CBD5E1" + DisabledBackground = "#F1F3F6"; DisabledForeground = "#94A3B8" + } + PrimaryButton = @{ + Background = "#2563EB"; Foreground = "#FFFFFF"; Border = "#2563EB" + HoverBackground = "#1D4ED8"; PressedBackground = "#1E40AF" + DisabledBackground = "#C7D8F8"; DisabledForeground = "#F8FAFC" + } + DangerButton = @{ + Background = "#DC2626"; Foreground = "#FFFFFF"; Border = "#DC2626" + HoverBackground = "#B91C1C"; PressedBackground = "#991B1B" + DisabledBackground = "#F2C5C5"; DisabledForeground = "#FFF7F7" + } +} +"@ +} +foreach ($themeFileName in $defaultThemeFiles.Keys) { + $themeFilePath = Join-Path $script:ThemesRoot ($themeFileName + '.psd1') + if (-not (Test-Path -LiteralPath $themeFilePath -PathType Leaf)) { + Set-Content -LiteralPath $themeFilePath -Value $defaultThemeFiles[$themeFileName] -Encoding UTF8 + } +} + +Read-DeployCenterConfig +$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" } +$script:CurrentTheme = $script:Config.Theme +try { + $script:Theme = Import-DeployCenterTheme -ThemesRoot $script:ThemesRoot -ThemeName $script:CurrentTheme +} catch { + $script:CurrentTheme = "Light" + $script:Config.Theme = "Light" + $script:Theme = Import-DeployCenterTheme -ThemesRoot $script:ThemesRoot -ThemeName "Light" +} +# ============================================================ +# POLICES +# ============================================================ + +$script:FontRegular = [System.Drawing.Font]::new("Segoe UI", [single]10, [System.Drawing.FontStyle]::Regular) +$script:FontSmall = [System.Drawing.Font]::new("Segoe UI", [single]9, [System.Drawing.FontStyle]::Regular) +$script:FontTitle = [System.Drawing.Font]::new("Segoe UI", [single]23, [System.Drawing.FontStyle]::Bold) +$script:FontSubtitle = [System.Drawing.Font]::new("Segoe UI", [single]10, [System.Drawing.FontStyle]::Regular) +$script:FontGroup = [System.Drawing.Font]::new("Segoe UI", [single]11, [System.Drawing.FontStyle]::Bold) + +# ============================================================ +# 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 + } 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. + +# VNC est désactivé par défaut ; il démarre uniquement avec le paramètre -vnc. +$script:EnableVnc = [bool]$Vnc +if ($script:EnableVnc) { + try { + # Le code VNC est exécuté dans le scope principal uniquement avec -vnc. + $vncModulePath = Join-Path $PSScriptRoot "Modules\VNC.psm1" + if (-not (Test-Path -LiteralPath $vncModulePath -PathType Leaf)) { + throw "Module VNC introuvable : $vncModulePath" + } + $vncCode = [System.IO.File]::ReadAllText($vncModulePath) + . ([scriptblock]::Create($vncCode)) + Start-DeployCenterVnc + } catch { + Write-Log "Impossible de démarrer VNC : $($_.Exception.Message)" 'ERROR' + } +} else { + Write-Log "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 +# ============================================================ + +$script:form = New-Object System.Windows.Forms.Form +$script:form.Text = "DeployCenter" +$script:form.BackColor = $script:Theme.Background +if ($script:Windowed) { + # Mode debug : fenetre classique, deplacable et reductible. + $script:form.StartPosition = [System.Windows.Forms.FormStartPosition]::CenterScreen + $script:form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::Sizable + $script:form.WindowState = [System.Windows.Forms.FormWindowState]::Normal + $script:form.TopMost = $false + $screen = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea + $debugWidth = [Math]::Min(1200, [Math]::Max(800, $screen.Width - 80)) + $debugHeight = [Math]::Min(850, [Math]::Max(600, $screen.Height - 80)) + $script:form.Size = New-Object System.Drawing.Size($debugWidth, $debugHeight) +} else { + # Mode normal : plein ecran, sans bordure et au premier plan. + $script:form.StartPosition = "Manual" + $script:form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::None + $script:form.WindowState = [System.Windows.Forms.FormWindowState]::Maximized + $script:form.TopMost = $true + $script:form.Bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds +} +$script:form.ForeColor = $script:Theme.Text +$script:form.Font = $script:FontRegular +$script:form.KeyPreview = $true +$script:form.MinimumSize = New-Object System.Drawing.Size(800, 600) + +$screenWidth = $script:form.ClientSize.Width +$screenHeight = $script:form.ClientSize.Height +$margin = 30 +$usableWidth = $screenWidth - (2 * $margin) + +# ============================================================ +# EN-TETE ET LOGO FACULTATIF +# ============================================================ + +$script:pictureLogo = $null +$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 +if ($null -ne $initialLogoPath) { + try { + $script:pictureLogo = New-Object System.Windows.Forms.PictureBox + $script:pictureLogo.Location = New-Object System.Drawing.Point(30, 20) + $script:pictureLogo.Size = New-Object System.Drawing.Size(64, 64) + $script:pictureLogo.SizeMode = [System.Windows.Forms.PictureBoxSizeMode]::Zoom + $script:pictureLogo.BackColor = [System.Drawing.Color]::Transparent + $script:pictureLogo.Image = [System.Drawing.Image]::FromFile($initialLogoPath) + $script:form.Controls.Add($script:pictureLogo) + $titleX = 110 + } + catch { + $script:pictureLogo = $null + $titleX = 30 + } +} + +$script:lblTitle = New-Label -Text "DeployCenter" ` + -X $titleX -Y 17 -Width 600 -Height 42 ` + -Color $script:Theme.Text -Font $script:FontTitle + +$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 + +$script:form.Controls.Add($script:lblTitle) +$script:form.Controls.Add($script:lblSubtitle) + +# Menu de selection du theme ; les noms correspondent aux fichiers Themes\.psd1 +$script:cmbTheme = New-Object System.Windows.Forms.ComboBox +$script:cmbTheme.DropDownStyle = [System.Windows.Forms.ComboBoxStyle]::DropDownList +# OwnerDraw permet de colorer aussi la liste deroulante, que WinForms laisse sinon au theme systeme. +$script:cmbTheme.DrawMode = [System.Windows.Forms.DrawMode]::OwnerDrawFixed +$script:cmbTheme.ItemHeight = 24 +$script:cmbTheme.Font = $script:FontRegular +$script:cmbTheme.Width = 190 +$script:cmbTheme.Height = 32 +$script:cmbTheme.Location = New-Object System.Drawing.Point(0, 29) +$script:cmbTheme.Anchor = [System.Windows.Forms.AnchorStyles]::Top -bor [System.Windows.Forms.AnchorStyles]::Right +foreach ($themeName in (Get-DeployCenterThemeNames -ThemesRoot $script:ThemesRoot)) { [void]$script:cmbTheme.Items.Add($themeName) } +$script:cmbTheme.SelectedItem = $script:CurrentTheme +$script:cmbTheme.BackColor = $script:Theme.Panel +$script:cmbTheme.ForeColor = $script:Theme.Text +$script:cmbTheme.Add_DrawItem({ + param($sender, $e) + if ($e.Index -lt 0) { + $itemText = [string]$sender.Text + } else { + $itemText = [string]$sender.Items[$e.Index] + } + $isSelected = (($e.State -band [System.Windows.Forms.DrawItemState]::Selected) -ne 0) + if ($isSelected) { + $backgroundColor = $script:Theme.Accent + $foregroundColor = [System.Drawing.Color]::White + } else { + $backgroundColor = $script:Theme.Panel + $foregroundColor = $script:Theme.Text + } + $backgroundBrush = New-Object System.Drawing.SolidBrush($backgroundColor) + try { $e.Graphics.FillRectangle($backgroundBrush, $e.Bounds) } + finally { $backgroundBrush.Dispose() } + if (-not [string]::IsNullOrEmpty($itemText)) { + $textBounds = $e.Bounds + $textBounds.X += 4 + [System.Windows.Forms.TextRenderer]::DrawText( + $e.Graphics, $itemText, $sender.Font, $textBounds, $foregroundColor, + ([System.Windows.Forms.TextFormatFlags]::Left -bor [System.Windows.Forms.TextFormatFlags]::VerticalCenter -bor [System.Windows.Forms.TextFormatFlags]::EndEllipsis) + ) + } + $e.DrawFocusRectangle() +}) +$script:form.Controls.Add($script:cmbTheme) +$script:cmbTheme.BringToFront() + +# ============================================================ +# INFORMATIONS DU PC +# ============================================================ + +$script:groupPC = New-GroupBox -Text "Informations du PC" ` + -X $margin -Y 100 -Width $usableWidth -Height 105 + +$script:lblManufacturer = New-Label -Text "Fabricant : -" ` + -X 18 -Y 25 -Width 330 -Height 24 +$script:lblModel = New-Label -Text "Modèle : -" ` + -X 360 -Y 25 -Width 400 -Height 24 +$script:lblSerial = New-Label -Text "Numéro de série : -" ` + -X 18 -Y 52 -Width 330 -Height 24 +$script:lblFirmware = New-Label -Text "Firmware : -" ` + -X 360 -Y 52 -Width 220 -Height 24 +$script:lblArchitecture = New-Label -Text "Architecture : -" ` + -X 600 -Y 52 -Width 220 -Height 24 +$script:lblWinPE = New-Label -Text "Environnement : -" ` + -X 18 -Y 78 -Width 350 -Height 22 ` + -Color $script:Theme.Secondary -Font $script:FontSmall + +$script:groupPC.Controls.AddRange(@( + $script:lblManufacturer, + $script:lblModel, + $script:lblSerial, + $script:lblFirmware, + $script:lblArchitecture, + $script:lblWinPE +)) +$script:form.Controls.Add($script:groupPC) + +# ============================================================ +# DISQUE PHYSIQUE - SELECTEUR COMPACT +# ============================================================ + +$script:groupDisks = New-GroupBox -Text "Disque physique" ` + -X $margin -Y 220 -Width $usableWidth -Height 78 + +$script:cmbDisk = New-Object System.Windows.Forms.ComboBox +$script:cmbDisk.Name = "cmbDisk" +$script:cmbDisk.Location = New-Object System.Drawing.Point(12, 30) +$script:cmbDisk.Size = New-Object System.Drawing.Size(($usableWidth - 24), 28) +$script:cmbDisk.Anchor = [System.Windows.Forms.AnchorStyles]::Top -bor [System.Windows.Forms.AnchorStyles]::Left -bor [System.Windows.Forms.AnchorStyles]::Right +$script:cmbDisk.DropDownStyle = [System.Windows.Forms.ComboBoxStyle]::DropDownList +$script:cmbDisk.FlatStyle = [System.Windows.Forms.FlatStyle]::Flat +$script:cmbDisk.BackColor = $script:Theme.PanelAlt +$script:cmbDisk.ForeColor = $script:Theme.Text +$script:cmbDisk.Font = $script:FontSmall +$script:cmbDisk.IntegralHeight = $false +$script:cmbDisk.MaxDropDownItems = 12 +$script:groupDisks.Controls.Add($script:cmbDisk) +$script:form.Controls.Add($script:groupDisks) + +function Set-DiskSelectorWidth { + if ($null -eq $script:cmbDisk -or $null -eq $script:groupDisks) { return } + $script:cmbDisk.Location = New-Object System.Drawing.Point(12, 30) + $script:cmbDisk.Size = New-Object System.Drawing.Size([Math]::Max(250, $script:groupDisks.ClientSize.Width - 24), 28) +} + +# ============================================================ +# JOURNAL +# ============================================================ + +$logY = $script:groupDisks.Bottom + 15 +$buttonHeight = 38 +# En plein ecran, rapprocher les boutons du bas sans empiéter sur le pied de page. +# En mode fenetre, conserver la position actuelle. +$buttonY = if ($script:Windowed) { + $screenHeight - 54 - $buttonHeight +} else { + $screenHeight - 42 - $buttonHeight +} +$logHeight = [math]::Max(100, $buttonY - $logY - 15) + +$script:groupLog = New-GroupBox -Text "Journal" ` + -X $margin -Y $logY -Width $usableWidth -Height $logHeight + +$script:txtLog = New-Object System.Windows.Forms.TextBox +$script:txtLog.Location = New-Object System.Drawing.Point(12, 28) +$script:txtLog.Size = New-Object System.Drawing.Size( + ($usableWidth - 28), ($logHeight - 44) +) +$script:txtLog.Multiline = $true +$script:txtLog.ReadOnly = $true +$script:txtLog.WordWrap = $false +$script:txtLog.ScrollBars = [System.Windows.Forms.ScrollBars]::Both +$script:txtLog.BackColor = $script:Theme.PanelAlt +$script:txtLog.ForeColor = $script:Theme.Text +$script:txtLog.BorderStyle = [System.Windows.Forms.BorderStyle]::FixedSingle +$script:txtLog.Font = [System.Drawing.Font]::new("Consolas", [single]9, [System.Drawing.FontStyle]::Regular) +$script:groupLog.Controls.Add($script:txtLog) +$script:form.Controls.Add($script:groupLog) + +# ============================================================ +# BOUTONS ACTUALISER ET FERMER +# ============================================================ + +$buttonWidth = 140 +$buttonGap = 12 +$closeX = $screenWidth - $margin - $buttonWidth +$refreshX = $closeX - $buttonGap - $buttonWidth +$sourcesX = $refreshX - $buttonGap - 170 + +$script:btnSources = New-Button -Text "Sources Windows" ` + -X $sourcesX -Y $buttonY -Width 170 -Height $buttonHeight ` + -Role "Button" +$script:btnSources.ForeColor = $script:Theme.Text + +$script:btnRefresh = New-Button -Text "Actualiser" ` + -X $refreshX -Y $buttonY -Width $buttonWidth -Height $buttonHeight ` + -Role "PrimaryButton" + +$script:btnClose = New-Button -Text "Fermer" ` + -X $closeX -Y $buttonY -Width $buttonWidth -Height $buttonHeight ` + -Role "DangerButton" + +$script:form.Controls.Add($script:btnSources) +$script:form.Controls.Add($script:btnRefresh) +$script:form.Controls.Add($script:btnClose) +Add-SessionFooter -TargetForm $script:form -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 + # 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.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 + + # 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:btnSources -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:groupLog.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 - 40)) + $lblStatus.Size = New-Object System.Drawing.Size([Math]::Max(300, $clientWidth - 32), 32) + $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) { + [System.Windows.Forms.MessageBox]::Show('Sélectionne d''abord une source dans la liste.', 'Éditions Windows', 'OK', 'Information') | Out-Null + 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' + } 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' + [System.Windows.Forms.MessageBox]::Show($_.Exception.Message, 'Analyse des editions', 'OK', 'Warning') | Out-Null + } finally { + $dlg.Cursor = [System.Windows.Forms.Cursors]::Default + $btnEditions.Enabled = $true + $btnScan.Enabled = $true + } + }) + + $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' + [System.Windows.Forms.MessageBox]::Show($_.Exception.Message, 'Detection des sources', 'OK', 'Warning') | Out-Null + } 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 + } + + $previousDiskIndex = $null + if ($null -ne $script:cmbDisk.SelectedItem -and $null -ne $script:cmbDisk.SelectedItem.Tag) { + $previousDiskIndex = [string]$script:cmbDisk.SelectedItem.Tag + } + $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.Interface, $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() + } + + $json = $info | ConvertTo-Json -Depth 6 + Set-Content -LiteralPath $script:HardwareJson -Value $json -Encoding UTF8 + 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" + + [System.Windows.Forms.MessageBox]::Show( + $_.Exception.Message, + "Erreur DeployCenter", + [System.Windows.Forms.MessageBoxButtons]::OK, + [System.Windows.Forms.MessageBoxIcon]::Error + ) | Out-Null + } + finally { + $script:btnRefresh.Enabled = $true + $script:form.Cursor = [System.Windows.Forms.Cursors]::Default + } +} + +# ============================================================ +# 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, 78) + Set-DiskSelectorWidth + + # Le journal absorbe l'espace vertical restant au-dessus des actions et du pied de page. + $footerHeight = 26 + $buttonHeightLocal = 38 + $buttonYLocal = $h - $footerHeight - 14 - $buttonHeightLocal + $logYLocal = $script:groupDisks.Bottom + 14 + $logHeightLocal = [Math]::Max(90, $buttonYLocal - $logYLocal - 12) + $script:groupLog.Location = New-Object System.Drawing.Point($m, $logYLocal) + $script:groupLog.Size = New-Object System.Drawing.Size($contentWidth, $logHeightLocal) + $script:txtLog.Location = New-Object System.Drawing.Point(12, 28) + $script:txtLog.Size = New-Object System.Drawing.Size([Math]::Max(250, $script:groupLog.ClientSize.Width - 24), [Math]::Max(50, $script:groupLog.ClientSize.Height - 40)) + + $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:btnSources.Size = New-Object System.Drawing.Size(170, $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:btnSources.Location = New-Object System.Drawing.Point([Math]::Max($m, $script:btnRefresh.Left - $buttonGapLocal - 170), $buttonYLocal) + Set-DiskSelectorWidth +} + +# ============================================================ +# EVENEMENTS +# ============================================================ + +$script:btnSources.Add_Click({ + Show-WindowsSourcesDialog +}) + +$script:btnRefresh.Add_Click({ + Update-HardwareView +}) + +$script:btnClose.Add_Click({ + $script:form.Close() +}) + +$script:cmbTheme.Add_SelectedIndexChanged({ + if ($script:cmbTheme.SelectedItem) { + Set-DeployCenterTheme -ThemeName ([string]$script:cmbTheme.SelectedItem) + } +}) + +$script:cmbDisk.Add_SizeChanged({ + Set-DiskSelectorWidth +}) + +$script:form.Add_SizeChanged({ + Update-DeployCenterLayout +}) + +$script:form.Add_KeyDown({ + param($sender, $eventArgs) + + if ($eventArgs.KeyCode -eq [System.Windows.Forms.Keys]::Escape) { + $script:form.Close() + } + elseif ($eventArgs.KeyCode -eq [System.Windows.Forms.Keys]::F5) { + Update-HardwareView + } +}) + +$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" + Update-HardwareView +}) + +$script:form.Add_FormClosed({ + if ($null -ne $script:pictureLogo -and $null -ne $script:pictureLogo.Image) { + $script:pictureLogo.Image.Dispose() + } + + $script:FontRegular.Dispose() + $script:FontSmall.Dispose() + $script:FontTitle.Dispose() + $script:FontSubtitle.Dispose() + $script:FontGroup.Dispose() +}) + +# ============================================================ +# LANCEMENT +# ============================================================ + +try { + [void]$script:form.ShowDialog() +} +finally { + if ($script:EnableVnc -and (Get-Command Stop-DeployCenterVnc -ErrorAction SilentlyContinue)) { + Stop-DeployCenterVnc + } + $script:form.Dispose() +} diff --git a/HardwareInfo.ps1 b/HardwareInfo.ps1 new file mode 100644 index 0000000..ebeafaf --- /dev/null +++ b/HardwareInfo.ps1 @@ -0,0 +1,179 @@ + +# 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-CimInstance ` + -ClassName Win32_DiskDrive ` + -ErrorAction Stop + + foreach ($disk in $disks) { + $info.Disks += [PSCustomObject]@{ + Index = [int]$disk.Index + Model = $disk.Model + SizeBytes = [long]$disk.Size + SizeGB = [math]::Round( + ([double]$disk.Size / 1GB), 1 + ) + InterfaceType = $disk.InterfaceType + Status = $disk.Status + } + } + } + catch { + $info.Errors += "Inventaire des disques : $($_.Exception.Message)" + } + + # Retourner un objet exploitable par les autres modules + [PSCustomObject]$info +} + + +# ============================================================ +# Execution du test +# ============================================================ + +$LogDirectory = "X:\DeployLogs" +$JsonPath = Join-Path $LogDirectory "hardware.json" +$TextPath = Join-Path $LogDirectory "hardware.log" + +try { + New-Item -ItemType Directory ` + -Path $LogDirectory ` + -Force ` + -ErrorAction Stop | Out-Null +} +catch { + throw "Impossible de creer le repertoire de logs : $($_.Exception.Message)" +} + +$result = Get-HardwareInfo + +# Export JSON en UTF-8 avec BOM sous Windows PowerShell 5.1 +try { + $json = $result | ConvertTo-Json -Depth 5 + + Set-Content ` + -Path $JsonPath ` + -Value $json ` + -Encoding UTF8 ` + -ErrorAction Stop +} +catch { + throw "Impossible d'enregistrer le JSON : $($_.Exception.Message)" +} + +# Construire un journal texte sans utiliser Write-Host +$lines = @( + "=== DeployCenter - Inventaire materiel ===" + "Date : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" + "Fabricant : $($result.Manufacturer)" + "Modele : $($result.Model)" + "Numero serie : $($result.SerialNumber)" + "Firmware : $($result.Firmware)" + "Architecture : $($result.Architecture)" + "WinPE : $($result.WinPE)" + "" + "=== Disques detectes ===" +) + +foreach ($disk in $result.Disks) { + $lines += ( + "Disque {0} : {1} - {2} Go - {3} - {4}" -f + $disk.Index, + $disk.Model, + $disk.SizeGB, + $disk.InterfaceType, + $disk.Status + ) +} + +if ($result.Errors.Count -gt 0) { + $lines += "" + $lines += "=== Erreurs ===" + + foreach ($message in $result.Errors) { + $lines += $message + } +} + +$utf8 = New-Object System.Text.UTF8Encoding($false) + +[System.IO.File]::WriteAllLines( + $TextPath, + [string[]]$lines, + $utf8 +) + +# Renvoyer l'objet comme sortie principale du script +$result diff --git a/Modules/Logging.psm1 b/Modules/Logging.psm1 new file mode 100644 index 0000000..a07e034 --- /dev/null +++ b/Modules/Logging.psm1 @@ -0,0 +1,59 @@ +# DeployCenter - Gestion des journaux +# Compatible Windows PowerShell 5.1 + +function Initialize-DeployCenterLogging { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$LogRoot, + + [AllowNull()] + [string]$IPv4 = $null + ) + + $script:LogRoot = $LogRoot + if (-not (Test-Path -LiteralPath $script:LogRoot -PathType Container)) { + New-Item -Path $script:LogRoot -ItemType Directory -Force -ErrorAction Stop | Out-Null + } + + # L'adresse IPv4 est fournie par Modules\\Network.psm1. + $script:LogIPv4 = $IPv4 + + $logIpTag = if ([string]::IsNullOrWhiteSpace($script:LogIPv4)) { + 'sans-ip' + } else { + $script:LogIPv4 -replace '[^0-9A-Za-z.-]', '_' + } + $script:LogFile = Join-Path $script:LogRoot ( + 'DeployCenter_{0}_{1}.log' -f $logIpTag, (Get-Date -Format 'yyyyMMdd_HHmmss') + ) +} + +function Write-Log { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Message, + + [ValidateSet('INFO', 'SUCCESS', 'WARNING', 'ERROR')] + [string]$Level = 'INFO' + ) + + $line = '{0} [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message + + try { + [System.IO.File]::AppendAllText( + $script:LogFile, + $line + [Environment]::NewLine, + [System.Text.Encoding]::UTF8 + ) + } catch { + # Le journal graphique reste disponible si le fichier n'est pas accessible. + } + + if ($null -ne $script:txtLog -and -not $script:txtLog.IsDisposed) { + $script:txtLog.AppendText($line + [Environment]::NewLine) + $script:txtLog.SelectionStart = $script:txtLog.TextLength + $script:txtLog.ScrollToCaret() + } +} diff --git a/Modules/Network.psm1 b/Modules/Network.psm1 new file mode 100644 index 0000000..df418bc --- /dev/null +++ b/Modules/Network.psm1 @@ -0,0 +1,29 @@ +# DeployCenter - Détection réseau +# Compatible Windows PowerShell 5.1 + +function Get-DeployNetworkIPv4 { + [CmdletBinding()] + param() + + # Priorité à une interface avec passerelle par défaut ; exclut loopback, + # APIPA et adresses IPv4 non routables couramment inutiles en WinPE. + $candidates = @() + try { + foreach ($cfg in (Get-CimInstance Win32_NetworkAdapterConfiguration -Filter 'IPEnabled=True' -ErrorAction Stop)) { + $hasGateway = @($cfg.DefaultIPGateway | Where-Object { $_ -match '^\d{1,3}(\.\d{1,3}){3}$' }).Count -gt 0 + foreach ($address in @($cfg.IPAddress | Where-Object { $_ -match '^\d{1,3}(\.\d{1,3}){3}$' })) { + if ($address -eq '127.0.0.1' -or $address -like '169.254.*' -or $address -eq '0.0.0.0') { continue } + $candidates += [pscustomobject]@{ Address = $address; HasGateway = $hasGateway; Index = [int]$cfg.Index } + } + } + } catch { + # La détection réseau ne doit pas empêcher le démarrage de DeployCenter. + if (Get-Command Write-Log -ErrorAction SilentlyContinue) { + Write-Log "Détection réseau WMI : $($_.Exception.Message)" 'WARNING' + } + } + + $chosen = $candidates | Sort-Object @{Expression='HasGateway';Descending=$true}, Index | Select-Object -First 1 + if ($chosen) { return [string]$chosen.Address } + return $null +} diff --git a/Modules/ThemeManager.psm1 b/Modules/ThemeManager.psm1 new file mode 100644 index 0000000..af599a5 --- /dev/null +++ b/Modules/ThemeManager.psm1 @@ -0,0 +1,62 @@ +Set-StrictMode -Version 2.0 + +function ConvertFrom-ThemeColor { + param([Parameter(Mandatory=$true)][string]$Value) + $hex = $Value.Trim().TrimStart('#') + if ($hex -notmatch '^[0-9A-Fa-f]{6}$') { + throw "Couleur invalide dans un thème : '$Value'. Utiliser #RRGGBB." + } + return [System.Drawing.Color]::FromArgb( + [Convert]::ToInt32($hex.Substring(0,2),16), + [Convert]::ToInt32($hex.Substring(2,2),16), + [Convert]::ToInt32($hex.Substring(4,2),16) + ) +} + +function Get-DeployCenterThemeNames { + param([Parameter(Mandatory=$true)][string]$ThemesRoot) + $names = @() + foreach ($file in (Get-ChildItem -LiteralPath $ThemesRoot -Filter '*.psd1' -File -ErrorAction SilentlyContinue)) { + if ($file.BaseName -match '^[A-Za-z0-9_-]+$') { $names += $file.BaseName } + } + return @($names | Sort-Object -Unique) +} + +function Import-DeployCenterTheme { + param( + [Parameter(Mandatory=$true)][string]$ThemesRoot, + [Parameter(Mandatory=$true)][string]$ThemeName + ) + if ($ThemeName -notmatch '^[A-Za-z0-9_-]+$') { throw "Nom de thème invalide : $ThemeName" } + $path = Join-Path $ThemesRoot ($ThemeName + '.psd1') + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Thème introuvable : $ThemeName" } + $data = Import-PowerShellDataFile -LiteralPath $path + $required = @('Background','Panel','PanelAlt','Text','Secondary','Accent','Success','Border','Danger') + foreach ($key in $required) { + if (-not $data.ContainsKey($key)) { throw "Le thème '$ThemeName' ne contient pas la clé '$key'." } + } + $theme = @{} + foreach ($key in $required) { $theme[$key] = ConvertFrom-ThemeColor -Value ([string]$data[$key]) } + foreach ($key in @('TableHeader','TableRowEven','TableRowOdd')) { + if ($data.ContainsKey($key)) { $theme[$key] = ConvertFrom-ThemeColor -Value ([string]$data[$key]) } + else { $theme[$key] = $theme.PanelAlt } + } + $buttonDefaults = @{ + Button = @{ Background=$theme.PanelAlt; Foreground=$theme.Text; Border=$theme.Border; HoverBackground=$theme.Panel; PressedBackground=$theme.Border; DisabledBackground=$theme.Panel; DisabledForeground=$theme.Secondary } + PrimaryButton = @{ Background=$theme.Accent; Foreground=[System.Drawing.Color]::White; Border=$theme.Accent; HoverBackground=$theme.Accent; PressedBackground=$theme.Accent; DisabledBackground=$theme.PanelAlt; DisabledForeground=$theme.Secondary } + DangerButton = @{ Background=$theme.Danger; Foreground=[System.Drawing.Color]::White; Border=$theme.Danger; HoverBackground=$theme.Danger; PressedBackground=$theme.Danger; DisabledBackground=$theme.PanelAlt; DisabledForeground=$theme.Secondary } + } + foreach ($role in @('Button','PrimaryButton','DangerButton')) { + $theme[$role] = @{} + $provided = $data[$role] + foreach ($property in @('Background','Foreground','Border','HoverBackground','PressedBackground','DisabledBackground','DisabledForeground')) { + $raw = $null + if ($provided -is [System.Collections.IDictionary] -and $provided.Contains($property)) { $raw = [string]$provided[$property] } + if ([string]::IsNullOrWhiteSpace($raw)) { $theme[$role][$property] = $buttonDefaults[$role][$property] } + else { $theme[$role][$property] = ConvertFrom-ThemeColor -Value $raw } + } + } + return $theme +} + +Export-ModuleMember -Function ConvertFrom-ThemeColor, Get-DeployCenterThemeNames, Import-DeployCenterTheme diff --git a/Modules/UI.psm1 b/Modules/UI.psm1 new file mode 100644 index 0000000..c375b4e --- /dev/null +++ b/Modules/UI.psm1 @@ -0,0 +1,22 @@ +function Set-DeployCenterButtonStyle { + param( + [Parameter(Mandatory=$true)][System.Windows.Forms.Button]$Button, + [Parameter(Mandatory=$true)][System.Collections.IDictionary]$Theme, + [ValidateSet('Button','PrimaryButton','DangerButton')][string]$Role = 'Button' + ) + $style = $Theme[$Role] + $Button.BackColor = $style.Background + $Button.ForeColor = $style.Foreground + $Button.FlatStyle = [System.Windows.Forms.FlatStyle]::Flat + $Button.UseVisualStyleBackColor = $false + $Button.FlatAppearance.BorderSize = 1 + $Button.FlatAppearance.BorderColor = $style.Border + $Button.FlatAppearance.MouseOverBackColor = $style.HoverBackground + $Button.FlatAppearance.MouseDownBackColor = $style.PressedBackground + if (-not $Button.Enabled) { + $Button.BackColor = $style.DisabledBackground + $Button.ForeColor = $style.DisabledForeground + } +} + +Export-ModuleMember -Function Set-DeployCenterButtonStyle diff --git a/Modules/VNC.psm1 b/Modules/VNC.psm1 new file mode 100644 index 0000000..d4ddc24 --- /dev/null +++ b/Modules/VNC.psm1 @@ -0,0 +1,127 @@ +# Module VNC de DeployCenter +# Exécuté uniquement avec -Vnc dans le scope principal pour partager le contexte WinPE. + +function Start-DeployCenterVnc { + $parentRoot = Split-Path -Path $script:DeployRoot -Parent + $sourceVnc = Join-Path $parentRoot 'uvnc' + $sourceExe = Join-Path $sourceVnc 'winvnc.exe' + $passwordTool = Join-Path $sourceVnc 'createpassword64.exe' + $sourceIni = Join-Path $sourceVnc 'ultravnc.ini' + + $script:DeployIPv4 = Get-DeployNetworkIPv4 + if ([string]::IsNullOrWhiteSpace($script:DeployIPv4)) { + Write-Log '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 + return + } + if (-not (Test-Path -LiteralPath $passwordTool -PathType Leaf)) { + Write-Log "UltraVNC non démarré : createpassword64.exe absent ($passwordTool)." 'WARNING' + Write-DeployInventory + return + } + if (-not (Test-Path -LiteralPath $sourceIni -PathType Leaf)) { + Write-Log "UltraVNC non démarré : fichier absent ($sourceIni)." 'WARNING' + Write-DeployInventory + return + } + + try { + if (Test-Path -LiteralPath $script:VncDirectory) { + Remove-Item -LiteralPath $script:VncDirectory -Recurse -Force -ErrorAction Stop + } + New-Item -Path $script:VncDirectory -ItemType Directory -Force | Out-Null + Copy-Item -Path (Join-Path $sourceVnc '*') -Destination $script:VncDirectory -Recurse -Force + # WinPE ne permet pas toujours d'ajouter des règles de pare-feu. + # La désactivation globale n'est effectuée que lorsque VNC est demandé. + $wpeutil = Join-Path $env:WINDIR 'System32\wpeutil.exe' + if (-not (Test-Path -LiteralPath $wpeutil -PathType Leaf)) { + $wpeutil = 'wpeutil.exe' + } + & $wpeutil DisableFirewall + if ($LASTEXITCODE -ne 0) { + throw "wpeutil DisableFirewall a renvoyé le code $LASTEXITCODE." + } + Write-Log 'Pare-feu WinPE désactivé à la demande de -Vnc.' 'WARNING' + + # Force le mode portable : l'INI actif est celui placé à côté de winvnc.exe. + $portableFlag = Join-Path $script:VncDirectory 'ultravnc.portable' + Set-Content -LiteralPath $portableFlag -Value '' -Encoding ASCII + + if (-not [string]::IsNullOrWhiteSpace([string]$script:Config.VncPassword)) { + $script:VncPassword = [string]$script:Config.VncPassword + $passwordSource = 'configuré dans DeployCenter.ini' + } + else { + $script:VncPassword = -join (1..8 | ForEach-Object { + $alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789' + $alphabet[(Get-Random -Minimum 0 -Maximum $alphabet.Length)] + }) + $passwordSource = 'généré automatiquement' + } + + # createpassword64 encode le mot de passe choisi dans le champ passwd d'ultravnc.ini. + $tool = Join-Path $script:VncDirectory 'createpassword64.exe' + $script:VncProcess = Start-Process -FilePath $tool -ArgumentList @($script:VncPassword) ` + -WorkingDirectory $script:VncDirectory -Wait -PassThru -WindowStyle Hidden + if ($script:VncProcess.ExitCode -ne 0) { + throw "createpassword64.exe a renvoyé le code $($script:VncProcess.ExitCode)." + } + + $activeIni = Join-Path $script:VncDirectory 'ultravnc.ini' + if (-not (Test-Path -LiteralPath $activeIni -PathType Leaf) -or + -not (Select-String -LiteralPath $activeIni -Pattern '^passwd\s*=' -Quiet)) { + throw "createpassword64.exe n'a pas créé le champ passwd dans ultravnc.ini." + } + + $server = Join-Path $script:VncDirectory 'winvnc.exe' + $script:VncProcess = Start-Process -FilePath $server -ArgumentList @('-run') ` + -WorkingDirectory $script:VncDirectory -PassThru -WindowStyle Hidden + Start-Sleep -Milliseconds 800 + if ($script:VncProcess.HasExited) { + throw "winvnc.exe s'est arrêté immédiatement (code $($script:VncProcess.ExitCode))." + } + $script:VncStarted = $true + Write-Log "UltraVNC démarré. IP : $($script:DeployIPv4) ; mot de passe $passwordSource." 'SUCCESS' + } catch { + Write-Log "Échec du démarrage UltraVNC : $($_.Exception.Message)" 'ERROR' + $script:VncStarted = $false + $script:VncPassword = $null + } + Write-DeployInventory +} + +function Stop-DeployCenterVnc { + try { + if ($null -ne $script:VncProcess) { + $proc = Get-Process -Id $script:VncProcess.Id -ErrorAction SilentlyContinue + if ($null -ne $proc) { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue } + } + # Au cas où winvnc lance un processus serveur distinct, ne cibler que les + # processus dont le chemin correspond à notre copie temporaire. + foreach ($proc in (Get-CimInstance Win32_Process -Filter "Name='winvnc.exe'" -ErrorAction SilentlyContinue)) { + if ($proc.ExecutablePath -and $proc.ExecutablePath.StartsWith($script:VncDirectory, [StringComparison]::OrdinalIgnoreCase)) { + Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue + } + } + } catch { + Write-Log "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 + # supprime pas récursivement le dossier partagé InventoryDir, ce qui évite + # de bloquer le lancement suivant si un handle est encore ouvert. + try { + if (Test-Path -LiteralPath $script:InventoryFile -PathType Leaf) { + Remove-Item -LiteralPath $script:InventoryFile -Force -ErrorAction SilentlyContinue + } + if (Test-Path -LiteralPath $script:VncDirectory -PathType Container) { + Start-Sleep -Milliseconds 500 + Remove-Item -LiteralPath $script:VncDirectory -Recurse -Force -ErrorAction SilentlyContinue + } + } catch { } +} + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e5f296f --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# 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é). +- `Themes/Light.psd1` et `Themes/Dark.psd1` : palettes et styles des boutons. + +## Lancement + +Depuis le dossier DeployCenter, lancer : + +```powershell +powershell.exe -ExecutionPolicy Bypass -File .\DeployCenter.ps1 +``` + +Pour lancer en fenêtre redimensionnable : + +```powershell +powershell.exe -ExecutionPolicy Bypass -File .\DeployCenter.ps1 -Windowed +``` + +Conserver à côté du script les ressources habituelles du projet (`Assets`, `uvnc`, `HardwareInfo.ps1`, etc.). Les dossiers `Modules` et `Themes` doivent rester à côté de `DeployCenter.ps1`. + +## Mise en page + +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 + +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. + +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. + + +## Mot de passe UltraVNC de test + +Dans `DeployCenter.ini`, ajoutez la section suivante. Un champ `Password` non vide est utilisé tel quel au lieu de générer un mot de passe aléatoire ; `createpassword64.exe` continue à le convertir dans le format attendu par UltraVNC. Laissez `Password=` vide pour conserver le comportement par défaut. + +```ini +[UltraVNC] +Password=MonMotDePasseDeTest +``` + +Le mot de passe est stocké en clair dans `DeployCenter.ini` : utilisez cette option uniquement dans un environnement de test protégé. + + +### Correctifs v3 +- En-têtes du tableau des éditions Windows avec une couleur dédiée définie par le thème (`TableHeader`). +- Sélection unique explicite dans la liste des éditions et dessin de la sélection basé sur l'élément réellement sélectionné. + +### Correctif v12 — pied de page +- La fenêtre principale affiche l'adresse IP de déploiement et le mot de passe UltraVNC dans son pied de page. +- Le pied de page commun reste activé par défaut ; le paramètre `-ShowFooter $false` permet de le masquer dans la fenêtre « Éditions Windows ». Les autres fenêtres conservent leur comportement habituel. + +### Correctif v15 — pied de page cohérent +- Le pied de page commun affiche par défaut l’adresse IP et le mot de passe VNC dans toutes les fenêtres qui l’utilisent. +- Seule la fenêtre de sélection « Éditions Windows » masque entièrement le pied de page via `-ShowFooter $false`. + + +### Alternance des lignes des tableaux + +Les tableaux utilisent une alternance paire/impaire définie dans chaque thème (`TableRowEven` et `TableRowOdd`). La sélection conserve la couleur d’accent du thème. Les valeurs sont partagées par les fenêtres Sources Windows et Éditions Windows. + + +## Paramètre de démarrage VNC (v18) + +- Sans paramètre : UltraVNC ne démarre pas. +- Avec `-vnc` : UltraVNC démarre. + +Exemple : `powershell.exe -ExecutionPolicy Bypass -File .\DeployCenter.ps1 -vnc` + + +## Options de lancement + +- Sans paramètre : UltraVNC n'est pas démarré. +- Avec `-Vnc` (alias PowerShell utilisable sous la forme `-vnc`) : UltraVNC est démarré. +- Les fenêtres sont non redimensionnables par défaut via `Set-DeployCenterFormSizing`; passer `-Resizable` à cette fonction commune pour autoriser le redimensionnement. + + +## Module VNC + +La gestion d'UltraVNC est isolée dans `Modules/VNC.psm1`. Le fichier est chargé par dot-sourcing uniquement lorsque `-Vnc` est fourni, afin de conserver le contexte partagé de DeployCenter. + +- Sans `-Vnc`, UltraVNC ne démarre pas et DeployCenter ne demande pas la désactivation du pare-feu. +- Avec `-Vnc`, le module VNC exécute `wpeutil DisableFirewall` avant de lancer UltraVNC. Cette commande désactive le pare-feu WinPE dans son ensemble ; utilisez cette option sur un réseau de déploiement maîtrisé. +- Le démarrage VNC et les erreurs sont consignés dans le journal DeployCenter. + + +## Modules + +- `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. diff --git a/Themes/Dark.psd1 b/Themes/Dark.psd1 new file mode 100644 index 0000000..024004e --- /dev/null +++ b/Themes/Dark.psd1 @@ -0,0 +1,29 @@ +@{ + Background = '#171A21' + Panel = '#222731' + PanelAlt = '#1C2029' + TableHeader = '#303744' + TableRowEven = '#303640' + TableRowOdd = '#414956' + Text = '#F1F5F9' + Secondary = '#9CA9BC' + Accent = '#3B82F6' + Success = '#4ADE80' + Border = '#414A59' + Danger = '#EF4444' + Button = @{ + Background = '#303744'; Foreground = '#F1F5F9'; Border = '#414A59' + HoverBackground = '#3B4555'; PressedBackground = '#485365' + DisabledBackground = '#292E38'; DisabledForeground = '#7F8999' + } + PrimaryButton = @{ + Background = '#3B82F6'; Foreground = '#FFFFFF'; Border = '#3B82F6' + HoverBackground = '#2563EB'; PressedBackground = '#1D4ED8' + DisabledBackground = '#344A6A'; DisabledForeground = '#9AAAC1' + } + DangerButton = @{ + Background = '#EF4444'; Foreground = '#FFFFFF'; Border = '#EF4444' + HoverBackground = '#DC2626'; PressedBackground = '#B91C1C' + DisabledBackground = '#59383B'; DisabledForeground = '#BBA0A2' + } +} diff --git a/Themes/Light.psd1 b/Themes/Light.psd1 new file mode 100644 index 0000000..da03ceb --- /dev/null +++ b/Themes/Light.psd1 @@ -0,0 +1,29 @@ +@{ + Background = '#D1D5DB' + Panel = '#E5E7EB' + PanelAlt = '#F1F3F5' + TableHeader = '#D1D5DB' + TableRowEven = '#E5E7EB' + TableRowOdd = '#C9CED6' + Text = '#1E293B' + Secondary = '#475569' + Accent = '#2563EB' + Success = '#16A34A' + Border = '#AEB6C2' + Danger = '#DC2626' + Button = @{ + Background = '#E5E7EB'; Foreground = '#1E293B'; Border = '#AEB6C2' + HoverBackground = '#D1D5DB'; PressedBackground = '#C2C8D0' + DisabledBackground = '#E5E7EB'; DisabledForeground = '#7B8492' + } + PrimaryButton = @{ + Background = '#2563EB'; Foreground = '#FFFFFF'; Border = '#2563EB' + HoverBackground = '#1D4ED8'; PressedBackground = '#1E40AF' + DisabledBackground = '#C7D8F8'; DisabledForeground = '#F8FAFC' + } + DangerButton = @{ + Background = '#DC2626'; Foreground = '#FFFFFF'; Border = '#DC2626' + HoverBackground = '#B91C1C'; PressedBackground = '#991B1B' + DisabledBackground = '#F2C5C5'; DisabledForeground = '#FFF7F7' + } +}