#Requires -RunAsAdministrator <# .SYNOPSIS Downloads a Windows ESD from Microsoft (via MCT catalog proxy) and integrates the latest cumulative update into a bootable ISO. .DESCRIPTION Steps: 1. Fetch ESD URL + SHA1 from https://vesperos.chillcraft.me/windows/esd.php 2. Download and verify ESD (SHA1) 3. Build ISO staging structure (media skeleton, boot.wim, install.wim) 4. Download latest cumulative update from Microsoft Update Catalog 5. Apply CU to install.wim with DISM (mount → add-package → cleanup → commit) 6. Create final bootable ISO with oscdimg .PARAMETER Mode Win11x64, Win11ARM64, Win10x64, Win10ARM64 .PARAMETER OutputDir Directory for output ISO (default: current directory). .PARAMETER Language Full name ("English (United States)") or short code ("en-us"). .PARAMETER Build Optional substring to pin a specific build (e.g. "26100", "19045"). .PARAMETER Edition Windows edition to extract (default: Professional). .PARAMETER ImageIndex Override which ESD index to use as the install image. .PARAMETER SkipUpdate Skip cumulative update download and integration. .PARAMETER KeepWork Keep the working directory after completion (for debugging). .EXAMPLE .\get-updated-windows-iso.ps1 -Mode Win11x64 -OutputDir C:\ISOs .EXAMPLE .\get-updated-windows-iso.ps1 -Mode Win10x64 -Build "19045" -SkipUpdate .EXAMPLE .\get-updated-windows-iso.ps1 -Mode Win11ARM64 -Language "fr-fr" -OutputDir D:\ISOs #> param( [Parameter(Mandatory)] [ValidateSet("Win11x64", "Win11ARM64", "Win10x64", "Win10ARM64")] [string]$Mode, [string]$OutputDir = ".", [string]$Language = "English (United States)", [string]$Build, [string]$Edition = "Professional", [string]$ImageIndex, [switch]$SkipUpdate, [switch]$KeepWork ) Set-StrictMode -Off $ErrorActionPreference = "Stop" $OsVer = if ($Mode -like "Win11*") { "11" } else { "10" } $Arch = if ($Mode -like "*ARM64") { "arm64" } else { "x64" } # --- Helpers ------------------------------------------------------------------ function Write-Status { param([string]$Msg) Write-Host "`n==> $Msg" -ForegroundColor Cyan } function Write-Step { param([string]$Msg) Write-Host " - $Msg" } function Get-OscdimgPath { foreach ($hive in @("HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows Kits\Installed Roots", "HKLM:\SOFTWARE\Microsoft\Windows Kits\Installed Roots")) { $root = (Get-ItemProperty $hive -ErrorAction SilentlyContinue)."KitsRoot10" if ($root) { $p = Join-Path ($root.TrimEnd('\')) "Assessment and Deployment Kit\Deployment Tools\$($Env:PROCESSOR_ARCHITECTURE)\Oscdimg\oscdimg.exe" if (Test-Path $p) { return $p } } } $local = Join-Path $PSScriptRoot "..\oscdimg.exe" if (Test-Path $local) { return (Resolve-Path $local).Path } Write-Step "Downloading oscdimg.exe..." Invoke-WebRequest -Uri "https://msdl.microsoft.com/download/symbols/oscdimg.exe/3D44737265000/oscdimg.exe" ` -OutFile $local -UseBasicParsing -ErrorAction Stop return (Resolve-Path $local).Path } function Confirm-Hash { param([string]$Path, [string]$Expected, [ValidateSet("SHA1","SHA256")][string]$Algo = "SHA1") $actual = (Get-FileHash -Path $Path -Algorithm $Algo).Hash if ($actual -ne $Expected.ToUpper()) { Write-Warning "Hash mismatch for $(Split-Path $Path -Leaf)" Write-Warning " Expected : $Expected" Write-Warning " Actual : $actual" return $false } return $true } $LanguageMap = [ordered]@{ "Arabic" = "ar-sa"; "Bulgarian" = "bg-bg" "Chinese (Simplified)" = "zh-cn"; "Chinese (Traditional)" = "zh-tw" "Croatian" = "hr-hr"; "Czech" = "cs-cz" "Danish" = "da-dk"; "Dutch" = "nl-nl" "English (United States)" = "en-us"; "English (United Kingdom)" = "en-gb" "Estonian" = "et-ee"; "Finnish" = "fi-fi" "French" = "fr-fr"; "French (Canada)" = "fr-ca" "German" = "de-de"; "Greek" = "el-gr" "Hebrew" = "he-il"; "Hungarian" = "hu-hu" "Indonesian" = "id-id"; "Italian" = "it-it" "Japanese" = "ja-jp"; "Korean" = "ko-kr" "Latvian" = "lv-lv"; "Lithuanian" = "lt-lt" "Norwegian" = "nb-no"; "Polish" = "pl-pl" "Portuguese (Brazil)" = "pt-br"; "Portuguese (Portugal)" = "pt-pt" "Romanian" = "ro-ro"; "Russian" = "ru-ru" "Serbian (Latin)" = "sr-latn-rs"; "Slovak" = "sk-sk" "Slovenian" = "sl-si"; "Spanish" = "es-es" "Spanish (Mexico)" = "es-mx"; "Swedish" = "sv-se" "Thai" = "th-th"; "Turkish" = "tr-tr" "Ukrainian" = "uk-ua" } function Resolve-LangCode { param([string]$Lang) if ($Lang -match '^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8}){0,2}$') { return $Lang.ToLower() } foreach ($key in $LanguageMap.Keys) { if ($key -ieq $Lang) { return $LanguageMap[$key] } } Write-Error "Unrecognized language '$Lang'. Use full name like 'English (United States)' or code like 'en-us'." exit 1 } function Search-UpdateCatalog { param([string]$WinVer, [string]$CatalogArch) $query = "Windows $WinVer $CatalogArch Cumulative Update" $searchUrl = "https://www.catalog.update.microsoft.com/Search.aspx?q=$([System.Uri]::EscapeDataString($query))" Write-Step "Searching catalog: $query" try { $html = (Invoke-WebRequest -Uri $searchUrl -UseBasicParsing -ErrorAction Stop).Content } catch { Write-Warning "Update catalog search failed: $($_.Exception.Message)" return @() } $updates = @() $rowPattern = '(]*>.*?)' $rows = [regex]::Matches($html, $rowPattern, [System.Text.RegularExpressions.RegexOptions]::Singleline) foreach ($row in $rows) { $id = $row.Groups[2].Value $htm = $row.Groups[1].Value $title = ""; $class = "" if ($htm -match 'class="contentTextItemSpacerNoBreakLink">([^<]+)') { $title = $Matches[1].Trim() } if ($htm -match ']*id="[^"]*_C3_R\d+"[^>]*>(.*?)') { $class = ($Matches[1] -replace '<[^>]+>','').Trim() } if ($title) { $updates += [PSCustomObject]@{ UpdateId = $id; Title = $title; Classification = $class } } } return $updates } function Get-UpdateDownloadUrl { param([string]$UpdateId) $body = @{ updateIDs = "[{`"size`":0,`"languages`":`"`",`"uidInfo`":`"$UpdateId`",`"updateID`":`"$UpdateId`"}]" } try { $html = (Invoke-WebRequest -Uri "https://www.catalog.update.microsoft.com/DownloadDialog.aspx" ` -Method Post -Body $body -UseBasicParsing -ContentType "application/x-www-form-urlencoded" -ErrorAction Stop).Content if ($html -match "downloadInformation\[\d+\]\.files\[\d+\]\.url\s*=\s*'([^']+)'") { return $Matches[1] } if ($html -match "'(https?://[^']+\.msu)'") { return $Matches[1] } if ($html -match '(https?://[^\s"<>]+\.msu)') { return $Matches[0] } } catch { Write-Warning "Failed to get update download URL: $($_.Exception.Message)" } return $null } # --- Setup -------------------------------------------------------------------- if (-not (Test-Path $OutputDir)) { New-Item -ItemType Directory -Path $OutputDir | Out-Null } $OutputDir = (Resolve-Path $OutputDir).Path $langCode = Resolve-LangCode $Language # catalog arch label differs from API arch for search query $catalogArch = if ($Arch -eq "arm64") { "ARM64" } else { "x64" } $workDir = Join-Path $OutputDir "work_win${OsVer}_${Arch}_${langCode}" $stageDir = Join-Path $workDir "staging" $mountDir = Join-Path $workDir "mount" foreach ($d in @($workDir, $stageDir, $mountDir)) { if (Test-Path $d) { Remove-Item $d -Recurse -Force } New-Item -ItemType Directory -Path $d | Out-Null } # -- Step 1: Fetch ESD from MCT catalog proxy ---------------------------------- Write-Status "Step 1: Querying MCT catalog - Windows $OsVer $Arch ($langCode)" $apiUrl = "https://vesperos.chillcraft.me/windows/esd.php?os=$OsVer&arch=$Arch&edition=$Edition&lang=$langCode" if ($Build) { $apiUrl += "&build=$Build" } Write-Step "URL: $apiUrl" try { $xmlRaw = (Invoke-WebRequest -Uri $apiUrl -UseBasicParsing -ErrorAction Stop).Content } catch { Write-Error "MCT catalog fetch failed: $($_.Exception.Message)" exit 1 } if (-not ($xmlRaw -match '')) { Write-Error "No ESD found for Windows $OsVer $Arch $langCode (edition=$Edition$(if ($Build){", build=$Build"}))." exit 1 } # If multiple File nodes returned (multiple builds), take the last one $allFileNodes = [regex]::Matches($xmlRaw, '(?s).*?') $fileBlock = if ($allFileNodes.Count -gt 0) { $allFileNodes[$allFileNodes.Count - 1].Value } else { $xmlRaw } $esdUrl = [regex]::Match($fileBlock, '\s*(.*?)\s*').Groups[1].Value.Trim() $sha1 = [regex]::Match($fileBlock, '\s*(.*?)\s*').Groups[1].Value.Trim() $sizeStr = [regex]::Match($fileBlock, '\s*(.*?)\s*').Groups[1].Value.Trim() $fileName = [regex]::Match($fileBlock, '\s*(.*?)\s*').Groups[1].Value.Trim() if (-not $esdUrl) { Write-Error "Could not parse ESD URL from catalog response." ; exit 1 } Write-Step "File : $fileName" if ($sizeStr) { Write-Step "Size : $([math]::Round([long]$sizeStr / 1MB, 1)) MB" } if ($sha1) { Write-Step "SHA1 : $sha1" } $esdPath = Join-Path $workDir "$([System.IO.Path]::GetFileNameWithoutExtension($fileName)).esd" if (Test-Path $esdPath) { Write-Step "Checking cached ESD..." if ($sha1 -and (Confirm-Hash -Path $esdPath -Expected $sha1 -Algo SHA1)) { Write-Step "Cached ESD valid - skipping download." } else { Write-Step "Cache invalid - re-downloading..." Remove-Item $esdPath -Force Invoke-WebRequest -Uri $esdUrl -OutFile $esdPath -UseBasicParsing -ErrorAction Stop } } else { Write-Step "Downloading ESD..." Invoke-WebRequest -Uri $esdUrl -OutFile $esdPath -UseBasicParsing -ErrorAction Stop } if ($sha1) { Write-Step "Verifying ESD hash..." if (-not (Confirm-Hash -Path $esdPath -Expected $sha1 -Algo SHA1)) { Remove-Item $esdPath -Force Write-Error "ESD corrupt after download. Delete working directory and retry." exit 1 } Write-Step "Hash OK." } else { Write-Step "No SHA1 in catalog response - skipping hash check." } # -- Step 2: Extract ESD → ISO staging structure ------------------------------- Write-Status "Step 2: Building ISO staging structure from ESD" $allIndexes = Get-WindowsImage -ImagePath $esdPath foreach ($idx in $allIndexes) { Write-Step " [$($idx.ImageIndex)] $($idx.ImageName)" } if ($ImageIndex) { $installIdx = [int]$ImageIndex } else { $installIdx = ($allIndexes | Where-Object { $_.ImageName -match 'Windows 1[01] Pro$' } | Select-Object -First 1).ImageIndex if (-not $installIdx) { $installIdx = $allIndexes[-1].ImageIndex Write-Step "No Pro edition found - using last index: $installIdx" } else { Write-Step "Auto-selected Pro at index $installIdx" } } # Index 1: media folder skeleton (boot files, efi, sources stub, etc.) Write-Step "Extracting media structure (index 1)..." Expand-WindowsImage -ImagePath $esdPath -Index 1 -ApplyPath $stageDir -ErrorAction Stop # Indexes 2+3: WinPE + Windows Setup → boot.wim $bootWim = Join-Path $stageDir "sources\boot.wim" if (Test-Path $bootWim) { Remove-Item $bootWim -Force } Write-Step "Building boot.wim (index 2 = WinPE, index 3 = Setup)..." & dism /English /Export-Image "/SourceImageFile:$esdPath" "/SourceIndex:2" ` "/DestinationImageFile:$bootWim" /Compress:max /CheckIntegrity | Out-Null & dism /English /Export-Image "/SourceImageFile:$esdPath" "/SourceIndex:3" ` "/DestinationImageFile:$bootWim" /Compress:max /CheckIntegrity | Out-Null # Target OS index → install.wim (WIM format needed for DISM mount in next step) $installWim = Join-Path $stageDir "sources\install.wim" Write-Step "Exporting install image (index $installIdx) to WIM..." & dism /English /Export-Image "/SourceImageFile:$esdPath" "/SourceIndex:$installIdx" ` "/DestinationImageFile:$installWim" /Compress:max /CheckIntegrity | Out-Null Remove-Item $esdPath -Force # -- Step 3: Download + apply latest cumulative update ------------------------ if ($SkipUpdate) { Write-Status "Step 3: Skipping cumulative update (-SkipUpdate)" } else { Write-Status "Step 3: Downloading latest cumulative update for Windows $OsVer $catalogArch" $allUpdates = Search-UpdateCatalog -WinVer $OsVer -CatalogArch $catalogArch $cuList = $allUpdates | Where-Object { $_.Title -match "Cumulative Update" -and $_.Title -notmatch "Preview|Dynamic|\.NET Framework|Internet Explorer|Server 20" -and $_.Title -match "Windows $OsVer" -and ($_.Classification -match "Updates|Security Updates") } | Sort-Object { if ($_.Title -match 'KB(\d+)') { [int]$Matches[1] } else { 0 } } -Descending $latest = $cuList | Select-Object -First 1 if (-not $latest) { Write-Warning "No cumulative update found - skipping update integration." } else { Write-Step "Found: $($latest.Title)" $kbNum = if ($latest.Title -match '(KB\d+)') { $Matches[1] } else { "KBUNKNOWN" } $cuUrl = Get-UpdateDownloadUrl -UpdateId $latest.UpdateId if (-not $cuUrl) { Write-Warning "Could not retrieve download URL for $kbNum - skipping." } else { $cuPath = Join-Path $workDir "${kbNum}.msu" if (-not (Test-Path $cuPath)) { Write-Step "Downloading $kbNum..." Invoke-WebRequest -Uri $cuUrl -OutFile $cuPath -UseBasicParsing -ErrorAction Stop } else { Write-Step "$kbNum already downloaded." } Write-Step "SHA256: $((Get-FileHash $cuPath -Algorithm SHA256).Hash)" Write-Step "Mounting install.wim..." & dism /Mount-Wim "/WimFile:$installWim" /Index:1 "/MountDir:$mountDir" | Out-Null if ($LASTEXITCODE -ne 0) { Write-Error "Failed to mount install.wim - aborting update step." exit 1 } Write-Step "Applying $kbNum..." & dism "/Image:$mountDir" /Add-Package "/PackagePath:$cuPath" | Out-Null Write-Step "Running component cleanup..." & dism "/Image:$mountDir" /Cleanup-Image /StartComponentCleanup /ResetBase | Out-Null Write-Step "Committing changes..." & dism /Unmount-Wim "/MountDir:$mountDir" /Commit | Out-Null if ($LASTEXITCODE -ne 0) { Write-Warning "Commit failed - discarding mount." & dism /Unmount-Wim "/MountDir:$mountDir" /Discard | Out-Null } else { Write-Step "$kbNum applied successfully." } } } } # -- Step 4: Create bootable ISO ----------------------------------------------- Write-Status "Step 4: Creating bootable ISO" $noprompt = Join-Path $stageDir "efi\microsoft\boot\efisys_noprompt.bin" $efisys = Join-Path $stageDir "efi\microsoft\boot\efisys.bin" if (Test-Path $noprompt) { Copy-Item $noprompt $efisys -Force Write-Step "Using efisys_noprompt.bin (no keypress required at boot)." } $OSCDIMG = Get-OscdimgPath $isoLabel = "Win${OsVer}_${Arch}_${langCode}".ToUpper() -replace '[^A-Z0-9_]', '_' $isoOut = Join-Path $OutputDir "win${OsVer}_${Arch}_${langCode}_updated.iso" $etfsboot = Join-Path $stageDir "boot\etfsboot.com" $bootData = if (Test-Path $etfsboot) { "2#p0,e,b$etfsboot#pEF,e,b$efisys" } else { Write-Warning "etfsboot.com not found - ISO will be EFI-only (no legacy BIOS boot)." "1#pEF,e,b$efisys" } & "$OSCDIMG" "-l$isoLabel" '-m' '-o' '-u2' '-udfver102' "-bootdata:$bootData" $stageDir $isoOut if ($LASTEXITCODE -ne 0) { Write-Error "oscdimg failed with exit code $LASTEXITCODE." exit 1 } if (-not $KeepWork) { Write-Step "Cleaning up working directory..." Remove-Item $workDir -Recurse -Force } Write-Status "Done." Write-Host "ISO: $isoOut" -ForegroundColor Green $isoSize = [math]::Round((Get-Item $isoOut).Length / 1GB, 2) Write-Host "Size: $isoSize GB" -ForegroundColor Gray