Files
windows-builder/includes/utils/get-updated-windows-iso.ps1
2026-06-02 03:37:09 -07:00

387 lines
16 KiB
PowerShell

#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 = '(<tr id="([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})_R\d+"[^>]*>.*?</tr>)'
$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">([^<]+)</a>') { $title = $Matches[1].Trim() }
if ($htm -match '<td[^>]*id="[^"]*_C3_R\d+"[^>]*>(.*?)</td>') { $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 '<FilePath>')) {
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)<File>.*?</File>')
$fileBlock = if ($allFileNodes.Count -gt 0) { $allFileNodes[$allFileNodes.Count - 1].Value } else { $xmlRaw }
$esdUrl = [regex]::Match($fileBlock, '<FilePath>\s*(.*?)\s*</FilePath>').Groups[1].Value.Trim()
$sha1 = [regex]::Match($fileBlock, '<Sha1>\s*(.*?)\s*</Sha1>').Groups[1].Value.Trim()
$sizeStr = [regex]::Match($fileBlock, '<Size>\s*(.*?)\s*</Size>').Groups[1].Value.Trim()
$fileName = [regex]::Match($fileBlock, '<FileName>\s*(.*?)\s*</FileName>').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