Get-Win11VHDX.ps1
|
<#
.SYNOPSIS Downloads Windows 11 install media (25H2 or 24H2) from Microsoft and builds a Gen-2/UEFI VHDX from it. .DESCRIPTION With no media parameters the download is fully automatic. The heavy lifting is David Segura's OSD module (github.com/OSDeploy/OSD) -- the module behind OSDCloud -- which supplies Microsoft's Feature Update catalog. Its Get-FeatureUpdate resolves a release/channel/language into a direct download URL on dl.delivery.mp.microsoft.com plus the expected SHA256: the same media Windows Update itself serves. That replaced the old Fido path, which scraped Microsoft's public download page and broke whenever that page IP-blocked the caller (error 715-123130). OSD is GPL-3.0 and is installed from PowerShell Gallery at runtime; no part of it is bundled or redistributed with VM-Pilot. The catalog serves an .esd. DISM applies an .esd directly, so that path skips the ISO mount entirely. A hand-supplied .iso still works via -IsoPath / -PickIso and is mounted as before. .EXAMPLE # Fully automatic: download 25H2 media and build C:\VMs\Win11-25H2.vhdx .\Get-Win11VHDX.ps1 .EXAMPLE # Same, for 24H2 -> C:\VMs\Win11-24H2.vhdx. Both parents can coexist. .\Get-Win11VHDX.ps1 -Release 24H2 .EXAMPLE .\Get-Win11VHDX.ps1 -Release 25H2 -Edition Pro -OutVhdx C:\VMs\Win11-25H2.vhdx .EXAMPLE # Browse to an existing ISO with a file picker instead of downloading. # The VHDX is auto-named after the Windows release detected inside the # picked ISO (e.g. C:\VMs\Win11-25H2.vhdx) unless you pin -OutVhdx: .\Get-Win11VHDX.ps1 -PickIso .LINK https://github.com/OSDeploy/OSD .NOTES Media resolution and download URLs courtesy of the OSD module by David Segura (@OSDeploy), author of OSDCloud. Go star it: https://github.com/OSDeploy/OSD #> [CmdletBinding()] param( [ValidateSet('25H2','24H2')] [string]$Release = '25H2', [ValidateSet('Home','Pro')] [string]$Edition = 'Pro', # Licensing channel for downloaded media. Retail is the default because it # assumes no volume-licensing agreement; pass Volume if you hold one. [ValidateSet('Retail','Volume')] [string]$OSActivation = 'Retail', # Media language, in OSD's culture form (en-us, de-de, fr-fr, ...). # Deliberately not ValidateSet'd here -- OSD owns the authoritative list # and validates it, so this can't drift as that list changes. [string]$OSLanguage = 'en-us', [int] $SizeGB = 64, [string]$WorkDir = 'C:\Tools\WinVHDX', [string]$OutVhdx = "C:\VMs\Win11-$Release.vhdx", # Pre-supplied media. If provided, skips the download entirely and # DISM-applies the existing file. Lets the GUI feed media from any source # (Microsoft Software Download page, Visual Studio, VLSC, USB, etc.). # An .iso is mounted; an .esd/.wim is applied directly. [string]$IsoPath, # Pop a Windows "Open file" dialog to pick the media interactively. Sets # $IsoPath from whatever the user selects, then follows the same # supplied-media path as -IsoPath (skips the download). [switch]$PickIso ) $ErrorActionPreference = 'Stop' # Captured before anything can reassign $Release (the detect step below does). # Distinguishes "caller asked for this release" from "caller took the default", # which decides whether a release mismatch in supplied media is an error. $releaseWasExplicit = $PSBoundParameters.ContainsKey('Release') # --- Admin check --------------------------------------------------------- $me = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() if (-not $me.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw "Run this from an elevated PowerShell — VHDX mount + DISM require admin." } # --- Optional ISO file picker ------------------------------------------- # If -PickIso was requested (and no explicit -IsoPath given), show a native # Open-file dialog so the user can browse to the ISO they want to convert. # OpenFileDialog requires an STA thread; PowerShell isn't guaranteed to run # STA (e.g. -MTA, or pwsh on some hosts), so run the dialog on a dedicated # STA thread when needed. if ($PickIso -and -not $IsoPath) { Add-Type -AssemblyName System.Windows.Forms $showDialog = { $dlg = New-Object System.Windows.Forms.OpenFileDialog $dlg.Title = 'Select the Windows install media to convert' $dlg.Filter = 'Windows install media (*.iso;*.esd;*.wim)|*.iso;*.esd;*.wim|All files (*.*)|*.*' $dlg.Multiselect = $false if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $dlg.FileName } } if ([System.Threading.Thread]::CurrentThread.GetApartmentState() -eq 'STA') { $picked = & $showDialog } else { $picked = $null $t = [System.Threading.Thread]::new([System.Threading.ThreadStart]{ $script:picked = & $showDialog }) $t.SetApartmentState('STA') $t.Start() $t.Join() } if (-not $picked) { throw "No ISO selected — cancelled." } $IsoPath = $picked Write-Host "Selected ISO: $IsoPath" } New-Item -ItemType Directory -Force $WorkDir | Out-Null New-Item -ItemType Directory -Force (Split-Path $OutVhdx) | Out-Null # --- Resolve install media ---------------------------------------------- # If -IsoPath was supplied (e.g. a hand-picked ISO from the SETUP wizard), use # that and skip the download flow entirely. if ($IsoPath) { if (-not (Test-Path $IsoPath -PathType Leaf)) { throw "Supplied -IsoPath does not exist: $IsoPath" } $media = $IsoPath Write-Host "Using supplied ISO: $media" } else { # --- Ensure the OSD module ------------------------------------------ # OSD carries Microsoft's Feature Update catalog. Get-FeatureUpdate turns # a release/channel/language into a direct dl.delivery.mp.microsoft.com # URL plus the expected SHA256 -- the same media Windows Update serves. if (-not (Get-Module -ListAvailable -Name OSD)) { Write-Host "Installing OSD module from PowerShell Gallery..." # Install-Module bootstraps the NuGet provider with a Y/N prompt on a # clean host. That prompt has no console to render into when the GUI # runs this in a runspace, so it would hang the build -- install the # provider up front instead. if (-not (Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue)) { Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Scope CurrentUser -Force | Out-Null } Install-Module -Name OSD -Scope CurrentUser -Force -AllowClobber } Import-Module OSD -ErrorAction Stop Write-Host "OSD module version: $((Get-Module OSD).Version)" # OSD names releases "Windows 11 <ReleaseID> x64". $osName = "Windows 11 $Release x64" Write-Host "Resolving $osName $OSActivation ($OSLanguage) from Microsoft's catalog..." # Get-FeatureUpdate warns and returns nothing rather than throwing when # it can't match or can't reach the catalog, so check the result. $fu = Get-FeatureUpdate -OSName $osName -OSActivation $OSActivation -OSLanguage $OSLanguage if (-not $fu -or -not $fu.Url) { throw "OSD could not resolve install media for '$osName' ($OSActivation, $OSLanguage). Check internet connectivity, or supply your own media with -PickIso." } Write-Host "Catalog match: $($fu.Name)" $media = Join-Path $WorkDir $fu.FileName # Reuse a previous download only when it still matches the catalog hash. # A truncated or superseded file would otherwise surface much later as an # opaque DISM failure. $needDownload = $true if (Test-Path $media -PathType Leaf) { if ($fu.SHA256) { Write-Host "Verifying cached media..." if ((Get-FileHash -Path $media -Algorithm SHA256).Hash -ieq $fu.SHA256) { Write-Host "Reusing cached media: $media" $needDownload = $false } else { Write-Warning "Cached media failed its SHA256 check - re-downloading." Remove-Item $media -Force } } else { # No catalog hash to check against (24H2 entries currently ship # without one), so the cached file is taken on trust. Delete it and # re-run, or use -PickIso, if you suspect it. Write-Host "Reusing cached media (unverified - no catalog hash): $media" $needDownload = $false } } if ($needDownload) { Write-Host "Downloading media -> $media" # Use BITS so we can emit real % progress that the GUI parses and shows # on its progress bar. Falls back to Invoke-WebRequest if BITS is broken # or unavailable (rare — BITS is a default Windows service). $useBits = $true try { Import-Module BitsTransfer -ErrorAction Stop } catch { $useBits = $false } # Content length as reported by the transfer, used for the completeness # check below. 0 means "never learned it". $totalBytes = 0 if ($useBits) { $bitsJob = $null try { $bitsJob = Start-BitsTransfer -Source $fu.Url -Destination $media -DisplayName 'VMPilot-Win11Media' -Asynchronous # BITS reports BytesTotal as BG_SIZE_UNKNOWN ([uint64]::MaxValue, # 18446744073709551615) until the server's headers arrive -- which # is exactly what the first poll sees while the job is still # Connecting/Queued. Dividing that by 1MB yields 17592186044416, # which overflows [int] and used to abort the whole build. Treat # the sentinel as "size not known yet" and keep going; a real # total lands within a poll or two. $sizeUnknown = [uint64]::MaxValue while ($bitsJob.JobState -in 'Transferring','Connecting','Queued') { $b = [uint64]$bitsJob.BytesTransferred $t = [uint64]$bitsJob.BytesTotal if ($t -gt 0 -and $t -ne $sizeUnknown) { $totalBytes = $t $pct = [int](($b / $t) * 100) $cur = [int64]($b / 1MB) $tot = [int64]($t / 1MB) Write-Host "Download progress: $pct% ($cur / $tot MB)" } else { Write-Host "Download progress: unknown ($([int64]($b / 1MB)) MB so far)" } Start-Sleep -Seconds 2 } if ($bitsJob.JobState -eq 'Transferred') { Complete-BitsTransfer -BitsJob $bitsJob Write-Host "Download progress: 100%" } else { $errDesc = $bitsJob.ErrorDescription Remove-BitsTransfer -BitsJob $bitsJob -ErrorAction SilentlyContinue throw "BITS transfer ended in state '$($bitsJob.JobState)': $errDesc" } } catch { # BITS failed (E_HANDLE, service error, transfer failure). Clean up # the job and fall through to Invoke-WebRequest below. if ($bitsJob) { Get-BitsTransfer -JobId $bitsJob.JobId -ErrorAction SilentlyContinue | Remove-BitsTransfer -ErrorAction SilentlyContinue } Write-Warning "BITS transfer failed ($($_.Exception.Message)) — falling back to Invoke-WebRequest." $useBits = $false } } if (-not $useBits) { $client = [System.Net.Http.HttpClient]::new() $client.Timeout = [System.Threading.Timeout]::InfiniteTimeSpan $response = $null try { $response = $client.GetAsync($fu.Url, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult() $response.EnsureSuccessStatusCode() | Out-Null $contentLength = $response.Content.Headers.ContentLength if ($contentLength) { $totalBytes = [uint64]$contentLength } $responseStream = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() $fs = [System.IO.FileStream]::new($media, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None, 1MB) $buf = [byte[]]::new(1MB) $downloaded = [uint64]0 $lastPct = -1 $lastMB = 0 try { while (($read = $responseStream.Read($buf, 0, $buf.Length)) -gt 0) { $fs.Write($buf, 0, $read) $downloaded += [uint64]$read if ($totalBytes -gt 0) { $pct = [int](($downloaded / $totalBytes) * 100) if ($pct -ne $lastPct) { $cur = [int64]($downloaded / 1MB) $tot = [int64]($totalBytes / 1MB) Write-Host "Download progress: $pct% ($cur / $tot MB)" $lastPct = $pct } } else { $curMB = [int64]($downloaded / 1MB) if ($curMB -ge $lastMB + 100) { Write-Host "Download progress: unknown ($curMB MB so far)" $lastMB = $curMB } } } } finally { $fs.Close() $responseStream.Close() } } finally { if ($response) { $response.Dispose() } $client.Dispose() } } # Completeness check: did we get every byte the server promised? This # catches a truncated transfer, which is the failure the SHA256 check # below would otherwise catch -- except that not every catalog entry # has a hash (24H2 currently doesn't), so for those this is the only # check there is. It proves nothing about tampering; only the hash does. if ($totalBytes -gt 0) { $onDisk = (Get-Item $media).Length if ($onDisk -ne $totalBytes) { Remove-Item $media -Force -ErrorAction SilentlyContinue throw "Downloaded media is incomplete: got $onDisk bytes, server reported $totalBytes. The file was deleted - re-run to retry." } Write-Host "Download size verified ($onDisk bytes)." } # Verify against the catalog hash before spending 10 minutes applying # a corrupt image. Delete on mismatch so a re-run starts clean rather # than "reusing" the bad file. if ($fu.SHA256) { Write-Host "Verifying SHA256..." $actualHash = (Get-FileHash -Path $media -Algorithm SHA256).Hash if ($actualHash -ine $fu.SHA256) { Remove-Item $media -Force -ErrorAction SilentlyContinue throw "Downloaded media failed SHA256 verification (expected $($fu.SHA256), got $actualHash). The file was deleted - re-run to retry." } Write-Host "SHA256 verified." } else { # Not every catalog entry carries a hash (24H2 currently doesn't). # Say so plainly rather than letting silence imply a passed check -- # the transport was still HTTPS-to-Microsoft either way. Write-Warning "Microsoft's catalog published no SHA256 for this release - skipping hash verification." } } } # --- Suppress shell popups for the duration of the build ---------------- # Mounting the ISO and partitioning/formatting the fresh VHDX both make the # Windows shell pop dialogs: AutoPlay when the ISO gets a drive letter, and # "You need to format the disk in drive X:" for the raw VHDX volumes. Those # popups steal focus and can interrupt Format-Volume mid-build (the failure # seen when building from the GUI wizard). Stop Shell Hardware Detection -- # the service behind AutoPlay and the format prompt -- for the whole build # and restore it in the finally block below, so it always comes back even if # the build throws. `mountvol /N` (further down) adds belt-and-suspenders # against auto-lettering races during partitioning. $shellHW = Get-Service -Name ShellHWDetection -ErrorAction SilentlyContinue $shellHWWasRunning = [bool]($shellHW -and $shellHW.Status -eq 'Running') try { if ($shellHWWasRunning) { Stop-Service -Name ShellHWDetection -Force -ErrorAction SilentlyContinue } # --- Locate the Windows image ------------------------------------------- # An ISO has to be mounted so we can reach sources\install.wim|esd. Media from # Microsoft's Feature Update catalog is already a standalone .esd -- it IS the # image container, so DISM reads it directly and no mount is needed. $mountedIso = $false if ([System.IO.Path]::GetExtension($media) -ieq '.iso') { Write-Host "Mounting ISO..." $isoMount = Mount-DiskImage -ImagePath $media -PassThru $mountedIso = $true $isoDrive = ($isoMount | Get-Volume).DriveLetter $sources = "${isoDrive}:\sources" $installImg = Get-ChildItem $sources -Filter 'install.*' | Where-Object { $_.Name -in 'install.wim','install.esd' } | Select-Object -First 1 if (-not $installImg) { throw "No install.wim/install.esd under $sources" } $imagePath = $installImg.FullName } else { Write-Host "Using downloaded image directly (no ISO mount needed)." $imagePath = $media } # Pick edition index $editionName = if ($Edition -eq 'Pro') { 'Windows 11 Pro' } else { 'Windows 11 Home' } $imgInfo = Get-WindowsImage -ImagePath $imagePath | Where-Object { $_.ImageName -eq $editionName } | Select-Object -First 1 if (-not $imgInfo) { $available = (Get-WindowsImage -ImagePath $imagePath).ImageName -join ', ' throw "Edition '$editionName' not found in $imagePath. Available: $available. Re-run with -Edition set to one of those." } Write-Host "Using image index $($imgInfo.ImageIndex): $($imgInfo.ImageName)" # --- Name the VHDX after the release actually inside the ISO ------------- # With -PickIso / -IsoPath the ISO can be any build, so the -Release param # (and thus the default output name) may not reflect what's really inside. # Read the image's build number, map it to a friendly release, and use that # to name the VHDX. Only override the name when the caller did NOT pin # -OutVhdx explicitly — the GUI always passes -OutVhdx, so it keeps full # control of naming; this auto-naming only kicks in on direct invocation. $imgDetail = Get-WindowsImage -ImagePath $imagePath -Index $imgInfo.ImageIndex $imgBuild = ([Version]$imgDetail.Version).Build $buildToRelease = @{ 26200 = '25H2'; 26100 = '24H2' } $detectedRelease = $buildToRelease[$imgBuild] if ($detectedRelease) { Write-Host "Detected Windows 11 $detectedRelease (build $imgBuild) in image." # Downloaded media always matches by construction, but hand-supplied media # can be anything. When the caller named a release explicitly (the GUI # always does, to keep C:\VMs\Win11-<release>.vhdx honest), a mismatch is # an error rather than something to silently rename around. if ($releaseWasExplicit -and $detectedRelease -ne $Release) { throw "Requested -Release $Release but this media is Windows 11 $detectedRelease (build $imgBuild). Supply matching media, or re-run with -Release $detectedRelease." } $Release = $detectedRelease } else { # Unknown/newer build: name it by build number so the file is still # accurate and distinct rather than mislabeled with the -Release default. Write-Warning "Unrecognized Windows build $imgBuild - naming VHDX by build number." $detectedRelease = "build$imgBuild" } if (-not $PSBoundParameters.ContainsKey('OutVhdx')) { $OutVhdx = Join-Path (Split-Path $OutVhdx -Parent) "Win11-$detectedRelease.vhdx" New-Item -ItemType Directory -Force (Split-Path $OutVhdx) | Out-Null Write-Host "Output VHDX name set from image: $OutVhdx" } # --- Remove any existing VHDX at the target path ----------------------- # A prior build, an Explorer/Disk-Management mount, or a VM created from # this VHDX can leave it locked or depended-on. A blind Remove-Item -Force # either fails ("being used by another process") or — worse — silently # deletes a differencing-disk parent and corrupts the child VM. So: refuse # if a VM depends on it (naming the VM), otherwise dismount + retry-delete. if (Test-Path $OutVhdx) { $target = [System.IO.Path]::GetFullPath($OutVhdx) # Refuse to delete a VHDX any VM is using — directly attached or as a # differencing-disk parent. Deleting it would break that VM. $dependents = @() try { foreach ($vm in (Get-VM -ErrorAction SilentlyContinue)) { foreach ($d in (Get-VMHardDiskDrive -VM $vm -ErrorAction SilentlyContinue)) { if (-not $d.Path) { continue } $dpFull = [System.IO.Path]::GetFullPath($d.Path) $info = Get-VHD -Path $d.Path -ErrorAction SilentlyContinue $parent = if ($info -and $info.ParentPath) { [System.IO.Path]::GetFullPath($info.ParentPath) } else { $null } if (($dpFull -ieq $target) -or ($parent -and ($parent -ieq $target))) { $dependents += $vm.Name break } } } } catch { } if ($dependents) { throw "Can't rebuild $OutVhdx - VM(s) depend on it as their disk/parent: $($dependents -join ', '). Remove those VMs (CLEANUP VMs) first, then rebuild." } # No dependents. Dismount it if attached, drop wimserv handles, retry-delete. if ((Get-VHD -Path $OutVhdx -ErrorAction SilentlyContinue).Attached) { Write-Host "Existing VHDX is attached - dismounting before rebuild..." Dismount-VHD -Path $OutVhdx -ErrorAction SilentlyContinue } Get-Process wimserv -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue $removed = $false for ($i = 0; $i -lt 5 -and -not $removed; $i++) { try { Remove-Item $OutVhdx -Force -ErrorAction Stop; $removed = $true } catch { Start-Sleep -Seconds 1 Dismount-VHD -Path $OutVhdx -ErrorAction SilentlyContinue Get-Process wimserv -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue } } if (-not $removed) { throw "Can't delete $OutVhdx - it's locked by another process. Is it open or mounted in Explorer / Disk Management? Close whatever is using it, then rebuild." } } # --- Create + partition VHDX ------------------------------------------- Write-Host "Creating $OutVhdx ($SizeGB GB, dynamic)..." $vhd = New-VHD -Path $OutVhdx -SizeBytes ($SizeGB * 1GB) -Dynamic # Disable Windows automount before Mount-VHD. Even on a fresh GPT disk, # Windows's shell can pop "format disk in drive X:" while we're partitioning # if it tries to auto-letter a partition mid-format. Restored at script end. & mountvol /N | Out-Null $vhd = Mount-VHD -Path $OutVhdx -Passthru # VDS needs a moment after Mount-VHD before the disk handle is published to # WMI. Sleep here — between Mount-VHD and Get-Disk — so the pause happens # before the first WMI call, not after. The pipelined form # (Mount-VHD | Get-Disk) throws 0x80070006 (E_HANDLE) if VDS hasn't settled # yet, which makes any post-pipeline sleep useless. Start-Sleep -Seconds 2 $disk = Get-Disk -Number $vhd.DiskNumber Initialize-Disk -Number $disk.Number -PartitionStyle GPT # Assign the drive letter FIRST, then format by letter. Format-Volume # -Partition fails with "Invalid Parameter" when formatting the ESP (and, # on some hosts, the NTFS volume) on newer Windows builds (observed on # 26200); Format-Volume -DriveLetter is reliable. Windows automount was # disabled above (mountvol /N), so assigning a letter to a not-yet-formatted # volume does NOT pop "You need to format the disk in drive X: before you # can use it." — the popup the old format-first ordering was avoiding. # EFI system partition (FAT32, 100 MB) $efi = New-Partition -DiskNumber $disk.Number -Size 100MB ` -GptType '{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}' $efi | Add-PartitionAccessPath -AssignDriveLetter $efiLetter = (Get-Partition -DiskNumber $disk.Number -PartitionNumber $efi.PartitionNumber).DriveLetter Format-Volume -DriveLetter $efiLetter -FileSystem FAT32 -NewFileSystemLabel 'System' -Confirm:$false | Out-Null # MSR (16 MB, no letter) New-Partition -DiskNumber $disk.Number -Size 16MB ` -GptType '{e3c9e316-0b5c-4db8-817d-f92df00215ae}' | Out-Null # Windows partition (rest, NTFS) $win = New-Partition -DiskNumber $disk.Number -UseMaximumSize ` -GptType '{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}' $win | Add-PartitionAccessPath -AssignDriveLetter $winLetter = (Get-Partition -DiskNumber $disk.Number -PartitionNumber $win.PartitionNumber).DriveLetter Format-Volume -DriveLetter $winLetter -FileSystem NTFS -NewFileSystemLabel 'Windows' -Confirm:$false | Out-Null # --- Apply image + boot files ------------------------------------------ Write-Host "Applying image (this takes a while)..." # Run dism.exe /Apply-Image directly so we can stream real percentage progress # to the GUI. Expand-WindowsImage has no progress callback that reaches # PowerShell streams; dism.exe emits its own CR-delimited progress lines which # we parse and re-emit as "Apply progress: X%" for the GUI pipeline to consume. $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = "$env:SystemRoot\System32\Dism.exe" $psi.Arguments = "/Apply-Image /ImageFile:`"$imagePath`" /Index:$($imgInfo.ImageIndex) /ApplyDir:${winLetter}:\" $psi.UseShellExecute = $false $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.CreateNoWindow = $true $dismProc = [System.Diagnostics.Process]::Start($psi) # DISM uses \r between progress updates when writing to a console; when # redirected it may use \r or \n — read in chunks and split on both. $buf = New-Object char[] 256 $pending = New-Object System.Text.StringBuilder $lastPct = -1 while ($true) { $read = $dismProc.StandardOutput.Read($buf, 0, $buf.Length) if ($read -eq 0) { break } for ($i = 0; $i -lt $read; $i++) { $ch = $buf[$i] if ($ch -eq [char]13 -or $ch -eq [char]10) { $line = $pending.ToString().Trim() [void]$pending.Clear() if ($line -match '\b(\d+(?:\.\d+)?)%') { $pct = [int][Math]::Round([double]$Matches[1]) if ($pct -ne $lastPct) { Write-Host "Apply progress: $pct%"; $lastPct = $pct } } } else { [void]$pending.Append($ch) } } } $dismProc.WaitForExit() if ($dismProc.ExitCode -ne 0) { $errText = try { $dismProc.StandardError.ReadToEnd().Trim() } catch { '' } throw "DISM /Apply-Image failed (exit $($dismProc.ExitCode))$(if ($errText) { ": $errText" })" } # Verify DISM apply actually wrote a complete Windows install. # The SYSTEM registry hive is a load-bearing file every Windows boot # needs — if it's missing or empty, the apply was interrupted and the # VHDX is unusable (boots to "Recovery: system registry file is missing"). $systemHive = "${winLetter}:\Windows\System32\config\SYSTEM" if (-not (Test-Path $systemHive)) { throw "DISM apply incomplete — $systemHive does not exist. The install.wim may be corrupt or the apply was interrupted." } $hiveSize = (Get-Item $systemHive).Length if ($hiveSize -lt 100KB) { throw "DISM apply incomplete — $systemHive is only $hiveSize bytes (expected several MB). The apply was likely interrupted." } Write-Host "DISM apply verified (SYSTEM hive: $([int]($hiveSize/1KB)) KB)." Write-Host "Writing UEFI boot files..." # Invoke bcdboot via cmd.exe — direct PowerShell invocation has been # observed to silently fail with exit 87 (invalid parameter) on some # hosts due to argument parsing quirks. cmd.exe sidesteps them entirely. $bcdCmd = "bcdboot ${winLetter}:\Windows /s ${efiLetter}: /f UEFI" & cmd /c $bcdCmd if ($LASTEXITCODE -ne 0) { throw "bcdboot failed with exit $LASTEXITCODE. Command attempted: $bcdCmd" } # Verify bcdboot actually wrote the boot files. Exit 0 has been observed # with no files written in edge cases; without this check the VHDX boots # straight to "Start PXE over IPv4" because the EFI partition is empty. $bootMgr = "${efiLetter}:\EFI\Microsoft\Boot\bootmgfw.efi" if (-not (Test-Path $bootMgr)) { throw "bcdboot reported success (exit 0) but $bootMgr was not written. EFI partition may not be FAT32 or may be unwriteable." } Write-Host "Boot files verified at $bootMgr." # --- Cleanup ------------------------------------------------------------ Write-Host "Dismounting..." # Robust dismount. wimserv.exe (Windows Imaging Service) frequently # holds the VHDX open after DISM operations and prevents Hyper-V from # using it as a differencing-disk parent. Kill it, then retry-loop # the dismount until Get-VHD confirms Attached=$false. Throw clearly # if we can't actually free the file — better than silently leaving # the user with a locked VHDX that fails at VM-creation time. Dismount-VHD -Path $OutVhdx -ErrorAction SilentlyContinue Get-Process wimserv -EA SilentlyContinue | Stop-Process -Force -EA SilentlyContinue Start-Sleep -Seconds 1 $retry = 0 while (((Get-VHD -Path $OutVhdx -ErrorAction SilentlyContinue).Attached) -and $retry -lt 5) { Start-Sleep -Seconds 2 Get-Process wimserv -EA SilentlyContinue | Stop-Process -Force -EA SilentlyContinue Dismount-VHD -Path $OutVhdx -ErrorAction SilentlyContinue $retry++ } if ((Get-VHD -Path $OutVhdx -ErrorAction SilentlyContinue).Attached) { throw "Failed to dismount $OutVhdx after build. A process is still holding it open." } # Only the ISO path mounted anything; downloaded .esd media never was. if ($mountedIso) { Dismount-DiskImage -ImagePath $media | Out-Null } Write-Host "`nDone: $OutVhdx" -ForegroundColor Green Write-Host "Attach to a Gen-2 Hyper-V VM with Secure Boot + TPM enabled." } finally { # Restore shell popups: re-enable automount and Shell Hardware Detection # (both disabled at the top of the build). Runs on success AND failure so # AutoPlay / format prompts are never left disabled system-wide. & mountvol /E | Out-Null if ($shellHWWasRunning) { Start-Service -Name ShellHWDetection -ErrorAction SilentlyContinue } } |