#Requires -Modules Hyper-V, Storage #Requires -PSEdition Desktop # To do list (will get around to this stuff sometime next week) # Change from using variables to parameters - this way you don't even need to open the script to edit it. # Clean up the output it sends to powershell so you know what step you're on # Do some real logging incase folks get in trouble and need my help # Edit the WinPECaptureFFUFiles\CaptureFFU.ps1 file within this script instead of doing it manually.  # Make Capture/Deploy media creation optional # Change DownloadFFU.xml file to match C:\FFUDevelopment path # If drivers = true, check if drivers folder exists # C:\VM\VMFolder isn't being deleted #Modify Required variables $ISOPath = "E:\software\ISOs\Windows\Windows 11\en-us_windows_11_consumer_editions_version_22h2_updated_feb_2023_x64_dvd_4fa87138.iso" $WindowsSKU = 'Pro' $FFUDevelopmentPath = 'C:\FFUDevelopment' $AppsISO = "$FFUDevelopmentPath\Apps.iso" $AppsPath = "$FFUDevelopmentPath\Apps" $InstallOffice = $true $InstallApps = $true $InstallDrivers = $true $memory = 8GB $disksize = 30GB $processors = 4 $VMSwitchName = '*intel*' #Optional variables $rand = get-random $VMName = "_FFU-$rand" $VMLocation = "c:\VM" $VMPath = $VMLocation + $VMName $VHDXPath = "$VMPath\$VMName.vhdx" #FUNCTIONS Function Get-ADK { # Define the registry key and value name to query $adkRegKey = "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows Kits\Installed Roots" $adkRegValueName = "KitsRoot10" # Check if the registry key exists if (Test-Path $adkRegKey) { # Get the registry value for the Windows ADK installation path $adkPath = (Get-ItemProperty -Path $adkRegKey -Name $adkRegValueName).$adkRegValueName if ($adkPath) { return $adkPath } } else { throw "Windows ADK is not installed or the installation path could not be found." } } function Get-ODTURL { [String]$MSWebPage = Invoke-RestMethod 'https://www.microsoft.com/en-us/download/confirmation.aspx?id=49117' $MSWebPage | ForEach-Object { if ($_ -match 'url=(https://.*officedeploymenttool.*\.exe)') { $matches[1] } } } function Get-Office { #Download ODT $ODTUrl = Get-ODTURL $ODTInstallFile = "$env:TEMP\odtsetup.exe" Invoke-WebRequest -Uri $ODTUrl -OutFile $ODTInstallFile # Extract ODT $ODTPath = "$AppsPath\Office" Start-Process -FilePath $ODTInstallFile -ArgumentList "/extract:$ODTPath /quiet" -Wait # Run setup.exe with config.xml $ConfigXml = "$ODTPath\DownloadFFU.xml" #Set-Location $ODTPath Start-Process -FilePath "$ODTPath\setup.exe" -ArgumentList "/download $ConfigXml" -Wait #Clean up default configuration files Remove-Item -Path "$ODTPath\configuration*" -Force } function New-AppsISO { #Create Apps ISO file $OSCDIMG = 'C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\amd64\Oscdimg\oscdimg.exe' Start-Process -FilePath $OSCDIMG -ArgumentList "-n -m -d $Appspath $AppsISO" -wait #Remove the Office Download and ODT if ($InstallOffice) { $ODTPath = "$AppsPath\Office" $OfficeDownloadPath = "$ODTPath\Office" Remove-Item -Path $OfficeDownloadPath -Recurse -Force Remove-Item -Path "$ODTPath\setup.exe" } } function Get-WimFromISO { # Mount the ISO file using Mount-DiskImage cmdlet $mountResult = Mount-DiskImage -ImagePath $isoPath -PassThru # Get the drive letter of the mounted ISO $driveLetter = ($mountResult | Get-Volume).DriveLetter # Construct the path to the install.wim file $wimPath = $driveLetter + ":\sources\install.wim" # Display the path to the install.wim file Write-Host "The path to the install.wim file is: $wimPath" return $wimpath } function Get-WimIndex { [Parameter(Mandatory = $true)] [string]$WindowsSKU $wimindex = switch ($WindowsSKU) { Home { 1 } Home_N { 2 } Home_SL { 3 } EDU { 4 } EDU_N { 5 } Pro { 6 } Pro_N { 7 } Pro_EDU { 8 } Pro_Edu_N { 9 } Pro_WKS { 10 } Pro_WKS_N { 11 } Default { 6 } } Return $WimIndex } #Build VHDX #Create VHDX function New-ScratchVhdx { param( [Parameter(Mandatory = $true)] [string]$VhdxPath, [uint64]$SizeBytes = 30GB, [ValidateSet(512, 4096)] [uint32]$LogicalSectorSizeBytes = 512, [switch]$Dynamic, [Microsoft.PowerShell.Cmdletization.GeneratedTypes.Disk.PartitionStyle]$PartitionStyle = [Microsoft.PowerShell.Cmdletization.GeneratedTypes.Disk.PartitionStyle]::GPT ) Write-Host "Creating new Scratch VHDX..." $newVHDX = New-VHD -Path $VhdxPath -SizeBytes $disksize -LogicalSectorSizeBytes $LogicalSectorSizeBytes -Fixed:$true $toReturn = $newVHDX | Mount-VHD -Passthru | Initialize-Disk -PassThru -PartitionStyle GPT #Remove auto-created system partition so we can create the correct partition layout remove-partition $toreturn.DiskNumber -PartitionNumber 1 -Confirm:$False Write-Host "Done." return $toReturn } #Add System Partition function New-SystemPartition { param( [Parameter(Mandatory = $true)] [ciminstance]$VhdxDisk, [uint64]$SystemPartitionSize = 256MB ) Write-Host "Creating System partition..." $sysPartition = $VhdxDisk | New-Partition -AssignDriveLetter -Size $SystemPartitionSize -GptType "{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}" -IsHidden $sysPartition | Format-Volume -FileSystem FAT32 -Force -NewFileSystemLabel "System" Write-Host "Done. System partition at drive $($sysPartition.DriveLetter):" return $sysPartition.DriveLetter } #Add MSRPartition - skip this initially unless needed function New-MSRPartition { param( [Parameter(Mandatory = $true)] [ciminstance]$VhdxDisk ) Write-Host "Creating MSR partition..." $toReturn = $VhdxDisk | New-Partition -AssignDriveLetter -Size 16MB -GptType "{e3c9e316-0b5c-4db8-817d-f92df00215ae}" -IsHidden Write-Host "Done." return $toReturn } #Add OS Partition function New-OSPartition { param( [Parameter(Mandatory = $true)] [ciminstance]$VhdxDisk, [Parameter(Mandatory = $true)] [string]$WimPath, [uint32]$WimIndex, [uint64]$OSPartitionSize = 0 ) Write-Host "Creating OS partition..." if ($OSPartitionSize -gt 0) { $osPartition = $vhdxDisk | New-Partition -AssignDriveLetter -Size $OSPartitionSize } else { $osPartition = $vhdxDisk | New-Partition -AssignDriveLetter -UseMaximumSize } $osPartition | Format-Volume -FileSystem NTFS -Confirm:$false -Force -NewFileSystemLabel "Windows" Write-Host "Done. OS partition at drive $($osPartition.DriveLetter):" Write-Host "Writing WIM at $WimPath to OS partition at drive $($osPartition.DriveLetter):..." #Server 2019 is missing the Windows Overlay Filter (wof.sys), likely other Server SKUs are missing it as well. Script will error if trying to use the -compact switch on Server OSes if ((Get-CimInstance Win32_OperatingSystem).Caption -match "Server") { Write-Host (Expand-WindowsImage -ImagePath $WimPath -Index $WimIndex -ApplyPath "$($osPartition.DriveLetter):\") } else { Write-Host (Expand-WindowsImage -ImagePath $WimPath -Index $WimIndex -ApplyPath "$($osPartition.DriveLetter):\" -Compact) } Write-Host "Done." return $osPartition } #Add Recovery partition function New-RecoveryPartition { param( [Parameter(Mandatory = $true)] [ciminstance]$VhdxDisk, [Parameter(Mandatory = $true)] $OsPartition, [uint64]$RecoveryPartitionSize = 0, [ciminstance]$DataPartition ) Write-Host "Creating empty Recovery partition (to be filled on first boot automatically)..." $calculatedRecoverySize = 0 $recoveryPartition = $null if ($RecoveryPartitionSize -gt 0) { $calculatedRecoverySize = $RecoveryPartitionSize } else { $winReWim = Get-ChildItem "$($OsPartition.DriveLetter):\Windows\System32\Recovery\Winre.wim" if (($null -ne $winReWim) -and ($winReWim.Count -eq 1)) { # Wim size + 52MB is minimum WinRE partition size. # NTFS and other partitioning size differences account for about 17MB of space that's unavailable. # Adding 32MB as a buffer to ensure there's enough space. $calculatedRecoverySize = $winReWim.Length + 52MB + 32MB Write-Host "Calculated space needed for recovery in bytes: $calculatedRecoverySize" if ($null -ne $DataPartition) { $DataPartition | Resize-Partition -Size ($DataPartition.Size - $calculatedRecoverySize) Write-Host "Data partition shrunk by $calculatedRecoverySize bytes for Recovery partition." } else { $OsPartition | Resize-Partition -Size ($OsPartition.Size - $calculatedRecoverySize) Write-Host "OS partition shrunk by $calculatedRecoverySize bytes for Recovery partition." } $recoveryPartition = $VhdxDisk | New-Partition -AssignDriveLetter -UseMaximumSize -GptType "{de94bba4-06d1-4d40-a16a-bfd50179d6ac}" ` | Format-Volume -FileSystem NTFS -Confirm:$false -Force -NewFileSystemLabel "WinRE" Write-Host "Done. Recovery partition at drive $($recoveryPartition.DriveLetter):" } else { Write-Host "No WinRE.WIM found in the OS partition under \Windows\System32\Recovery." Write-Host "Skipping creating the Recovery partition." Write-Host "If a Recovery partition is desired, please re-run the script setting the -RecoveryPartitionSize flag as appropriate." } } return $recoveryPartition } #Add boot files function Add-BootFiles { param( [Parameter(Mandatory = $true)] [string]$OsPartitionDriveLetter, [Parameter(Mandatory = $true)] [string]$SystemPartitionDriveLetter, [string]$FirmwareType = 'UEFI' ) Write-Host "Adding boot files for `"$($OsPartitionDriveLetter):\Windows`" to System partition `"$($SystemPartitionDriveLetter):`"..." bcdboot "$($OsPartitionDriveLetter):\Windows" /S "$($SystemPartitionDriveLetter):" /F "$FirmwareType" Write-Host "Done." } #Dismount VHDX function Dismount-ScratchVhdx { param( [Parameter(Mandatory = $true)] [string]$VhdxPath ) if (Test-Path $VhdxPath) { Write-Host "Dismounting scratch VHDX..." Dismount-VHD -Path $VhdxPath Write-Host "Done." } } #Delete old VMs and remove old certs function Remove-FFUVM { $certPath = 'Cert:\LocalMachine\Shielded VM Local Certificates\' $OLDFFUVMs = get-vm _ffu-* | Where-Object { $_.state -ne 'running' } If ($null -ne $OLDFFUVMs) { Foreach ($OLDFFUVM in $OLDFFUVMs) { $OldVMName = $OLDFFUVM.VMName Remove-VM -Name $OLDFFUVM.name -Force -ErrorAction SilentlyContinue #Remove-Item -Path "C:\VM\$OldVMName" -Force -Recurse -ErrorAction SilentlyContinue Remove-Item -Path "$VMLocation\$OLDVMName" -Force -Recurse -ErrorAction SilentlyContinue Remove-HgsGuardian -Name $OldVMName $certs = Get-ChildItem -Path $certPath -Recurse | Where-Object { $_.Subject -like "*$OldVMName*" } foreach ($cert in $Certs) { Remove-item -Path $cert.PSPath -force } } } } function New-FFUVM { #Create new Gen2 VM $VM = New-VM -Name $VMName -Path $VMPath -MemoryStartupBytes $memory -VHDPath $VHDXPath -Generation 2 Set-VMProcessor -VMName $VMName -Count $processors #Mount Office ISO Add-VMDvdDrive -VMName $VMName -Path $AppsISO $VMHardDiskDrive = Get-VMHarddiskdrive -VMName $VMName Set-VMFirmware -VMName $VMName -FirstBootDevice $VMHardDiskDrive Set-VM -Name $VMName -AutomaticCheckpointsEnabled $false -StaticMemory #Configure TPM New-HgsGuardian -Name $VMName -GenerateCertificates $owner = get-hgsguardian -Name $VMName $kp = New-HgsKeyProtector -Owner $owner -AllowUntrustedRoot Set-VMKeyProtector -VMName $VMName -KeyProtector $kp.RawData Enable-VMTPM -VMName $VMName #Connect to VM vmconnect $VM.ComputerName $VMName #Start VM Start-VM -Name $VMName return $VM } function New-PEMedia { param ( [Parameter()] [switch] $Capture, [Parameter()] [switch] $Deploy ) #Need to use the Demployment and Imaging tools environment to create winPE media $DandIEnv = "$adkPath`Assessment and Deployment Kit\Deployment Tools\DandISetEnv.bat" $WinPEFFUPath = "$FFUDevelopmentPath\WinPE" If (Test-path -Path "$WinPEFFUPath") { #Dismount-WindowsImage -Path "$WinPEFFUPath\mount" -discard Remove-Item -Path "$WinPEFFUPath" -Recurse -Force } & cmd /c """$DandIEnv"" && copype amd64 $WinPEFFUPath" Mount-WindowsImage -ImagePath "$WinPEFFUPath\media\sources\boot.wim" -Index 1 -Path "$WinPEFFUPath\mount" $Packages = @( "WinPE-WMI.cab", "en-us\WinPE-WMI_en-us.cab", "WinPE-NetFX.cab", "en-us\WinPE-NetFX_en-us.cab", "WinPE-Scripting.cab", "en-us\WinPE-Scripting_en-us.cab", "WinPE-PowerShell.cab", "en-us\WinPE-PowerShell_en-us.cab", "WinPE-StorageWMI.cab", "en-us\WinPE-StorageWMI_en-us.cab", "WinPE-DismCmdlets.cab", "en-us\WinPE-DismCmdlets_en-us.cab" ) $PackagePathBase = "$adkPath`Assessment and Deployment Kit\Windows Preinstallation Environment\amd64\WinPE_OCs\" foreach ($Package in $Packages) { $PackagePath = Join-Path $PackagePathBase $Package Add-WindowsPackage -Path "$WinPEFFUPath\mount" -PackagePath $PackagePath | Out-Null } If($Capture){ Copy-Item -Path "$FFUDevelopmentPath\WinPECaptureFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force #Remove Bootfix.bin if capturing from BIOS systems Remove-Item -Path "$WinPEFFUPath\media\boot\bootfix.bin" -Force } If($Deploy){ Copy-Item -Path "$FFUDevelopmentPath\WinPEDeployFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force # If you need to add drivers (storage/keyboard most likely), remove the '#' from the below line and change the /Driver:Path to a folder of drivers # & dism /image:$WinPEFFUPath\mount /Add-Driver /Driver: /Recurse } Dismount-WindowsImage -Path "$WinPEFFUPath\mount" -Save #Make ISO $OSCDIMGPath = "$adkPath`Assessment and Deployment Kit\Deployment Tools\amd64\Oscdimg" $OSCDIMG = "$OSCDIMGPath\oscdimg.exe" & "$OSCDIMG" -m -o -u2 -udfver102 -bootdata:2`#p0,e,b$OSCDIMGPath\etfsboot.com`#pEF,e,b$OSCDIMGPath\Efisys_noprompt.bin $WinPEFFUPath\media $FFUDevelopmentPath\WinPE_FFU_Capture.iso Remove-Item -Path "$WinPEFFUPath" -Recurse -Force } function New-FFU { #Need to use the Demployment and Imaging tools environment to use dism from the Insider ADK to optimize the FFU. This is only needed until Windows 23H2. $DandIEnv = "$adkPath`Assessment and Deployment Kit\Deployment Tools\DandISetEnv.bat" #Mount the Capture ISO to the VM $CaptureISOPath = "$FFUDevelopmentPath\WinPE_FFU_Capture.iso" $FFUVMs = get-vm _ffu-* | Where-Object { $_.state -ne 'running' } If ($null -ne $FFUVMs) { Foreach ($FFUVM in $FFUVMs) { $VMName = $FFUVM.name $VMDVDDrive = Get-VMDvdDrive -VMName $VMName Set-VMFirmware -VMName $VMName -FirstBootDevice $VMDVDDrive Set-VMDvdDrive -VMName $VMName -Path $CaptureISOPath $VMSwitch = Get-VMSwitch -name $VMSwitchName get-vm $VMName | Get-VMNetworkAdapter | Connect-VMNetworkAdapter -SwitchName $VMSwitch.Name #vmconnect $FFUVM.ComputerName $VMName } } #Start VM Start-VM -Name $VMName # Wait for the VM to turn off do { $FFUVM = Get-VM -Name $VMName Start-Sleep -Seconds 5 } while ($FFUVM.State -ne 'Off') # Check for .ffu files in the FFUDevelopment folder $FFUFiles = Get-ChildItem -Path $FFUDevelopmentPath -Filter "*.ffu" -File # If there's more than one .ffu file, get the most recent and store its path in $FFUFile if ($FFUFiles.Count -gt 0) { $FFUFile = ($FFUFiles | Sort-Object -Property LastWriteTime -Descending | Select-Object -First 1).FullName Write-Host "Most recent .ffu file: $FFUFile" } else { Write-Host "No .ffu files found in $FFUFolderPath" } #Add drivers If ($InstallDrivers){ New-Item -Path "$FFUDevelopmentPath\Mount" -ItemType Directory -Force Mount-WindowsImage -ImagePath $FFUFile -Index 1 -Path "$FFUDevelopmentPath\Mount" Add-WindowsDriver -Path "$FFUDevelopmentPath\Mount" -Driver "$FFUDevelopmentPath\Drivers" -Recurse Dismount-WindowsImage -Path "$FFUDevelopmentPath\Mount" -Save Remove-Item -Path "$FFUDevelopmentPath\Mount" -Recurse -Force } #Optimize FFU & cmd /c """$DandIEnv"" && dism /optimize-ffu /imagefile:$FFUFile" } try { #Check if the Windows ADK is installed $adkPath = Get-ADK } catch { throw $_ } #Build ISO for Office and Apps try{ if($InstallOffice){ Get-Office } if($InstallApps){ New-AppsISO } } catch { Write-Host "Getting Office and/or building the AppsISO failed" throw $_ } #Create VHDX try { $wimPath = Get-WimFromISO $WimIndex = Get-WimIndex $vhdxDisk = New-ScratchVhdx -VhdxPath $VHDXPath -SizeBytes $disksize -Dynamic:$true $systemPartitionDriveLetter = New-SystemPartition -VhdxDisk $vhdxDisk New-MSRPartition -VhdxDisk $vhdxDisk $osPartition = New-OSPartition -VhdxDisk $vhdxDisk -OSPartitionSize $OSPartitionSize -WimPath $WimPath -WimIndex $WimIndex[1] $osPartitionDriveLetter = $osPartition[1].DriveLetter $recoveryPartition = New-RecoveryPartition -VhdxDisk $vhdxDisk -OsPartition $osPartition[1] -RecoveryPartitionSize $RecoveryPartitionSize -DataPartition $dataPartition Write-Host "All necessary partitions created." Add-BootFiles -OsPartitionDriveLetter $osPartitionDriveLetter -SystemPartitionDriveLetter $systemPartitionDriveLetter[1] New-Item -Path "$($osPartitionDriveLetter):\Windows\Panther\unattend" -ItemType Directory Copy-Item -Path "$FFUDevelopmentPath\BuildFFUUnattend\unattend.xml" -Destination "$($osPartitionDriveLetter):\Windows\Panther\Unattend\Unattend.xml" -Force } finally { Dismount-ScratchVhdx -VhdxPath $VHDXPath Dismount-DiskImage -ImagePath $ISOPath } #Clean up old VMs try { Remove-FFUVM } catch { Write-Host "VM cleanup failed" throw $_ } #Create VM and attach VHDX try { $FFUVM = New-FFUVM } catch { Write-Host 'VM creation failed' throw $_ } #Create Capture Media try{ #This should happen while the FFUVM is building New-PEMedia -Capture } catch{ throw $_ } #Capture FFU file try { #Check if VM is done provisioning do { $FFUVM = Get-VM -Name $FFUVM.Name Start-Sleep -Seconds 10 } while ($FFUVM.State -ne 'Off') #Capture FFU file New-FFU } Catch { throw $_ }