mirror of
https://github.com/ChrisTitusTech/winutil
synced 2026-04-05 22:28:31 +00:00
809 lines
38 KiB
PowerShell
809 lines
38 KiB
PowerShell
function Write-Win11ISOLog {
|
||
<#
|
||
.SYNOPSIS
|
||
Appends a timestamped message to the Win11ISO status log TextBox.
|
||
.PARAMETER Message
|
||
The message to append.
|
||
#>
|
||
param([string]$Message)
|
||
$timestamp = (Get-Date).ToString("HH:mm:ss")
|
||
$sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{
|
||
$current = $sync["WPFWin11ISOStatusLog"].Text
|
||
if ($current -eq "Ready. Please select a Windows 11 ISO to begin.") {
|
||
$sync["WPFWin11ISOStatusLog"].Text = "[$timestamp] $Message"
|
||
} else {
|
||
$sync["WPFWin11ISOStatusLog"].Text += "`n[$timestamp] $Message"
|
||
}
|
||
$sync["WPFWin11ISOStatusLog"].CaretIndex = $sync["WPFWin11ISOStatusLog"].Text.Length
|
||
$sync["WPFWin11ISOStatusLog"].ScrollToEnd()
|
||
})
|
||
}
|
||
|
||
function Invoke-WinUtilISOBrowse {
|
||
<#
|
||
.SYNOPSIS
|
||
Opens an OpenFileDialog so the user can choose a Windows 11 ISO file.
|
||
Populates WPFWin11ISOPath and reveals the Mount & Verify section (Step 2).
|
||
#>
|
||
Add-Type -AssemblyName System.Windows.Forms
|
||
|
||
$dlg = [System.Windows.Forms.OpenFileDialog]::new()
|
||
$dlg.Title = "Select Windows 11 ISO"
|
||
$dlg.Filter = "ISO files (*.iso)|*.iso|All files (*.*)|*.*"
|
||
$dlg.InitialDirectory = [System.Environment]::GetFolderPath("Desktop")
|
||
|
||
if ($dlg.ShowDialog() -ne [System.Windows.Forms.DialogResult]::OK) { return }
|
||
|
||
$isoPath = $dlg.FileName
|
||
|
||
# ── Basic size sanity-check (a Win11 ISO is typically > 4 GB) ──
|
||
$fileSizeGB = [math]::Round((Get-Item $isoPath).Length / 1GB, 2)
|
||
|
||
$sync["WPFWin11ISOPath"].Text = $isoPath
|
||
$sync["WPFWin11ISOFileInfo"].Text = "File size: $fileSizeGB GB"
|
||
$sync["WPFWin11ISOFileInfo"].Visibility = "Visible"
|
||
|
||
# Reveal Step 2
|
||
$sync["WPFWin11ISOMountSection"].Visibility = "Visible"
|
||
|
||
# Collapse all later steps whenever a new ISO is chosen
|
||
$sync["WPFWin11ISOVerifyResultPanel"].Visibility = "Collapsed"
|
||
$sync["WPFWin11ISOModifySection"].Visibility = "Collapsed"
|
||
$sync["WPFWin11ISOOutputSection"].Visibility = "Collapsed"
|
||
|
||
Write-Win11ISOLog "ISO selected: $isoPath ($fileSizeGB GB)"
|
||
}
|
||
|
||
function Invoke-WinUtilISOMountAndVerify {
|
||
<#
|
||
.SYNOPSIS
|
||
Mounts the selected ISO, verifies it is a valid Windows 11 image,
|
||
and populates the edition list. Reveals Step 3 on success.
|
||
#>
|
||
$isoPath = $sync["WPFWin11ISOPath"].Text
|
||
|
||
if ([string]::IsNullOrWhiteSpace($isoPath) -or $isoPath -eq "No ISO selected...") {
|
||
[System.Windows.MessageBox]::Show(
|
||
"Please select an ISO file first.",
|
||
"No ISO Selected", "OK", "Warning")
|
||
return
|
||
}
|
||
|
||
Write-Win11ISOLog "Mounting ISO: $isoPath"
|
||
Set-WinUtilProgressBar -Label "Mounting ISO..." -Percent 10
|
||
|
||
try {
|
||
# Mount the ISO
|
||
$diskImage = Mount-DiskImage -ImagePath $isoPath -PassThru -ErrorAction Stop
|
||
$driveLetter = ($diskImage | Get-Volume).DriveLetter + ":"
|
||
Write-Win11ISOLog "Mounted at drive $driveLetter"
|
||
|
||
Set-WinUtilProgressBar -Label "Verifying ISO contents..." -Percent 30
|
||
|
||
# ── Verify install.wim / install.esd presence ──
|
||
$wimPath = Join-Path $driveLetter "sources\install.wim"
|
||
$esdPath = Join-Path $driveLetter "sources\install.esd"
|
||
|
||
if (-not (Test-Path $wimPath) -and -not (Test-Path $esdPath)) {
|
||
Dismount-DiskImage -ImagePath $isoPath | Out-Null
|
||
Write-Win11ISOLog "ERROR: install.wim/install.esd not found — not a valid Windows ISO."
|
||
[System.Windows.MessageBox]::Show(
|
||
"This does not appear to be a valid Windows ISO.`n`ninstall.wim / install.esd was not found.",
|
||
"Invalid ISO", "OK", "Error")
|
||
Set-WinUtilProgressBar -Label "" -Percent 0
|
||
return
|
||
}
|
||
|
||
$activeWim = if (Test-Path $wimPath) { $wimPath } else { $esdPath }
|
||
|
||
# ── Read edition / architecture info ──
|
||
Set-WinUtilProgressBar -Label "Reading image metadata..." -Percent 55
|
||
|
||
$imageInfo = Get-WindowsImage -ImagePath $activeWim | Select-Object ImageIndex, ImageName
|
||
|
||
# ── Verify at least one Win11 edition is present ──
|
||
$isWin11 = $imageInfo | Where-Object { $_.ImageName -match "Windows 11" }
|
||
if (-not $isWin11) {
|
||
Dismount-DiskImage -ImagePath $isoPath | Out-Null
|
||
Write-Win11ISOLog "ERROR: No 'Windows 11' edition found in the image."
|
||
[System.Windows.MessageBox]::Show(
|
||
"No Windows 11 edition was found in this ISO.`n`nOnly official Windows 11 ISOs are supported.",
|
||
"Not a Windows 11 ISO", "OK", "Error")
|
||
Set-WinUtilProgressBar -Label "" -Percent 0
|
||
return
|
||
}
|
||
|
||
# Store edition info for later index lookup
|
||
$sync["Win11ISOImageInfo"] = $imageInfo
|
||
|
||
# ── Populate UI ──
|
||
$sync["WPFWin11ISOMountDriveLetter"].Text = "Mounted at: $driveLetter | Image file: $(Split-Path $activeWim -Leaf)"
|
||
$sync["WPFWin11ISOEditionComboBox"].Dispatcher.Invoke([action]{
|
||
$sync["WPFWin11ISOEditionComboBox"].Items.Clear()
|
||
foreach ($img in $imageInfo) {
|
||
[void]$sync["WPFWin11ISOEditionComboBox"].Items.Add("$($img.ImageIndex): $($img.ImageName)")
|
||
}
|
||
if ($sync["WPFWin11ISOEditionComboBox"].Items.Count -gt 0) {
|
||
# Default to Windows 11 Pro; fall back to first item if not found
|
||
$proIndex = -1
|
||
for ($i = 0; $i -lt $sync["WPFWin11ISOEditionComboBox"].Items.Count; $i++) {
|
||
if ($sync["WPFWin11ISOEditionComboBox"].Items[$i] -match "Windows 11 Pro(?![\w ])") {
|
||
$proIndex = $i
|
||
break
|
||
}
|
||
}
|
||
$sync["WPFWin11ISOEditionComboBox"].SelectedIndex = if ($proIndex -ge 0) { $proIndex } else { 0 }
|
||
}
|
||
})
|
||
$sync["WPFWin11ISOVerifyResultPanel"].Visibility = "Visible"
|
||
|
||
# Store for later steps
|
||
$sync["Win11ISODriveLetter"] = $driveLetter
|
||
$sync["Win11ISOWimPath"] = $activeWim
|
||
$sync["Win11ISOImagePath"] = $isoPath
|
||
|
||
# Reveal Step 3
|
||
$sync["WPFWin11ISOModifySection"].Visibility = "Visible"
|
||
|
||
Set-WinUtilProgressBar -Label "ISO verified ✔" -Percent 100
|
||
Write-Win11ISOLog "ISO verified OK. Editions found: $($imageInfo.Count)"
|
||
}
|
||
catch {
|
||
Write-Win11ISOLog "ERROR during mount/verify: $_"
|
||
[System.Windows.MessageBox]::Show(
|
||
"An error occurred while mounting or verifying the ISO:`n`n$_",
|
||
"Error", "OK", "Error")
|
||
}
|
||
finally {
|
||
Start-Sleep -Milliseconds 800
|
||
Set-WinUtilProgressBar -Label "" -Percent 0
|
||
}
|
||
}
|
||
|
||
function Invoke-WinUtilISOModify {
|
||
<#
|
||
.SYNOPSIS
|
||
Extracts ISO contents to a temp working directory, modifies install.wim,
|
||
then repackages the image. Reveals Step 4 (output options) on success.
|
||
|
||
.NOTES
|
||
This function runs inside a PowerShell runspace so the UI stays responsive.
|
||
Placeholder modification logic is provided; extend as needed.
|
||
#>
|
||
|
||
$isoPath = $sync["Win11ISOImagePath"]
|
||
$driveLetter= $sync["Win11ISODriveLetter"]
|
||
$wimPath = $sync["Win11ISOWimPath"]
|
||
|
||
if (-not $isoPath) {
|
||
[System.Windows.MessageBox]::Show(
|
||
"No verified ISO found. Please complete Steps 1 and 2 first.",
|
||
"Not Ready", "OK", "Warning")
|
||
return
|
||
}
|
||
|
||
# ── Resolve selected edition index from the ComboBox ──
|
||
$selectedItem = $sync["WPFWin11ISOEditionComboBox"].SelectedItem
|
||
$selectedWimIndex = 1 # default fallback
|
||
if ($selectedItem -and $selectedItem -match '^(\d+):') {
|
||
$selectedWimIndex = [int]$Matches[1]
|
||
} elseif ($sync["Win11ISOImageInfo"]) {
|
||
$selectedWimIndex = $sync["Win11ISOImageInfo"][0].ImageIndex
|
||
}
|
||
$selectedEditionName = if ($selectedItem) { ($selectedItem -replace '^\d+:\s*', '') } else { "Unknown" }
|
||
Write-Win11ISOLog "Selected edition: $selectedEditionName (Index $selectedWimIndex)"
|
||
|
||
# Disable the modify button to prevent double-click
|
||
$sync["WPFWin11ISOModifyButton"].IsEnabled = $false
|
||
|
||
$existingWorkDir = Get-Item -Path (Join-Path $env:TEMP "WinUtil_Win11ISO*") -ErrorAction SilentlyContinue |
|
||
Where-Object { $_.PSIsContainer } |
|
||
Sort-Object LastWriteTime -Descending |
|
||
Select-Object -First 1
|
||
|
||
$workDir = if ($existingWorkDir) {
|
||
Write-Win11ISOLog "Reusing existing temp directory: $($existingWorkDir.FullName)"
|
||
$existingWorkDir.FullName
|
||
} else {
|
||
Join-Path $env:TEMP "WinUtil_Win11ISO_$(Get-Date -Format 'yyyyMMdd_HHmmss')"
|
||
}
|
||
|
||
# ── Resolve autounattend.xml content ──────────────────────────────────────
|
||
# Compiled winutil.ps1 sets $WinUtilAutounattendXml before main.ps1 runs.
|
||
# In dev/source mode fall back to reading tools\autounattend.xml directly.
|
||
$autounattendContent = if ($WinUtilAutounattendXml) {
|
||
$WinUtilAutounattendXml
|
||
} else {
|
||
$toolsXml = Join-Path $PSScriptRoot "..\..\tools\autounattend.xml"
|
||
if (Test-Path $toolsXml) { Get-Content $toolsXml -Raw } else { "" }
|
||
}
|
||
|
||
# ── Run modification in a background runspace ──
|
||
$runspace = [Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
|
||
$runspace.ApartmentState = "STA"
|
||
$runspace.ThreadOptions = "ReuseThread"
|
||
$runspace.Open()
|
||
$injectDrivers = $sync["WPFWin11ISOInjectDrivers"].IsChecked -eq $true
|
||
|
||
$runspace.SessionStateProxy.SetVariable("sync", $sync)
|
||
$runspace.SessionStateProxy.SetVariable("isoPath", $isoPath)
|
||
$runspace.SessionStateProxy.SetVariable("driveLetter", $driveLetter)
|
||
$runspace.SessionStateProxy.SetVariable("wimPath", $wimPath)
|
||
$runspace.SessionStateProxy.SetVariable("workDir", $workDir)
|
||
$runspace.SessionStateProxy.SetVariable("selectedWimIndex", $selectedWimIndex)
|
||
$runspace.SessionStateProxy.SetVariable("selectedEditionName", $selectedEditionName)
|
||
$runspace.SessionStateProxy.SetVariable("autounattendContent", $autounattendContent)
|
||
$runspace.SessionStateProxy.SetVariable("injectDrivers", $injectDrivers)
|
||
|
||
# Serialize functions so they are available inside the runspace
|
||
$isoScriptFuncDef = "function Invoke-WinUtilISOScript {`n" + `
|
||
${function:Invoke-WinUtilISOScript}.ToString() + "`n}"
|
||
$runspace.SessionStateProxy.SetVariable("isoScriptFuncDef", $isoScriptFuncDef)
|
||
|
||
$win11ISOLogFuncDef = "function Write-Win11ISOLog {`n" + `
|
||
${function:Write-Win11ISOLog}.ToString() + "`n}"
|
||
$runspace.SessionStateProxy.SetVariable("win11ISOLogFuncDef", $win11ISOLogFuncDef)
|
||
|
||
$refreshUSBFuncDef = "function Invoke-WinUtilISORefreshUSBDrives {`n" + `
|
||
${function:Invoke-WinUtilISORefreshUSBDrives}.ToString() + "`n}"
|
||
$runspace.SessionStateProxy.SetVariable("refreshUSBFuncDef", $refreshUSBFuncDef)
|
||
|
||
$script = [Management.Automation.PowerShell]::Create()
|
||
$script.Runspace = $runspace
|
||
$script.AddScript({
|
||
|
||
# Import helper functions into this runspace
|
||
. ([scriptblock]::Create($isoScriptFuncDef))
|
||
. ([scriptblock]::Create($win11ISOLogFuncDef))
|
||
. ([scriptblock]::Create($refreshUSBFuncDef))
|
||
|
||
function Log($msg) {
|
||
$ts = (Get-Date).ToString("HH:mm:ss")
|
||
$sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{
|
||
$sync["WPFWin11ISOStatusLog"].Text += "`n[$ts] $msg"
|
||
$sync["WPFWin11ISOStatusLog"].CaretIndex = $sync["WPFWin11ISOStatusLog"].Text.Length
|
||
$sync["WPFWin11ISOStatusLog"].ScrollToEnd()
|
||
})
|
||
Add-Content -Path (Join-Path $workDir "WinUtil_Win11ISO.log") -Value "[$ts] $msg" -ErrorAction SilentlyContinue
|
||
}
|
||
|
||
function SetProgress($label, $pct) {
|
||
$sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{
|
||
$sync.progressBarTextBlock.Text = $label
|
||
$sync.progressBarTextBlock.ToolTip = $label
|
||
$sync.ProgressBar.Value = [Math]::Max($pct, 5)
|
||
})
|
||
}
|
||
|
||
try {
|
||
# ── Hide Steps 1-3 while modification is running; expand log to fill screen ──
|
||
$sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{
|
||
$sync["WPFWin11ISOSelectSection"].Visibility = "Collapsed"
|
||
$sync["WPFWin11ISOMountSection"].Visibility = "Collapsed"
|
||
$sync["WPFWin11ISOModifySection"].Visibility = "Collapsed"
|
||
$expandedHeight = [Math]::Max(400, $sync["Form"].ActualHeight - 100)
|
||
$sync["WPFWin11ISOStatusLog"].Height = $expandedHeight
|
||
$sync["Win11ISOLogExpanded"] = $true
|
||
# Register the resize handler once so the log tracks window resizes
|
||
if (-not $sync["Win11ISOResizeHandlerAdded"]) {
|
||
$sync["Form"].add_SizeChanged({
|
||
if ($sync["Win11ISOLogExpanded"]) {
|
||
$sync["WPFWin11ISOStatusLog"].Height = [Math]::Max(400, $sync["Form"].ActualHeight - 100)
|
||
$sync["WPFWin11ISOStatusLog"].CaretIndex = $sync["WPFWin11ISOStatusLog"].Text.Length
|
||
$sync["WPFWin11ISOStatusLog"].ScrollToEnd()
|
||
}
|
||
})
|
||
$sync["Win11ISOResizeHandlerAdded"] = $true
|
||
}
|
||
})
|
||
|
||
# ── 1. Create working directory structure ──
|
||
Log "Creating working directory: $workDir"
|
||
$isoContents = Join-Path $workDir "iso_contents"
|
||
$mountDir = Join-Path $workDir "wim_mount"
|
||
New-Item -ItemType Directory -Path $isoContents, $mountDir -Force | Out-Null
|
||
SetProgress "Copying ISO contents..." 10
|
||
|
||
# ── 2. Copy all ISO contents to the working directory ──
|
||
Log "Copying ISO contents from $driveLetter to $isoContents..."
|
||
$robocopyArgs = @($driveLetter, $isoContents, "/E", "/NFL", "/NDL", "/NJH", "/NJS")
|
||
& robocopy @robocopyArgs | Out-Null
|
||
Log "ISO contents copied."
|
||
SetProgress "Mounting install.wim..." 25
|
||
|
||
# ── 3. Copy install.wim to working dir (it may be read-only on the DVD) ──
|
||
$localWim = Join-Path $isoContents "sources\install.wim"
|
||
if (-not (Test-Path $localWim)) {
|
||
# ESD path
|
||
$localWim = Join-Path $isoContents "sources\install.esd"
|
||
}
|
||
# Ensure the file is writable
|
||
Set-ItemProperty -Path $localWim -Name IsReadOnly -Value $false
|
||
|
||
# ── 4. Mount the selected edition of install.wim ──
|
||
Log "Mounting install.wim (Index ${selectedWimIndex}: $selectedEditionName) at $mountDir..."
|
||
Mount-WindowsImage -ImagePath $localWim -Index $selectedWimIndex -Path $mountDir -ErrorAction Stop | Out-Null
|
||
SetProgress "Modifying install.wim..." 45
|
||
|
||
# ── Apply all WinUtil modifications via Invoke-WinUtilISOScript ──
|
||
Log "Applying WinUtil modifications to install.wim..."
|
||
Invoke-WinUtilISOScript -ScratchDir $mountDir -ISOContentsDir $isoContents -AutoUnattendXml $autounattendContent -InjectCurrentSystemDrivers $injectDrivers -Log { param($m) Log $m }
|
||
|
||
# ── 4b. DISM component store cleanup ──
|
||
# /ResetBase removes all superseded component versions from WinSxS,
|
||
# which is the single largest space saving possible (typically 300–800 MB).
|
||
# This must be done while the image is still mounted.
|
||
SetProgress "Cleaning up component store (WinSxS)..." 56
|
||
Log "Running DISM component store cleanup (/ResetBase)..."
|
||
& dism /English "/image:$mountDir" /Cleanup-Image /StartComponentCleanup /ResetBase | ForEach-Object { Log $_ }
|
||
Log "Component store cleanup complete."
|
||
|
||
# ── 5. Save and dismount the WIM ──
|
||
SetProgress "Saving modified install.wim..." 65
|
||
Log "Dismounting and saving install.wim. This will take several minutes..."
|
||
Dismount-WindowsImage -Path $mountDir -Save -ErrorAction Stop | Out-Null
|
||
Log "install.wim saved."
|
||
|
||
# ── 5b. Strip unused editions — export only the selected index ──
|
||
# A standard multi-edition install.wim can be 4–5 GB; exporting a
|
||
# single index typically drops it to ~3 GB, saving 1–2 GB in the ISO.
|
||
SetProgress "Removing unused editions from install.wim..." 70
|
||
Log "Exporting edition '$selectedEditionName' (Index $selectedWimIndex) to a single-edition install.wim..."
|
||
$exportWim = Join-Path $isoContents "sources\install_export.wim"
|
||
Export-WindowsImage `
|
||
-SourceImagePath $localWim `
|
||
-SourceIndex $selectedWimIndex `
|
||
-DestinationImagePath $exportWim `
|
||
-ErrorAction Stop | Out-Null
|
||
Remove-Item -Path $localWim -Force
|
||
Rename-Item -Path $exportWim -NewName "install.wim" -Force
|
||
# Update local path so later steps (e.g. ISO build) reference the new file
|
||
$localWim = Join-Path $isoContents "sources\install.wim"
|
||
Log "Unused editions removed. install.wim now contains only '$selectedEditionName'."
|
||
|
||
SetProgress "Dismounting source ISO..." 80
|
||
|
||
# ── 6. Dismount the original ISO ──
|
||
Log "Dismounting original ISO..."
|
||
Dismount-DiskImage -ImagePath $isoPath | Out-Null
|
||
|
||
# Store work directory for output steps
|
||
$sync["Win11ISOWorkDir"] = $workDir
|
||
$sync["Win11ISOContentsDir"] = $isoContents
|
||
|
||
SetProgress "Modification complete ✔" 100
|
||
Log "install.wim modification complete. Choose an output option in Step 4."
|
||
|
||
# ── Reveal Step 4 on the UI thread ──
|
||
# Note: USB drive enumeration (Get-Disk) is intentionally deferred to
|
||
# when the user explicitly selects the USB option, to avoid blocking
|
||
# the UI thread here.
|
||
$sync["WPFWin11ISOOutputSection"].Dispatcher.Invoke([action]{
|
||
$sync["WPFWin11ISOOutputSection"].Visibility = "Visible"
|
||
$sync["WPFWin11ISOStatusLog"].Height = 300
|
||
})
|
||
}
|
||
catch {
|
||
Log "ERROR during modification: $_"
|
||
|
||
# ── Cleanup: dismount WIM if still mounted ──
|
||
try {
|
||
if (Test-Path $mountDir) {
|
||
$mountedImages = Get-WindowsImage -Mounted -ErrorAction SilentlyContinue |
|
||
Where-Object { $_.Path -eq $mountDir }
|
||
if ($mountedImages) {
|
||
Log "Cleaning up: dismounting install.wim (discarding changes)..."
|
||
Dismount-WindowsImage -Path $mountDir -Discard -ErrorAction SilentlyContinue | Out-Null
|
||
}
|
||
}
|
||
} catch {
|
||
Log "Warning: could not dismount install.wim during cleanup: $_"
|
||
}
|
||
|
||
# ── Cleanup: dismount the source ISO ──
|
||
try {
|
||
$mountedISO = Get-DiskImage -ImagePath $isoPath -ErrorAction SilentlyContinue
|
||
if ($mountedISO -and $mountedISO.Attached) {
|
||
Log "Cleaning up: dismounting source ISO..."
|
||
Dismount-DiskImage -ImagePath $isoPath -ErrorAction SilentlyContinue | Out-Null
|
||
}
|
||
} catch {
|
||
Log "Warning: could not dismount ISO during cleanup: $_"
|
||
}
|
||
|
||
# ── Cleanup: remove temp working directory ──
|
||
try {
|
||
if (Test-Path $workDir) {
|
||
Log "Cleaning up: removing temp directory $workDir..."
|
||
Remove-Item -Path $workDir -Recurse -Force -ErrorAction SilentlyContinue
|
||
}
|
||
} catch {
|
||
Log "Warning: could not remove temp directory during cleanup: $_"
|
||
}
|
||
|
||
$sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{
|
||
[System.Windows.MessageBox]::Show(
|
||
"An error occurred during install.wim modification:`n`n$_",
|
||
"Modification Error", "OK", "Error")
|
||
})
|
||
}
|
||
finally {
|
||
Start-Sleep -Milliseconds 800
|
||
$sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{
|
||
$sync.progressBarTextBlock.Text = ""
|
||
$sync.progressBarTextBlock.ToolTip = ""
|
||
$sync.ProgressBar.Value = 0
|
||
$sync["WPFWin11ISOModifyButton"].IsEnabled = $true
|
||
# ── Only restore steps 1-3 if Step 4 was NOT successfully shown ──
|
||
# When modification succeeds, Step 4 is visible and steps 1-3 stay
|
||
# hidden until the user clicks Clean & Reset.
|
||
if ($sync["WPFWin11ISOOutputSection"].Visibility -ne "Visible") {
|
||
$sync["WPFWin11ISOSelectSection"].Visibility = "Visible"
|
||
$sync["WPFWin11ISOMountSection"].Visibility = "Visible"
|
||
$sync["WPFWin11ISOModifySection"].Visibility = "Visible"
|
||
}
|
||
$sync["Win11ISOLogExpanded"] = $false
|
||
$sync["WPFWin11ISOStatusLog"].Height = 140
|
||
})
|
||
}
|
||
}) | Out-Null
|
||
|
||
$script.BeginInvoke() | Out-Null
|
||
}
|
||
|
||
function Invoke-WinUtilISOCheckExistingWork {
|
||
<#
|
||
.SYNOPSIS
|
||
Called when the Win11ISO tab is opened. Checks for a pre-existing
|
||
WinUtil_Win11ISO temp directory and, if found, restores the working-
|
||
directory state so the user can proceed directly to Step 4 (output
|
||
options) without repeating the modification.
|
||
#>
|
||
|
||
# If state is already loaded (e.g. user just switched tabs mid-session)
|
||
# do nothing so we don't overwrite in-progress work.
|
||
if ($sync["Win11ISOContentsDir"] -and (Test-Path $sync["Win11ISOContentsDir"])) {
|
||
return
|
||
}
|
||
|
||
$existingWorkDir = Get-Item -Path (Join-Path $env:TEMP "WinUtil_Win11ISO*") -ErrorAction SilentlyContinue |
|
||
Where-Object { $_.PSIsContainer } |
|
||
Sort-Object LastWriteTime -Descending |
|
||
Select-Object -First 1
|
||
|
||
if (-not $existingWorkDir) { return }
|
||
|
||
$isoContents = Join-Path $existingWorkDir.FullName "iso_contents"
|
||
if (-not (Test-Path $isoContents)) { return }
|
||
|
||
# Restore state
|
||
$sync["Win11ISOWorkDir"] = $existingWorkDir.FullName
|
||
$sync["Win11ISOContentsDir"] = $isoContents
|
||
|
||
# Show Step 4 and collapse steps 1-3 (modification already happened)
|
||
$sync["WPFWin11ISOSelectSection"].Visibility = "Collapsed"
|
||
$sync["WPFWin11ISOMountSection"].Visibility = "Collapsed"
|
||
$sync["WPFWin11ISOModifySection"].Visibility = "Collapsed"
|
||
$sync["WPFWin11ISOOutputSection"].Visibility = "Visible"
|
||
$sync["WPFWin11ISOStatusLog"].Height = 300
|
||
|
||
# Notify via the status log
|
||
$dirName = $existingWorkDir.Name
|
||
$modified = $existingWorkDir.LastWriteTime.ToString("yyyy-MM-dd HH:mm")
|
||
Write-Win11ISOLog "Existing working directory found: $($existingWorkDir.FullName)"
|
||
Write-Win11ISOLog "Last modified: $modified - Skipping Steps 1-3 and resuming at Step 4."
|
||
Write-Win11ISOLog "Click 'Clean & Reset' if you want to start over with a new ISO."
|
||
|
||
[System.Windows.MessageBox]::Show(
|
||
"A previous WinUtil ISO working directory was found:`n`n$($existingWorkDir.FullName)`n`n(Last modified: $modified)`n`nStep 4 (output options) has been restored so you can save the already-modified image.`n`nClick 'Clean & Reset' in Step 4 if you want to start over.",
|
||
"Existing Work Found", "OK", "Info")
|
||
}
|
||
|
||
function Invoke-WinUtilISOCleanAndReset {
|
||
<#
|
||
.SYNOPSIS
|
||
Deletes the temporary working directory created during ISO modification
|
||
and resets the entire ISO UI back to its initial state (Step 1 only).
|
||
Deletion runs in a background runspace so the UI stays responsive and
|
||
progress is reported to the user.
|
||
#>
|
||
|
||
$workDir = $sync["Win11ISOWorkDir"]
|
||
|
||
if ($workDir -and (Test-Path $workDir)) {
|
||
$confirm = [System.Windows.MessageBox]::Show(
|
||
"This will delete the temporary working directory:`n`n$workDir`n`nAnd reset the interface back to the start.`n`nContinue?",
|
||
"Clean & Reset", "YesNo", "Warning")
|
||
if ($confirm -ne "Yes") { return }
|
||
}
|
||
|
||
# Disable button so it cannot be clicked twice
|
||
$sync["WPFWin11ISOCleanResetButton"].IsEnabled = $false
|
||
|
||
$runspace = [Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
|
||
$runspace.ApartmentState = "STA"
|
||
$runspace.ThreadOptions = "ReuseThread"
|
||
$runspace.Open()
|
||
$runspace.SessionStateProxy.SetVariable("sync", $sync)
|
||
$runspace.SessionStateProxy.SetVariable("workDir", $workDir)
|
||
|
||
$script = [Management.Automation.PowerShell]::Create()
|
||
$script.Runspace = $runspace
|
||
$script.AddScript({
|
||
|
||
function Log($msg) {
|
||
$ts = (Get-Date).ToString("HH:mm:ss")
|
||
$sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{
|
||
$sync["WPFWin11ISOStatusLog"].Text += "`n[$ts] $msg"
|
||
$sync["WPFWin11ISOStatusLog"].CaretIndex = $sync["WPFWin11ISOStatusLog"].Text.Length
|
||
$sync["WPFWin11ISOStatusLog"].ScrollToEnd()
|
||
})
|
||
Add-Content -Path (Join-Path $workDir "WinUtil_Win11ISO.log") -Value "[$ts] $msg" -ErrorAction SilentlyContinue
|
||
}
|
||
function SetProgress($label, $pct) {
|
||
$sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{
|
||
$sync.progressBarTextBlock.Text = $label
|
||
$sync.progressBarTextBlock.ToolTip = $label
|
||
$sync.ProgressBar.Value = [Math]::Max($pct, 5)
|
||
})
|
||
}
|
||
|
||
try {
|
||
# ── Dismount any WIM images still mounted under workDir ──────────────
|
||
if ($workDir) {
|
||
$mountDir = Join-Path $workDir "wim_mount"
|
||
try {
|
||
$mountedImages = Get-WindowsImage -Mounted -ErrorAction SilentlyContinue |
|
||
Where-Object { $_.Path -like "$workDir*" }
|
||
if ($mountedImages) {
|
||
foreach ($img in $mountedImages) {
|
||
Log "Dismounting WIM at: $($img.Path) (discarding changes)..."
|
||
SetProgress "Dismounting WIM image..." 3
|
||
Dismount-WindowsImage -Path $img.Path -Discard -ErrorAction Stop | Out-Null
|
||
Log "WIM dismounted successfully."
|
||
}
|
||
} else {
|
||
# Fallback: run dism /Cleanup-Wim in case metadata is corrupt
|
||
# and Get-WindowsImage cannot enumerate the stuck mount
|
||
if (Test-Path $mountDir) {
|
||
Log "No mounted WIM reported by Get-WindowsImage, running DISM /Cleanup-Wim as a precaution..."
|
||
SetProgress "Running DISM cleanup..." 3
|
||
& dism /English /Cleanup-Wim 2>&1 | ForEach-Object { Log $_ }
|
||
}
|
||
}
|
||
} catch {
|
||
Log "Warning: could not dismount WIM cleanly, attempting DISM /Cleanup-Wim fallback: $_"
|
||
try {
|
||
& dism /English /Cleanup-Wim 2>&1 | ForEach-Object { Log $_ }
|
||
} catch {
|
||
Log "Warning: DISM /Cleanup-Wim also failed: $_"
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($workDir -and (Test-Path $workDir)) {
|
||
Log "Scanning files to delete in: $workDir"
|
||
SetProgress "Scanning files..." 5
|
||
|
||
$allItems = @(Get-ChildItem -Path $workDir -Recurse -Force -ErrorAction SilentlyContinue)
|
||
$total = $allItems.Count
|
||
$deleted = 0
|
||
|
||
Log "Found $total items to delete."
|
||
|
||
# Delete files first, then directories (deepest first)
|
||
$files = $allItems | Where-Object { -not $_.PSIsContainer }
|
||
$dirs = $allItems | Where-Object { $_.PSIsContainer } |
|
||
Sort-Object { $_.FullName.Length } -Descending
|
||
|
||
foreach ($f in $files) {
|
||
Log "Deleting file: $($f.FullName)"
|
||
try { Remove-Item -Path $f.FullName -Force -ErrorAction Stop } catch { Log " WARNING: could not delete file: $_" }
|
||
$deleted++
|
||
if ($deleted % 100 -eq 0 -or $deleted -eq $files.Count) {
|
||
$pct = [math]::Round(($deleted / [Math]::Max($total,1)) * 85) + 5
|
||
SetProgress "Deleting files... ($deleted / $total)" $pct
|
||
Log "Progress: $deleted of $total items processed."
|
||
}
|
||
}
|
||
|
||
foreach ($d in $dirs) {
|
||
Log "Removing directory: $($d.FullName)"
|
||
try { Remove-Item -Path $d.FullName -Force -Recurse -ErrorAction Stop } catch { Log " WARNING: could not remove directory: $_" }
|
||
$deleted++
|
||
if ($deleted % 50 -eq 0 -or $deleted -eq $total) {
|
||
$pct = [math]::Round(($deleted / [Math]::Max($total,1)) * 85) + 5
|
||
SetProgress "Removing directories... ($deleted / $total)" $pct
|
||
Log "Progress: $deleted of $total items processed."
|
||
}
|
||
}
|
||
|
||
# Remove the root work directory itself
|
||
try { Remove-Item -Path $workDir -Force -Recurse -ErrorAction Stop } catch {}
|
||
|
||
if (Test-Path $workDir) {
|
||
Log "WARNING: some items could not be deleted in $workDir"
|
||
} else {
|
||
Log "Temp directory deleted successfully."
|
||
}
|
||
} else {
|
||
Log "No temp directory found — resetting UI."
|
||
}
|
||
|
||
SetProgress "Resetting UI..." 95
|
||
Log "Resetting interface..."
|
||
|
||
# ── Full UI reset on the dispatcher thread ──────────────────────
|
||
$sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{
|
||
# Clear stored state
|
||
$sync["Win11ISOWorkDir"] = $null
|
||
$sync["Win11ISOContentsDir"] = $null
|
||
$sync["Win11ISOImagePath"] = $null
|
||
$sync["Win11ISODriveLetter"] = $null
|
||
$sync["Win11ISOWimPath"] = $null
|
||
$sync["Win11ISOImageInfo"] = $null
|
||
$sync["Win11ISOUSBDisks"] = $null
|
||
|
||
# Reset UI elements
|
||
$sync["WPFWin11ISOPath"].Text = "No ISO selected..."
|
||
$sync["WPFWin11ISOFileInfo"].Visibility = "Collapsed"
|
||
$sync["WPFWin11ISOVerifyResultPanel"].Visibility = "Collapsed"
|
||
$sync["WPFWin11ISOOptionUSB"].Visibility = "Collapsed"
|
||
$sync["WPFWin11ISOOutputSection"].Visibility = "Collapsed"
|
||
$sync["WPFWin11ISOModifySection"].Visibility = "Collapsed"
|
||
$sync["WPFWin11ISOMountSection"].Visibility = "Collapsed"
|
||
$sync["WPFWin11ISOSelectSection"].Visibility = "Visible"
|
||
$sync["WPFWin11ISOModifyButton"].IsEnabled = $true
|
||
$sync["WPFWin11ISOCleanResetButton"].IsEnabled = $true
|
||
|
||
$sync.progressBarTextBlock.Text = ""
|
||
$sync.progressBarTextBlock.ToolTip = ""
|
||
$sync.ProgressBar.Value = 0
|
||
|
||
$sync["WPFWin11ISOStatusLog"].Height = 140
|
||
$sync["WPFWin11ISOStatusLog"].Text = "Ready. Please select a Windows 11 ISO to begin."
|
||
})
|
||
}
|
||
catch {
|
||
Log "ERROR during Clean & Reset: $_"
|
||
$sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{
|
||
$sync.progressBarTextBlock.Text = ""
|
||
$sync.progressBarTextBlock.ToolTip = ""
|
||
$sync.ProgressBar.Value = 0
|
||
$sync["WPFWin11ISOCleanResetButton"].IsEnabled = $true
|
||
})
|
||
}
|
||
}) | Out-Null
|
||
|
||
$script.BeginInvoke() | Out-Null
|
||
}
|
||
|
||
function Invoke-WinUtilISOExport {
|
||
<#
|
||
.SYNOPSIS
|
||
Saves the modified ISO contents as a new bootable ISO file.
|
||
Uses oscdimg.exe (part of the Windows ADK) if present; falls back
|
||
to a reminder message if not installed.
|
||
#>
|
||
$contentsDir = $sync["Win11ISOContentsDir"]
|
||
|
||
if (-not $contentsDir -or -not (Test-Path $contentsDir)) {
|
||
[System.Windows.MessageBox]::Show(
|
||
"No modified ISO content found. Please complete Steps 1–3 first.",
|
||
"Not Ready", "OK", "Warning")
|
||
return
|
||
}
|
||
|
||
Add-Type -AssemblyName System.Windows.Forms
|
||
|
||
$dlg = [System.Windows.Forms.SaveFileDialog]::new()
|
||
$dlg.Title = "Save Modified Windows 11 ISO"
|
||
$dlg.Filter = "ISO files (*.iso)|*.iso"
|
||
$dlg.FileName = "Win11_Modified_$(Get-Date -Format 'yyyyMMdd').iso"
|
||
$dlg.InitialDirectory = [System.Environment]::GetFolderPath("Desktop")
|
||
|
||
if ($dlg.ShowDialog() -ne [System.Windows.Forms.DialogResult]::OK) { return }
|
||
|
||
$outputISO = $dlg.FileName
|
||
Write-Win11ISOLog "Exporting to ISO: $outputISO"
|
||
|
||
Set-WinUtilProgressBar -Label "Building ISO..." -Percent 10
|
||
|
||
# Locate oscdimg.exe (Windows ADK or winget per-user install)
|
||
$oscdimg = Get-ChildItem "C:\Program Files (x86)\Windows Kits" -Recurse -Filter "oscdimg.exe" -ErrorAction SilentlyContinue |
|
||
Select-Object -First 1 -ExpandProperty FullName
|
||
if (-not $oscdimg) {
|
||
$oscdimg = Get-ChildItem "$env:LOCALAPPDATA\Microsoft\WinGet\Packages" -Recurse -Filter "oscdimg.exe" -ErrorAction SilentlyContinue |
|
||
Where-Object { $_.FullName -match 'Microsoft\.OSCDIMG' } |
|
||
Select-Object -First 1 -ExpandProperty FullName
|
||
}
|
||
|
||
if (-not $oscdimg) {
|
||
Write-Win11ISOLog "oscdimg.exe not found. Attempting to install via winget..."
|
||
Set-WinUtilProgressBar -Label "Installing oscdimg..." -Percent 5
|
||
try {
|
||
$winget = Get-Command winget -ErrorAction Stop
|
||
$result = & $winget install -e --id Microsoft.OSCDIMG --accept-package-agreements --accept-source-agreements 2>&1
|
||
Write-Win11ISOLog "winget output: $result"
|
||
# Re-scan after install
|
||
$oscdimg = Get-ChildItem "$env:LOCALAPPDATA\Microsoft\WinGet\Packages" -Recurse -Filter "oscdimg.exe" -ErrorAction SilentlyContinue |
|
||
Where-Object { $_.FullName -match 'Microsoft\.OSCDIMG' } |
|
||
Select-Object -First 1 -ExpandProperty FullName
|
||
} catch {
|
||
Write-Win11ISOLog "winget not available or install failed: $_"
|
||
}
|
||
|
||
if (-not $oscdimg) {
|
||
Set-WinUtilProgressBar -Label "" -Percent 0
|
||
Write-Win11ISOLog "oscdimg.exe still not found after install attempt."
|
||
[System.Windows.MessageBox]::Show(
|
||
"oscdimg.exe could not be found or installed automatically.`n`nPlease install it manually:`n winget install -e --id Microsoft.OSCDIMG`n`nOr install the Windows ADK from:`nhttps://learn.microsoft.com/windows-hardware/get-started/adk-install",
|
||
"oscdimg Not Found", "OK", "Warning")
|
||
return
|
||
}
|
||
Write-Win11ISOLog "oscdimg.exe installed successfully."
|
||
}
|
||
|
||
# Build boot parameters (BIOS + UEFI dual-boot)
|
||
$bootData = "2#p0,e,b`"$contentsDir\boot\etfsboot.com`"#pEF,e,b`"$contentsDir\efi\microsoft\boot\efisys.bin`""
|
||
$oscdimgArgs = @(
|
||
"-m", # ignore source path max size
|
||
"-o", # optimise storage
|
||
"-u2", # UDF 2.01
|
||
"-udfver102",
|
||
"-bootdata:$bootData",
|
||
"-l`"CTOS_MODIFIED`"",
|
||
"`"$contentsDir`"",
|
||
"`"$outputISO`""
|
||
)
|
||
|
||
try {
|
||
Write-Win11ISOLog "Running oscdimg..."
|
||
$psi = [System.Diagnostics.ProcessStartInfo]::new()
|
||
$psi.FileName = $oscdimg
|
||
$psi.Arguments = $oscdimgArgs -join " "
|
||
$psi.RedirectStandardOutput = $true
|
||
$psi.RedirectStandardError = $true
|
||
$psi.UseShellExecute = $false
|
||
$psi.CreateNoWindow = $true
|
||
|
||
$proc = [System.Diagnostics.Process]::new()
|
||
$proc.StartInfo = $psi
|
||
$proc.Start() | Out-Null
|
||
|
||
# Stream stdout and stderr line-by-line to the status log
|
||
$stdoutTask = $proc.StandardOutput.ReadToEndAsync()
|
||
$stderrTask = $proc.StandardError.ReadToEndAsync()
|
||
$proc.WaitForExit()
|
||
[System.Threading.Tasks.Task]::WaitAll($stdoutTask, $stderrTask)
|
||
|
||
foreach ($line in ($stdoutTask.Result -split "`r?`n")) {
|
||
if ($line.Trim()) { Write-Win11ISOLog $line }
|
||
}
|
||
foreach ($line in ($stderrTask.Result -split "`r?`n")) {
|
||
if ($line.Trim()) { Write-Win11ISOLog "[stderr]$line" }
|
||
}
|
||
|
||
if ($proc.ExitCode -eq 0) {
|
||
Set-WinUtilProgressBar -Label "ISO exported" -Percent 100
|
||
Write-Win11ISOLog "ISO exported successfully: $outputISO"
|
||
[System.Windows.MessageBox]::Show(
|
||
"ISO exported successfully!`n`n$outputISO",
|
||
"Export Complete", "OK", "Info")
|
||
} else {
|
||
Write-Win11ISOLog "oscdimg exited with code $($proc.ExitCode)."
|
||
[System.Windows.MessageBox]::Show(
|
||
"oscdimg exited with code $($proc.ExitCode).`nCheck the status log for details.",
|
||
"Export Error", "OK", "Error")
|
||
}
|
||
}
|
||
catch {
|
||
Write-Win11ISOLog "ERROR during ISO export: $_"
|
||
[System.Windows.MessageBox]::Show("ISO export failed:`n`n$_","Error","OK","Error")
|
||
}
|
||
finally {
|
||
Start-Sleep -Milliseconds 800
|
||
Set-WinUtilProgressBar -Label "" -Percent 0
|
||
}
|
||
}
|